Chat working with multiple users

This commit is contained in:
James Ketr 2025-05-29 16:43:57 -07:00
parent 27d9ab467a
commit c2601bf17a
17 changed files with 1134 additions and 692 deletions

View File

@ -10,6 +10,7 @@ import { useMediaQuery } from '@mui/material';
import { useUser } from "../hooks/useUser";
import { Candidate } from '../types/types';
import { CopyBubble } from "./CopyBubble";
import { rest } from 'lodash';
interface CandidateInfoProps {
candidate: Candidate;
@ -22,6 +23,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
const {
sx,
action = '',
...rest
} = props;
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
@ -40,6 +42,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
transition: 'all 0.3s ease',
...sx
}}
{...rest}
>
<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 { connectionBase } from 'utils/Global';
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 { PaginatedResponse } from 'types/conversion';
@ -260,7 +260,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
);
controllerRef.current = apiClient.sendMessageStream(sessionId, query, {
onMessage: (msg) => {
onMessage: (msg: ChatMessageBase) => {
console.log("onMessage:", msg);
if (msg.type === "response") {
setConversation([
@ -288,11 +288,11 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
setProcessingMessage({ ...defaultMessage, content: error as string });
}
},
onStreaming: (chunk) => {
onStreaming: (chunk: ChatMessageBase) => {
console.log("onStreaming:", chunk);
setStreamingMessage({ ...defaultMessage, ...chunk });
},
onStatusChange: (status) => {
onStatusChange: (status: string) => {
console.log("onStatusChange:", status);
},
onComplete: () => {

View File

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

View File

@ -1,486 +0,0 @@
import React, { 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';
// Mock API client (replace with your actual implementation)
const mockApiClient = {
async createCandidateChatSession(username: string, chatType: any, aiParameters: any, title: any) {
return {
id: `session-${Date.now()}`,
title: title || `Chat about ${username}`,
userId: 'current-user-id',
createdAt: new Date().toISOString(),
lastActivity: new Date().toISOString(),
context: {
type: chatType,
relatedEntityId: `candidate-${username}`,
relatedEntityType: 'candidate',
aiParameters,
additionalContext: {
candidateInfo: {
id: `candidate-${username}`,
name: `${username} Candidate`,
email: `${username}@example.com`,
username: username,
skills: ['JavaScript', 'React', 'Python'],
experience: 3,
location: 'San Francisco'
}
}
}
};
},
async getCandidateChatSessions(username: string) {
return {
candidate: {
id: `candidate-${username}`,
username: username,
fullName: `${username} Candidate`,
email: `${username}@example.com`
},
sessions: {
data: [
{
id: 'session-1',
title: `Previous chat about ${username}`,
lastActivity: new Date(Date.now() - 86400000).toISOString(),
context: { type: 'candidate_screening' }
}
],
total: 1,
page: 1,
limit: 20,
totalPages: 1,
hasMore: false
}
};
},
async getChatMessages(sessionId: any) {
return {
data: [
{
id: 'msg-1',
sessionId,
sender: 'user',
content: 'Tell me about this candidate',
timestamp: new Date(Date.now() - 60000).toISOString(),
status: 'done'
},
{
id: 'msg-2',
sessionId,
sender: 'ai',
content: 'This candidate has strong technical skills in JavaScript and React...',
timestamp: new Date(Date.now() - 30000).toISOString(),
status: 'done'
}
],
total: 2,
page: 1,
limit: 50,
totalPages: 1,
hasMore: false
};
},
async sendMessageStream(sessionId: any, query: any, onMessage: any, onError: any, onComplete: any) {
// Simulate user message
const userMessage = {
id: `msg-user-${Date.now()}`,
sessionId,
sender: 'user',
content: typeof query === 'string' ? query : query.prompt,
timestamp: new Date().toISOString(),
status: 'done'
};
onMessage(userMessage);
// Simulate AI response with streaming
setTimeout(() => {
const aiMessage = {
id: `msg-ai-${Date.now()}`,
sessionId,
sender: 'ai',
content: 'This is a simulated AI response to your question...',
timestamp: new Date().toISOString(),
status: 'done'
};
onMessage(aiMessage);
onComplete();
}, 1000);
}
};
const CandidateChatSystem = () => {
const [username, setUsername] = useState('johndoe');
const [sessions, setSessions] = useState<any>(null);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const messagesEndRef = useRef(null);
const aiParameters = {
name: 'Chat Assistant',
model: 'gpt-4',
temperature: 0.7,
maxTokens: 2000,
topP: 0.95,
frequencyPenalty: 0.0,
presencePenalty: 0.0,
isDefault: false,
createdAt: new Date(),
updatedAt: new Date()
};
// Load sessions for the candidate
const loadSessions = async () => {
if (!username) return;
try {
setLoading(true);
const result = await mockApiClient.getCandidateChatSessions(username);
setSessions(result as any);
} catch (error) {
console.error('Failed to load sessions:', error);
} finally {
setLoading(false);
}
};
// Load messages for current session
const loadMessages = async () => {
if (!currentSessionId) return;
try {
const result = await mockApiClient.getChatMessages(currentSessionId);
setMessages(result.data as any);
} catch (error) {
console.error('Failed to load messages:', error);
}
};
// Create new session
const createNewSession = async () => {
try {
setLoading(true);
const newSession = await mockApiClient.createCandidateChatSession(
username,
'candidate_screening',
aiParameters,
`Interview Discussion - ${username}`
);
setCurrentSessionId(newSession.id as any);
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() || !currentSessionId || streaming) return;
const messageContent = newMessage;
setNewMessage('');
setStreaming(true);
try {
await mockApiClient.sendMessageStream(
currentSessionId,
{ prompt: messageContent },
(message : any) => {
setMessages(prev => {
const filtered = prev.filter((m : any)=> m.id !== message.id);
return [...filtered, message].sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
) as any;
});
},
(error: any) => {
console.error('Streaming error:', error);
setStreaming(false);
},
() => {
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();
}, [username]);
// Load messages when session changes
useEffect(() => {
if (currentSessionId) {
loadMessages();
}
}, [currentSessionId]);
return (
<Box sx={{ p: 3, maxWidth: 1200, mx: 'auto' }}>
<Typography variant="h4" gutterBottom>
Candidate Chat System
</Typography>
{/* Username Input */}
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Candidate Username"
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
loadSessions();
}
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Button
variant="contained"
onClick={loadSessions}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : null}
>
Load Sessions
</Button>
</Grid>
</Grid>
</Paper>
<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 || !username}
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={currentSessionId === session.id}
onClick={() => setCurrentSessionId(session.id)}
sx={{
mb: 1,
borderRadius: 1,
border: '1px solid',
borderColor: currentSessionId === 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' }}>
{currentSessionId ? (
<>
{/* Messages Area */}
<Box sx={{ flexGrow: 1, overflow: 'auto', p: 2 }}>
{messages.map((message: any) => (
<Box
key={message.id}
sx={{
display: 'flex',
mb: 2,
alignItems: 'flex-start',
justifyContent: message.sender === 'user' ? 'flex-end' : 'flex-start'
}}
>
{message.sender === 'ai' && (
<Avatar sx={{ mr: 1, bgcolor: 'primary.main' }}>
🤖
</Avatar>
)}
<Card
sx={{
maxWidth: '70%',
bgcolor: message.sender === 'user' ? 'primary.main' : 'background.paper',
color: message.sender === 'user' ? 'primary.contrastText' : 'text.primary'
}}
>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Typography variant="body2">
{message.content}
</Typography>
<Typography variant="caption" sx={{ opacity: 0.7, mt: 1, display: 'block' }}>
{new Date(message.timestamp).toLocaleTimeString()}
</Typography>
</CardContent>
</Card>
{message.sender === 'user' && (
<Avatar sx={{ ml: 1, bgcolor: 'secondary.main' }}>
👤
</Avatar>
)}
</Box>
))}
{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 { CandidateChatSystem };

View File

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

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

@ -39,7 +39,6 @@ import { BackstoryAppAnalysisPage } from 'documents/BackstoryAppAnalysisPage';
import { BackstoryThemeVisualizerPage } from 'documents/BackstoryThemeVisualizerPage';
import { UserManagement } from 'documents/UserManagement';
import { MockupPage } from 'documents/MockupPage';
import { CandidateChatSystem } from 'documents/CandidateChatSystem';
// Sidebar navigation component using MUI components
const Sidebar: React.FC<{
@ -257,8 +256,6 @@ const DocsPage = (props: BackstoryPageProps) => {
// Render the appropriate content based on current page
function renderContent() {
switch (page) {
case 'mockup-chat-system':
return (<CandidateChatSystem />);
case 'ui-overview':
return (<BackstoryUIOverviewPage />);
case 'theme-visualizer':

View File

@ -18,7 +18,7 @@ import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryT
import { StyledMarkdown } from 'components/StyledMarkdown';
import { Scrollable } from '../components/Scrollable';
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 { useUser } from 'hooks/useUser';

View File

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

View File

@ -6,7 +6,7 @@
*/
// Import generated types (from running generate_types.py)
import * as Types from './types';
import * as Types from 'types/types';
import {
formatApiRequest,
// parseApiResponse,
@ -19,7 +19,7 @@ import {
// ApiResponse,
PaginatedResponse,
PaginatedRequest
} from './conversion';
} from 'types/conversion';
// ============================
// 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 {
@ -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> {
const response = await fetch(`${this.baseUrl}/chat/sessions`, {
method: 'POST',
@ -467,8 +549,16 @@ class ApiClient {
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 response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages?${params}`, {

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models
// 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
// ============================
@ -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 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";
@ -224,7 +224,7 @@ export interface Certification {
}
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;
relatedEntityType?: "job" | "candidate" | "employer";
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)
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]:
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(
session_id=session_id,
session_id=user_message.session_id,
tunables=query.tunables,
status=ChatStatusType.INITIALIZING,
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

@ -429,6 +429,215 @@ class RedisDatabase:
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
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
async def get_ai_parameters(self, param_id: str) -> Optional[Dict]:
"""Get AI parameters by ID"""

View File

@ -31,7 +31,7 @@ from models import (
Job, JobApplication, ApplicationStatus,
# Chat models
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase,
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType,
# Supporting models
Location, Skill, WorkExperience, Education
@ -904,111 +904,192 @@ async def search_jobs(
# ============================
# 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")
async def create_chat_session(
session_data: Dict[str, Any] = Body(...),
current_user: BaseUserWithType = Depends(get_current_user),
database: RedisDatabase = Depends(get_database)
):
"""Create a new chat session"""
"""Create a new chat session with optional candidate username association"""
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
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["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
chat_session = ChatSession.model_validate(session_data)
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))
except Exception as e:
logger.error(traceback.format_exc())
logger.error(f"Chat session creation error: {e}")
logger.info(json.dumps(session_data, indent=2))
return JSONResponse(
status_code=400,
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")
async def post_chat_session_message_stream(
session_id: str = Path(...),
data: Dict[str, Any] = Body(...),
current_user = Depends(get_current_user),
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:
chat_session_data = await database.get_chat_session(session_id)
if not chat_session_data:
@ -1019,99 +1100,241 @@ async def post_chat_session_message_stream(
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}")
query = data.get("query")
if not query:
return JSONResponse(
status_code=400,
content=create_error_response("INVALID_QUERY", "Query cannot be empty")
)
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:
return JSONResponse(
status_code=400,
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():
"""Generator to stream messages"""
"""Generator to stream messages with persistence"""
last_log = None
ai_message = None
async for chat_message in chat_agent.generate(
llm=llm_manager.get_llm(),
model=defines.model,
query=chat_query,
session_id=session_id,
user_message=user_message,
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
# metadata and other unnecessary fields
# metadata and other unnecessary fields for streaming
if chat_message.status != ChatStatusType.DONE:
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_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:
last_log = log
logger.info(log)
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(
message_stream_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
#"Access-Control-Allow-Origin": "*", # CORS
"X-Accel-Buffering": "no", # Prevents Nginx buffering if you're using it
"X-Accel-Buffering": "no",
},
)
except Exception as e:
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("FETCH_ERROR", str(e))
content=create_error_response("STREAMING_ERROR", str(e))
)
@api_router.get("/chat/sessions")
async def get_chat_sessions(
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),
@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 paginated list of chat sessions"""
"""Get persisted chat messages for a session"""
try:
filter_dict = None
if filters:
filter_dict = json.loads(filters)
# Get all chat sessions from Redis
all_sessions_data = await database.get_all_chat_sessions()
sessions_list = [ChatSession.model_validate(data) for data in all_sessions_data.values()]
paginated_sessions, total = filter_and_paginate(
sessions_list, page, limit, sortBy, sortOrder, filter_dict
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(
[s.model_dump(by_alias=True, exclude_unset=True) for s in paginated_sessions],
[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 sessions error: {e}")
logger.error(f"Get chat messages error: {e}")
return JSONResponse(
status_code=400,
content=create_error_response("FETCH_FAILED", str(e))
status_code=500,
content=create_error_response("FETCH_ERROR", str(e))
)
@api_router.get("/candidates/{username}/chat-sessions")
async def get_candidate_chat_sessions(
username: str = Path(...),
current_user = Depends(get_current_user),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
database: RedisDatabase = Depends(get_database)
):
"""Get all chat sessions related to a specific candidate"""
try:
# Find candidate by username
all_candidates_data = await database.get_all_candidates()
candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()]
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()
sessions_list = []
for index, session_data in enumerate(all_sessions_data.values()):
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(
[s.model_dump(by_alias=True, exclude_unset=True) for s in paginated_sessions],
page, limit, total
)
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:
logger.error(f"Get candidate chat sessions error: {e}")
return JSONResponse(
status_code=500,
content=create_error_response("FETCH_ERROR", str(e))
)
# ============================

View File

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