Compare commits

...

3 Commits

Author SHA1 Message Date
c2601bf17a Chat working with multiple users 2025-05-29 16:43:57 -07:00
27d9ab467a Added mockup chat page 2025-05-29 15:05:41 -07:00
b823c1e839 Fixed a couple style issues 2025-05-29 14:34:56 -07:00
20 changed files with 1157 additions and 223 deletions

View File

@ -10,6 +10,7 @@ import { useMediaQuery } from '@mui/material';
import { useUser } from "../hooks/useUser"; import { useUser } from "../hooks/useUser";
import { Candidate } from '../types/types'; import { Candidate } from '../types/types';
import { CopyBubble } from "./CopyBubble"; import { CopyBubble } from "./CopyBubble";
import { rest } from 'lodash';
interface CandidateInfoProps { interface CandidateInfoProps {
candidate: Candidate; candidate: Candidate;
@ -20,8 +21,9 @@ interface CandidateInfoProps {
const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => { const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => {
const { candidate } = props; const { candidate } = props;
const { const {
sx, sx,
action = '', action = '',
...rest
} = props; } = props;
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@ -40,6 +42,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
transition: 'all 0.3s ease', transition: 'all 0.3s ease',
...sx ...sx
}} }}
{...rest}
> >
<CardContent sx={{ flexGrow: 1, p: 3, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}> <CardContent sx={{ flexGrow: 1, p: 3, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>

View File

@ -14,7 +14,7 @@ import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryT
import { BackstoryElementProps } from './BackstoryTab'; import { BackstoryElementProps } from './BackstoryTab';
import { connectionBase } from 'utils/Global'; import { connectionBase } from 'utils/Global';
import { useUser } from "hooks/useUser"; import { useUser } from "hooks/useUser";
import { StreamingResponse } from 'types/api-client'; import { StreamingResponse } from 'services/api-client';
import { ChatMessage, ChatMessageBase, ChatContext, ChatSession, ChatQuery } from 'types/types'; import { ChatMessage, ChatMessageBase, ChatContext, ChatSession, ChatQuery } from 'types/types';
import { PaginatedResponse } from 'types/conversion'; import { PaginatedResponse } from 'types/conversion';
@ -260,7 +260,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
); );
controllerRef.current = apiClient.sendMessageStream(sessionId, query, { controllerRef.current = apiClient.sendMessageStream(sessionId, query, {
onMessage: (msg) => { onMessage: (msg: ChatMessageBase) => {
console.log("onMessage:", msg); console.log("onMessage:", msg);
if (msg.type === "response") { if (msg.type === "response") {
setConversation([ setConversation([
@ -288,11 +288,11 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
setProcessingMessage({ ...defaultMessage, content: error as string }); setProcessingMessage({ ...defaultMessage, content: error as string });
} }
}, },
onStreaming: (chunk) => { onStreaming: (chunk: ChatMessageBase) => {
console.log("onStreaming:", chunk); console.log("onStreaming:", chunk);
setStreamingMessage({ ...defaultMessage, ...chunk }); setStreamingMessage({ ...defaultMessage, ...chunk });
}, },
onStatusChange: (status) => { onStatusChange: (status: string) => {
console.log("onStatusChange:", status); console.log("onStatusChange:", status);
}, },
onComplete: () => { onComplete: () => {

View File

@ -92,15 +92,12 @@ const BackstoryPageContainer = (props : BackstoryPageContainerProps) => {
return ( return (
<Container <Container
className="BackstoryPageContainer" className="BackstoryPageContainer"
maxWidth="xl"
sx={{ sx={{
display: "flex", display: "flex",
flexGrow: 1, flexGrow: 1,
p: { xs: 0, sm: 0.5 }, // Zero padding on mobile (xs), 0.5 on larger screens (sm and up) p: { xs: 0, sm: 0.5 }, // Zero padding on mobile (xs), 0.5 on larger screens (sm and up)
mt: 0, m: "0 auto !important",
mb: 0, maxWidth: '1024px', //{ xs: '100%', md: '700px', lg: '1024px' },
// width: "100%",
maxWidth: { xs: '100%', md: '700px', lg: '1024px' },
...sx ...sx
}}> }}>
<Paper <Paper
@ -112,8 +109,8 @@ const BackstoryPageContainer = (props : BackstoryPageContainerProps) => {
backgroundColor: 'background.paper', backgroundColor: 'background.paper',
borderRadius: 0.5, borderRadius: 0.5,
minHeight: '80vh', minHeight: '80vh',
maxWidth: { xs: '100%', md: '700px', lg: '1024px' }, maxWidth: '100%',
flexDirection: "column", flexDirection: "column",
}}> }}>
{children} {children}
</Paper> </Paper>

View File

@ -6,7 +6,7 @@ import { BackstoryPageProps } from '../BackstoryTab';
import { ConversationHandle } from '../Conversation'; import { ConversationHandle } from '../Conversation';
import { User } from 'types/types'; import { User } from 'types/types';
import { ChatPage } from 'pages/ChatPage'; import { CandidateChatPage } from 'pages/CandidateChatPage';
import { ResumeBuilderPage } from 'pages/ResumeBuilderPage'; import { ResumeBuilderPage } from 'pages/ResumeBuilderPage';
import { DocsPage } from 'pages/DocsPage'; import { DocsPage } from 'pages/DocsPage';
import { CreateProfilePage } from 'pages/CreateProfilePage'; import { CreateProfilePage } from 'pages/CreateProfilePage';
@ -41,7 +41,7 @@ const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNod
let index=0 let index=0
const routes = [ const routes = [
<Route key={`${index++}`} path="/" element={<HomePage/>} />, <Route key={`${index++}`} path="/" element={<HomePage/>} />,
<Route key={`${index++}`} path="/chat" element={<ChatPage ref={chatRef} setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/chat" element={<CandidateChatPage ref={chatRef} setSnack={setSnack} submitQuery={submitQuery} />} />,
<Route key={`${index++}`} path="/docs" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/docs" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />,
<Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />,
<Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage setSnack={setSnack} submitQuery={submitQuery} />} />, <Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage setSnack={setSnack} submitQuery={submitQuery} />} />,

View File

@ -62,7 +62,7 @@ const Footer = () => {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
return ( return (
<FooterContainer elevation={0}> <FooterContainer elevation={0} >
<Container maxWidth="lg"> <Container maxWidth="lg">
<Grid container spacing={4} justifyContent="space-between"> <Grid container spacing={4} justifyContent="space-between">
{/* About Company */} {/* About Company */}
@ -79,11 +79,11 @@ const Footer = () => {
> >
BACKSTORY BACKSTORY
</Typography> </Typography>
<Typography variant="body2" sx={{ mb: 2 }}> <Typography variant="body2" sx={{ mb: 2, color: "white" }}>
Helping candidates share their professional journey and connect with the right employers through compelling backstories. Helping candidates share their professional journey and connect with the right employers through compelling backstories.
</Typography> </Typography>
<Stack direction="row"> <Stack direction="row">
<IconButton {/* <IconButton
size="small" size="small"
aria-label="Facebook" aria-label="Facebook"
sx={{ sx={{
@ -112,7 +112,7 @@ const Footer = () => {
onClick={() => window.open('https://twitter.com/', '_blank')} onClick={() => window.open('https://twitter.com/', '_blank')}
> >
<Twitter /> <Twitter />
</IconButton> </IconButton> */}
<IconButton <IconButton
size="small" size="small"
aria-label="LinkedIn" aria-label="LinkedIn"
@ -124,11 +124,11 @@ const Footer = () => {
color: theme.palette.action.active, color: theme.palette.action.active,
} }
}} }}
onClick={() => window.open('https://linkedin.com/', '_blank')} onClick={() => window.open('https://www.linkedin.com/in/james-ketrenos/', '_blank')}
> >
<LinkedIn /> <LinkedIn />
</IconButton> </IconButton>
<IconButton {/* <IconButton
size="small" size="small"
aria-label="Instagram" aria-label="Instagram"
sx={{ sx={{
@ -157,7 +157,7 @@ const Footer = () => {
onClick={() => window.open('https://youtube.com/', '_blank')} onClick={() => window.open('https://youtube.com/', '_blank')}
> >
<YouTube /> <YouTube />
</IconButton> </IconButton> */}
</Stack> </Stack>
</Box> </Box>
</Grid> </Grid>
@ -211,7 +211,7 @@ const Footer = () => {
</ContactItem> */} </ContactItem> */}
<ContactItem> <ContactItem>
<LocationOn sx={{ mr: 1, fontSize: 20 }} /> <LocationOn sx={{ mr: 1, fontSize: 20 }} />
<Typography variant="body2"> <Typography variant="body2" sx={{ color: "white" }}>
Beaverton, OR 97003 Beaverton, OR 97003
</Typography> </Typography>
</ContactItem> </ContactItem>
@ -224,8 +224,8 @@ const Footer = () => {
<Grid container spacing={2} alignItems="center"> <Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
<Copyright sx={{ fontSize: 16, mr: 1 }} /> <Copyright sx={{ fontSize: 16, mr: 1, color: "white" }} />
<Typography variant="body2"> <Typography variant="body2" sx={{ color: "white" }}>
{currentYear} James P. Ketrenos. All rights reserved. {currentYear} James P. Ketrenos. All rights reserved.
</Typography> </Typography>
</Box> </Box>

View File

@ -1,7 +1,7 @@
import React, { createContext, useContext, useEffect, useState } from "react"; import React, { createContext, useContext, useEffect, useState } from "react";
import { SetSnackType } from '../components/Snack'; import { SetSnackType } from '../components/Snack';
import { User, Guest, Candidate } from 'types/types'; import { User, Guest, Candidate } from 'types/types';
import { ApiClient } from "types/api-client"; import { ApiClient } from "services/api-client";
import { debugConversion } from "types/conversion"; import { debugConversion } from "types/conversion";
type UserContextType = { type UserContextType = {

View File

@ -38,7 +38,7 @@ const BetaPage: React.FC<BetaPageProps> = ({
const location = useLocation(); const location = useLocation();
if (!children) { if (!children) {
children = (<Box>Location: {location.pathname}</Box>); children = (<Box sx={{ width: "100%", display: "flex", justifyContent: "center" }}>The page you requested (<b>{location.pathname.replace(/^\//, '')}</b>) is not yet read.</Box>);
} }
console.log("BetaPage", children); console.log("BetaPage", children);

View File

@ -0,0 +1,317 @@
import React, { forwardRef, useState, useEffect, useRef } from 'react';
import {
Box,
Paper,
Typography,
TextField,
Button,
List,
ListItem,
ListItemText,
Chip,
IconButton,
CircularProgress,
Divider,
Card,
CardContent,
Avatar,
Grid
} from '@mui/material';
import { useUser } from 'hooks/useUser';
import { ChatMessageBase, ChatMessage, ChatSession } from 'types/types';
import { ConversationHandle } from 'components/Conversation';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { Message } from 'components/Message';
import { DeleteConfirmation } from 'components/DeleteConfirmation';
import { CandidateSessionsResponse } from 'services/api-client';
import { CandidateInfo } from 'components/CandidateInfo';
const CandidateChatPage = forwardRef<ConversationHandle, BackstoryPageProps>((props: BackstoryPageProps, ref) => {
const { apiClient, candidate } = useUser();
const {
setSnack,
submitQuery,
} = props;
const [sessions, setSessions] = useState<CandidateSessionsResponse | null>(null);
const [chatSession, setChatSession] = useState<ChatSession | null>(null);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const messagesEndRef = useRef(null);
// Load sessions for the candidate
const loadSessions = async () => {
if (!candidate) return;
try {
setLoading(true);
const result = await apiClient.getCandidateChatSessions(candidate.username);
setSessions(result);
} catch (error) {
console.error('Failed to load sessions:', error);
} finally {
setLoading(false);
}
};
// Load messages for current session
const loadMessages = async () => {
if (!chatSession?.id) return;
try {
const result = await apiClient.getChatMessages(chatSession.id);
setMessages(result.data as any);
} catch (error) {
console.error('Failed to load messages:', error);
}
};
// Create new session
const createNewSession = async () => {
if (!candidate) { return }
try {
setLoading(true);
const newSession = await apiClient.createCandidateChatSession(
candidate.username,
'candidate_chat',
`Interview Discussion - ${candidate.username}`
);
setChatSession(newSession);
setMessages([]);
await loadSessions(); // Refresh sessions list
} catch (error) {
console.error('Failed to create session:', error);
} finally {
setLoading(false);
}
};
// Send message
const sendMessage = async () => {
if (!newMessage.trim() || !chatSession?.id || streaming) return;
const messageContent = newMessage;
setNewMessage('');
setStreaming(true);
try {
await apiClient.sendMessageStream(
chatSession.id,
{ prompt: messageContent }, {
onMessage: (msg) => {
console.log("onMessage:", msg);
if (msg.type === "response") {
setMessages(prev => {
const filtered = prev.filter((m: any) => m.id !== msg.id);
return [...filtered, msg].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
) as any;
});
} else {
console.log(msg);
}
},
onError: (error: string | ChatMessageBase) => {
console.log("onError:", error);
setStreaming(false);
},
onStreaming: (chunk) => {
console.log("onStreaming:", chunk);
},
onStatusChange: (status) => {
console.log("onStatusChange:", status);
},
onComplete: () => {
console.log("onComplete");
setStreaming(false);
}
});
} catch (error) {
console.error('Failed to send message:', error);
setStreaming(false);
}
};
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
(messagesEndRef.current as any)?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Load sessions when username changes
useEffect(() => {
loadSessions();
}, [candidate]);
// Load messages when session changes
useEffect(() => {
if (chatSession?.id) {
loadMessages();
}
}, [chatSession]);
return (
<Box ref={ref} sx={{ width: "100%" }}>
{ candidate && <CandidateInfo candidate={candidate} /> }
<Grid container spacing={3}>
{/* Sessions Sidebar */}
<Grid size={{ xs: 12, md: 4 }}>
<Paper sx={{ p: 2, height: '600px', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" gutterBottom>
Chat Sessions
{sessions && (
<Chip
size="small"
label={`${sessions.sessions.total} total`}
sx={{ ml: 1 }}
/>
)}
</Typography>
<Button
variant="outlined"
onClick={createNewSession}
disabled={loading || !candidate}
sx={{ mb: 2 }}
>
New Session
</Button>
<Box sx={{ flexGrow: 1, overflow: 'auto' }}>
{sessions ? (
<List>
{sessions.sessions.data.map((session : any) => (
<ListItem
key={session.id}
// selected={chatSession?.id === session.id}
onClick={() => setChatSession(session)}
sx={{
mb: 1,
borderRadius: 1,
border: '1px solid',
borderColor: chatSession?.id === session.id ? 'primary.main' : 'divider',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'action.hover'
}
}}
>
<ListItemText
primary={session.title}
secondary={`${new Date(session.lastActivity).toLocaleDateString()}${session.context.type}`}
/>
</ListItem>
))}
</List>
) : (
<Typography color="text.secondary" align="center">
Enter a username and click "Load Sessions"
</Typography>
)}
</Box>
{sessions && (
<Box sx={{ mt: 2, p: 2, bgcolor: 'background.default', borderRadius: 1 }}>
<Typography variant="subtitle2" gutterBottom>
Candidate Info
</Typography>
<Typography variant="body2">
<strong>Name:</strong> {sessions.candidate.fullName}
</Typography>
<Typography variant="body2">
<strong>Email:</strong> {sessions.candidate.email}
</Typography>
</Box>
)}
</Paper>
</Grid>
{/* Chat Interface */}
<Grid size={{ xs: 12, md: 8 }}>
<Paper sx={{ height: '600px', display: 'flex', flexDirection: 'column' }}>
{chatSession?.id ? (
<>
{/* Messages Area */}
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
{messages.map((message: ChatMessageBase) => (
<Message key={message.id} {...{ chatSession, message, setSnack, submitQuery }} />
))}
{streaming && (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Avatar sx={{ mr: 1, bgcolor: 'primary.main' }}>
🤖
</Avatar>
<Card>
<CardContent sx={{ display: 'flex', alignItems: 'center', p: 2 }}>
<CircularProgress size={16} sx={{ mr: 1 }} />
<Typography variant="body2">AI is typing...</Typography>
</CardContent>
</Card>
</Box>
)}
<div ref={messagesEndRef} />
</Box>
<Divider />
{/* Message Input */}
<Box sx={{ p: 2, display: 'flex', gap: 1 }}>
<TextField
fullWidth
variant="outlined"
placeholder="Type your message about the candidate..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
disabled={streaming}
multiline
maxRows={4}
/>
<Button
variant="contained"
onClick={sendMessage}
disabled={!newMessage.trim() || streaming}
sx={{ minWidth: 'auto', px: 2 }}
>
</Button>
</Box>
</>
) : (
<Box
sx={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 2
}}
>
<Typography variant="h1" sx={{ fontSize: 64, color: 'text.secondary' }}>
🤖
</Typography>
<Typography variant="h6" color="text.secondary">
Select a session to start chatting
</Typography>
<Typography variant="body2" color="text.secondary" align="center">
Create a new session or choose from existing ones to begin discussing the candidate
</Typography>
</Box>
)}
</Paper>
</Grid>
</Grid>
</Box>
);
});
export { CandidateChatPage };

View File

@ -144,6 +144,7 @@ const documents : DocType[] = [
{ title: "Application Architecture", route: "about-app", description: "System design and technical stack information", icon: <LayersIcon /> }, { title: "Application Architecture", route: "about-app", description: "System design and technical stack information", icon: <LayersIcon /> },
{ title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions", icon: <DashboardIcon /> }, { title: "UI Overview", route: "ui-overview", description: "Guide to the user interface components and interactions", icon: <DashboardIcon /> },
{ title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts", icon: <DashboardIcon /> }, { title: "UI Mockup", route: "ui-mockup", description: "Visual previews of interfaces and layout concepts", icon: <DashboardIcon /> },
{ title: "Chat Mockup", route: "mockup-chat-system", description: "Mockup of chat system", icon: <DashboardIcon /> },
{ title: "Theme Visualizer", route: "theme-visualizer", description: "Explore and customize application themes and visual styles", icon: <PaletteIcon /> }, { title: "Theme Visualizer", route: "theme-visualizer", description: "Explore and customize application themes and visual styles", icon: <PaletteIcon /> },
{ title: "App Analysis", route: "app-analysis", description: "Statistics and performance metrics of the application", icon: <AnalyticsIcon /> }, { title: "App Analysis", route: "app-analysis", description: "Statistics and performance metrics of the application", icon: <AnalyticsIcon /> },
{ title: 'Text Mockups', route: "backstory-ui-mockups", description: "Early text mockups of many of the interaction points." }, { title: 'Text Mockups', route: "backstory-ui-mockups", description: "Early text mockups of many of the interaction points." },

View File

@ -18,7 +18,7 @@ import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryT
import { StyledMarkdown } from 'components/StyledMarkdown'; import { StyledMarkdown } from 'components/StyledMarkdown';
import { Scrollable } from '../components/Scrollable'; import { Scrollable } from '../components/Scrollable';
import { Pulse } from 'components/Pulse'; import { Pulse } from 'components/Pulse';
import { StreamingResponse } from 'types/api-client'; import { StreamingResponse } from 'services/api-client';
import { ChatContext, ChatMessage, ChatMessageBase, ChatSession, ChatQuery } from 'types/types'; import { ChatContext, ChatMessage, ChatMessageBase, ChatSession, ChatQuery } from 'types/types';
import { useUser } from 'hooks/useUser'; import { useUser } from 'hooks/useUser';

View File

@ -133,6 +133,8 @@ const FeatureCard = ({
}; };
const HomePage = () => { const HomePage = () => {
const testimonials = false;
return (<Box sx={{display: "flex", flexDirection: "column"}}> return (<Box sx={{display: "flex", flexDirection: "column"}}>
{/* Hero Section */} {/* Hero Section */}
<HeroSection> <HeroSection>
@ -152,7 +154,8 @@ const HomePage = () => {
sx={{ sx={{
fontWeight: 700, fontWeight: 700,
fontSize: { xs: '2rem', md: '3rem' }, fontSize: { xs: '2rem', md: '3rem' },
mb: 2 mb: 2,
color: "white"
}} }}
> >
Your complete professional story, beyond a single page Your complete professional story, beyond a single page
@ -452,6 +455,7 @@ const HomePage = () => {
</Box> </Box>
{/* Testimonials Section */} {/* Testimonials Section */}
{testimonials &&
<Container sx={{ py: 8 }}> <Container sx={{ py: 8 }}>
<Typography <Typography
variant="h3" variant="h3"
@ -472,6 +476,7 @@ const HomePage = () => {
<Testimonials /> <Testimonials />
</Container> </Container>
}
{/* CTA Section */} {/* CTA Section */}
<Box sx={{ <Box sx={{
@ -488,7 +493,7 @@ const HomePage = () => {
maxWidth: 800, maxWidth: 800,
mx: 'auto' mx: 'auto'
}}> }}>
<Typography variant="h3" component="h2" gutterBottom> <Typography variant="h3" component="h2" gutterBottom sx={{ color: "white" }}>
Ready to transform your hiring process? Ready to transform your hiring process?
</Typography> </Typography>
<Typography variant="h6" sx={{ mb: 4 }}> <Typography variant="h6" sx={{ mb: 4 }}>

View File

@ -24,7 +24,7 @@ import PhoneInput from 'react-phone-number-input';
import { E164Number } from 'libphonenumber-js/core'; import { E164Number } from 'libphonenumber-js/core';
import './LoginPage.css'; import './LoginPage.css';
import { ApiClient } from 'types/api-client'; import { ApiClient } from 'services/api-client';
import { useUser } from 'hooks/useUser'; import { useUser } from 'hooks/useUser';
// Import conversion utilities // Import conversion utilities

View File

@ -6,7 +6,7 @@
*/ */
// Import generated types (from running generate_types.py) // Import generated types (from running generate_types.py)
import * as Types from './types'; import * as Types from 'types/types';
import { import {
formatApiRequest, formatApiRequest,
// parseApiResponse, // parseApiResponse,
@ -19,7 +19,7 @@ import {
// ApiResponse, // ApiResponse,
PaginatedResponse, PaginatedResponse,
PaginatedRequest PaginatedRequest
} from './conversion'; } from 'types/conversion';
// ============================ // ============================
// Streaming Types and Interfaces // Streaming Types and Interfaces
@ -42,7 +42,37 @@ interface StreamingResponse {
} }
// ============================ // ============================
// Enhanced API Client Class // Chat Types and Interfaces
// ============================
export interface CandidateInfo {
id: string;
name: string;
email: string;
username: string;
skills: string[];
experience: number;
location: string;
}
export interface CreateChatSessionRequest {
username?: string; // Optional candidate username to associate with
context: Types.ChatContext;
title?: string;
}
export interface CandidateSessionsResponse {
candidate: {
id: string;
username: string;
fullName: string;
email: string;
};
sessions: PaginatedResponse<Types.ChatSession>;
}
// ============================
// API Client Class
// ============================ // ============================
class ApiClient { class ApiClient {
@ -291,9 +321,61 @@ class ApiClient {
} }
// ============================ // ============================
// Chat Methods (Enhanced with Streaming) // Chat Methods
// ============================ // ============================
/**
* Create a chat session with optional candidate association
*/
async createChatSessionWithCandidate(
request: CreateChatSessionRequest
): Promise<Types.ChatSession> {
const response = await fetch(`${this.baseUrl}/chat/sessions`, {
method: 'POST',
headers: this.defaultHeaders,
body: JSON.stringify(formatApiRequest(request))
});
return handleApiResponse<Types.ChatSession>(response);
}
/**
* Get all chat sessions related to a specific candidate
*/
async getCandidateChatSessions(
username: string,
request: Partial<PaginatedRequest> = {}
): Promise<CandidateSessionsResponse> {
const paginatedRequest = createPaginatedRequest(request);
const params = toUrlParams(formatApiRequest(paginatedRequest));
const response = await fetch(`${this.baseUrl}/candidates/${username}/chat-sessions?${params}`, {
headers: this.defaultHeaders
});
return handleApiResponse<CandidateSessionsResponse>(response);
}
/**
* Create a chat session about a specific candidate
*/
async createCandidateChatSession(
username: string,
chatType: Types.ChatContextType = 'candidate_chat',
title?: string
): Promise<Types.ChatSession> {
const request: CreateChatSessionRequest = {
username,
title: title || `Discussion about ${username}`,
context: {
type: chatType,
additionalContext: {}
}
};
return this.createChatSessionWithCandidate(request);
}
async createChatSession(context: Types.ChatContext): Promise<Types.ChatSession> { async createChatSession(context: Types.ChatContext): Promise<Types.ChatSession> {
const response = await fetch(`${this.baseUrl}/chat/sessions`, { const response = await fetch(`${this.baseUrl}/chat/sessions`, {
method: 'POST', method: 'POST',
@ -467,8 +549,16 @@ class ApiClient {
return [await this.sendMessage(sessionId, query)]; return [await this.sendMessage(sessionId, query)];
} }
async getChatMessages(sessionId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.ChatMessage>> { /**
const paginatedRequest = createPaginatedRequest(request); * Get persisted chat messages for a session
*/
async getChatMessages(
sessionId: string,
request: Partial<PaginatedRequest> = {}
): Promise<PaginatedResponse<Types.ChatMessage>> {
const paginatedRequest = createPaginatedRequest({
limit: 50, // Higher default for chat messages
...request});
const params = toUrlParams(formatApiRequest(paginatedRequest)); const params = toUrlParams(formatApiRequest(paginatedRequest));
const response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages?${params}`, { const response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages?${params}`, {

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models // Generated TypeScript types from Pydantic models
// Source: src/backend/models.py // Source: src/backend/models.py
// Generated on: 2025-05-29T21:15:06.572082 // Generated on: 2025-05-29T23:38:18.286927
// DO NOT EDIT MANUALLY - This file is auto-generated // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================
@ -13,7 +13,7 @@ export type ActivityType = "login" | "search" | "view_job" | "apply_job" | "mess
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn";
export type ChatContextType = "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile"; export type ChatContextType = "job_search" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile";
export type ChatMessageType = "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user"; export type ChatMessageType = "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user";
@ -224,7 +224,7 @@ export interface Certification {
} }
export interface ChatContext { export interface ChatContext {
type: "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile"; type: "job_search" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile";
relatedEntityId?: string; relatedEntityId?: string;
relatedEntityType?: "job" | "candidate" | "employer"; relatedEntityType?: "job" | "candidate" | "employer";
additionalContext?: Record<string, any>; additionalContext?: Record<string, any>;

View File

@ -342,22 +342,12 @@ class Agent(BaseModel, ABC):
self.metrics.tokens_eval.labels(agent=self.agent_type).inc(response.eval_count) self.metrics.tokens_eval.labels(agent=self.agent_type).inc(response.eval_count)
async def generate( async def generate(
self, llm: Any, model: str, query: ChatQuery, session_id: str, user_id: str, temperature=0.7 self, llm: Any, model: str, query: ChatQuery, user_message: ChatMessageUser, user_id: str, temperature=0.7
) -> AsyncGenerator[ChatMessage | ChatMessageBase, None]: ) -> AsyncGenerator[ChatMessage | ChatMessageBase, None]:
logger.info(f"{self.agent_type} - {inspect.stack()[0].function}") logger.info(f"{self.agent_type} - {inspect.stack()[0].function}")
user_message = ChatMessageUser(
session_id=session_id,
tunables=query.tunables,
type=ChatMessageType.USER,
status=ChatStatusType.DONE,
sender=ChatSenderType.USER,
content=query.prompt.strip(),
timestamp=datetime.now(UTC)
)
chat_message = ChatMessage( chat_message = ChatMessage(
session_id=session_id, session_id=user_message.session_id,
tunables=query.tunables, tunables=query.tunables,
status=ChatStatusType.INITIALIZING, status=ChatStatusType.INITIALIZING,
type=ChatMessageType.PREPARING, type=ChatMessageType.PREPARING,

View File

@ -0,0 +1,88 @@
from __future__ import annotations
from typing import Literal, AsyncGenerator, ClassVar, Optional, Any
from datetime import datetime
import inspect
from .base import Agent, agent_registry
from logger import logger
from .registry import agent_registry
from models import ( ChatQuery, ChatMessage, Tunables, ChatStatusType)
system_message = f"""
Launched on {datetime.now().isoformat()}.
When answering queries, follow these steps:
- First analyze the query to determine if real-time information from the tools might be helpful
- Even when <|context|> or <|resume|> is provided, consider whether the tools would provide more current or comprehensive information
- Use the provided tools whenever they would enhance your response, regardless of whether context is also available
- When presenting weather forecasts, include relevant emojis immediately before the corresponding text. For example, for a sunny day, say \"☀️ Sunny\" or if the forecast says there will be \"rain showers, say \"🌧️ Rain showers\". Use this mapping for weather emojis: Sunny: ☀️, Cloudy: ☁️, Rainy: 🌧️, Snowy: ❄️
- When any combination of <|context|>, <|resume|> and tool outputs are relevant, synthesize information from all sources to provide the most complete answer
- Always prioritize the most up-to-date and relevant information, whether it comes from <|context|>, <|resume|> or tools
- If <|context|> and tool outputs contain conflicting information, prefer the tool outputs as they likely represent more current data
- If there is information in the <|context|> or <|resume|> sections to enhance the answer, incorporate it seamlessly and refer to it as 'the latest information' or 'recent data' instead of mentioning '<|context|>' (etc.) or quoting it directly.
- Avoid phrases like 'According to the <|context|>' or similar references to the <|context|> or <|resume|>.
CRITICAL INSTRUCTIONS FOR IMAGE GENERATION:
1. When the user requests to generate an image, inject the following into the response: <GenerateImage prompt="USER-PROMPT"/>. Do this when users request images, drawings, or visual content.
3. MANDATORY: You must respond with EXACTLY this format: <GenerateImage prompt="{{USER-PROMPT}}"/>
4. FORBIDDEN: DO NOT use markdown image syntax ![](url)
5. FORBIDDEN: DO NOT create fake URLs or file paths
6. FORBIDDEN: DO NOT use any other image embedding format
CORRECT EXAMPLE:
User: "Draw a cat"
Your response: "<GenerateImage prompt='Draw a cat'/>"
WRONG EXAMPLES (DO NOT DO THIS):
- ![](https://example.com/...)
- ![Cat image](any_url)
- <img src="...">
The <GenerateImage prompt="{{USER-PROMPT}}"/> format is the ONLY way to display images in this system.
DO NOT make up a URL for an image or provide markdown syntax for embedding an image. Only use <GenerateImage prompt="{{USER-PROMPT}}".
Always use tools, <|resume|>, and <|context|> when possible. Be concise, and never make up information. If you do not know the answer, say so.
"""
class CandidateChat(Agent):
"""
CandidateChat Agent
"""
agent_type: Literal["candidate_chat"] = "candidate_chat" # type: ignore
_agent_type: ClassVar[str] = agent_type # Add this for registration
system_prompt: str = system_message
# async def prepare_message(self, message: Message) -> AsyncGenerator[Message, None]:
# logger.info(f"{self.agent_type} - {inspect.stack()[0].function}")
# if not self.context:
# raise ValueError("Context is not set for this agent.")
# async for message in super().prepare_message(message):
# if message.status != "done":
# yield message
# if message.preamble:
# excluded = {}
# preamble_types = [
# f"<|{p}|>" for p in message.preamble.keys() if p not in excluded
# ]
# preamble_types_AND = " and ".join(preamble_types)
# preamble_types_OR = " or ".join(preamble_types)
# message.preamble[
# "rules"
# ] = f"""\
# - Answer the question based on the information provided in the {preamble_types_AND} sections by incorporate it seamlessly and refer to it using natural language instead of mentioning {preamble_types_OR} or quoting it directly.
# - If there is no information in these sections, answer based on your knowledge, or use any available tools.
# - Avoid phrases like 'According to the {preamble_types[0]}' or similar references to the {preamble_types_OR}.
# """
# message.preamble["question"] = "Respond to:"
# Register the base agent
agent_registry.register(CandidateChat._agent_type, CandidateChat)

View File

@ -428,7 +428,216 @@ class RedisDatabase:
"""Delete all chat messages for a session""" """Delete all chat messages for a session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}" key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
await self.redis_client.delete(key) await self.redis_client.delete(key)
# Enhanced Chat Session Methods
async def get_chat_sessions_by_user(self, user_id: str) -> List[Dict]:
"""Get all chat sessions for a specific user"""
all_sessions = await self.get_all_chat_sessions()
user_sessions = []
for session_data in all_sessions.values():
if session_data.get("userId") == user_id or session_data.get("guestId") == user_id:
user_sessions.append(session_data)
# Sort by last activity (most recent first)
user_sessions.sort(key=lambda x: x.get("lastActivity", ""), reverse=True)
return user_sessions
async def get_chat_sessions_by_candidate(self, candidate_id: str) -> List[Dict]:
"""Get all chat sessions related to a specific candidate"""
all_sessions = await self.get_all_chat_sessions()
candidate_sessions = []
for session_data in all_sessions.values():
context = session_data.get("context", {})
if (context.get("relatedEntityType") == "candidate" and
context.get("relatedEntityId") == candidate_id):
candidate_sessions.append(session_data)
# Sort by last activity (most recent first)
candidate_sessions.sort(key=lambda x: x.get("lastActivity", ""), reverse=True)
return candidate_sessions
async def update_chat_session_activity(self, session_id: str):
"""Update the last activity timestamp for a chat session"""
session_data = await self.get_chat_session(session_id)
if session_data:
session_data["lastActivity"] = datetime.now(UTC).isoformat()
await self.set_chat_session(session_id, session_data)
async def get_recent_chat_messages(self, session_id: str, limit: int = 10) -> List[Dict]:
"""Get the most recent chat messages for a session"""
messages = await self.get_chat_messages(session_id)
# Return the last 'limit' messages
return messages[-limit:] if len(messages) > limit else messages
async def get_chat_message_count(self, session_id: str) -> int:
"""Get the total number of messages in a chat session"""
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
return await self.redis_client.llen(key)
async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]:
"""Search for messages containing specific text in a session"""
messages = await self.get_chat_messages(session_id)
query_lower = query.lower()
matching_messages = []
for msg in messages:
content = msg.get("content", "").lower()
if query_lower in content:
matching_messages.append(msg)
return matching_messages
# Chat Session Management
async def archive_chat_session(self, session_id: str):
"""Archive a chat session"""
session_data = await self.get_chat_session(session_id)
if session_data:
session_data["isArchived"] = True
session_data["updatedAt"] = datetime.now(UTC).isoformat()
await self.set_chat_session(session_id, session_data)
async def delete_chat_session_completely(self, session_id: str):
"""Delete a chat session and all its messages"""
# Delete the session
await self.delete_chat_session(session_id)
# Delete all messages
await self.delete_chat_messages(session_id)
async def cleanup_old_chat_sessions(self, days_old: int = 90):
"""Archive or delete chat sessions older than specified days"""
cutoff_date = datetime.now(UTC) - timedelta(days=days_old)
cutoff_iso = cutoff_date.isoformat()
all_sessions = await self.get_all_chat_sessions()
archived_count = 0
for session_id, session_data in all_sessions.items():
last_activity = session_data.get("lastActivity", session_data.get("createdAt", ""))
if last_activity < cutoff_iso and not session_data.get("isArchived", False):
await self.archive_chat_session(session_id)
archived_count += 1
return archived_count
# Enhanced User Operations
async def get_user_by_username(self, username: str) -> Optional[Dict]:
"""Get user by username specifically"""
username_key = f"{self.KEY_PREFIXES['users']}{username.lower()}"
data = await self.redis_client.get(username_key)
return self._deserialize(data) if data else None
async def find_candidate_by_username(self, username: str) -> Optional[Dict]:
"""Find candidate by username"""
all_candidates = await self.get_all_candidates()
username_lower = username.lower()
for candidate_data in all_candidates.values():
if candidate_data.get("username", "").lower() == username_lower:
return candidate_data
return None
# Analytics and Reporting
async def get_chat_statistics(self) -> Dict[str, Any]:
"""Get comprehensive chat statistics"""
all_sessions = await self.get_all_chat_sessions()
all_messages = await self.get_all_chat_messages()
stats = {
"total_sessions": len(all_sessions),
"total_messages": sum(len(messages) for messages in all_messages.values()),
"active_sessions": 0,
"archived_sessions": 0,
"sessions_by_type": {},
"sessions_with_candidates": 0,
"average_messages_per_session": 0
}
# Analyze sessions
for session_data in all_sessions.values():
if session_data.get("isArchived", False):
stats["archived_sessions"] += 1
else:
stats["active_sessions"] += 1
# Count by type
context_type = session_data.get("context", {}).get("type", "unknown")
stats["sessions_by_type"][context_type] = stats["sessions_by_type"].get(context_type, 0) + 1
# Count sessions with candidate association
if session_data.get("context", {}).get("relatedEntityType") == "candidate":
stats["sessions_with_candidates"] += 1
# Calculate averages
if stats["total_sessions"] > 0:
stats["average_messages_per_session"] = stats["total_messages"] / stats["total_sessions"]
return stats
async def get_candidate_chat_summary(self, candidate_id: str) -> Dict[str, Any]:
"""Get a summary of chat activity for a specific candidate"""
sessions = await self.get_chat_sessions_by_candidate(candidate_id)
if not sessions:
return {
"candidate_id": candidate_id,
"total_sessions": 0,
"total_messages": 0,
"first_chat": None,
"last_chat": None
}
total_messages = 0
for session in sessions:
session_id = session.get("id")
if session_id:
message_count = await self.get_chat_message_count(session_id)
total_messages += message_count
# Sort sessions by creation date
sessions_by_date = sorted(sessions, key=lambda x: x.get("createdAt", ""))
return {
"candidate_id": candidate_id,
"total_sessions": len(sessions),
"total_messages": total_messages,
"first_chat": sessions_by_date[0].get("createdAt") if sessions_by_date else None,
"last_chat": sessions_by_date[-1].get("lastActivity") if sessions_by_date else None,
"recent_sessions": sessions[:5] # Last 5 sessions
}
# Batch Operations
async def get_multiple_candidates_by_usernames(self, usernames: List[str]) -> Dict[str, Dict]:
"""Get multiple candidates by their usernames efficiently"""
all_candidates = await self.get_all_candidates()
username_set = {username.lower() for username in usernames}
result = {}
for candidate_data in all_candidates.values():
candidate_username = candidate_data.get("username", "").lower()
if candidate_username in username_set:
result[candidate_username] = candidate_data
return result
async def bulk_update_chat_sessions(self, session_updates: Dict[str, Dict]):
"""Bulk update multiple chat sessions"""
pipe = self.redis_client.pipeline()
for session_id, updates in session_updates.items():
session_data = await self.get_chat_session(session_id)
if session_data:
session_data.update(updates)
session_data["updatedAt"] = datetime.now(UTC).isoformat()
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
pipe.set(key, self._serialize(session_data))
await pipe.execute()
# AI Parameters operations # AI Parameters operations
async def get_ai_parameters(self, param_id: str) -> Optional[Dict]: async def get_ai_parameters(self, param_id: str) -> Optional[Dict]:
"""Get AI parameters by ID""" """Get AI parameters by ID"""

View File

@ -31,7 +31,7 @@ from models import (
Job, JobApplication, ApplicationStatus, Job, JobApplication, ApplicationStatus,
# Chat models # Chat models
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType,
# Supporting models # Supporting models
Location, Skill, WorkExperience, Education Location, Skill, WorkExperience, Education
@ -904,101 +904,182 @@ async def search_jobs(
# ============================ # ============================
# Chat Endpoints # Chat Endpoints
# ============================ # ============================
# Enhanced Chat Session Endpoints with Username Association
# Add these modifications to your main.py file
@api_router.get("/chat/statistics")
async def get_chat_statistics(
current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database)
):
"""Get chat statistics (admin/analytics endpoint)"""
try:
stats = await database.get_chat_statistics()
return create_success_response(stats)
except Exception as e:
logger.error(f"Get chat statistics error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("STATS_ERROR", str(e))
)
@api_router.get("/candidates/{username}/chat-summary")
async def get_candidate_chat_summary(
username: str = Path(...),
current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database)
):
"""Get chat activity summary for a candidate"""
try:
# Find candidate by username
candidate_data = await database.find_candidate_by_username(username)
if not candidate_data:
return JSONResponse(
status_code=404,
content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found")
)
summary = await database.get_candidate_chat_summary(candidate_data["id"])
summary["candidate"] = {
"username": candidate_data.get("username"),
"fullName": candidate_data.get("fullName"),
"email": candidate_data.get("email")
}
return create_success_response(summary)
except Exception as e:
logger.error(f"Get candidate chat summary error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("SUMMARY_ERROR", str(e))
)
@api_router.post("/chat/sessions/{session_id}/archive")
async def archive_chat_session(
session_id: str = Path(...),
current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database)
):
"""Archive a chat session"""
try:
session_data = await database.get_chat_session(session_id)
if not session_data:
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Chat session not found")
)
# Check if user owns this session or is admin
if session_data.get("userId") != current_user.id:
return JSONResponse(
status_code=403,
content=create_error_response("FORBIDDEN", "Cannot archive another user's session")
)
await database.archive_chat_session(session_id)
return create_success_response({
"message": "Chat session archived successfully",
"sessionId": session_id
})
except Exception as e:
logger.error(f"Archive chat session error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("ARCHIVE_ERROR", str(e))
)
# ============================
# Chat Endpoints (Enhanced)
# ============================
@api_router.post("/chat/sessions") @api_router.post("/chat/sessions")
async def create_chat_session( async def create_chat_session(
session_data: Dict[str, Any] = Body(...), session_data: Dict[str, Any] = Body(...),
current_user : BaseUserWithType = Depends(get_current_user), current_user: BaseUserWithType = Depends(get_current_user),
database: RedisDatabase = Depends(get_database) database: RedisDatabase = Depends(get_database)
): ):
"""Create a new chat session""" """Create a new chat session with optional candidate username association"""
try: try:
# Extract username if provided
username = session_data.get("username")
candidate_id = None
candidate_data = None
# If username is provided, look up the candidate
if username:
logger.info(f"🔍 Looking up candidate with username: {username}")
# Get all candidates and find by username
all_candidates_data = await database.get_all_candidates()
candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()]
# Find candidate by username (case-insensitive)
matching_candidates = [
c for c in candidates_list
if c.username.lower() == username.lower()
]
if not matching_candidates:
return JSONResponse(
status_code=404,
content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found")
)
candidate_data = matching_candidates[0]
candidate_id = candidate_data.id
logger.info(f"✅ Found candidate: {candidate_data.full_name} (ID: {candidate_id})")
# Add required fields # Add required fields
session_data["id"] = str(uuid.uuid4()) session_id = str(uuid.uuid4())
session_data["id"] = session_id
session_data["userId"] = current_user.id
session_data["createdAt"] = datetime.now(UTC).isoformat() session_data["createdAt"] = datetime.now(UTC).isoformat()
session_data["updatedAt"] = datetime.now(UTC).isoformat() session_data["lastActivity"] = datetime.now(UTC).isoformat()
# Set up context with candidate association if username was provided
context = session_data.get("context", {})
if candidate_id and candidate_data:
context["relatedEntityId"] = candidate_id
context["relatedEntityType"] = "candidate"
# Add candidate info to additional context for AI reference
additional_context = context.get("additionalContext", {})
additional_context["candidateInfo"] = {
"id": candidate_data.id,
"name": candidate_data.full_name,
"email": candidate_data.email,
"username": candidate_data.username,
"skills": [skill.name for skill in candidate_data.skills] if candidate_data.skills else [],
"experience": len(candidate_data.experience) if candidate_data.experience else 0,
"location": candidate_data.location.city if candidate_data.location else "Unknown"
}
context["additionalContext"] = additional_context
# Set a descriptive title if not provided
if not session_data.get("title"):
session_data["title"] = f"Chat about {candidate_data.full_name}"
session_data["context"] = context
# Create chat session # Create chat session
chat_session = ChatSession.model_validate(session_data) chat_session = ChatSession.model_validate(session_data)
await database.set_chat_session(chat_session.id, chat_session.model_dump()) await database.set_chat_session(chat_session.id, chat_session.model_dump())
logger.info(f"✅ Chat session created: {chat_session.id} for user {current_user.id}") logger.info(f"✅ Chat session created: {chat_session.id} for user {current_user.id}" +
(f" about candidate {candidate_data.full_name}" if candidate_data else ""))
return create_success_response(chat_session.model_dump(by_alias=True, exclude_unset=True)) return create_success_response(chat_session.model_dump(by_alias=True, exclude_unset=True))
except Exception as e: except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"Chat session creation error: {e}") logger.error(f"Chat session creation error: {e}")
logger.info(json.dumps(session_data, indent=2))
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content=create_error_response("CREATION_FAILED", str(e)) content=create_error_response("CREATION_FAILED", str(e))
) )
@api_router.get("/chat/sessions/{session_id}")
async def get_chat_session(
session_id: str = Path(...),
current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database)
):
"""Get a chat session by ID"""
try:
chat_session_data = await database.get_chat_session(session_id)
if not chat_session_data:
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Chat session not found")
)
chat_session = ChatSession.model_validate(chat_session_data)
return create_success_response(chat_session.model_dump(by_alias=True, exclude_unset=True))
except Exception as e:
logger.error(f"Get chat session error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("FETCH_ERROR", str(e))
)
@api_router.get("/chat/sessions/{session_id}/messages")
async def get_chat_session_messages(
session_id: str = Path(...),
current_user = Depends(get_current_user),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
sortBy: Optional[str] = Query(None, alias="sortBy"),
sortOrder: str = Query("desc", pattern="^(asc|desc)$", alias="sortOrder"),
filters: Optional[str] = Query(None),
database: RedisDatabase = Depends(get_database)
):
"""Get a chat session by ID"""
try:
chat_session_data = await database.get_chat_session(session_id)
if not chat_session_data:
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Chat session not found")
)
chat_messages = await database.get_chat_messages(session_id)
# Convert messages to ChatMessage objects
messages_list = [ChatMessage.model_validate(msg) for msg in chat_messages]
# Apply filters and pagination
filter_dict = None
if filters:
filter_dict = json.loads(filters)
paginated_messages, total = filter_and_paginate(
messages_list, page, limit, sortBy, sortOrder, filter_dict
)
paginated_response = create_paginated_response(
[m.model_dump(by_alias=True, exclude_unset=True) for m in paginated_messages],
page, limit, total
)
return create_success_response(paginated_response)
except Exception as e:
logger.error(f"Get chat session error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("FETCH_ERROR", str(e))
)
@api_router.post("/chat/sessions/{session_id}/messages/stream") @api_router.post("/chat/sessions/{session_id}/messages/stream")
async def post_chat_session_message_stream( async def post_chat_session_message_stream(
@ -1006,9 +1087,9 @@ async def post_chat_session_message_stream(
data: Dict[str, Any] = Body(...), data: Dict[str, Any] = Body(...),
current_user = Depends(get_current_user), current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database), database: RedisDatabase = Depends(get_database),
request: Request = Request, # For streaming response request: Request = Request,
): ):
"""Post a message to a chat session and stream the response""" """Post a message to a chat session and stream the response with persistence"""
try: try:
chat_session_data = await database.get_chat_session(session_id) chat_session_data = await database.get_chat_session(session_id)
if not chat_session_data: if not chat_session_data:
@ -1018,43 +1099,95 @@ async def post_chat_session_message_stream(
) )
chat_type = chat_session_data.get("context", {}).get("type", "general") chat_type = chat_session_data.get("context", {}).get("type", "general")
# Get candidate info if this chat is about a specific candidate
candidate_info = chat_session_data.get("context", {}).get("additionalContext", {}).get("candidateInfo")
if candidate_info:
logger.info(f"🔗 Chat session {session_id} about candidate {candidate_info['name']} accessed by user {current_user.id}")
else:
logger.info(f"🔗 Chat session {session_id} type {chat_type} accessed by user {current_user.id}")
logger.info(f"🔗 Chat session {session_id} type {chat_type} accessed by user {current_user.id}")
query = data.get("query") query = data.get("query")
if not query: if not query:
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content=create_error_response("INVALID_QUERY", "Query cannot be empty") content=create_error_response("INVALID_QUERY", "Query cannot be empty")
) )
chat_query = ChatQuery.model_validate(query) chat_query = ChatQuery.model_validate(query)
chat_agent = agents.get_or_create_agent(agent_type=chat_type, prometheus_collector=prometheus_collector, database=database) chat_agent = agents.get_or_create_agent(
agent_type=chat_type,
prometheus_collector=prometheus_collector,
database=database
)
if not chat_agent: if not chat_agent:
return JSONResponse( return JSONResponse(
status_code=400, status_code=400,
content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type") content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type")
) )
# Store the user's message first
user_message = ChatMessageUser(
session_id=session_id,
type=ChatMessageType.USER,
status=ChatStatusType.DONE,
sender=ChatSenderType.USER,
content=chat_query.prompt,
timestamp=datetime.now(UTC)
)
# Persist user message to database
await database.add_chat_message(session_id, user_message.model_dump())
logger.info(f"💬 User message saved to database for session {session_id}")
# Update session last activity
chat_session_data["lastActivity"] = datetime.now(UTC).isoformat()
await database.set_chat_session(session_id, chat_session_data)
async def message_stream_generator(): async def message_stream_generator():
"""Generator to stream messages""" """Generator to stream messages with persistence"""
last_log = None last_log = None
ai_message = None
async for chat_message in chat_agent.generate( async for chat_message in chat_agent.generate(
llm=llm_manager.get_llm(), llm=llm_manager.get_llm(),
model=defines.model, model=defines.model,
query=chat_query, query=chat_query,
session_id=session_id, user_message=user_message,
user_id=current_user.id, user_id=current_user.id,
): ):
# Store reference to the complete AI message
if chat_message.status == ChatStatusType.DONE:
ai_message = chat_message
# If the message is not done, convert it to a ChatMessageBase to remove # If the message is not done, convert it to a ChatMessageBase to remove
# metadata and other unnecessary fields # metadata and other unnecessary fields for streaming
if chat_message.status != ChatStatusType.DONE: if chat_message.status != ChatStatusType.DONE:
chat_message = model_cast.cast_to_model(ChatMessageBase, chat_message) chat_message = model_cast.cast_to_model(ChatMessageBase, chat_message)
json_data = chat_message.model_dump(mode='json', by_alias=True, exclude_unset=True) json_data = chat_message.model_dump(mode='json', by_alias=True, exclude_unset=True)
json_str = json.dumps(json_data) json_str = json.dumps(json_data)
log = f"🔗 Message status={chat_message.status}, type={chat_message.type}"
log = f"🔗 Message status={chat_message.status}, sender={getattr(chat_message, 'sender', 'unknown')}"
if last_log != log: if last_log != log:
last_log = log last_log = log
logger.info(log) logger.info(log)
yield f"data: {json_str}\n\n" yield f"data: {json_str}\n\n"
# After streaming is complete, persist the final AI message to database
if ai_message and ai_message.status == ChatStatusType.DONE:
try:
await database.add_chat_message(session_id, ai_message.model_dump())
logger.info(f"🤖 AI message saved to database for session {session_id}")
# Update session last activity again
chat_session_data["lastActivity"] = datetime.now(UTC).isoformat()
await database.set_chat_session(session_id, chat_session_data)
except Exception as e:
logger.error(f"Failed to save AI message to database: {e}")
return StreamingResponse( return StreamingResponse(
message_stream_generator(), message_stream_generator(),
@ -1062,58 +1195,148 @@ async def post_chat_session_message_stream(
headers={ headers={
"Cache-Control": "no-cache", "Cache-Control": "no-cache",
"Connection": "keep-alive", "Connection": "keep-alive",
#"Access-Control-Allow-Origin": "*", # CORS "X-Accel-Buffering": "no",
"X-Accel-Buffering": "no", # Prevents Nginx buffering if you're using it
}, },
) )
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
logger.error(f"Get chat session error: {e}") logger.error(f"Chat message streaming error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("STREAMING_ERROR", str(e))
)
@api_router.get("/chat/sessions/{session_id}/messages")
async def get_chat_session_messages(
session_id: str = Path(...),
current_user = Depends(get_current_user),
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=100), # Increased default for chat messages
database: RedisDatabase = Depends(get_database)
):
"""Get persisted chat messages for a session"""
try:
chat_session_data = await database.get_chat_session(session_id)
if not chat_session_data:
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Chat session not found")
)
# Get messages from database
chat_messages = await database.get_chat_messages(session_id)
# Convert to ChatMessage objects and sort by timestamp
messages_list = []
for msg_data in chat_messages:
try:
message = ChatMessage.model_validate(msg_data)
messages_list.append(message)
except Exception as e:
logger.warning(f"Failed to validate message: {e}")
continue
# Sort by timestamp (oldest first for chat history)
messages_list.sort(key=lambda x: x.timestamp)
# Apply pagination
total = len(messages_list)
start = (page - 1) * limit
end = start + limit
paginated_messages = messages_list[start:end]
paginated_response = create_paginated_response(
[m.model_dump(by_alias=True, exclude_unset=True) for m in paginated_messages],
page, limit, total
)
return create_success_response(paginated_response)
except Exception as e:
logger.error(f"Get chat messages error: {e}")
return JSONResponse( return JSONResponse(
status_code=500, status_code=500,
content=create_error_response("FETCH_ERROR", str(e)) content=create_error_response("FETCH_ERROR", str(e))
) )
@api_router.get("/candidates/{username}/chat-sessions")
@api_router.get("/chat/sessions") async def get_candidate_chat_sessions(
async def get_chat_sessions( username: str = Path(...),
current_user = Depends(get_current_user),
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100), limit: int = Query(20, ge=1, le=100),
sortBy: Optional[str] = Query(None, alias="sortBy"),
sortOrder: str = Query("desc", pattern="^(asc|desc)$", alias="sortOrder"),
filters: Optional[str] = Query(None),
current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database) database: RedisDatabase = Depends(get_database)
): ):
"""Get paginated list of chat sessions""" """Get all chat sessions related to a specific candidate"""
try: try:
filter_dict = None # Find candidate by username
if filters: all_candidates_data = await database.get_all_candidates()
filter_dict = json.loads(filters) candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()]
# Get all chat sessions from Redis matching_candidates = [
c for c in candidates_list
if c.username.lower() == username.lower()
]
if not matching_candidates:
return JSONResponse(
status_code=404,
content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found")
)
candidate = matching_candidates[0]
# Get all chat sessions
all_sessions_data = await database.get_all_chat_sessions() all_sessions_data = await database.get_all_chat_sessions()
sessions_list = [ChatSession.model_validate(data) for data in all_sessions_data.values()] sessions_list = []
paginated_sessions, total = filter_and_paginate( for index, session_data in enumerate(all_sessions_data.values()):
sessions_list, page, limit, sortBy, sortOrder, filter_dict try:
) session = ChatSession.model_validate(session_data)
# Check if this session is related to the candidate
context = session.context
if (context and
context.related_entity_type == "candidate" and
context.related_entity_id == candidate.id):
sessions_list.append(session)
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"Failed to validate session ({index}): {e}")
logger.error(f"Session data: {session_data}")
continue
# Sort by last activity (most recent first)
sessions_list.sort(key=lambda x: x.last_activity, reverse=True)
# Apply pagination
total = len(sessions_list)
start = (page - 1) * limit
end = start + limit
paginated_sessions = sessions_list[start:end]
paginated_response = create_paginated_response( paginated_response = create_paginated_response(
[s.model_dump(by_alias=True, exclude_unset=True) for s in paginated_sessions], [s.model_dump(by_alias=True, exclude_unset=True) for s in paginated_sessions],
page, limit, total page, limit, total
) )
return create_success_response(paginated_response) return create_success_response({
"candidate": {
"id": candidate.id,
"username": candidate.username,
"fullName": candidate.full_name,
"email": candidate.email
},
"sessions": paginated_response
})
except Exception as e: except Exception as e:
logger.error(f"Get chat sessions error: {e}") logger.error(f"Get candidate chat sessions error: {e}")
return JSONResponse( return JSONResponse(
status_code=400, status_code=500,
content=create_error_response("FETCH_FAILED", str(e)) content=create_error_response("FETCH_ERROR", str(e))
) )
# ============================ # ============================
# Health Check and Info Endpoints # Health Check and Info Endpoints
# ============================ # ============================

View File

@ -1,5 +1,5 @@
from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated
from pydantic import BaseModel, Field, EmailStr, HttpUrl, validator # type: ignore from pydantic import BaseModel, Field, EmailStr, HttpUrl, model_validator # type: ignore
from pydantic.types import constr, conint # type: ignore from pydantic.types import constr, conint # type: ignore
from datetime import datetime, date, UTC from datetime import datetime, date, UTC
from enum import Enum from enum import Enum
@ -88,7 +88,7 @@ class ChatStatusType(str, Enum):
class ChatContextType(str, Enum): class ChatContextType(str, Enum):
JOB_SEARCH = "job_search" JOB_SEARCH = "job_search"
CANDIDATE_SCREENING = "candidate_screening" CANDIDATE_CHAT = "candidate_chat"
INTERVIEW_PREP = "interview_prep" INTERVIEW_PREP = "interview_prep"
RESUME_REVIEW = "resume_review" RESUME_REVIEW = "resume_review"
GENERAL = "general" GENERAL = "general"
@ -373,9 +373,10 @@ class BaseUser(BaseModel):
profile_image: Optional[str] = Field(None, alias="profileImage") profile_image: Optional[str] = Field(None, alias="profileImage")
status: UserStatus status: UserStatus
class Config: model_config = {
use_enum_values = True "populate_by_name": True, # Allow both field names and aliases
populate_by_name = True # Allow both field names and aliases "use_enum_values": True # Use enum values instead of names
}
# Generic base user with user_type for API responses # Generic base user with user_type for API responses
class BaseUserWithType(BaseUser): class BaseUserWithType(BaseUser):
@ -429,8 +430,9 @@ class Guest(BaseModel):
converted_to_user_id: Optional[str] = Field(None, alias="convertedToUserId") converted_to_user_id: Optional[str] = Field(None, alias="convertedToUserId")
ip_address: Optional[str] = Field(None, alias="ipAddress") ip_address: Optional[str] = Field(None, alias="ipAddress")
user_agent: Optional[str] = Field(None, alias="userAgent") user_agent: Optional[str] = Field(None, alias="userAgent")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class Authentication(BaseModel): class Authentication(BaseModel):
user_id: str = Field(..., alias="userId") user_id: str = Field(..., alias="userId")
@ -445,16 +447,18 @@ class Authentication(BaseModel):
mfa_secret: Optional[str] = Field(None, alias="mfaSecret") mfa_secret: Optional[str] = Field(None, alias="mfaSecret")
login_attempts: int = Field(..., alias="loginAttempts") login_attempts: int = Field(..., alias="loginAttempts")
locked_until: Optional[datetime] = Field(None, alias="lockedUntil") locked_until: Optional[datetime] = Field(None, alias="lockedUntil")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class AuthResponse(BaseModel): class AuthResponse(BaseModel):
access_token: str = Field(..., alias="accessToken") access_token: str = Field(..., alias="accessToken")
refresh_token: str = Field(..., alias="refreshToken") refresh_token: str = Field(..., alias="refreshToken")
user: Candidate | Employer user: Candidate | Employer
expires_at: int = Field(..., alias="expiresAt") expires_at: int = Field(..., alias="expiresAt")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class Job(BaseModel): class Job(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -478,8 +482,9 @@ class Job(BaseModel):
featured_until: Optional[datetime] = Field(None, alias="featuredUntil") featured_until: Optional[datetime] = Field(None, alias="featuredUntil")
views: int = 0 views: int = 0
application_count: int = Field(0, alias="applicationCount") application_count: int = Field(0, alias="applicationCount")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class InterviewFeedback(BaseModel): class InterviewFeedback(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -496,8 +501,9 @@ class InterviewFeedback(BaseModel):
updated_at: datetime = Field(..., alias="updatedAt") updated_at: datetime = Field(..., alias="updatedAt")
is_visible: bool = Field(..., alias="isVisible") is_visible: bool = Field(..., alias="isVisible")
skill_assessments: Optional[List[SkillAssessment]] = Field(None, alias="skillAssessments") skill_assessments: Optional[List[SkillAssessment]] = Field(None, alias="skillAssessments")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class InterviewSchedule(BaseModel): class InterviewSchedule(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -511,8 +517,9 @@ class InterviewSchedule(BaseModel):
feedback: Optional[InterviewFeedback] = None feedback: Optional[InterviewFeedback] = None
status: Literal["scheduled", "completed", "cancelled", "rescheduled"] status: Literal["scheduled", "completed", "cancelled", "rescheduled"]
meeting_link: Optional[HttpUrl] = Field(None, alias="meetingLink") meeting_link: Optional[HttpUrl] = Field(None, alias="meetingLink")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class JobApplication(BaseModel): class JobApplication(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -528,8 +535,9 @@ class JobApplication(BaseModel):
custom_questions: Optional[List[CustomQuestion]] = Field(None, alias="customQuestions") custom_questions: Optional[List[CustomQuestion]] = Field(None, alias="customQuestions")
candidate_contact: Optional[CandidateContact] = Field(None, alias="candidateContact") candidate_contact: Optional[CandidateContact] = Field(None, alias="candidateContact")
decision: Optional[ApplicationDecision] = None decision: Optional[ApplicationDecision] = None
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class RagEntry(BaseModel): class RagEntry(BaseModel):
name: str name: str
@ -555,8 +563,9 @@ class ChatContext(BaseModel):
related_entity_id: Optional[str] = Field(None, alias="relatedEntityId") related_entity_id: Optional[str] = Field(None, alias="relatedEntityId")
related_entity_type: Optional[Literal["job", "candidate", "employer"]] = Field(None, alias="relatedEntityType") related_entity_type: Optional[Literal["job", "candidate", "employer"]] = Field(None, alias="relatedEntityType")
additional_context: Optional[Dict[str, Any]] = Field(None, alias="additionalContext") additional_context: Optional[Dict[str, Any]] = Field(None, alias="additionalContext")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class ChatOptions(BaseModel): class ChatOptions(BaseModel):
seed: Optional[int] = 8911 seed: Optional[int] = 8911
@ -580,8 +589,9 @@ class ChatMessageMetaData(BaseModel):
options: Optional[ChatOptions] = None options: Optional[ChatOptions] = None
tools: Optional[Dict[str, Any]] = None tools: Optional[Dict[str, Any]] = None
timers: Optional[Dict[str, float]] = None timers: Optional[Dict[str, float]] = None
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class ChatMessageBase(BaseModel): class ChatMessageBase(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -592,8 +602,9 @@ class ChatMessageBase(BaseModel):
sender: ChatSenderType sender: ChatSenderType
timestamp: datetime timestamp: datetime
content: str = "" content: str = ""
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class ChatMessageUser(ChatMessageBase): class ChatMessageUser(ChatMessageBase):
type: ChatMessageType = ChatMessageType.USER type: ChatMessageType = ChatMessageType.USER
@ -616,19 +627,15 @@ class ChatSession(BaseModel):
messages: Optional[List[ChatMessage]] = None messages: Optional[List[ChatMessage]] = None
is_archived: bool = Field(False, alias="isArchived") is_archived: bool = Field(False, alias="isArchived")
system_prompt: Optional[str] = Field(None, alias="systemPrompt") system_prompt: Optional[str] = Field(None, alias="systemPrompt")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
@validator('user_id', 'guest_id') @model_validator(mode="after")
def validate_user_or_guest(cls, v, values, **kwargs): def check_user_or_guest(self) -> "ChatSession":
field = kwargs.get('field') if not self.user_id and not self.guest_id:
if not field: raise ValueError("Either user_id or guest_id must be provided")
raise ValueError('field must be provided') return self
if field.name == 'user_id' and 'guest_id' in values and not v and not values['guest_id']:
raise ValueError('Either user_id or guest_id must be provided')
if field.name == 'guest_id' and 'user_id' in values and not v and not values['user_id']:
raise ValueError('Either user_id or guest_id must be provided')
return v
class DataSourceConfiguration(BaseModel): class DataSourceConfiguration(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -642,8 +649,9 @@ class DataSourceConfiguration(BaseModel):
status: Literal["active", "pending", "error", "processing"] status: Literal["active", "pending", "error", "processing"]
error_details: Optional[str] = Field(None, alias="errorDetails") error_details: Optional[str] = Field(None, alias="errorDetails")
metadata: Optional[Dict[str, Any]] = None metadata: Optional[Dict[str, Any]] = None
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class RAGConfiguration(BaseModel): class RAGConfiguration(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -658,8 +666,9 @@ class RAGConfiguration(BaseModel):
updated_at: datetime = Field(..., alias="updatedAt") updated_at: datetime = Field(..., alias="updatedAt")
version: int version: int
is_active: bool = Field(..., alias="isActive") is_active: bool = Field(..., alias="isActive")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class UserActivity(BaseModel): class UserActivity(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -671,19 +680,15 @@ class UserActivity(BaseModel):
ip_address: Optional[str] = Field(None, alias="ipAddress") ip_address: Optional[str] = Field(None, alias="ipAddress")
user_agent: Optional[str] = Field(None, alias="userAgent") user_agent: Optional[str] = Field(None, alias="userAgent")
session_id: Optional[str] = Field(None, alias="sessionId") session_id: Optional[str] = Field(None, alias="sessionId")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
@validator('user_id', 'guest_id') @model_validator(mode="after")
def validate_user_or_guest(cls, v, values, **kwargs): def check_user_or_guest(self) -> "ChatSession":
field = kwargs.get('field') if not self.user_id and not self.guest_id:
if not field: raise ValueError("Either user_id or guest_id must be provided")
raise ValueError('field must be provided') return self
if field.name == 'user_id' and 'guest_id' in values and not v and not values['guest_id']:
raise ValueError('Either user_id or guest_id must be provided')
if field.name == 'guest_id' and 'user_id' in values and not v and not values['user_id']:
raise ValueError('Either user_id or guest_id must be provided')
return v
class Analytics(BaseModel): class Analytics(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -694,8 +699,9 @@ class Analytics(BaseModel):
timestamp: datetime timestamp: datetime
dimensions: Optional[Dict[str, Any]] = None dimensions: Optional[Dict[str, Any]] = None
segment: Optional[str] = None segment: Optional[str] = None
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class UserPreference(BaseModel): class UserPreference(BaseModel):
user_id: str = Field(..., alias="userId") user_id: str = Field(..., alias="userId")
@ -706,8 +712,9 @@ class UserPreference(BaseModel):
language: str language: str
timezone: str timezone: str
email_frequency: Literal["immediate", "daily", "weekly", "never"] = Field(..., alias="emailFrequency") email_frequency: Literal["immediate", "daily", "weekly", "never"] = Field(..., alias="emailFrequency")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
# ============================ # ============================
# API Request/Response Models # API Request/Response Models
@ -716,8 +723,9 @@ class ChatQuery(BaseModel):
prompt: str prompt: str
tunables: Optional[Tunables] = None tunables: Optional[Tunables] = None
agent_options: Optional[Dict[str, Any]] = Field(None, alias="agentOptions") agent_options: Optional[Dict[str, Any]] = Field(None, alias="agentOptions")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class PaginatedRequest(BaseModel): class PaginatedRequest(BaseModel):
page: Annotated[int, Field(ge=1)] = 1 page: Annotated[int, Field(ge=1)] = 1
@ -725,8 +733,9 @@ class PaginatedRequest(BaseModel):
sort_by: Optional[str] = Field(None, alias="sortBy") sort_by: Optional[str] = Field(None, alias="sortBy")
sort_order: Optional[SortOrder] = Field(None, alias="sortOrder") sort_order: Optional[SortOrder] = Field(None, alias="sortOrder")
filters: Optional[Dict[str, Any]] = None filters: Optional[Dict[str, Any]] = None
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class SearchQuery(BaseModel): class SearchQuery(BaseModel):
query: str query: str
@ -735,8 +744,9 @@ class SearchQuery(BaseModel):
limit: Annotated[int, Field(ge=1, le=100)] = 20 limit: Annotated[int, Field(ge=1, le=100)] = 20
sort_by: Optional[str] = Field(None, alias="sortBy") sort_by: Optional[str] = Field(None, alias="sortBy")
sort_order: Optional[SortOrder] = Field(None, alias="sortOrder") sort_order: Optional[SortOrder] = Field(None, alias="sortOrder")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class PaginatedResponse(BaseModel): class PaginatedResponse(BaseModel):
data: List[Any] # Will be typed specifically when used data: List[Any] # Will be typed specifically when used
@ -745,8 +755,9 @@ class PaginatedResponse(BaseModel):
limit: int limit: int
total_pages: int = Field(..., alias="totalPages") total_pages: int = Field(..., alias="totalPages")
has_more: bool = Field(..., alias="hasMore") has_more: bool = Field(..., alias="hasMore")
class Config: model_config = {
populate_by_name = True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
}
class ApiResponse(BaseModel): class ApiResponse(BaseModel):
success: bool success: bool