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