diff --git a/docker-compose.yml b/docker-compose.yml index 1cf355f..1a19592 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,7 @@ services: networks: - internal ports: + - 7860:7860 # gradio port for testing - 8912:8911 # FastAPI React server volumes: - ./cache:/root/.cache # Persist all models and GPU kernel cache diff --git a/frontend/src/components/JobMatchAnalysis.tsx b/frontend/src/components/JobMatchAnalysis.tsx index b767f86..b3794e6 100644 --- a/frontend/src/components/JobMatchAnalysis.tsx +++ b/frontend/src/components/JobMatchAnalysis.tsx @@ -14,6 +14,7 @@ import { useMediaQuery, Button, Paper, + SxProps, } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; @@ -26,12 +27,18 @@ import { BackstoryPageProps } from './BackstoryTab'; import { Job } from 'types/types'; import * as Types from 'types/types'; import { JobInfo } from './ui/JobInfo'; +import { Scrollable } from 'components/Scrollable'; + +interface JobAnalysisScore { + score: number; + skills: SkillAssessment[]; +} interface JobAnalysisProps extends BackstoryPageProps { job: Job; candidate: Candidate; variant?: 'small' | 'normal'; - onAnalysisComplete: (skills: SkillAssessment[]) => void; + onAnalysisComplete: (analysis: JobAnalysisScore) => void; } interface SkillMatch extends SkillAssessment { @@ -40,6 +47,111 @@ interface SkillMatch extends SkillAssessment { matchScore: number; } +const JobMatchScore: React.FC<{ score: number; variant?: 'small' | 'normal'; sx?: SxProps }> = ({ + variant = 'normal', + score, + sx = {}, +}) => { + const theme = useTheme(); + const getMatchColor = (score: number): string => { + if (score >= 80) return theme.palette.success.main; + if (score >= 60) return theme.palette.info.main; + if (score >= 40) return theme.palette.warning.main; + return theme.palette.error.main; + }; + const suffix = variant === 'small' ? '' : ' Match'; + return ( + + = 80 + ? `Excellent${suffix}` + : score >= 60 + ? `Good${suffix}` + : score >= 40 + ? `Partial${suffix}` + : `Low${suffix}` + } + sx={{ + bgcolor: getMatchColor(score), + color: 'white', + fontWeight: 'bold', + }} + /> + + + + + {`${Math.round(score)}%`} + + + + + ); +}; + +const calculateScore = (skillMatch: SkillAssessment): number => { + let score = 0; + switch (skillMatch.evidenceStrength.toUpperCase()) { + case 'STRONG': + score = 100; + break; + case 'MODERATE': + score = 75; + break; + case 'WEAK': + score = 50; + break; + case 'NONE': + score = 0; + break; + } + if ( + skillMatch.evidenceStrength === 'none' && + skillMatch.evidenceDetails && + skillMatch.evidenceDetails.length > 3 + ) { + score = Math.min(skillMatch.evidenceDetails.length * 8, 40); + } + return score; +}; + const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) => { const { job, candidate, onAnalysisComplete, variant = 'normal' } = props; const { apiClient } = useAuth(); @@ -49,11 +161,12 @@ const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) = const [loadingRequirements, setLoadingRequirements] = useState(false); const [expanded, setExpanded] = useState(false); const [overallScore, setOverallScore] = useState(0); - const [startAnalysis, setStartAnalysis] = useState(false); const [analyzing, setAnalyzing] = useState(false); const [matchStatus, setMatchStatus] = useState(''); const [percentage, setPercentage] = useState(0); - + const [analysis, setAnalysis] = useState(null); + const [startAnalysis, setStartAnalysis] = useState(true); + const [firstRun, setFirstRun] = useState(true); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); // Handle accordion expansion @@ -155,59 +268,54 @@ const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) = // Fetch match data for each requirement useEffect(() => { - if (!startAnalysis || analyzing || !job.requirements) { + if ( + (!startAnalysis && !firstRun) || + analyzing || + !job.requirements || + requirements.length === 0 + ) { return; } - const fetchMatchData = async (skills: SkillAssessment[]): Promise => { - if (requirements.length === 0) return; - - // Process requirements one by one + const fetchMatchData = async (firstRun: boolean): Promise => { + const currentAnalysis = await apiClient.getJobAnalysis(job, candidate); for (let i = 0; i < requirements.length; i++) { try { - setSkillMatches(prev => { - const updated = [...prev]; - updated[i] = { ...updated[i], status: 'pending' }; - return updated; - }); - - const request = await apiClient.candidateMatchForRequirement( - candidate.id || '', - requirements[i].requirement, - skillMatchHandlers + let match: SkillMatch; + const existingMatch = currentAnalysis?.skills.find( + (match: SkillAssessment) => match.skill === requirements[i].requirement ); - const result = await request.promise; - const skillMatch = result.skillAssessment; - skills.push(skillMatch); - setMatchStatus(''); - let matchScore = 0; - switch (skillMatch.evidenceStrength.toUpperCase()) { - case 'STRONG': - matchScore = 100; - break; - case 'MODERATE': - matchScore = 75; - break; - case 'WEAK': - matchScore = 50; - break; - case 'NONE': - matchScore = 0; - break; + if (existingMatch) { + match = { + ...existingMatch, + status: 'complete', + matchScore: calculateScore(existingMatch), + domain: requirements[i].domain, + }; + } else { + setSkillMatches(prev => { + const updated = [...prev]; + updated[i] = { ...updated[i], status: 'pending' }; + return updated; + }); + + const request = await apiClient.candidateMatchForRequirement( + candidate.id || '', + requirements[i].requirement, + skillMatchHandlers + ); + const result = await request.promise; /* Wait for the streaming result to complete */ + const skillMatch = result.skillAssessment; + setMatchStatus(''); + + match = { + ...skillMatch, + status: 'complete', + matchScore: calculateScore(skillMatch), + domain: requirements[i].domain, + }; } - if ( - skillMatch.evidenceStrength === 'none' && - skillMatch.evidenceDetails && - skillMatch.evidenceDetails.length > 3 - ) { - matchScore = Math.min(skillMatch.evidenceDetails.length * 8, 40); - } - const match: SkillMatch = { - ...skillMatch, - status: 'complete', - matchScore, - domain: requirements[i].domain, - }; + setSkillMatches(prev => { const updated = [...prev]; updated[i] = match; @@ -221,7 +329,7 @@ const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) = const newOverallScore = completedMatches.reduce((sum, match) => sum + match.matchScore, 0) / completedMatches.length; - setOverallScore(newOverallScore); + setOverallScore(Math.round(newOverallScore)); } return current; }); @@ -243,15 +351,14 @@ const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) = setAnalyzing(true); setPercentage(0); - const skills: SkillAssessment[] = []; - fetchMatchData(skills).then(() => { + + fetchMatchData(firstRun).then(() => { + setFirstRun(false); setAnalyzing(false); setStartAnalysis(false); - onAnalysisComplete && onAnalysisComplete(skills); }); }, [ job, - onAnalysisComplete, startAnalysis, analyzing, requirements, @@ -259,8 +366,30 @@ const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) = apiClient, candidate.id, skillMatchHandlers, + firstRun, ]); + useEffect(() => { + if (skillMatches.length === 0) { + return; + } + const finishedAnalysis = skillMatches.every( + match => match.status === 'complete' || match.status === 'error' + ); + if (!finishedAnalysis) { + return; + } + if (analysis && analysis.score === overallScore) { + return; // No change in score, skip setting analysis + } + const newAnalysis: JobAnalysisScore = { + score: overallScore, + skills: skillMatches, + }; + setAnalysis(newAnalysis); + onAnalysisComplete && onAnalysisComplete(newAnalysis); + }, [onAnalysisComplete, skillMatches, overallScore, analysis]); + // Get color based on match score const getMatchColor = (score: number): string => { if (score >= 80) return theme.palette.success.main; @@ -284,7 +413,17 @@ const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) = }; return ( - + {variant !== 'small' && } = (props: JobAnalysisProps) = gap: 1, }} > - {overallScore !== 0 && ( - - = 80 - ? 'Excellent Match' - : overallScore >= 60 - ? 'Good Match' - : overallScore >= 40 - ? 'Partial Match' - : 'Low Match' - } - sx={{ - bgcolor: getMatchColor(overallScore), - color: 'white', - fontWeight: 'bold', - }} - /> - - - - - {`${Math.round(overallScore)}%`} - - - - + {analyzing && overallScore !== 0 && ( + )} {analyzing && ( = (props: JobAnalysisProps) = onClick={beginAnalysis} variant="contained" > - {analyzing ? 'Assessment in Progress' : 'Start Skill Assessment'} + {analyzing ? 'Assessment in Progress' : 'Assess Unknown Skills'} @@ -638,8 +720,9 @@ const JobMatchAnalysis: React.FC = (props: JobAnalysisProps) = ))} )} - + ); }; -export { JobMatchAnalysis }; +export type { JobAnalysisScore }; +export { JobMatchAnalysis, JobMatchScore }; diff --git a/frontend/src/components/ResumeGenerator.tsx b/frontend/src/components/ResumeGenerator.tsx index 44fb829..23a15dc 100644 --- a/frontend/src/components/ResumeGenerator.tsx +++ b/frontend/src/components/ResumeGenerator.tsx @@ -138,22 +138,25 @@ const ResumeGenerator: React.FC = (props: ResumeGeneratorP }, [apiClient, candidate.id, job.id, resume, setSnack, navigate, prompt, systemPrompt]); return ( - - {user?.isAdmin && ( - - - } label="System" /> - } label="Prompt" /> - } label="Resume" /> - - - )} + + + } label="System" /> + } label="Prompt" /> + } label="Resume" /> + + {status && ( @@ -191,7 +194,7 @@ const ResumeGenerator: React.FC = (props: ResumeGeneratorP Save Resume and Edit )} - + ); }; diff --git a/frontend/src/components/ui/JobsView.tsx b/frontend/src/components/ui/JobsView.tsx index dd52e67..0d60717 100644 --- a/frontend/src/components/ui/JobsView.tsx +++ b/frontend/src/components/ui/JobsView.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { Box, Paper, @@ -29,6 +29,7 @@ import { Alert, Tooltip, Grid, + SxProps, } from '@mui/material'; import { KeyboardArrowUp as ArrowUpIcon, @@ -77,6 +78,7 @@ interface JobsViewProps { showActions?: boolean; showDetailsPanel?: boolean; variant?: 'table' | 'list' | 'responsive'; + sx?: SxProps; } const Transition = React.forwardRef(function Transition( @@ -93,7 +95,7 @@ const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?: onClose, inDialog = false, }) => ( - void; inDialog?: )} */} - + ); const JobsView: React.FC = ({ @@ -175,6 +177,7 @@ const JobsView: React.FC = ({ showActions = true, showDetailsPanel = true, filter = {}, + sx = {}, }) => { const theme = useTheme(); const { apiClient, user } = useAuth(); @@ -196,9 +199,11 @@ const JobsView: React.FC = ({ const [sortOrder, setSortOrder] = React.useState('desc'); const [mobileDialogOpen, setMobileDialogOpen] = React.useState(false); const [detailsPanelOpen, setDetailsPanelOpen] = React.useState(showDetailsPanel); + if (location.pathname.indexOf('/candidate/jobs') === 0) { filter = { ...filter, owner_id: user?.id || '' }; } + const fetchJobs = React.useCallback( async (pageNum = 0, searchTerm = '') => { try { @@ -220,11 +225,24 @@ const JobsView: React.FC = ({ } const sortedJobs = sortJobs(paginationResponse.data, sortField, sortOrder); - setJobs(sortedJobs); - setTotal(paginationResponse.total); - - if (sortedJobs.length > 0 && !selectedJob && detailsPanelOpen) { - setSelectedJob(sortedJobs[0]); + let updated = false; + if (jobs.length) { + if (sortedJobs.length !== jobs.length) { + updated = true; + } else { + for (let i = 0; i < sortedJobs.length; i++) { + if (sortedJobs[i].id !== jobs[i].id) { + updated = true; + break; + } + } + } + } else { + updated = true; + } + if (updated) { + setJobs(sortedJobs); + setTotal(paginationResponse.total); } } catch (err) { setError(err instanceof Error ? err.message : 'An error occurred while fetching jobs'); @@ -234,10 +252,18 @@ const JobsView: React.FC = ({ setLoading(false); } }, - [limit, sortField, sortOrder, selectedJob, detailsPanelOpen, apiClient] + [limit, sortField, sortOrder, apiClient] ); + useEffect(() => { + if (jobs.length > 0 && !selectedJob && detailsPanelOpen) { + console.log('Setting selected job from fetchJobs'); + setSelectedJob(jobs[0]); + } + }, [jobs, selectedJob, detailsPanelOpen]); + React.useEffect(() => { + console.log('Fetching jobs with filter:', filter, 'searchQuery:', searchQuery); fetchJobs(0, searchQuery); }, [fetchJobs, searchQuery]); @@ -274,6 +300,7 @@ const JobsView: React.FC = ({ }; const handleSearchChange = (event: React.ChangeEvent): void => { + console.log('Handling search change:', event.target.value); const value = event.target.value; setSearchQuery(value); @@ -290,11 +317,13 @@ const JobsView: React.FC = ({ }; const handlePageChange = (event: unknown, newPage: number): void => { + console.log('Handling page change:', newPage); setPage(newPage); fetchJobs(newPage, searchQuery); }; const handleRowsPerPageChange = (event: React.ChangeEvent): void => { + console.log('Handling rows per page change:', event.target.value); const newLimit = parseInt(event.target.value, 10); setLimit(newLimit); setPage(0); @@ -335,17 +364,10 @@ const JobsView: React.FC = ({ }; const handleJobRowClick = (job: Types.Job): void => { - /* If not selectable, just view the job */ - if (!selectable) { - setSelectedJob(job); - onJobView?.(job); - return; - } - if (isMobile) { - setSelectedJob(job); + setSelectedJob(job); + if (isMobile && showDetailsPanel) { setMobileDialogOpen(true); - } else if (detailsPanelOpen) { - setSelectedJob(job); + } else if (detailsPanelOpen || !isMobile) { setDetailsPanelOpen(true); } onJobView?.(job); @@ -477,7 +499,7 @@ const JobsView: React.FC = ({ Updated {getSortIcon('updatedAt')} - Status + {/* Status */} {showActions && Actions} @@ -550,13 +572,13 @@ const JobsView: React.FC = ({ {formatDate(job.updatedAt)} - + {/* - + */} {showActions && ( e.stopPropagation()}> @@ -605,7 +627,7 @@ const JobsView: React.FC = ({ ); return ( - + @@ -618,7 +640,14 @@ const JobsView: React.FC = ({ > {selectedJob ? ( - setSelectedJob(null)} /> + { + console.log('Closing JobInfoPanel'); + setDetailsPanelOpen(false); + setSelectedJob(null); + }} + /> ) : ( }, - { requiredState: ['job'], title: 'Select Candidate', icon: }, - { - requiredState: ['job', 'candidate'], - title: 'Job Analysis', - icon: , - }, - { - requiredState: ['job', 'candidate', 'analysis'], - title: 'Generated Resume', - icon: , - }, -].map((item, index) => { - return { ...item, index, label: item.title.toLowerCase().replace(/ /g, '-') }; -}); - const capitalize = (str: string): string => { return str.charAt(0).toUpperCase() + str.slice(1); }; @@ -110,53 +82,73 @@ const JobAnalysisPage: React.FC = () => { const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate(); const { selectedJob, setSelectedJob } = useSelectedJob(); - const [activeStep, setActiveStep] = useState(steps[0]); const [error, setError] = useState(null); const [jobTab, setJobTab] = useState('select'); - const [analysisState, setAnalysisState] = useState(null); + const [analysisState, setAnalysisState] = useState({ + ...initialState, + candidate: selectedCandidate, + job: selectedJob, + }); const [canAdvance, setCanAdvance] = useState(false); const scrollRef = useRef(null); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + // const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const [activeStep, setActiveStep] = useState(user === null ? 0 : 1); + const maxStep = 4; - const canAccessStep = useCallback( - (step: StepData) => { - if (!analysisState) { - return; + const getMissingStepRequirement = useCallback( + (step: number) => { + switch (step) { + case 0 /* candidate selection */: + break; + case 1 /* job selection */: + if (!analysisState.candidate) { + return 'candidate'; + } + break; + case 2 /* job analysis */: + if (!analysisState.candidate) { + return 'candidate'; + } + if (!analysisState.job) { + return 'job'; + } + break; + case 3 /* resume generation */: + if (!analysisState.candidate) { + return 'candidate'; + } + if (!analysisState.job) { + return 'job'; + } + if (!analysisState.analysis) { + return 'analysis'; + } + break; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const missing = step.requiredState.find(f => !(analysisState as any)[f]); - return missing; + return null; }, [analysisState] ); useEffect(() => { - if (analysisState !== null) { + /* Prevent recusrive state war */ + if (analysisState.candidate === selectedCandidate && analysisState.job === selectedJob) { return; } - const analysis = { ...initialState, candidate: selectedCandidate, job: selectedJob, }; setAnalysisState(analysis); - for (let i = steps.length - 1; i >= 0; i--) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const missing = steps[i].requiredState.find(f => !(analysis as any)[f]); - if (!missing) { - setActiveStep(steps[i]); - return; - } - } - }, [analysisState, selectedCandidate, selectedJob, setActiveStep, canAccessStep]); + }, [analysisState, selectedCandidate, selectedJob, setActiveStep, getMissingStepRequirement]); useEffect(() => { - if (activeStep.index === steps.length - 1) { + if (activeStep === maxStep) { setCanAdvance(false); return; } - const blocked = canAccessStep(steps[activeStep.index + 1]); + const blocked = getMissingStepRequirement(activeStep + 1); if (blocked) { setCanAdvance(false); } else { @@ -168,58 +160,51 @@ const JobAnalysisPage: React.FC = () => { behavior: 'smooth', }); } - }, [setCanAdvance, analysisState, activeStep, canAccessStep]); + }, [setCanAdvance, analysisState, activeStep, getMissingStepRequirement]); const handleNext = (): void => { - if (activeStep.index === steps.length - 1) { + if (activeStep === maxStep) { return; } - const missing = canAccessStep(steps[activeStep.index + 1]); - if (missing) { - setError(`${capitalize(missing)} is necessary before continuing.`); - return; + let nextStep = activeStep; + for (let i = activeStep + 1; i < maxStep; i++) { + if (getMissingStepRequirement(i)) { + break; + } + nextStep = i; } - - if (activeStep.index < steps.length - 1) { - setActiveStep(prevActiveStep => steps[prevActiveStep.index + 1]); + if (nextStep !== activeStep) { + setActiveStep(nextStep); } }; const handleBack = (): void => { - if (activeStep.index === 0) { + if (activeStep === 0) { return; } - setActiveStep(prevActiveStep => steps[prevActiveStep.index - 1]); + setActiveStep(prevActiveStep => prevActiveStep - 1); }; const moveToStep = (step: number): void => { - const missing = canAccessStep(steps[step]); + const missing = getMissingStepRequirement(step); if (missing) { setError(`${capitalize(missing)} is needed to access this step.`); return; } - setActiveStep(steps[step]); + setActiveStep(step); }; const onCandidateSelect = (candidate: Candidate): void => { - if (!analysisState) { - return; - } analysisState.candidate = candidate; setAnalysisState({ ...analysisState }); setSelectedCandidate(candidate); - handleNext(); }; const onJobsSelected = (job: Job): void => { - if (!analysisState) { - return; - } analysisState.job = job; setAnalysisState({ ...analysisState }); setSelectedJob(job); - handleNext(); }; // Render function for the candidate selection step @@ -234,8 +219,25 @@ const JobAnalysisPage: React.FC = () => { // Render function for the job description step const renderJobDescription = (): JSX.Element => { return ( - - + + } label="Select Job" /> } label="Create Job" /> @@ -243,7 +245,18 @@ const JobAnalysisPage: React.FC = () => { {jobTab === 'select' && ( - + )} {jobTab === 'create' && user && ( = () => { ); }; - const onAnalysisComplete = (skills: SkillAssessment[]): void => { - if (!analysisState) { - return; - } - analysisState.analysis = skills; - setAnalysisState({ ...analysisState }); - }; + const onAnalysisComplete = useCallback( + (analysis: JobAnalysisScore): void => { + if (analysis.score === analysisState.analysis?.score) { + return; + } + console.log('Analysis complete:', analysis); + setAnalysisState({ ...analysisState, analysis }); + }, + [analysisState] + ); // Render function for the analysis step const renderAnalysis = (): JSX.Element => { - if (!analysisState) { - return <>; - } if (!analysisState.job || !analysisState.candidate) { return ( @@ -289,33 +302,26 @@ const JobAnalysisPage: React.FC = () => { ); } return ( - - - + ); }; const renderResume = (): JSX.Element => { - if (!analysisState) { - return <>; - } if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) { return <>; } return ( - - - + ); }; @@ -326,102 +332,245 @@ const JobAnalysisPage: React.FC = () => { flexDirection: 'column', height: '100%' /* Restrict to main-container's height */, width: '100%', - minHeight: 0 /* Prevent flex overflow */, - maxHeight: 'min-content', - '& > *:not(.Scrollable)': { - flexShrink: 0 /* Prevent shrinking */, - }, position: 'relative', }} > - - - {steps.map((step, index) => ( - - { - moveToStep(index); - }} - slots={{ - stepIcon: (): JSX.Element => ( - = step.index - ? theme.palette.primary.main - : theme.palette.grey[300], - color: 'white', - }} - > - {step.icon} - - ), + + + + { + moveToStep(0); + }} + slots={{ + stepIcon: (): JSX.Element => ( + = 0 ? theme.palette.primary.main : theme.palette.grey[300], + color: 'white', + }} + > + + + ), + }} + > + - {step.title} - - - ))} + Candidate Selection + {user !== null && ( + + + Name + {user?.fullName} + + + )} + + + + + + { + moveToStep(1); + }} + slots={{ + stepIcon: (): JSX.Element => ( + = 1 ? theme.palette.primary.main : theme.palette.grey[300], + color: 'white', + }} + > + + + ), + }} + > + + Job Selection + {selectedJob !== null && ( + + + Company + {selectedJob.company} + + + Title + {selectedJob.title} + + + )} + + + + + + { + moveToStep(2); + }} + slots={{ + stepIcon: (): JSX.Element => ( + = 2 ? theme.palette.primary.main : theme.palette.grey[300], + color: 'white', + }} + > + + + ), + }} + > + + Job Analysis + {analysisState.analysis !== null && ( + + + + )} + + + + + + { + moveToStep(3); + }} + slots={{ + stepIcon: (): JSX.Element => ( + = 3 ? theme.palette.primary.main : theme.palette.grey[300], + color: 'white', + }} + > + + + ), + }} + > + + Generate Resume + + + - - {analysisState && analysisState.job && ( - - {!isMobile && ( - - - - )} - - - )} - {isMobile && } - {!isMobile && } - {analysisState && analysisState.candidate && ( - - - - )} - - - {activeStep.label === 'job-selection' && renderJobDescription()} - {activeStep.label === 'select-candidate' && renderCandidateSelection()} - {activeStep.label === 'job-analysis' && renderAnalysis()} - {activeStep.label === 'generated-resume' && renderResume()} - + {activeStep === 0 && renderCandidateSelection()} + {activeStep === 1 && renderJobDescription()} + {activeStep === 2 && renderAnalysis()} + {activeStep === 3 && renderResume()} + - - {activeStep.index === steps[steps.length - 1].index ? ( + {activeStep === maxStep ? ( ) : ( )} diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index 36f6e3f..a4f0f44 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -545,6 +545,22 @@ class ApiClient { return this.handleApiResponseWithConversion(response, 'Candidate'); } + async getJobAnalysis(job: Types.Job, candidate: Types.Candidate): Promise { + const data: Types.JobAnalysis = { + jobId: job.id || '', + candidateId: candidate.id || '', + skills: [], + }; + const request = formatApiRequest(data); + const response = await fetch(`${this.baseUrl}/candidates/job-analysis`, { + method: 'POST', + headers: this.defaultHeaders, + body: JSON.stringify(request), + }); + + return this.handleApiResponseWithConversion(response, 'JobAnalysis'); + } + async updateCandidate(id: string, updates: Partial): Promise { const request = formatApiRequest(updates); const response = await fetch(`${this.baseUrl}/candidates/${id}`, { diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 3171691..f2521b2 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-06-19T22:17:35.101284 +// Generated on: 2025-07-01T21:24:10.743667 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -721,6 +721,12 @@ export interface Job { details?: JobDetails; } +export interface JobAnalysis { + jobId: string; + candidateId: string; + skills: Array; +} + export interface JobApplication { id?: string; jobId: string; @@ -1042,6 +1048,7 @@ export interface SkillAssessment { createdAt?: Date; updatedAt?: Date; ragResults?: Array; + matchScore: number; } export interface SocialLink { @@ -1666,6 +1673,19 @@ export function convertJobFromApi(data: any): Job { details: data.details ? convertJobDetailsFromApi(data.details) : undefined, }; } +/** + * Convert JobAnalysis from API response + * Nested models: skills (SkillAssessment) + */ +export function convertJobAnalysisFromApi(data: any): JobAnalysis { + if (!data) return data; + + return { + ...data, + // Convert nested SkillAssessment model + skills: data.skills.map((item: any) => convertSkillAssessmentFromApi(item)), + }; +} /** * Convert JobApplication from API response * Date fields: appliedDate, updatedDate @@ -1973,6 +1993,8 @@ export function convertFromApi(data: any, modelType: string): T { return convertInterviewScheduleFromApi(data) as T; case 'Job': return convertJobFromApi(data) as T; + case 'JobAnalysis': + return convertJobAnalysisFromApi(data) as T; case 'JobApplication': return convertJobApplicationFromApi(data) as T; case 'JobDetails': diff --git a/src/backend/agents/generate_resume.py b/src/backend/agents/generate_resume.py index e14c513..92dd99f 100644 --- a/src/backend/agents/generate_resume.py +++ b/src/backend/agents/generate_resume.py @@ -74,13 +74,14 @@ class GenerateResume(Agent): # Build the system prompt system_prompt = f"""You are a professional resume writer with expertise in highlighting candidate strengths and experiences. -Create a polished, concise, and ATS-friendly resume for the candidate based on the assessment data provided. +Create a polished, concise, and ATS-friendly resume for the candidate based on the assessment data provided. Rephrase skills to avoid +direct duplication from the assessment. ## CANDIDATE INFORMATION: Name: {self.user.full_name} -Email: {self.user.email or 'N/A'} -Phone: {self.user.phone or 'N/A'} -{f'Location: {json.dumps(self.user.location.model_dump())}' if self.user.location else ''} +Email: {self.user.email or "N/A"} +Phone: {self.user.phone or "N/A"} +{f"Location: {json.dumps(self.user.location.model_dump())}" if self.user.location else ""} ## SKILL ASSESSMENT RESULTS: """ @@ -148,7 +149,7 @@ When sections lack data, output "Information not provided" or use placeholder te 5. Use action verbs and quantifiable achievements where possible. 6. Maintain a professional tone throughout. 7. Be concise and impactful - the resume should be 1-2 pages MAXIMUM. -8. Ensure all information is accurate to the original resume - do not embellish or fabricate experiences. +8. Ensure all information is accurate to the evidence provided - do not embellish or fabricate experiences. If SKILL ASSESSMENT RESULTS or EXPERIENCE EVIDENCE sections are empty: - Do not create fictional work history diff --git a/src/backend/models.py b/src/backend/models.py index 4c7da03..49b7f58 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -126,7 +126,6 @@ class ChromaDBGetResponse(BaseModel): umap_embedding_2d: Optional[List[float]] = Field(default=None, alias=str("umapEmbedding2D")) umap_embedding_3d: Optional[List[float]] = Field(default=None, alias=str("umapEmbedding3D")) - class SkillAssessment(BaseModel): candidate_id: str = Field(..., alias=str("candidateId")) skill: str = Field(..., alias=str("skill"), description="The skill being assessed") @@ -157,8 +156,14 @@ class SkillAssessment(BaseModel): created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("createdAt")) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt")) rag_results: List[ChromaDBGetResponse] = Field(default_factory=list, alias=str("ragResults")) + match_score: float = Field(default=0.0, alias=str("matchScore")) model_config = ConfigDict(populate_by_name=True) +class JobAnalysis(BaseModel): + job_id: str = Field(..., alias=str("jobId")) + candidate_id: str = Field(..., alias=str("candidateId")) + skills: List[SkillAssessment] = Field(...) + model_config = ConfigDict(populate_by_name=True) class ApiMessageType(str, Enum): BINARY = "binary" diff --git a/src/backend/routes/candidates.py b/src/backend/routes/candidates.py index f45bd19..becbdb3 100644 --- a/src/backend/routes/candidates.py +++ b/src/backend/routes/candidates.py @@ -51,6 +51,7 @@ from models import ( DocumentType, DocumentUpdateRequest, Job, + JobAnalysis, JobRequirements, CreateCandidateRequest, Candidate, @@ -1436,6 +1437,66 @@ async def get_candidate_chat_summary( return JSONResponse(status_code=500, content=create_error_response("SUMMARY_ERROR", str(e))) +@router.post("/job-analysis") +async def post_job_analysis( + request: JobAnalysis = Body(...), + current_user=Depends(get_current_user), + database: RedisDatabase = Depends(get_database), +): + """Get chat activity summary for a candidate""" + try: + candidate_id = request.candidate_id + candidate_data = await database.get_candidate(candidate_id) + if not candidate_data: + logger.warning(f"⚠️ Candidate not found for ID: {candidate_id}") + return JSONResponse( + status_code=404, + content=create_error_response("CANDIDATE_NOT_FOUND", f"Candidate with id '{candidate_id}' not found"), + ) + + candidate = Candidate.model_validate(candidate_data) + + job_id = request.job_id + job_data = await database.get_job(job_id) + if not job_data: + logger.warning(f"⚠️ Job not found for ID: {job_id}") + return JSONResponse( + status_code=404, + content=create_error_response("JOB_NOT_FOUND", f"Job with id '{job_id}' not found"), + ) + + job = Job.model_validate(job_data) + + uninitalized = False + requirements = get_requirements_list(job) + + logger.info( + f"🔍 Checking skill match for candidate {candidate.username} against job {job.id}'s {len(requirements)} requirements." + ) + matched_skills: List[SkillAssessment] = [] + + for req in requirements: + skill = req.get("requirement", None) + if not skill: + logger.warning(f"⚠️ No 'requirement' found in entry: {req}") + continue + cache_key = get_skill_cache_key(candidate.id, skill) + assessment: SkillAssessment | None = await database.get_cached_skill_match(cache_key) + if not assessment: + logger.info(f"💾 No cached skill match data: {cache_key}, {candidate.id}, {skill}") + continue + else: + logger.info(f"✅ Assessment found for {candidate.username} skill {assessment.skill}: {cache_key}") + matched_skills.append(assessment) + + request.skills = matched_skills + return create_success_response(request.model_dump(by_alias=True)) + + except Exception as e: + logger.error(f"❌ Get candidate job analysis error: {e}") + return JSONResponse(status_code=500, content=create_error_response("JOB_ANALYSIS_ERROR", str(e))) + + @router.post("/{candidate_id}/skill-match") async def get_candidate_skill_match( candidate_id: str = Path(...), diff --git a/src/backend/system_info.py b/src/backend/system_info.py index 3803255..e7a552d 100644 --- a/src/backend/system_info.py +++ b/src/backend/system_info.py @@ -2,7 +2,7 @@ import defines import re import subprocess import math -from models import SystemInfo +from models import GPUInfo, SystemInfo def get_installed_ram(): @@ -12,11 +12,12 @@ def get_installed_ram(): match = re.search(r"MemTotal:\s+(\d+)", meminfo) if match: return f"{math.floor(int(match.group(1)) / 1000**2)}GB" # Convert KB to GB + return "RAM information not found" except Exception as e: return f"Error retrieving RAM: {e}" -def get_graphics_cards(): +def get_graphics_cards() -> list[GPUInfo]: gpus = [] try: # Run the ze-monitor utility @@ -55,8 +56,8 @@ def get_graphics_cards(): continue return gpus - except Exception as e: - return f"Error retrieving GPU info: {e}" + except Exception: + return gpus def get_cpu_info(): @@ -67,6 +68,7 @@ def get_cpu_info(): cores_match = re.findall(r"processor\s+:\s+\d+", cpuinfo) if model_match and cores_match: return f"{model_match.group(1)} with {len(cores_match)} cores" + return "CPU information not found" except Exception as e: return f"Error retrieving CPU info: {e}"