Chat working with multiple users
This commit is contained in:
parent
27d9ab467a
commit
c2601bf17a
@ -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;
|
||||
@ -20,8 +21,9 @@ interface CandidateInfoProps {
|
||||
const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => {
|
||||
const { candidate } = props;
|
||||
const {
|
||||
sx,
|
||||
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' }}>
|
||||
|
||||
|
@ -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: () => {
|
||||
|
@ -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} />} />,
|
||||
|
@ -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 };
|
@ -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 = {
|
||||
|
317
frontend/src/pages/CandidateChatPage.tsx
Normal file
317
frontend/src/pages/CandidateChatPage.tsx
Normal 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 };
|
@ -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':
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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
|
||||
|
@ -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}`, {
|
@ -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>;
|
||||
|
@ -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,
|
||||
|
88
src/backend/agents/candidate_chat.py
Normal file
88
src/backend/agents/candidate_chat.py
Normal 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 
|
||||
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):
|
||||
- 
|
||||
- 
|
||||
- <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)
|
@ -428,7 +428,216 @@ class RedisDatabase:
|
||||
"""Delete all chat messages for a session"""
|
||||
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"""
|
||||
|
@ -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,101 +904,182 @@ 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),
|
||||
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(
|
||||
@ -1006,9 +1087,9 @@ async def post_chat_session_message_stream(
|
||||
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:
|
||||
@ -1018,43 +1099,95 @@ 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}")
|
||||
|
||||
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(),
|
||||
@ -1062,58 +1195,148 @@ async def post_chat_session_message_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("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(
|
||||
status_code=500,
|
||||
content=create_error_response("FETCH_ERROR", str(e))
|
||||
)
|
||||
|
||||
|
||||
@api_router.get("/chat/sessions")
|
||||
async def get_chat_sessions(
|
||||
@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),
|
||||
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)
|
||||
):
|
||||
"""Get paginated list of chat sessions"""
|
||||
"""Get all chat sessions related to a specific candidate"""
|
||||
try:
|
||||
filter_dict = None
|
||||
if filters:
|
||||
filter_dict = json.loads(filters)
|
||||
# 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()]
|
||||
|
||||
# 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()
|
||||
sessions_list = [ChatSession.model_validate(data) for data in all_sessions_data.values()]
|
||||
sessions_list = []
|
||||
|
||||
paginated_sessions, total = filter_and_paginate(
|
||||
sessions_list, page, limit, sortBy, sortOrder, filter_dict
|
||||
)
|
||||
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(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:
|
||||
logger.error(f"Get chat sessions error: {e}")
|
||||
logger.error(f"Get candidate chat sessions 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))
|
||||
)
|
||||
|
||||
|
||||
# ============================
|
||||
# Health Check and Info Endpoints
|
||||
# ============================
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user