diff --git a/frontend/src/pages/CandidateChatPage.tsx b/frontend/src/pages/CandidateChatPage.tsx index 747d10d..6aeafd2 100644 --- a/frontend/src/pages/CandidateChatPage.tsx +++ b/frontend/src/pages/CandidateChatPage.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, useState, useEffect, useRef, JSX } from 'react'; -import { Box, Button, Tooltip, SxProps } from '@mui/material'; -import { Send as SendIcon } from '@mui/icons-material'; +import { Box, Button, Tooltip, SxProps, useMediaQuery, useTheme, Tabs, Tab } from '@mui/material'; +import { Send as SendIcon, Tune as TuneIcon } from '@mui/icons-material'; +import DescriptionIcon from '@mui/icons-material/Description'; import { useAuth } from 'hooks/AuthContext'; import { ChatMessage, @@ -70,15 +71,23 @@ const CandidateChatPage = forwardRef >(null); const [streamingMessage, setStreamingMessage] = useState(null); const backstoryTextRef = useRef(null); - + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); const { setSnack } = useAppState(); const [chatSession, setChatSession] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(false); const [streaming, setStreaming] = useState(false); + const [tabValue, setTabValue] = useState('chat'); const messagesEndRef = useRef(null); + const handleTabChange = (event: React.SyntheticEvent, newValue: string): void => { + if (newValue !== tabValue) { + setTabValue(newValue); + } + }; + useEffect(() => { if (!resumeId || resume) return; apiClient @@ -264,8 +273,13 @@ const CandidateChatPage = forwardRef timestamp: new Date(), content: `Welcome to the Backstory Chat about ${selectedCandidate.fullName}` + - (resume && ` and the ${resume.job?.title} position at ${resume.job?.company}`) + - `. Enter any questions you have about ${candidatePossessive} resume or skills, or select from the available questions.`, + (resume ? ` and the ${resume.job?.title} position at ${resume.job?.company}` : '') + + `. Enter any questions you have about ${candidatePossessive} ${ + resume ? 'resume' : 'work history' + } or skills, or select from the available questions.` + + (isMobile + ? `\n\nYou can also click on the "Resume" tab to ${candidatePossessive} resume for this position.` + : ''), metadata: emptyMetadata, }; @@ -275,183 +289,204 @@ const CandidateChatPage = forwardRef return ( *:not(.Scrollable)': { - flexShrink: 0 /* Prevent shrinking */, - }, - ...sx, + flexDirection: 'column', + flexGrow: 1, p: 0, - m: 0, - backgroundColor: '#D3CDBF' /* Warm Gray */, + height: '100%', + position: 'relative', }} > - {resume && ( - - - {resume.job?.title} at  - {resume.job?.company}. Last updated{' '} - {formatDate(resume.updatedAt, false, true)}. - - - - - + {isMobile && ( + + } label="Resume" /> + } label="Chat" /> + )} - *:not(.Scrollable)': { + flexShrink: 0 /* Prevent shrinking */, + }, + ...sx, + p: 0, + m: 0, + backgroundColor: '#D3CDBF' /* Warm Gray */, }} > - {!resume && ( - - )} - {/* Chat Interface */} - {/* Scrollable Messages Area */} - {chatSession && ( - - - {messages.length === 0 && } - {messages.map((message: ChatMessage) => ( - - ))} - {processingMessage !== null && ( - - )} - {streamingMessage !== null && ( - - )} - {streaming && ( - - - - )} -
- - )} - - {selectedCandidate.questions?.map((q, i) => ( - - ))} - {resume && - defaultQuestions.map((question: string, i) => ( - - ))} - - {/* Fixed Message Input */} - - { - chatSession && onDelete(chatSession); - }} - disabled={!chatSession} - sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }} - action="reset" - label="chat session" - title="Reset Chat Session" - message={`Are you sure you want to reset the session? This action cannot be undone.`} - /> - - - - - - - + {messages.length === 0 && ( + + )} + {messages.map((message: ChatMessage) => ( + + ))} + {processingMessage !== null && ( + + )} + {streamingMessage !== null && ( + + )} + {streaming && ( + + + + )} +
+ + )} + + {selectedCandidate.questions?.map((q, i) => ( + + ))} + {resume && + defaultQuestions.map((question: string, i) => ( + + ))} + + {/* Fixed Message Input */} + + { + chatSession && onDelete(chatSession); + }} + disabled={!chatSession} + sx={{ minWidth: 'auto', px: 2, maxHeight: 'min-content' }} + action="reset" + label="chat session" + title="Reset Chat Session" + message={`Are you sure you want to reset the session? This action cannot be undone.`} + /> + + + + + + + + + )} ); diff --git a/src/backend/agents/resume_chat.py b/src/backend/agents/resume_chat.py index 56b61f4..d84653f 100644 --- a/src/backend/agents/resume_chat.py +++ b/src/backend/agents/resume_chat.py @@ -80,7 +80,7 @@ class ResumeChat(Agent): ) yield error_message return - resume_data = await database.get_resume(user_id=candidate.id, resume_id=extra_context.resume_id) + resume_data = await database.get_resume(resume_id=extra_context.resume_id, user_id=candidate.id) resume = Resume.model_validate(resume_data) if resume_data else None if not resume: error_message = ChatMessageError( diff --git a/src/backend/database/mixins/protocols.py b/src/backend/database/mixins/protocols.py index 028a202..a20db87 100644 --- a/src/backend/database/mixins/protocols.py +++ b/src/backend/database/mixins/protocols.py @@ -320,7 +320,7 @@ class DatabaseProtocol(Protocol): async def get_resumes_by_job(self, user_id: str, job_id: str) -> List[Resume]: ... - async def get_resume(self, user_id: str, resume_id: str) -> Optional[Resume]: + async def get_resume(self, resume_id: str, user_id: Optional[str] = None) -> Optional[Resume]: ... async def get_resume_statistics(self, user_id: str) -> Dict[str, Any]: diff --git a/src/backend/database/mixins/resume.py b/src/backend/database/mixins/resume.py index a7774d3..14d6c7c 100644 --- a/src/backend/database/mixins/resume.py +++ b/src/backend/database/mixins/resume.py @@ -33,21 +33,52 @@ class ResumeMixin(DatabaseProtocol): logger.error(f"❌ Error saving resume for user {user_id}: {e}") return False - async def get_resume(self, user_id: str, resume_id: str) -> Optional[Resume]: - """Get a specific resume for a user""" + async def get_resume(self, resume_id: str, user_id: Optional[str] = None) -> Optional[Resume]: + """Get a specific resume by resume_id, optionally filtered by user_id""" try: - key = f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}" - data = await self.redis.get(key) - if data: - resume_data = self._deserialize(data) - logger.info(f"📄 Retrieved resume {resume_id} for user {user_id}") - return Resume.model_validate(resume_data) - logger.info(f"📄 Resume {resume_id} not found for user {user_id}") - return None - except Exception as e: - logger.error(f"❌ Error retrieving resume {resume_id} for user {user_id}: {e}") - return None + if user_id: + # If user_id is provided, use the original key structure + key = f"{KEY_PREFIXES['resumes']}{user_id}:{resume_id}" + data = await self.redis.get(key) + if data: + resume_data = self._deserialize(data) + logger.info(f"📄 Retrieved resume {resume_id} for user {user_id}") + return Resume.model_validate(resume_data) + logger.info(f"📄 Resume {resume_id} not found for user {user_id}") + return None + else: + # If no user_id provided, search for the resume across all users + pattern = f"{KEY_PREFIXES['resumes']}*:{resume_id}" + # Use SCAN to find matching keys + matching_keys = [] + async for key in self.redis.scan_iter(match=pattern): + matching_keys.append(key) + + if not matching_keys: + logger.info(f"📄 Resume {resume_id} not found") + return None + + if len(matching_keys) > 1: + logger.warning(f"📄 Multiple resumes found with ID {resume_id}, returning first match") + + # Get data from the first matching key + target_key = matching_keys[0] + data = await self.redis.get(target_key) + + if data: + resume_data = self._deserialize(data) + # Extract user_id from key for logging + key_user_id = target_key.replace(KEY_PREFIXES["resumes"], "").split(":")[0] + logger.info(f"📄 Retrieved resume {resume_id} for user {key_user_id}") + return Resume.model_validate(resume_data) + + logger.info(f"📄 Resume {resume_id} data not found") + return None + + except Exception as e: + logger.error(f"❌ Error retrieving resume {resume_id}: {e}") + return None async def get_all_resumes_for_user(self, user_id: str) -> List[Dict]: """Get all resumes for a specific user""" try: @@ -283,7 +314,7 @@ class ResumeMixin(DatabaseProtocol): """Update specific fields of a resume and save the previous version to history""" try: # Get current resume data - current_resume = await self.get_resume(user_id, resume_id) + current_resume = await self.get_resume(resume_id, user_id) if not current_resume: logger.warning(f"📄 Resume {resume_id} not found for user {user_id}") return None @@ -412,7 +443,7 @@ class ResumeMixin(DatabaseProtocol): return None # Save current version to history before restoring - current_resume = await self.get_resume(user_id, resume_id) + current_resume = await self.get_resume(resume_id, user_id) if current_resume: await self._save_resume_revision(user_id, resume_id, current_resume.model_dump()) diff --git a/src/backend/routes/resumes.py b/src/backend/routes/resumes.py index 6ed3f89..fbf5054 100644 --- a/src/backend/routes/resumes.py +++ b/src/backend/routes/resumes.py @@ -1,6 +1,7 @@ """ Resume Routes """ + import json from typing import List import uuid @@ -12,12 +13,13 @@ import backstory_traceback as backstory_traceback from database.manager import RedisDatabase from logger import logger from models import MOCK_UUID, ChatMessageError, Job, Candidate, Resume, ResumeMessage -from utils.dependencies import get_database, get_current_user +from utils.dependencies import get_database, get_current_user, get_current_user_or_guest from utils.responses import create_success_response # Create router for authentication endpoints router = APIRouter(prefix="/resumes", tags=["resumes"]) + @router.post("") async def create_candidate_resume( resume: Resume = Body(...), @@ -142,12 +144,12 @@ async def get_user_resumes(current_user=Depends(get_current_user), database: Red @router.get("/{resume_id}") async def get_resume( resume_id: str = Path(..., description="ID of the resume"), - current_user=Depends(get_current_user), + current_user=Depends(get_current_user_or_guest), database: RedisDatabase = Depends(get_database), ): """Get a specific resume by ID""" try: - resume_data = await database.get_resume(current_user.id, resume_id) + resume_data = await database.get_resume(resume_id) if not resume_data: raise HTTPException(status_code=404, detail="Resume not found") resume = Resume.model_validate(resume_data) @@ -242,6 +244,7 @@ async def get_resume_statistics( logger.error(f"❌ Error retrieving resume statistics for user {current_user.id}: {e}") raise HTTPException(status_code=500, detail="Failed to retrieve resume statistics") + @router.patch("") async def update_resume( resume: Resume = Body(...), @@ -260,7 +263,7 @@ async def update_resume( logger.warning(f"⚠️ Resume {resume.id} could not be updated for user {current_user.id}") raise HTTPException(status_code=400, detail="Failed to update resume") return create_success_response(updated_resume.model_dump(by_alias=True)) - + except HTTPException: raise except Exception as e: @@ -420,4 +423,4 @@ async def compare_resume_revisions( raise except Exception as e: logger.error(f"❌ Error comparing revisions {revision_id1} and {revision_id2} for resume {resume_id}: {e}") - raise HTTPException(status_code=500, detail="Failed to compare resume revisions") \ No newline at end of file + raise HTTPException(status_code=500, detail="Failed to compare resume revisions")