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 { useUser } from "../hooks/useUser";
|
||||||
import { Candidate } from '../types/types';
|
import { Candidate } from '../types/types';
|
||||||
import { CopyBubble } from "./CopyBubble";
|
import { CopyBubble } from "./CopyBubble";
|
||||||
|
import { rest } from 'lodash';
|
||||||
|
|
||||||
interface CandidateInfoProps {
|
interface CandidateInfoProps {
|
||||||
candidate: Candidate;
|
candidate: Candidate;
|
||||||
@ -20,8 +21,9 @@ interface CandidateInfoProps {
|
|||||||
const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => {
|
const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps) => {
|
||||||
const { candidate } = props;
|
const { candidate } = props;
|
||||||
const {
|
const {
|
||||||
sx,
|
sx,
|
||||||
action = '',
|
action = '',
|
||||||
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
@ -40,6 +42,7 @@ const CandidateInfo: React.FC<CandidateInfoProps> = (props: CandidateInfoProps)
|
|||||||
transition: 'all 0.3s ease',
|
transition: 'all 0.3s ease',
|
||||||
...sx
|
...sx
|
||||||
}}
|
}}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ flexGrow: 1, p: 3, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
|
<CardContent sx={{ flexGrow: 1, p: 3, height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}>
|
||||||
|
|
||||||
|
@ -14,7 +14,7 @@ import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryT
|
|||||||
import { BackstoryElementProps } from './BackstoryTab';
|
import { BackstoryElementProps } from './BackstoryTab';
|
||||||
import { connectionBase } from 'utils/Global';
|
import { connectionBase } from 'utils/Global';
|
||||||
import { useUser } from "hooks/useUser";
|
import { useUser } from "hooks/useUser";
|
||||||
import { StreamingResponse } from 'types/api-client';
|
import { StreamingResponse } from 'services/api-client';
|
||||||
import { ChatMessage, ChatMessageBase, ChatContext, ChatSession, ChatQuery } from 'types/types';
|
import { ChatMessage, ChatMessageBase, ChatContext, ChatSession, ChatQuery } from 'types/types';
|
||||||
import { PaginatedResponse } from 'types/conversion';
|
import { PaginatedResponse } from 'types/conversion';
|
||||||
|
|
||||||
@ -260,7 +260,7 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
|
|||||||
);
|
);
|
||||||
|
|
||||||
controllerRef.current = apiClient.sendMessageStream(sessionId, query, {
|
controllerRef.current = apiClient.sendMessageStream(sessionId, query, {
|
||||||
onMessage: (msg) => {
|
onMessage: (msg: ChatMessageBase) => {
|
||||||
console.log("onMessage:", msg);
|
console.log("onMessage:", msg);
|
||||||
if (msg.type === "response") {
|
if (msg.type === "response") {
|
||||||
setConversation([
|
setConversation([
|
||||||
@ -288,11 +288,11 @@ const Conversation = forwardRef<ConversationHandle, ConversationProps>((props: C
|
|||||||
setProcessingMessage({ ...defaultMessage, content: error as string });
|
setProcessingMessage({ ...defaultMessage, content: error as string });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onStreaming: (chunk) => {
|
onStreaming: (chunk: ChatMessageBase) => {
|
||||||
console.log("onStreaming:", chunk);
|
console.log("onStreaming:", chunk);
|
||||||
setStreamingMessage({ ...defaultMessage, ...chunk });
|
setStreamingMessage({ ...defaultMessage, ...chunk });
|
||||||
},
|
},
|
||||||
onStatusChange: (status) => {
|
onStatusChange: (status: string) => {
|
||||||
console.log("onStatusChange:", status);
|
console.log("onStatusChange:", status);
|
||||||
},
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
|
@ -6,7 +6,7 @@ import { BackstoryPageProps } from '../BackstoryTab';
|
|||||||
import { ConversationHandle } from '../Conversation';
|
import { ConversationHandle } from '../Conversation';
|
||||||
import { User } from 'types/types';
|
import { User } from 'types/types';
|
||||||
|
|
||||||
import { ChatPage } from 'pages/ChatPage';
|
import { CandidateChatPage } from 'pages/CandidateChatPage';
|
||||||
import { ResumeBuilderPage } from 'pages/ResumeBuilderPage';
|
import { ResumeBuilderPage } from 'pages/ResumeBuilderPage';
|
||||||
import { DocsPage } from 'pages/DocsPage';
|
import { DocsPage } from 'pages/DocsPage';
|
||||||
import { CreateProfilePage } from 'pages/CreateProfilePage';
|
import { CreateProfilePage } from 'pages/CreateProfilePage';
|
||||||
@ -41,7 +41,7 @@ const getBackstoryDynamicRoutes = (props: BackstoryDynamicRoutesProps): ReactNod
|
|||||||
let index=0
|
let index=0
|
||||||
const routes = [
|
const routes = [
|
||||||
<Route key={`${index++}`} path="/" element={<HomePage/>} />,
|
<Route key={`${index++}`} path="/" element={<HomePage/>} />,
|
||||||
<Route key={`${index++}`} path="/chat" element={<ChatPage ref={chatRef} setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/chat" element={<CandidateChatPage ref={chatRef} setSnack={setSnack} submitQuery={submitQuery} />} />,
|
||||||
<Route key={`${index++}`} path="/docs" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/docs" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
||||||
<Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/docs/:subPage" element={<DocsPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
||||||
<Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
<Route key={`${index++}`} path="/resume-builder" element={<ResumeBuilderPage setSnack={setSnack} submitQuery={submitQuery} />} />,
|
||||||
|
@ -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 React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
import { SetSnackType } from '../components/Snack';
|
import { SetSnackType } from '../components/Snack';
|
||||||
import { User, Guest, Candidate } from 'types/types';
|
import { User, Guest, Candidate } from 'types/types';
|
||||||
import { ApiClient } from "types/api-client";
|
import { ApiClient } from "services/api-client";
|
||||||
import { debugConversion } from "types/conversion";
|
import { debugConversion } from "types/conversion";
|
||||||
|
|
||||||
type UserContextType = {
|
type UserContextType = {
|
||||||
|
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 { BackstoryThemeVisualizerPage } from 'documents/BackstoryThemeVisualizerPage';
|
||||||
import { UserManagement } from 'documents/UserManagement';
|
import { UserManagement } from 'documents/UserManagement';
|
||||||
import { MockupPage } from 'documents/MockupPage';
|
import { MockupPage } from 'documents/MockupPage';
|
||||||
import { CandidateChatSystem } from 'documents/CandidateChatSystem';
|
|
||||||
|
|
||||||
// Sidebar navigation component using MUI components
|
// Sidebar navigation component using MUI components
|
||||||
const Sidebar: React.FC<{
|
const Sidebar: React.FC<{
|
||||||
@ -257,8 +256,6 @@ const DocsPage = (props: BackstoryPageProps) => {
|
|||||||
// Render the appropriate content based on current page
|
// Render the appropriate content based on current page
|
||||||
function renderContent() {
|
function renderContent() {
|
||||||
switch (page) {
|
switch (page) {
|
||||||
case 'mockup-chat-system':
|
|
||||||
return (<CandidateChatSystem />);
|
|
||||||
case 'ui-overview':
|
case 'ui-overview':
|
||||||
return (<BackstoryUIOverviewPage />);
|
return (<BackstoryUIOverviewPage />);
|
||||||
case 'theme-visualizer':
|
case 'theme-visualizer':
|
||||||
|
@ -18,7 +18,7 @@ import { BackstoryTextField, BackstoryTextFieldRef } from 'components/BackstoryT
|
|||||||
import { StyledMarkdown } from 'components/StyledMarkdown';
|
import { StyledMarkdown } from 'components/StyledMarkdown';
|
||||||
import { Scrollable } from '../components/Scrollable';
|
import { Scrollable } from '../components/Scrollable';
|
||||||
import { Pulse } from 'components/Pulse';
|
import { Pulse } from 'components/Pulse';
|
||||||
import { StreamingResponse } from 'types/api-client';
|
import { StreamingResponse } from 'services/api-client';
|
||||||
import { ChatContext, ChatMessage, ChatMessageBase, ChatSession, ChatQuery } from 'types/types';
|
import { ChatContext, ChatMessage, ChatMessageBase, ChatSession, ChatQuery } from 'types/types';
|
||||||
import { useUser } from 'hooks/useUser';
|
import { useUser } from 'hooks/useUser';
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ import PhoneInput from 'react-phone-number-input';
|
|||||||
import { E164Number } from 'libphonenumber-js/core';
|
import { E164Number } from 'libphonenumber-js/core';
|
||||||
import './LoginPage.css';
|
import './LoginPage.css';
|
||||||
|
|
||||||
import { ApiClient } from 'types/api-client';
|
import { ApiClient } from 'services/api-client';
|
||||||
import { useUser } from 'hooks/useUser';
|
import { useUser } from 'hooks/useUser';
|
||||||
|
|
||||||
// Import conversion utilities
|
// Import conversion utilities
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Import generated types (from running generate_types.py)
|
// Import generated types (from running generate_types.py)
|
||||||
import * as Types from './types';
|
import * as Types from 'types/types';
|
||||||
import {
|
import {
|
||||||
formatApiRequest,
|
formatApiRequest,
|
||||||
// parseApiResponse,
|
// parseApiResponse,
|
||||||
@ -19,7 +19,7 @@ import {
|
|||||||
// ApiResponse,
|
// ApiResponse,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
PaginatedRequest
|
PaginatedRequest
|
||||||
} from './conversion';
|
} from 'types/conversion';
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Streaming Types and Interfaces
|
// Streaming Types and Interfaces
|
||||||
@ -42,7 +42,37 @@ interface StreamingResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Enhanced API Client Class
|
// Chat Types and Interfaces
|
||||||
|
// ============================
|
||||||
|
|
||||||
|
export interface CandidateInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
skills: string[];
|
||||||
|
experience: number;
|
||||||
|
location: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChatSessionRequest {
|
||||||
|
username?: string; // Optional candidate username to associate with
|
||||||
|
context: Types.ChatContext;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CandidateSessionsResponse {
|
||||||
|
candidate: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
sessions: PaginatedResponse<Types.ChatSession>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// API Client Class
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
@ -291,9 +321,61 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Chat Methods (Enhanced with Streaming)
|
// Chat Methods
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a chat session with optional candidate association
|
||||||
|
*/
|
||||||
|
async createChatSessionWithCandidate(
|
||||||
|
request: CreateChatSessionRequest
|
||||||
|
): Promise<Types.ChatSession> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/chat/sessions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(formatApiRequest(request))
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<Types.ChatSession>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all chat sessions related to a specific candidate
|
||||||
|
*/
|
||||||
|
async getCandidateChatSessions(
|
||||||
|
username: string,
|
||||||
|
request: Partial<PaginatedRequest> = {}
|
||||||
|
): Promise<CandidateSessionsResponse> {
|
||||||
|
const paginatedRequest = createPaginatedRequest(request);
|
||||||
|
const params = toUrlParams(formatApiRequest(paginatedRequest));
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/candidates/${username}/chat-sessions?${params}`, {
|
||||||
|
headers: this.defaultHeaders
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleApiResponse<CandidateSessionsResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a chat session about a specific candidate
|
||||||
|
*/
|
||||||
|
async createCandidateChatSession(
|
||||||
|
username: string,
|
||||||
|
chatType: Types.ChatContextType = 'candidate_chat',
|
||||||
|
title?: string
|
||||||
|
): Promise<Types.ChatSession> {
|
||||||
|
const request: CreateChatSessionRequest = {
|
||||||
|
username,
|
||||||
|
title: title || `Discussion about ${username}`,
|
||||||
|
context: {
|
||||||
|
type: chatType,
|
||||||
|
additionalContext: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.createChatSessionWithCandidate(request);
|
||||||
|
}
|
||||||
|
|
||||||
async createChatSession(context: Types.ChatContext): Promise<Types.ChatSession> {
|
async createChatSession(context: Types.ChatContext): Promise<Types.ChatSession> {
|
||||||
const response = await fetch(`${this.baseUrl}/chat/sessions`, {
|
const response = await fetch(`${this.baseUrl}/chat/sessions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -467,8 +549,16 @@ class ApiClient {
|
|||||||
return [await this.sendMessage(sessionId, query)];
|
return [await this.sendMessage(sessionId, query)];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChatMessages(sessionId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.ChatMessage>> {
|
/**
|
||||||
const paginatedRequest = createPaginatedRequest(request);
|
* Get persisted chat messages for a session
|
||||||
|
*/
|
||||||
|
async getChatMessages(
|
||||||
|
sessionId: string,
|
||||||
|
request: Partial<PaginatedRequest> = {}
|
||||||
|
): Promise<PaginatedResponse<Types.ChatMessage>> {
|
||||||
|
const paginatedRequest = createPaginatedRequest({
|
||||||
|
limit: 50, // Higher default for chat messages
|
||||||
|
...request});
|
||||||
const params = toUrlParams(formatApiRequest(paginatedRequest));
|
const params = toUrlParams(formatApiRequest(paginatedRequest));
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages?${params}`, {
|
const response = await fetch(`${this.baseUrl}/chat/sessions/${sessionId}/messages?${params}`, {
|
@ -1,6 +1,6 @@
|
|||||||
// Generated TypeScript types from Pydantic models
|
// Generated TypeScript types from Pydantic models
|
||||||
// Source: src/backend/models.py
|
// Source: src/backend/models.py
|
||||||
// Generated on: 2025-05-29T21:15:06.572082
|
// Generated on: 2025-05-29T23:38:18.286927
|
||||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@ -13,7 +13,7 @@ export type ActivityType = "login" | "search" | "view_job" | "apply_job" | "mess
|
|||||||
|
|
||||||
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn";
|
export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn";
|
||||||
|
|
||||||
export type ChatContextType = "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile";
|
export type ChatContextType = "job_search" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile";
|
||||||
|
|
||||||
export type ChatMessageType = "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user";
|
export type ChatMessageType = "error" | "generating" | "info" | "preparing" | "processing" | "response" | "searching" | "system" | "thinking" | "tooling" | "user";
|
||||||
|
|
||||||
@ -224,7 +224,7 @@ export interface Certification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatContext {
|
export interface ChatContext {
|
||||||
type: "job_search" | "candidate_screening" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile";
|
type: "job_search" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile";
|
||||||
relatedEntityId?: string;
|
relatedEntityId?: string;
|
||||||
relatedEntityType?: "job" | "candidate" | "employer";
|
relatedEntityType?: "job" | "candidate" | "employer";
|
||||||
additionalContext?: Record<string, any>;
|
additionalContext?: Record<string, any>;
|
||||||
|
@ -342,22 +342,12 @@ class Agent(BaseModel, ABC):
|
|||||||
self.metrics.tokens_eval.labels(agent=self.agent_type).inc(response.eval_count)
|
self.metrics.tokens_eval.labels(agent=self.agent_type).inc(response.eval_count)
|
||||||
|
|
||||||
async def generate(
|
async def generate(
|
||||||
self, llm: Any, model: str, query: ChatQuery, session_id: str, user_id: str, temperature=0.7
|
self, llm: Any, model: str, query: ChatQuery, user_message: ChatMessageUser, user_id: str, temperature=0.7
|
||||||
) -> AsyncGenerator[ChatMessage | ChatMessageBase, None]:
|
) -> AsyncGenerator[ChatMessage | ChatMessageBase, None]:
|
||||||
logger.info(f"{self.agent_type} - {inspect.stack()[0].function}")
|
logger.info(f"{self.agent_type} - {inspect.stack()[0].function}")
|
||||||
|
|
||||||
user_message = ChatMessageUser(
|
|
||||||
session_id=session_id,
|
|
||||||
tunables=query.tunables,
|
|
||||||
type=ChatMessageType.USER,
|
|
||||||
status=ChatStatusType.DONE,
|
|
||||||
sender=ChatSenderType.USER,
|
|
||||||
content=query.prompt.strip(),
|
|
||||||
timestamp=datetime.now(UTC)
|
|
||||||
)
|
|
||||||
|
|
||||||
chat_message = ChatMessage(
|
chat_message = ChatMessage(
|
||||||
session_id=session_id,
|
session_id=user_message.session_id,
|
||||||
tunables=query.tunables,
|
tunables=query.tunables,
|
||||||
status=ChatStatusType.INITIALIZING,
|
status=ChatStatusType.INITIALIZING,
|
||||||
type=ChatMessageType.PREPARING,
|
type=ChatMessageType.PREPARING,
|
||||||
|
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"""
|
"""Delete all chat messages for a session"""
|
||||||
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
||||||
await self.redis_client.delete(key)
|
await self.redis_client.delete(key)
|
||||||
|
|
||||||
|
# Enhanced Chat Session Methods
|
||||||
|
async def get_chat_sessions_by_user(self, user_id: str) -> List[Dict]:
|
||||||
|
"""Get all chat sessions for a specific user"""
|
||||||
|
all_sessions = await self.get_all_chat_sessions()
|
||||||
|
user_sessions = []
|
||||||
|
|
||||||
|
for session_data in all_sessions.values():
|
||||||
|
if session_data.get("userId") == user_id or session_data.get("guestId") == user_id:
|
||||||
|
user_sessions.append(session_data)
|
||||||
|
|
||||||
|
# Sort by last activity (most recent first)
|
||||||
|
user_sessions.sort(key=lambda x: x.get("lastActivity", ""), reverse=True)
|
||||||
|
return user_sessions
|
||||||
|
|
||||||
|
async def get_chat_sessions_by_candidate(self, candidate_id: str) -> List[Dict]:
|
||||||
|
"""Get all chat sessions related to a specific candidate"""
|
||||||
|
all_sessions = await self.get_all_chat_sessions()
|
||||||
|
candidate_sessions = []
|
||||||
|
|
||||||
|
for session_data in all_sessions.values():
|
||||||
|
context = session_data.get("context", {})
|
||||||
|
if (context.get("relatedEntityType") == "candidate" and
|
||||||
|
context.get("relatedEntityId") == candidate_id):
|
||||||
|
candidate_sessions.append(session_data)
|
||||||
|
|
||||||
|
# Sort by last activity (most recent first)
|
||||||
|
candidate_sessions.sort(key=lambda x: x.get("lastActivity", ""), reverse=True)
|
||||||
|
return candidate_sessions
|
||||||
|
|
||||||
|
async def update_chat_session_activity(self, session_id: str):
|
||||||
|
"""Update the last activity timestamp for a chat session"""
|
||||||
|
session_data = await self.get_chat_session(session_id)
|
||||||
|
if session_data:
|
||||||
|
session_data["lastActivity"] = datetime.now(UTC).isoformat()
|
||||||
|
await self.set_chat_session(session_id, session_data)
|
||||||
|
|
||||||
|
async def get_recent_chat_messages(self, session_id: str, limit: int = 10) -> List[Dict]:
|
||||||
|
"""Get the most recent chat messages for a session"""
|
||||||
|
messages = await self.get_chat_messages(session_id)
|
||||||
|
# Return the last 'limit' messages
|
||||||
|
return messages[-limit:] if len(messages) > limit else messages
|
||||||
|
|
||||||
|
async def get_chat_message_count(self, session_id: str) -> int:
|
||||||
|
"""Get the total number of messages in a chat session"""
|
||||||
|
key = f"{self.KEY_PREFIXES['chat_messages']}{session_id}"
|
||||||
|
return await self.redis_client.llen(key)
|
||||||
|
|
||||||
|
async def search_chat_messages(self, session_id: str, query: str) -> List[Dict]:
|
||||||
|
"""Search for messages containing specific text in a session"""
|
||||||
|
messages = await self.get_chat_messages(session_id)
|
||||||
|
query_lower = query.lower()
|
||||||
|
|
||||||
|
matching_messages = []
|
||||||
|
for msg in messages:
|
||||||
|
content = msg.get("content", "").lower()
|
||||||
|
if query_lower in content:
|
||||||
|
matching_messages.append(msg)
|
||||||
|
|
||||||
|
return matching_messages
|
||||||
|
|
||||||
|
# Chat Session Management
|
||||||
|
async def archive_chat_session(self, session_id: str):
|
||||||
|
"""Archive a chat session"""
|
||||||
|
session_data = await self.get_chat_session(session_id)
|
||||||
|
if session_data:
|
||||||
|
session_data["isArchived"] = True
|
||||||
|
session_data["updatedAt"] = datetime.now(UTC).isoformat()
|
||||||
|
await self.set_chat_session(session_id, session_data)
|
||||||
|
|
||||||
|
async def delete_chat_session_completely(self, session_id: str):
|
||||||
|
"""Delete a chat session and all its messages"""
|
||||||
|
# Delete the session
|
||||||
|
await self.delete_chat_session(session_id)
|
||||||
|
# Delete all messages
|
||||||
|
await self.delete_chat_messages(session_id)
|
||||||
|
|
||||||
|
async def cleanup_old_chat_sessions(self, days_old: int = 90):
|
||||||
|
"""Archive or delete chat sessions older than specified days"""
|
||||||
|
cutoff_date = datetime.now(UTC) - timedelta(days=days_old)
|
||||||
|
cutoff_iso = cutoff_date.isoformat()
|
||||||
|
|
||||||
|
all_sessions = await self.get_all_chat_sessions()
|
||||||
|
archived_count = 0
|
||||||
|
|
||||||
|
for session_id, session_data in all_sessions.items():
|
||||||
|
last_activity = session_data.get("lastActivity", session_data.get("createdAt", ""))
|
||||||
|
|
||||||
|
if last_activity < cutoff_iso and not session_data.get("isArchived", False):
|
||||||
|
await self.archive_chat_session(session_id)
|
||||||
|
archived_count += 1
|
||||||
|
|
||||||
|
return archived_count
|
||||||
|
|
||||||
|
# Enhanced User Operations
|
||||||
|
async def get_user_by_username(self, username: str) -> Optional[Dict]:
|
||||||
|
"""Get user by username specifically"""
|
||||||
|
username_key = f"{self.KEY_PREFIXES['users']}{username.lower()}"
|
||||||
|
data = await self.redis_client.get(username_key)
|
||||||
|
return self._deserialize(data) if data else None
|
||||||
|
|
||||||
|
async def find_candidate_by_username(self, username: str) -> Optional[Dict]:
|
||||||
|
"""Find candidate by username"""
|
||||||
|
all_candidates = await self.get_all_candidates()
|
||||||
|
username_lower = username.lower()
|
||||||
|
|
||||||
|
for candidate_data in all_candidates.values():
|
||||||
|
if candidate_data.get("username", "").lower() == username_lower:
|
||||||
|
return candidate_data
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Analytics and Reporting
|
||||||
|
async def get_chat_statistics(self) -> Dict[str, Any]:
|
||||||
|
"""Get comprehensive chat statistics"""
|
||||||
|
all_sessions = await self.get_all_chat_sessions()
|
||||||
|
all_messages = await self.get_all_chat_messages()
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_sessions": len(all_sessions),
|
||||||
|
"total_messages": sum(len(messages) for messages in all_messages.values()),
|
||||||
|
"active_sessions": 0,
|
||||||
|
"archived_sessions": 0,
|
||||||
|
"sessions_by_type": {},
|
||||||
|
"sessions_with_candidates": 0,
|
||||||
|
"average_messages_per_session": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Analyze sessions
|
||||||
|
for session_data in all_sessions.values():
|
||||||
|
if session_data.get("isArchived", False):
|
||||||
|
stats["archived_sessions"] += 1
|
||||||
|
else:
|
||||||
|
stats["active_sessions"] += 1
|
||||||
|
|
||||||
|
# Count by type
|
||||||
|
context_type = session_data.get("context", {}).get("type", "unknown")
|
||||||
|
stats["sessions_by_type"][context_type] = stats["sessions_by_type"].get(context_type, 0) + 1
|
||||||
|
|
||||||
|
# Count sessions with candidate association
|
||||||
|
if session_data.get("context", {}).get("relatedEntityType") == "candidate":
|
||||||
|
stats["sessions_with_candidates"] += 1
|
||||||
|
|
||||||
|
# Calculate averages
|
||||||
|
if stats["total_sessions"] > 0:
|
||||||
|
stats["average_messages_per_session"] = stats["total_messages"] / stats["total_sessions"]
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
async def get_candidate_chat_summary(self, candidate_id: str) -> Dict[str, Any]:
|
||||||
|
"""Get a summary of chat activity for a specific candidate"""
|
||||||
|
sessions = await self.get_chat_sessions_by_candidate(candidate_id)
|
||||||
|
|
||||||
|
if not sessions:
|
||||||
|
return {
|
||||||
|
"candidate_id": candidate_id,
|
||||||
|
"total_sessions": 0,
|
||||||
|
"total_messages": 0,
|
||||||
|
"first_chat": None,
|
||||||
|
"last_chat": None
|
||||||
|
}
|
||||||
|
|
||||||
|
total_messages = 0
|
||||||
|
for session in sessions:
|
||||||
|
session_id = session.get("id")
|
||||||
|
if session_id:
|
||||||
|
message_count = await self.get_chat_message_count(session_id)
|
||||||
|
total_messages += message_count
|
||||||
|
|
||||||
|
# Sort sessions by creation date
|
||||||
|
sessions_by_date = sorted(sessions, key=lambda x: x.get("createdAt", ""))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"candidate_id": candidate_id,
|
||||||
|
"total_sessions": len(sessions),
|
||||||
|
"total_messages": total_messages,
|
||||||
|
"first_chat": sessions_by_date[0].get("createdAt") if sessions_by_date else None,
|
||||||
|
"last_chat": sessions_by_date[-1].get("lastActivity") if sessions_by_date else None,
|
||||||
|
"recent_sessions": sessions[:5] # Last 5 sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
# Batch Operations
|
||||||
|
async def get_multiple_candidates_by_usernames(self, usernames: List[str]) -> Dict[str, Dict]:
|
||||||
|
"""Get multiple candidates by their usernames efficiently"""
|
||||||
|
all_candidates = await self.get_all_candidates()
|
||||||
|
username_set = {username.lower() for username in usernames}
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for candidate_data in all_candidates.values():
|
||||||
|
candidate_username = candidate_data.get("username", "").lower()
|
||||||
|
if candidate_username in username_set:
|
||||||
|
result[candidate_username] = candidate_data
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def bulk_update_chat_sessions(self, session_updates: Dict[str, Dict]):
|
||||||
|
"""Bulk update multiple chat sessions"""
|
||||||
|
pipe = self.redis_client.pipeline()
|
||||||
|
|
||||||
|
for session_id, updates in session_updates.items():
|
||||||
|
session_data = await self.get_chat_session(session_id)
|
||||||
|
if session_data:
|
||||||
|
session_data.update(updates)
|
||||||
|
session_data["updatedAt"] = datetime.now(UTC).isoformat()
|
||||||
|
key = f"{self.KEY_PREFIXES['chat_sessions']}{session_id}"
|
||||||
|
pipe.set(key, self._serialize(session_data))
|
||||||
|
|
||||||
|
await pipe.execute()
|
||||||
|
|
||||||
|
|
||||||
# AI Parameters operations
|
# AI Parameters operations
|
||||||
async def get_ai_parameters(self, param_id: str) -> Optional[Dict]:
|
async def get_ai_parameters(self, param_id: str) -> Optional[Dict]:
|
||||||
"""Get AI parameters by ID"""
|
"""Get AI parameters by ID"""
|
||||||
|
@ -31,7 +31,7 @@ from models import (
|
|||||||
Job, JobApplication, ApplicationStatus,
|
Job, JobApplication, ApplicationStatus,
|
||||||
|
|
||||||
# Chat models
|
# Chat models
|
||||||
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase,
|
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType,
|
||||||
|
|
||||||
# Supporting models
|
# Supporting models
|
||||||
Location, Skill, WorkExperience, Education
|
Location, Skill, WorkExperience, Education
|
||||||
@ -904,101 +904,182 @@ async def search_jobs(
|
|||||||
# ============================
|
# ============================
|
||||||
# Chat Endpoints
|
# Chat Endpoints
|
||||||
# ============================
|
# ============================
|
||||||
|
# Enhanced Chat Session Endpoints with Username Association
|
||||||
|
# Add these modifications to your main.py file
|
||||||
|
@api_router.get("/chat/statistics")
|
||||||
|
async def get_chat_statistics(
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get chat statistics (admin/analytics endpoint)"""
|
||||||
|
try:
|
||||||
|
stats = await database.get_chat_statistics()
|
||||||
|
return create_success_response(stats)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get chat statistics error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("STATS_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_router.get("/candidates/{username}/chat-summary")
|
||||||
|
async def get_candidate_chat_summary(
|
||||||
|
username: str = Path(...),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get chat activity summary for a candidate"""
|
||||||
|
try:
|
||||||
|
# Find candidate by username
|
||||||
|
candidate_data = await database.find_candidate_by_username(username)
|
||||||
|
if not candidate_data:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = await database.get_candidate_chat_summary(candidate_data["id"])
|
||||||
|
summary["candidate"] = {
|
||||||
|
"username": candidate_data.get("username"),
|
||||||
|
"fullName": candidate_data.get("fullName"),
|
||||||
|
"email": candidate_data.get("email")
|
||||||
|
}
|
||||||
|
|
||||||
|
return create_success_response(summary)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get candidate chat summary error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("SUMMARY_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_router.post("/chat/sessions/{session_id}/archive")
|
||||||
|
async def archive_chat_session(
|
||||||
|
session_id: str = Path(...),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Archive a chat session"""
|
||||||
|
try:
|
||||||
|
session_data = await database.get_chat_session(session_id)
|
||||||
|
if not session_data:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content=create_error_response("NOT_FOUND", "Chat session not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user owns this session or is admin
|
||||||
|
if session_data.get("userId") != current_user.id:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content=create_error_response("FORBIDDEN", "Cannot archive another user's session")
|
||||||
|
)
|
||||||
|
|
||||||
|
await database.archive_chat_session(session_id)
|
||||||
|
|
||||||
|
return create_success_response({
|
||||||
|
"message": "Chat session archived successfully",
|
||||||
|
"sessionId": session_id
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Archive chat session error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("ARCHIVE_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================
|
||||||
|
# Chat Endpoints (Enhanced)
|
||||||
|
# ============================
|
||||||
|
|
||||||
@api_router.post("/chat/sessions")
|
@api_router.post("/chat/sessions")
|
||||||
async def create_chat_session(
|
async def create_chat_session(
|
||||||
session_data: Dict[str, Any] = Body(...),
|
session_data: Dict[str, Any] = Body(...),
|
||||||
current_user : BaseUserWithType = Depends(get_current_user),
|
current_user: BaseUserWithType = Depends(get_current_user),
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
):
|
):
|
||||||
"""Create a new chat session"""
|
"""Create a new chat session with optional candidate username association"""
|
||||||
try:
|
try:
|
||||||
|
# Extract username if provided
|
||||||
|
username = session_data.get("username")
|
||||||
|
candidate_id = None
|
||||||
|
candidate_data = None
|
||||||
|
|
||||||
|
# If username is provided, look up the candidate
|
||||||
|
if username:
|
||||||
|
logger.info(f"🔍 Looking up candidate with username: {username}")
|
||||||
|
|
||||||
|
# Get all candidates and find by username
|
||||||
|
all_candidates_data = await database.get_all_candidates()
|
||||||
|
candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()]
|
||||||
|
|
||||||
|
# Find candidate by username (case-insensitive)
|
||||||
|
matching_candidates = [
|
||||||
|
c for c in candidates_list
|
||||||
|
if c.username.lower() == username.lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
if not matching_candidates:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_data = matching_candidates[0]
|
||||||
|
candidate_id = candidate_data.id
|
||||||
|
logger.info(f"✅ Found candidate: {candidate_data.full_name} (ID: {candidate_id})")
|
||||||
|
|
||||||
# Add required fields
|
# Add required fields
|
||||||
session_data["id"] = str(uuid.uuid4())
|
session_id = str(uuid.uuid4())
|
||||||
|
session_data["id"] = session_id
|
||||||
|
session_data["userId"] = current_user.id
|
||||||
session_data["createdAt"] = datetime.now(UTC).isoformat()
|
session_data["createdAt"] = datetime.now(UTC).isoformat()
|
||||||
session_data["updatedAt"] = datetime.now(UTC).isoformat()
|
session_data["lastActivity"] = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
# Set up context with candidate association if username was provided
|
||||||
|
context = session_data.get("context", {})
|
||||||
|
if candidate_id and candidate_data:
|
||||||
|
context["relatedEntityId"] = candidate_id
|
||||||
|
context["relatedEntityType"] = "candidate"
|
||||||
|
|
||||||
|
# Add candidate info to additional context for AI reference
|
||||||
|
additional_context = context.get("additionalContext", {})
|
||||||
|
additional_context["candidateInfo"] = {
|
||||||
|
"id": candidate_data.id,
|
||||||
|
"name": candidate_data.full_name,
|
||||||
|
"email": candidate_data.email,
|
||||||
|
"username": candidate_data.username,
|
||||||
|
"skills": [skill.name for skill in candidate_data.skills] if candidate_data.skills else [],
|
||||||
|
"experience": len(candidate_data.experience) if candidate_data.experience else 0,
|
||||||
|
"location": candidate_data.location.city if candidate_data.location else "Unknown"
|
||||||
|
}
|
||||||
|
context["additionalContext"] = additional_context
|
||||||
|
|
||||||
|
# Set a descriptive title if not provided
|
||||||
|
if not session_data.get("title"):
|
||||||
|
session_data["title"] = f"Chat about {candidate_data.full_name}"
|
||||||
|
|
||||||
|
session_data["context"] = context
|
||||||
|
|
||||||
# Create chat session
|
# Create chat session
|
||||||
chat_session = ChatSession.model_validate(session_data)
|
chat_session = ChatSession.model_validate(session_data)
|
||||||
await database.set_chat_session(chat_session.id, chat_session.model_dump())
|
await database.set_chat_session(chat_session.id, chat_session.model_dump())
|
||||||
|
|
||||||
logger.info(f"✅ Chat session created: {chat_session.id} for user {current_user.id}")
|
logger.info(f"✅ Chat session created: {chat_session.id} for user {current_user.id}" +
|
||||||
|
(f" about candidate {candidate_data.full_name}" if candidate_data else ""))
|
||||||
|
|
||||||
return create_success_response(chat_session.model_dump(by_alias=True, exclude_unset=True))
|
return create_success_response(chat_session.model_dump(by_alias=True, exclude_unset=True))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
logger.error(f"Chat session creation error: {e}")
|
logger.error(f"Chat session creation error: {e}")
|
||||||
|
logger.info(json.dumps(session_data, indent=2))
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
content=create_error_response("CREATION_FAILED", str(e))
|
content=create_error_response("CREATION_FAILED", str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
@api_router.get("/chat/sessions/{session_id}")
|
|
||||||
async def get_chat_session(
|
|
||||||
session_id: str = Path(...),
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
database: RedisDatabase = Depends(get_database)
|
|
||||||
):
|
|
||||||
"""Get a chat session by ID"""
|
|
||||||
try:
|
|
||||||
chat_session_data = await database.get_chat_session(session_id)
|
|
||||||
if not chat_session_data:
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content=create_error_response("NOT_FOUND", "Chat session not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
chat_session = ChatSession.model_validate(chat_session_data)
|
|
||||||
return create_success_response(chat_session.model_dump(by_alias=True, exclude_unset=True))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Get chat session error: {e}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content=create_error_response("FETCH_ERROR", str(e))
|
|
||||||
)
|
|
||||||
|
|
||||||
@api_router.get("/chat/sessions/{session_id}/messages")
|
|
||||||
async def get_chat_session_messages(
|
|
||||||
session_id: str = Path(...),
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
limit: int = Query(20, ge=1, le=100),
|
|
||||||
sortBy: Optional[str] = Query(None, alias="sortBy"),
|
|
||||||
sortOrder: str = Query("desc", pattern="^(asc|desc)$", alias="sortOrder"),
|
|
||||||
filters: Optional[str] = Query(None),
|
|
||||||
database: RedisDatabase = Depends(get_database)
|
|
||||||
):
|
|
||||||
"""Get a chat session by ID"""
|
|
||||||
try:
|
|
||||||
chat_session_data = await database.get_chat_session(session_id)
|
|
||||||
if not chat_session_data:
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=404,
|
|
||||||
content=create_error_response("NOT_FOUND", "Chat session not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
chat_messages = await database.get_chat_messages(session_id)
|
|
||||||
# Convert messages to ChatMessage objects
|
|
||||||
messages_list = [ChatMessage.model_validate(msg) for msg in chat_messages]
|
|
||||||
# Apply filters and pagination
|
|
||||||
filter_dict = None
|
|
||||||
if filters:
|
|
||||||
filter_dict = json.loads(filters)
|
|
||||||
paginated_messages, total = filter_and_paginate(
|
|
||||||
messages_list, page, limit, sortBy, sortOrder, filter_dict
|
|
||||||
)
|
|
||||||
paginated_response = create_paginated_response(
|
|
||||||
[m.model_dump(by_alias=True, exclude_unset=True) for m in paginated_messages],
|
|
||||||
page, limit, total
|
|
||||||
)
|
|
||||||
|
|
||||||
return create_success_response(paginated_response)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Get chat session error: {e}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=500,
|
|
||||||
content=create_error_response("FETCH_ERROR", str(e))
|
|
||||||
)
|
|
||||||
|
|
||||||
@api_router.post("/chat/sessions/{session_id}/messages/stream")
|
@api_router.post("/chat/sessions/{session_id}/messages/stream")
|
||||||
async def post_chat_session_message_stream(
|
async def post_chat_session_message_stream(
|
||||||
@ -1006,9 +1087,9 @@ async def post_chat_session_message_stream(
|
|||||||
data: Dict[str, Any] = Body(...),
|
data: Dict[str, Any] = Body(...),
|
||||||
current_user = Depends(get_current_user),
|
current_user = Depends(get_current_user),
|
||||||
database: RedisDatabase = Depends(get_database),
|
database: RedisDatabase = Depends(get_database),
|
||||||
request: Request = Request, # For streaming response
|
request: Request = Request,
|
||||||
):
|
):
|
||||||
"""Post a message to a chat session and stream the response"""
|
"""Post a message to a chat session and stream the response with persistence"""
|
||||||
try:
|
try:
|
||||||
chat_session_data = await database.get_chat_session(session_id)
|
chat_session_data = await database.get_chat_session(session_id)
|
||||||
if not chat_session_data:
|
if not chat_session_data:
|
||||||
@ -1018,43 +1099,95 @@ async def post_chat_session_message_stream(
|
|||||||
)
|
)
|
||||||
|
|
||||||
chat_type = chat_session_data.get("context", {}).get("type", "general")
|
chat_type = chat_session_data.get("context", {}).get("type", "general")
|
||||||
|
|
||||||
|
# Get candidate info if this chat is about a specific candidate
|
||||||
|
candidate_info = chat_session_data.get("context", {}).get("additionalContext", {}).get("candidateInfo")
|
||||||
|
if candidate_info:
|
||||||
|
logger.info(f"🔗 Chat session {session_id} about candidate {candidate_info['name']} accessed by user {current_user.id}")
|
||||||
|
else:
|
||||||
|
logger.info(f"🔗 Chat session {session_id} type {chat_type} accessed by user {current_user.id}")
|
||||||
|
|
||||||
logger.info(f"🔗 Chat session {session_id} type {chat_type} accessed by user {current_user.id}")
|
|
||||||
query = data.get("query")
|
query = data.get("query")
|
||||||
if not query:
|
if not query:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
content=create_error_response("INVALID_QUERY", "Query cannot be empty")
|
content=create_error_response("INVALID_QUERY", "Query cannot be empty")
|
||||||
)
|
)
|
||||||
|
|
||||||
chat_query = ChatQuery.model_validate(query)
|
chat_query = ChatQuery.model_validate(query)
|
||||||
chat_agent = agents.get_or_create_agent(agent_type=chat_type, prometheus_collector=prometheus_collector, database=database)
|
chat_agent = agents.get_or_create_agent(
|
||||||
|
agent_type=chat_type,
|
||||||
|
prometheus_collector=prometheus_collector,
|
||||||
|
database=database
|
||||||
|
)
|
||||||
|
|
||||||
if not chat_agent:
|
if not chat_agent:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type")
|
content=create_error_response("AGENT_NOT_FOUND", "No agent found for this chat type")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store the user's message first
|
||||||
|
user_message = ChatMessageUser(
|
||||||
|
session_id=session_id,
|
||||||
|
type=ChatMessageType.USER,
|
||||||
|
status=ChatStatusType.DONE,
|
||||||
|
sender=ChatSenderType.USER,
|
||||||
|
content=chat_query.prompt,
|
||||||
|
timestamp=datetime.now(UTC)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist user message to database
|
||||||
|
await database.add_chat_message(session_id, user_message.model_dump())
|
||||||
|
logger.info(f"💬 User message saved to database for session {session_id}")
|
||||||
|
|
||||||
|
# Update session last activity
|
||||||
|
chat_session_data["lastActivity"] = datetime.now(UTC).isoformat()
|
||||||
|
await database.set_chat_session(session_id, chat_session_data)
|
||||||
|
|
||||||
async def message_stream_generator():
|
async def message_stream_generator():
|
||||||
"""Generator to stream messages"""
|
"""Generator to stream messages with persistence"""
|
||||||
last_log = None
|
last_log = None
|
||||||
|
ai_message = None
|
||||||
|
|
||||||
async for chat_message in chat_agent.generate(
|
async for chat_message in chat_agent.generate(
|
||||||
llm=llm_manager.get_llm(),
|
llm=llm_manager.get_llm(),
|
||||||
model=defines.model,
|
model=defines.model,
|
||||||
query=chat_query,
|
query=chat_query,
|
||||||
session_id=session_id,
|
user_message=user_message,
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
):
|
):
|
||||||
|
# Store reference to the complete AI message
|
||||||
|
if chat_message.status == ChatStatusType.DONE:
|
||||||
|
ai_message = chat_message
|
||||||
|
|
||||||
# If the message is not done, convert it to a ChatMessageBase to remove
|
# If the message is not done, convert it to a ChatMessageBase to remove
|
||||||
# metadata and other unnecessary fields
|
# metadata and other unnecessary fields for streaming
|
||||||
if chat_message.status != ChatStatusType.DONE:
|
if chat_message.status != ChatStatusType.DONE:
|
||||||
chat_message = model_cast.cast_to_model(ChatMessageBase, chat_message)
|
chat_message = model_cast.cast_to_model(ChatMessageBase, chat_message)
|
||||||
|
|
||||||
json_data = chat_message.model_dump(mode='json', by_alias=True, exclude_unset=True)
|
json_data = chat_message.model_dump(mode='json', by_alias=True, exclude_unset=True)
|
||||||
json_str = json.dumps(json_data)
|
json_str = json.dumps(json_data)
|
||||||
log = f"🔗 Message status={chat_message.status}, type={chat_message.type}"
|
|
||||||
|
log = f"🔗 Message status={chat_message.status}, sender={getattr(chat_message, 'sender', 'unknown')}"
|
||||||
if last_log != log:
|
if last_log != log:
|
||||||
last_log = log
|
last_log = log
|
||||||
logger.info(log)
|
logger.info(log)
|
||||||
|
|
||||||
yield f"data: {json_str}\n\n"
|
yield f"data: {json_str}\n\n"
|
||||||
|
|
||||||
|
# After streaming is complete, persist the final AI message to database
|
||||||
|
if ai_message and ai_message.status == ChatStatusType.DONE:
|
||||||
|
try:
|
||||||
|
await database.add_chat_message(session_id, ai_message.model_dump())
|
||||||
|
logger.info(f"🤖 AI message saved to database for session {session_id}")
|
||||||
|
|
||||||
|
# Update session last activity again
|
||||||
|
chat_session_data["lastActivity"] = datetime.now(UTC).isoformat()
|
||||||
|
await database.set_chat_session(session_id, chat_session_data)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save AI message to database: {e}")
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
message_stream_generator(),
|
message_stream_generator(),
|
||||||
@ -1062,58 +1195,148 @@ async def post_chat_session_message_stream(
|
|||||||
headers={
|
headers={
|
||||||
"Cache-Control": "no-cache",
|
"Cache-Control": "no-cache",
|
||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
#"Access-Control-Allow-Origin": "*", # CORS
|
"X-Accel-Buffering": "no",
|
||||||
"X-Accel-Buffering": "no", # Prevents Nginx buffering if you're using it
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
logger.error(f"Get chat session error: {e}")
|
logger.error(f"Chat message streaming error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=create_error_response("STREAMING_ERROR", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
@api_router.get("/chat/sessions/{session_id}/messages")
|
||||||
|
async def get_chat_session_messages(
|
||||||
|
session_id: str = Path(...),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(50, ge=1, le=100), # Increased default for chat messages
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Get persisted chat messages for a session"""
|
||||||
|
try:
|
||||||
|
chat_session_data = await database.get_chat_session(session_id)
|
||||||
|
if not chat_session_data:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content=create_error_response("NOT_FOUND", "Chat session not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get messages from database
|
||||||
|
chat_messages = await database.get_chat_messages(session_id)
|
||||||
|
|
||||||
|
# Convert to ChatMessage objects and sort by timestamp
|
||||||
|
messages_list = []
|
||||||
|
for msg_data in chat_messages:
|
||||||
|
try:
|
||||||
|
message = ChatMessage.model_validate(msg_data)
|
||||||
|
messages_list.append(message)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to validate message: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by timestamp (oldest first for chat history)
|
||||||
|
messages_list.sort(key=lambda x: x.timestamp)
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
total = len(messages_list)
|
||||||
|
start = (page - 1) * limit
|
||||||
|
end = start + limit
|
||||||
|
paginated_messages = messages_list[start:end]
|
||||||
|
|
||||||
|
paginated_response = create_paginated_response(
|
||||||
|
[m.model_dump(by_alias=True, exclude_unset=True) for m in paginated_messages],
|
||||||
|
page, limit, total
|
||||||
|
)
|
||||||
|
|
||||||
|
return create_success_response(paginated_response)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Get chat messages error: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
content=create_error_response("FETCH_ERROR", str(e))
|
content=create_error_response("FETCH_ERROR", str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api_router.get("/candidates/{username}/chat-sessions")
|
||||||
@api_router.get("/chat/sessions")
|
async def get_candidate_chat_sessions(
|
||||||
async def get_chat_sessions(
|
username: str = Path(...),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
sortBy: Optional[str] = Query(None, alias="sortBy"),
|
|
||||||
sortOrder: str = Query("desc", pattern="^(asc|desc)$", alias="sortOrder"),
|
|
||||||
filters: Optional[str] = Query(None),
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
):
|
):
|
||||||
"""Get paginated list of chat sessions"""
|
"""Get all chat sessions related to a specific candidate"""
|
||||||
try:
|
try:
|
||||||
filter_dict = None
|
# Find candidate by username
|
||||||
if filters:
|
all_candidates_data = await database.get_all_candidates()
|
||||||
filter_dict = json.loads(filters)
|
candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()]
|
||||||
|
|
||||||
# Get all chat sessions from Redis
|
matching_candidates = [
|
||||||
|
c for c in candidates_list
|
||||||
|
if c.username.lower() == username.lower()
|
||||||
|
]
|
||||||
|
|
||||||
|
if not matching_candidates:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with username '{username}' not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate = matching_candidates[0]
|
||||||
|
|
||||||
|
# Get all chat sessions
|
||||||
all_sessions_data = await database.get_all_chat_sessions()
|
all_sessions_data = await database.get_all_chat_sessions()
|
||||||
sessions_list = [ChatSession.model_validate(data) for data in all_sessions_data.values()]
|
sessions_list = []
|
||||||
|
|
||||||
paginated_sessions, total = filter_and_paginate(
|
for index, session_data in enumerate(all_sessions_data.values()):
|
||||||
sessions_list, page, limit, sortBy, sortOrder, filter_dict
|
try:
|
||||||
)
|
session = ChatSession.model_validate(session_data)
|
||||||
|
# Check if this session is related to the candidate
|
||||||
|
context = session.context
|
||||||
|
if (context and
|
||||||
|
context.related_entity_type == "candidate" and
|
||||||
|
context.related_entity_id == candidate.id):
|
||||||
|
sessions_list.append(session)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
logger.error(f"Failed to validate session ({index}): {e}")
|
||||||
|
logger.error(f"Session data: {session_data}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by last activity (most recent first)
|
||||||
|
sessions_list.sort(key=lambda x: x.last_activity, reverse=True)
|
||||||
|
|
||||||
|
# Apply pagination
|
||||||
|
total = len(sessions_list)
|
||||||
|
start = (page - 1) * limit
|
||||||
|
end = start + limit
|
||||||
|
paginated_sessions = sessions_list[start:end]
|
||||||
|
|
||||||
paginated_response = create_paginated_response(
|
paginated_response = create_paginated_response(
|
||||||
[s.model_dump(by_alias=True, exclude_unset=True) for s in paginated_sessions],
|
[s.model_dump(by_alias=True, exclude_unset=True) for s in paginated_sessions],
|
||||||
page, limit, total
|
page, limit, total
|
||||||
)
|
)
|
||||||
|
|
||||||
return create_success_response(paginated_response)
|
return create_success_response({
|
||||||
|
"candidate": {
|
||||||
|
"id": candidate.id,
|
||||||
|
"username": candidate.username,
|
||||||
|
"fullName": candidate.full_name,
|
||||||
|
"email": candidate.email
|
||||||
|
},
|
||||||
|
"sessions": paginated_response
|
||||||
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Get chat sessions error: {e}")
|
logger.error(f"Get candidate chat sessions error: {e}")
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=400,
|
status_code=500,
|
||||||
content=create_error_response("FETCH_FAILED", str(e))
|
content=create_error_response("FETCH_ERROR", str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
# ============================
|
# ============================
|
||||||
# Health Check and Info Endpoints
|
# Health Check and Info Endpoints
|
||||||
# ============================
|
# ============================
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated
|
from typing import List, Dict, Optional, Any, Union, Literal, TypeVar, Generic, Annotated
|
||||||
from pydantic import BaseModel, Field, EmailStr, HttpUrl, validator # type: ignore
|
from pydantic import BaseModel, Field, EmailStr, HttpUrl, model_validator # type: ignore
|
||||||
from pydantic.types import constr, conint # type: ignore
|
from pydantic.types import constr, conint # type: ignore
|
||||||
from datetime import datetime, date, UTC
|
from datetime import datetime, date, UTC
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@ -88,7 +88,7 @@ class ChatStatusType(str, Enum):
|
|||||||
|
|
||||||
class ChatContextType(str, Enum):
|
class ChatContextType(str, Enum):
|
||||||
JOB_SEARCH = "job_search"
|
JOB_SEARCH = "job_search"
|
||||||
CANDIDATE_SCREENING = "candidate_screening"
|
CANDIDATE_CHAT = "candidate_chat"
|
||||||
INTERVIEW_PREP = "interview_prep"
|
INTERVIEW_PREP = "interview_prep"
|
||||||
RESUME_REVIEW = "resume_review"
|
RESUME_REVIEW = "resume_review"
|
||||||
GENERAL = "general"
|
GENERAL = "general"
|
||||||
@ -373,9 +373,10 @@ class BaseUser(BaseModel):
|
|||||||
profile_image: Optional[str] = Field(None, alias="profileImage")
|
profile_image: Optional[str] = Field(None, alias="profileImage")
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
|
|
||||||
class Config:
|
model_config = {
|
||||||
use_enum_values = True
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"use_enum_values": True # Use enum values instead of names
|
||||||
|
}
|
||||||
|
|
||||||
# Generic base user with user_type for API responses
|
# Generic base user with user_type for API responses
|
||||||
class BaseUserWithType(BaseUser):
|
class BaseUserWithType(BaseUser):
|
||||||
@ -429,8 +430,9 @@ class Guest(BaseModel):
|
|||||||
converted_to_user_id: Optional[str] = Field(None, alias="convertedToUserId")
|
converted_to_user_id: Optional[str] = Field(None, alias="convertedToUserId")
|
||||||
ip_address: Optional[str] = Field(None, alias="ipAddress")
|
ip_address: Optional[str] = Field(None, alias="ipAddress")
|
||||||
user_agent: Optional[str] = Field(None, alias="userAgent")
|
user_agent: Optional[str] = Field(None, alias="userAgent")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class Authentication(BaseModel):
|
class Authentication(BaseModel):
|
||||||
user_id: str = Field(..., alias="userId")
|
user_id: str = Field(..., alias="userId")
|
||||||
@ -445,16 +447,18 @@ class Authentication(BaseModel):
|
|||||||
mfa_secret: Optional[str] = Field(None, alias="mfaSecret")
|
mfa_secret: Optional[str] = Field(None, alias="mfaSecret")
|
||||||
login_attempts: int = Field(..., alias="loginAttempts")
|
login_attempts: int = Field(..., alias="loginAttempts")
|
||||||
locked_until: Optional[datetime] = Field(None, alias="lockedUntil")
|
locked_until: Optional[datetime] = Field(None, alias="lockedUntil")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class AuthResponse(BaseModel):
|
class AuthResponse(BaseModel):
|
||||||
access_token: str = Field(..., alias="accessToken")
|
access_token: str = Field(..., alias="accessToken")
|
||||||
refresh_token: str = Field(..., alias="refreshToken")
|
refresh_token: str = Field(..., alias="refreshToken")
|
||||||
user: Candidate | Employer
|
user: Candidate | Employer
|
||||||
expires_at: int = Field(..., alias="expiresAt")
|
expires_at: int = Field(..., alias="expiresAt")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class Job(BaseModel):
|
class Job(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -478,8 +482,9 @@ class Job(BaseModel):
|
|||||||
featured_until: Optional[datetime] = Field(None, alias="featuredUntil")
|
featured_until: Optional[datetime] = Field(None, alias="featuredUntil")
|
||||||
views: int = 0
|
views: int = 0
|
||||||
application_count: int = Field(0, alias="applicationCount")
|
application_count: int = Field(0, alias="applicationCount")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class InterviewFeedback(BaseModel):
|
class InterviewFeedback(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -496,8 +501,9 @@ class InterviewFeedback(BaseModel):
|
|||||||
updated_at: datetime = Field(..., alias="updatedAt")
|
updated_at: datetime = Field(..., alias="updatedAt")
|
||||||
is_visible: bool = Field(..., alias="isVisible")
|
is_visible: bool = Field(..., alias="isVisible")
|
||||||
skill_assessments: Optional[List[SkillAssessment]] = Field(None, alias="skillAssessments")
|
skill_assessments: Optional[List[SkillAssessment]] = Field(None, alias="skillAssessments")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class InterviewSchedule(BaseModel):
|
class InterviewSchedule(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -511,8 +517,9 @@ class InterviewSchedule(BaseModel):
|
|||||||
feedback: Optional[InterviewFeedback] = None
|
feedback: Optional[InterviewFeedback] = None
|
||||||
status: Literal["scheduled", "completed", "cancelled", "rescheduled"]
|
status: Literal["scheduled", "completed", "cancelled", "rescheduled"]
|
||||||
meeting_link: Optional[HttpUrl] = Field(None, alias="meetingLink")
|
meeting_link: Optional[HttpUrl] = Field(None, alias="meetingLink")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class JobApplication(BaseModel):
|
class JobApplication(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -528,8 +535,9 @@ class JobApplication(BaseModel):
|
|||||||
custom_questions: Optional[List[CustomQuestion]] = Field(None, alias="customQuestions")
|
custom_questions: Optional[List[CustomQuestion]] = Field(None, alias="customQuestions")
|
||||||
candidate_contact: Optional[CandidateContact] = Field(None, alias="candidateContact")
|
candidate_contact: Optional[CandidateContact] = Field(None, alias="candidateContact")
|
||||||
decision: Optional[ApplicationDecision] = None
|
decision: Optional[ApplicationDecision] = None
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class RagEntry(BaseModel):
|
class RagEntry(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -555,8 +563,9 @@ class ChatContext(BaseModel):
|
|||||||
related_entity_id: Optional[str] = Field(None, alias="relatedEntityId")
|
related_entity_id: Optional[str] = Field(None, alias="relatedEntityId")
|
||||||
related_entity_type: Optional[Literal["job", "candidate", "employer"]] = Field(None, alias="relatedEntityType")
|
related_entity_type: Optional[Literal["job", "candidate", "employer"]] = Field(None, alias="relatedEntityType")
|
||||||
additional_context: Optional[Dict[str, Any]] = Field(None, alias="additionalContext")
|
additional_context: Optional[Dict[str, Any]] = Field(None, alias="additionalContext")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class ChatOptions(BaseModel):
|
class ChatOptions(BaseModel):
|
||||||
seed: Optional[int] = 8911
|
seed: Optional[int] = 8911
|
||||||
@ -580,8 +589,9 @@ class ChatMessageMetaData(BaseModel):
|
|||||||
options: Optional[ChatOptions] = None
|
options: Optional[ChatOptions] = None
|
||||||
tools: Optional[Dict[str, Any]] = None
|
tools: Optional[Dict[str, Any]] = None
|
||||||
timers: Optional[Dict[str, float]] = None
|
timers: Optional[Dict[str, float]] = None
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class ChatMessageBase(BaseModel):
|
class ChatMessageBase(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -592,8 +602,9 @@ class ChatMessageBase(BaseModel):
|
|||||||
sender: ChatSenderType
|
sender: ChatSenderType
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
content: str = ""
|
content: str = ""
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class ChatMessageUser(ChatMessageBase):
|
class ChatMessageUser(ChatMessageBase):
|
||||||
type: ChatMessageType = ChatMessageType.USER
|
type: ChatMessageType = ChatMessageType.USER
|
||||||
@ -616,19 +627,15 @@ class ChatSession(BaseModel):
|
|||||||
messages: Optional[List[ChatMessage]] = None
|
messages: Optional[List[ChatMessage]] = None
|
||||||
is_archived: bool = Field(False, alias="isArchived")
|
is_archived: bool = Field(False, alias="isArchived")
|
||||||
system_prompt: Optional[str] = Field(None, alias="systemPrompt")
|
system_prompt: Optional[str] = Field(None, alias="systemPrompt")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
@validator('user_id', 'guest_id')
|
@model_validator(mode="after")
|
||||||
def validate_user_or_guest(cls, v, values, **kwargs):
|
def check_user_or_guest(self) -> "ChatSession":
|
||||||
field = kwargs.get('field')
|
if not self.user_id and not self.guest_id:
|
||||||
if not field:
|
raise ValueError("Either user_id or guest_id must be provided")
|
||||||
raise ValueError('field must be provided')
|
return self
|
||||||
if field.name == 'user_id' and 'guest_id' in values and not v and not values['guest_id']:
|
|
||||||
raise ValueError('Either user_id or guest_id must be provided')
|
|
||||||
if field.name == 'guest_id' and 'user_id' in values and not v and not values['user_id']:
|
|
||||||
raise ValueError('Either user_id or guest_id must be provided')
|
|
||||||
return v
|
|
||||||
|
|
||||||
class DataSourceConfiguration(BaseModel):
|
class DataSourceConfiguration(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -642,8 +649,9 @@ class DataSourceConfiguration(BaseModel):
|
|||||||
status: Literal["active", "pending", "error", "processing"]
|
status: Literal["active", "pending", "error", "processing"]
|
||||||
error_details: Optional[str] = Field(None, alias="errorDetails")
|
error_details: Optional[str] = Field(None, alias="errorDetails")
|
||||||
metadata: Optional[Dict[str, Any]] = None
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class RAGConfiguration(BaseModel):
|
class RAGConfiguration(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -658,8 +666,9 @@ class RAGConfiguration(BaseModel):
|
|||||||
updated_at: datetime = Field(..., alias="updatedAt")
|
updated_at: datetime = Field(..., alias="updatedAt")
|
||||||
version: int
|
version: int
|
||||||
is_active: bool = Field(..., alias="isActive")
|
is_active: bool = Field(..., alias="isActive")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class UserActivity(BaseModel):
|
class UserActivity(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -671,19 +680,15 @@ class UserActivity(BaseModel):
|
|||||||
ip_address: Optional[str] = Field(None, alias="ipAddress")
|
ip_address: Optional[str] = Field(None, alias="ipAddress")
|
||||||
user_agent: Optional[str] = Field(None, alias="userAgent")
|
user_agent: Optional[str] = Field(None, alias="userAgent")
|
||||||
session_id: Optional[str] = Field(None, alias="sessionId")
|
session_id: Optional[str] = Field(None, alias="sessionId")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
@validator('user_id', 'guest_id')
|
@model_validator(mode="after")
|
||||||
def validate_user_or_guest(cls, v, values, **kwargs):
|
def check_user_or_guest(self) -> "ChatSession":
|
||||||
field = kwargs.get('field')
|
if not self.user_id and not self.guest_id:
|
||||||
if not field:
|
raise ValueError("Either user_id or guest_id must be provided")
|
||||||
raise ValueError('field must be provided')
|
return self
|
||||||
if field.name == 'user_id' and 'guest_id' in values and not v and not values['guest_id']:
|
|
||||||
raise ValueError('Either user_id or guest_id must be provided')
|
|
||||||
if field.name == 'guest_id' and 'user_id' in values and not v and not values['user_id']:
|
|
||||||
raise ValueError('Either user_id or guest_id must be provided')
|
|
||||||
return v
|
|
||||||
|
|
||||||
class Analytics(BaseModel):
|
class Analytics(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
@ -694,8 +699,9 @@ class Analytics(BaseModel):
|
|||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
dimensions: Optional[Dict[str, Any]] = None
|
dimensions: Optional[Dict[str, Any]] = None
|
||||||
segment: Optional[str] = None
|
segment: Optional[str] = None
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class UserPreference(BaseModel):
|
class UserPreference(BaseModel):
|
||||||
user_id: str = Field(..., alias="userId")
|
user_id: str = Field(..., alias="userId")
|
||||||
@ -706,8 +712,9 @@ class UserPreference(BaseModel):
|
|||||||
language: str
|
language: str
|
||||||
timezone: str
|
timezone: str
|
||||||
email_frequency: Literal["immediate", "daily", "weekly", "never"] = Field(..., alias="emailFrequency")
|
email_frequency: Literal["immediate", "daily", "weekly", "never"] = Field(..., alias="emailFrequency")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
# ============================
|
# ============================
|
||||||
# API Request/Response Models
|
# API Request/Response Models
|
||||||
@ -716,8 +723,9 @@ class ChatQuery(BaseModel):
|
|||||||
prompt: str
|
prompt: str
|
||||||
tunables: Optional[Tunables] = None
|
tunables: Optional[Tunables] = None
|
||||||
agent_options: Optional[Dict[str, Any]] = Field(None, alias="agentOptions")
|
agent_options: Optional[Dict[str, Any]] = Field(None, alias="agentOptions")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class PaginatedRequest(BaseModel):
|
class PaginatedRequest(BaseModel):
|
||||||
page: Annotated[int, Field(ge=1)] = 1
|
page: Annotated[int, Field(ge=1)] = 1
|
||||||
@ -725,8 +733,9 @@ class PaginatedRequest(BaseModel):
|
|||||||
sort_by: Optional[str] = Field(None, alias="sortBy")
|
sort_by: Optional[str] = Field(None, alias="sortBy")
|
||||||
sort_order: Optional[SortOrder] = Field(None, alias="sortOrder")
|
sort_order: Optional[SortOrder] = Field(None, alias="sortOrder")
|
||||||
filters: Optional[Dict[str, Any]] = None
|
filters: Optional[Dict[str, Any]] = None
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class SearchQuery(BaseModel):
|
class SearchQuery(BaseModel):
|
||||||
query: str
|
query: str
|
||||||
@ -735,8 +744,9 @@ class SearchQuery(BaseModel):
|
|||||||
limit: Annotated[int, Field(ge=1, le=100)] = 20
|
limit: Annotated[int, Field(ge=1, le=100)] = 20
|
||||||
sort_by: Optional[str] = Field(None, alias="sortBy")
|
sort_by: Optional[str] = Field(None, alias="sortBy")
|
||||||
sort_order: Optional[SortOrder] = Field(None, alias="sortOrder")
|
sort_order: Optional[SortOrder] = Field(None, alias="sortOrder")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class PaginatedResponse(BaseModel):
|
class PaginatedResponse(BaseModel):
|
||||||
data: List[Any] # Will be typed specifically when used
|
data: List[Any] # Will be typed specifically when used
|
||||||
@ -745,8 +755,9 @@ class PaginatedResponse(BaseModel):
|
|||||||
limit: int
|
limit: int
|
||||||
total_pages: int = Field(..., alias="totalPages")
|
total_pages: int = Field(..., alias="totalPages")
|
||||||
has_more: bool = Field(..., alias="hasMore")
|
has_more: bool = Field(..., alias="hasMore")
|
||||||
class Config:
|
model_config = {
|
||||||
populate_by_name = True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
class ApiResponse(BaseModel):
|
class ApiResponse(BaseModel):
|
||||||
success: bool
|
success: bool
|
||||||
|
Loading…
x
Reference in New Issue
Block a user