Refactored job analysis sequence

This commit is contained in:
James Ketr 2025-07-01 14:59:08 -07:00
parent e0992e77b2
commit aa6be077e6
11 changed files with 721 additions and 349 deletions

View File

@ -23,6 +23,7 @@ services:
networks: networks:
- internal - internal
ports: ports:
- 7860:7860 # gradio port for testing
- 8912:8911 # FastAPI React server - 8912:8911 # FastAPI React server
volumes: volumes:
- ./cache:/root/.cache # Persist all models and GPU kernel cache - ./cache:/root/.cache # Persist all models and GPU kernel cache

View File

@ -14,6 +14,7 @@ import {
useMediaQuery, useMediaQuery,
Button, Button,
Paper, Paper,
SxProps,
} from '@mui/material'; } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
@ -26,12 +27,18 @@ import { BackstoryPageProps } from './BackstoryTab';
import { Job } from 'types/types'; import { Job } from 'types/types';
import * as Types from 'types/types'; import * as Types from 'types/types';
import { JobInfo } from './ui/JobInfo'; import { JobInfo } from './ui/JobInfo';
import { Scrollable } from 'components/Scrollable';
interface JobAnalysisScore {
score: number;
skills: SkillAssessment[];
}
interface JobAnalysisProps extends BackstoryPageProps { interface JobAnalysisProps extends BackstoryPageProps {
job: Job; job: Job;
candidate: Candidate; candidate: Candidate;
variant?: 'small' | 'normal'; variant?: 'small' | 'normal';
onAnalysisComplete: (skills: SkillAssessment[]) => void; onAnalysisComplete: (analysis: JobAnalysisScore) => void;
} }
interface SkillMatch extends SkillAssessment { interface SkillMatch extends SkillAssessment {
@ -40,6 +47,111 @@ interface SkillMatch extends SkillAssessment {
matchScore: number; 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 (
<Box
sx={{
width: variant === 'small' ? '8rem' : '10rem',
ml: 1,
p: 1,
gap: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: variant === 'small' ? 'row' : 'column',
...sx,
}}
>
<Chip
label={
score >= 80
? `Excellent${suffix}`
: score >= 60
? `Good${suffix}`
: score >= 40
? `Partial${suffix}`
: `Low${suffix}`
}
sx={{
bgcolor: getMatchColor(score),
color: 'white',
fontWeight: 'bold',
}}
/>
<Box
sx={{
position: 'relative',
display: 'inline-flex',
}}
>
<CircularProgress
variant="determinate"
value={score}
size={variant === 'small' ? 45 : 60}
thickness={5}
sx={{
color: getMatchColor(score),
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}>
{`${Math.round(score)}%`}
</Typography>
</Box>
</Box>
</Box>
);
};
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<JobAnalysisProps> = (props: JobAnalysisProps) => { const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => {
const { job, candidate, onAnalysisComplete, variant = 'normal' } = props; const { job, candidate, onAnalysisComplete, variant = 'normal' } = props;
const { apiClient } = useAuth(); const { apiClient } = useAuth();
@ -49,11 +161,12 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const [loadingRequirements, setLoadingRequirements] = useState<boolean>(false); const [loadingRequirements, setLoadingRequirements] = useState<boolean>(false);
const [expanded, setExpanded] = useState<string | false>(false); const [expanded, setExpanded] = useState<string | false>(false);
const [overallScore, setOverallScore] = useState<number>(0); const [overallScore, setOverallScore] = useState<number>(0);
const [startAnalysis, setStartAnalysis] = useState<boolean>(false);
const [analyzing, setAnalyzing] = useState<boolean>(false); const [analyzing, setAnalyzing] = useState<boolean>(false);
const [matchStatus, setMatchStatus] = useState<string>(''); const [matchStatus, setMatchStatus] = useState<string>('');
const [percentage, setPercentage] = useState<number>(0); const [percentage, setPercentage] = useState<number>(0);
const [analysis, setAnalysis] = useState<JobAnalysisScore | null>(null);
const [startAnalysis, setStartAnalysis] = useState<boolean>(true);
const [firstRun, setFirstRun] = useState<boolean>(true);
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
// Handle accordion expansion // Handle accordion expansion
@ -155,59 +268,54 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
// Fetch match data for each requirement // Fetch match data for each requirement
useEffect(() => { useEffect(() => {
if (!startAnalysis || analyzing || !job.requirements) { if (
(!startAnalysis && !firstRun) ||
analyzing ||
!job.requirements ||
requirements.length === 0
) {
return; return;
} }
const fetchMatchData = async (skills: SkillAssessment[]): Promise<void> => { const fetchMatchData = async (firstRun: boolean): Promise<void> => {
if (requirements.length === 0) return; const currentAnalysis = await apiClient.getJobAnalysis(job, candidate);
// Process requirements one by one
for (let i = 0; i < requirements.length; i++) { for (let i = 0; i < requirements.length; i++) {
try { try {
setSkillMatches(prev => { let match: SkillMatch;
const updated = [...prev]; const existingMatch = currentAnalysis?.skills.find(
updated[i] = { ...updated[i], status: 'pending' }; (match: SkillAssessment) => match.skill === requirements[i].requirement
return updated;
});
const request = await apiClient.candidateMatchForRequirement(
candidate.id || '',
requirements[i].requirement,
skillMatchHandlers
); );
const result = await request.promise; if (existingMatch) {
const skillMatch = result.skillAssessment; match = {
skills.push(skillMatch); ...existingMatch,
setMatchStatus(''); status: 'complete',
let matchScore = 0; matchScore: calculateScore(existingMatch),
switch (skillMatch.evidenceStrength.toUpperCase()) { domain: requirements[i].domain,
case 'STRONG': };
matchScore = 100; } else {
break; setSkillMatches(prev => {
case 'MODERATE': const updated = [...prev];
matchScore = 75; updated[i] = { ...updated[i], status: 'pending' };
break; return updated;
case 'WEAK': });
matchScore = 50;
break; const request = await apiClient.candidateMatchForRequirement(
case 'NONE': candidate.id || '',
matchScore = 0; requirements[i].requirement,
break; 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 => { setSkillMatches(prev => {
const updated = [...prev]; const updated = [...prev];
updated[i] = match; updated[i] = match;
@ -221,7 +329,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const newOverallScore = const newOverallScore =
completedMatches.reduce((sum, match) => sum + match.matchScore, 0) / completedMatches.reduce((sum, match) => sum + match.matchScore, 0) /
completedMatches.length; completedMatches.length;
setOverallScore(newOverallScore); setOverallScore(Math.round(newOverallScore));
} }
return current; return current;
}); });
@ -243,15 +351,14 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setAnalyzing(true); setAnalyzing(true);
setPercentage(0); setPercentage(0);
const skills: SkillAssessment[] = [];
fetchMatchData(skills).then(() => { fetchMatchData(firstRun).then(() => {
setFirstRun(false);
setAnalyzing(false); setAnalyzing(false);
setStartAnalysis(false); setStartAnalysis(false);
onAnalysisComplete && onAnalysisComplete(skills);
}); });
}, [ }, [
job, job,
onAnalysisComplete,
startAnalysis, startAnalysis,
analyzing, analyzing,
requirements, requirements,
@ -259,8 +366,30 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
apiClient, apiClient,
candidate.id, candidate.id,
skillMatchHandlers, 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 // Get color based on match score
const getMatchColor = (score: number): string => { const getMatchColor = (score: number): string => {
if (score >= 80) return theme.palette.success.main; if (score >= 80) return theme.palette.success.main;
@ -284,7 +413,17 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
}; };
return ( return (
<Box sx={{ display: 'flex', flexDirection: 'column', m: 0, p: 0 }}> <Scrollable
sx={{
display: 'flex',
flexDirection: 'column',
m: 0,
p: 0,
width: '100%',
minHeight: 0,
flexGrow: 1,
}}
>
{variant !== 'small' && <JobInfo job={job} variant="normal" />} {variant !== 'small' && <JobInfo job={job} variant="normal" />}
<Box <Box
@ -305,68 +444,11 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
gap: 1, gap: 1,
}} }}
> >
{overallScore !== 0 && ( {analyzing && overallScore !== 0 && (
<Paper <JobMatchScore
sx={{ score={overallScore}
width: '10rem', sx={{ width: isMobile ? '100%' : 'auto', flexGrow: 1 }}
ml: 1, />
p: 1,
gap: 1,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'column',
}}
>
<Chip
label={
overallScore >= 80
? 'Excellent Match'
: overallScore >= 60
? 'Good Match'
: overallScore >= 40
? 'Partial Match'
: 'Low Match'
}
sx={{
bgcolor: getMatchColor(overallScore),
color: 'white',
fontWeight: 'bold',
}}
/>
<Box
sx={{
position: 'relative',
display: 'inline-flex',
}}
>
<CircularProgress
variant="determinate"
value={overallScore}
size={60}
thickness={5}
sx={{
color: getMatchColor(overallScore),
}}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}>
{`${Math.round(overallScore)}%`}
</Typography>
</Box>
</Box>
</Paper>
)} )}
{analyzing && ( {analyzing && (
<Paper <Paper
@ -425,7 +507,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
onClick={beginAnalysis} onClick={beginAnalysis}
variant="contained" variant="contained"
> >
{analyzing ? 'Assessment in Progress' : 'Start Skill Assessment'} {analyzing ? 'Assessment in Progress' : 'Assess Unknown Skills'}
</Button> </Button>
</Box> </Box>
@ -638,8 +720,9 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
))} ))}
</Box> </Box>
)} )}
</Box> </Scrollable>
); );
}; };
export { JobMatchAnalysis }; export type { JobAnalysisScore };
export { JobMatchAnalysis, JobMatchScore };

View File

@ -138,22 +138,25 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
}, [apiClient, candidate.id, job.id, resume, setSnack, navigate, prompt, systemPrompt]); }, [apiClient, candidate.id, job.id, resume, setSnack, navigate, prompt, systemPrompt]);
return ( return (
<Box <Scrollable
className="ResumeGenerator" className="ResumeGenerator"
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
m: 0,
p: 0,
width: '100%',
minHeight: 0,
position: 'relative',
}} }}
> >
{user?.isAdmin && ( <Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}> <Tabs value={tabValue} onChange={handleTabChange} centered>
<Tabs value={tabValue} onChange={handleTabChange} centered> <Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" /> <Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" /> <Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" />
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" /> </Tabs>
</Tabs> </Box>
</Box>
)}
{status && ( {status && (
<Box sx={{ mt: 0, mb: 1 }}> <Box sx={{ mt: 0, mb: 1 }}>
@ -191,7 +194,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
Save Resume and Edit Save Resume and Edit
</Button> </Button>
)} )}
</Box> </Scrollable>
); );
}; };

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect } from 'react';
import { import {
Box, Box,
Paper, Paper,
@ -29,6 +29,7 @@ import {
Alert, Alert,
Tooltip, Tooltip,
Grid, Grid,
SxProps,
} from '@mui/material'; } from '@mui/material';
import { import {
KeyboardArrowUp as ArrowUpIcon, KeyboardArrowUp as ArrowUpIcon,
@ -77,6 +78,7 @@ interface JobsViewProps {
showActions?: boolean; showActions?: boolean;
showDetailsPanel?: boolean; showDetailsPanel?: boolean;
variant?: 'table' | 'list' | 'responsive'; variant?: 'table' | 'list' | 'responsive';
sx?: SxProps;
} }
const Transition = React.forwardRef(function Transition( const Transition = React.forwardRef(function Transition(
@ -93,7 +95,7 @@ const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?:
onClose, onClose,
inDialog = false, inDialog = false,
}) => ( }) => (
<Scrollable <Box
sx={{ sx={{
p: inDialog ? 2 : 1.5, p: inDialog ? 2 : 1.5,
height: '100%', height: '100%',
@ -163,7 +165,7 @@ const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?:
</Typography> </Typography>
)} */} )} */}
</Box> </Box>
</Scrollable> </Box>
); );
const JobsView: React.FC<JobsViewProps> = ({ const JobsView: React.FC<JobsViewProps> = ({
@ -175,6 +177,7 @@ const JobsView: React.FC<JobsViewProps> = ({
showActions = true, showActions = true,
showDetailsPanel = true, showDetailsPanel = true,
filter = {}, filter = {},
sx = {},
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const { apiClient, user } = useAuth(); const { apiClient, user } = useAuth();
@ -196,9 +199,11 @@ const JobsView: React.FC<JobsViewProps> = ({
const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc'); const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc');
const [mobileDialogOpen, setMobileDialogOpen] = React.useState(false); const [mobileDialogOpen, setMobileDialogOpen] = React.useState(false);
const [detailsPanelOpen, setDetailsPanelOpen] = React.useState(showDetailsPanel); const [detailsPanelOpen, setDetailsPanelOpen] = React.useState(showDetailsPanel);
if (location.pathname.indexOf('/candidate/jobs') === 0) { if (location.pathname.indexOf('/candidate/jobs') === 0) {
filter = { ...filter, owner_id: user?.id || '' }; filter = { ...filter, owner_id: user?.id || '' };
} }
const fetchJobs = React.useCallback( const fetchJobs = React.useCallback(
async (pageNum = 0, searchTerm = '') => { async (pageNum = 0, searchTerm = '') => {
try { try {
@ -220,11 +225,24 @@ const JobsView: React.FC<JobsViewProps> = ({
} }
const sortedJobs = sortJobs(paginationResponse.data, sortField, sortOrder); const sortedJobs = sortJobs(paginationResponse.data, sortField, sortOrder);
setJobs(sortedJobs); let updated = false;
setTotal(paginationResponse.total); if (jobs.length) {
if (sortedJobs.length !== jobs.length) {
if (sortedJobs.length > 0 && !selectedJob && detailsPanelOpen) { updated = true;
setSelectedJob(sortedJobs[0]); } 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) { } catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred while fetching jobs'); setError(err instanceof Error ? err.message : 'An error occurred while fetching jobs');
@ -234,10 +252,18 @@ const JobsView: React.FC<JobsViewProps> = ({
setLoading(false); 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(() => { React.useEffect(() => {
console.log('Fetching jobs with filter:', filter, 'searchQuery:', searchQuery);
fetchJobs(0, searchQuery); fetchJobs(0, searchQuery);
}, [fetchJobs, searchQuery]); }, [fetchJobs, searchQuery]);
@ -274,6 +300,7 @@ const JobsView: React.FC<JobsViewProps> = ({
}; };
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>): void => { const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
console.log('Handling search change:', event.target.value);
const value = event.target.value; const value = event.target.value;
setSearchQuery(value); setSearchQuery(value);
@ -290,11 +317,13 @@ const JobsView: React.FC<JobsViewProps> = ({
}; };
const handlePageChange = (event: unknown, newPage: number): void => { const handlePageChange = (event: unknown, newPage: number): void => {
console.log('Handling page change:', newPage);
setPage(newPage); setPage(newPage);
fetchJobs(newPage, searchQuery); fetchJobs(newPage, searchQuery);
}; };
const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>): void => { const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
console.log('Handling rows per page change:', event.target.value);
const newLimit = parseInt(event.target.value, 10); const newLimit = parseInt(event.target.value, 10);
setLimit(newLimit); setLimit(newLimit);
setPage(0); setPage(0);
@ -335,17 +364,10 @@ const JobsView: React.FC<JobsViewProps> = ({
}; };
const handleJobRowClick = (job: Types.Job): void => { const handleJobRowClick = (job: Types.Job): void => {
/* If not selectable, just view the job */ setSelectedJob(job);
if (!selectable) { if (isMobile && showDetailsPanel) {
setSelectedJob(job);
onJobView?.(job);
return;
}
if (isMobile) {
setSelectedJob(job);
setMobileDialogOpen(true); setMobileDialogOpen(true);
} else if (detailsPanelOpen) { } else if (detailsPanelOpen || !isMobile) {
setSelectedJob(job);
setDetailsPanelOpen(true); setDetailsPanelOpen(true);
} }
onJobView?.(job); onJobView?.(job);
@ -477,7 +499,7 @@ const JobsView: React.FC<JobsViewProps> = ({
Updated {getSortIcon('updatedAt')} Updated {getSortIcon('updatedAt')}
</Box> </Box>
</TableCell> </TableCell>
<TableCell>Status</TableCell> {/* <TableCell>Status</TableCell> */}
{showActions && <TableCell align="center">Actions</TableCell>} {showActions && <TableCell align="center">Actions</TableCell>}
</TableRow> </TableRow>
</TableHead> </TableHead>
@ -550,13 +572,13 @@ const JobsView: React.FC<JobsViewProps> = ({
<TableCell> <TableCell>
<Typography variant="body2">{formatDate(job.updatedAt)}</Typography> <Typography variant="body2">{formatDate(job.updatedAt)}</Typography>
</TableCell> </TableCell>
<TableCell> {/* <TableCell>
<Chip <Chip
label={job.details?.isActive ? 'Active' : 'Inactive'} label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'} color={job.details?.isActive ? 'success' : 'default'}
size="small" size="small"
/> />
</TableCell> </TableCell> */}
{showActions && ( {showActions && (
<TableCell align="center" onClick={(e): void => e.stopPropagation()}> <TableCell align="center" onClick={(e): void => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.5 }}> <Box sx={{ display: 'flex', gap: 0.5 }}>
@ -605,7 +627,7 @@ const JobsView: React.FC<JobsViewProps> = ({
); );
return ( return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'row', position: 'relative' }}> <Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', position: 'relative', ...sx }}>
<Scrollable <Scrollable
sx={{ display: 'flex', flex: 1, flexDirection: 'column', height: '100%', width: '100%' }} sx={{ display: 'flex', flex: 1, flexDirection: 'column', height: '100%', width: '100%' }}
> >
@ -618,7 +640,14 @@ const JobsView: React.FC<JobsViewProps> = ({
> >
<Paper sx={{ flex: 1, ml: 1 }}> <Paper sx={{ flex: 1, ml: 1 }}>
{selectedJob ? ( {selectedJob ? (
<JobInfoPanel job={selectedJob} onClose={(): void => setSelectedJob(null)} /> <JobInfoPanel
job={selectedJob}
onClose={(): void => {
console.log('Closing JobInfoPanel');
setDetailsPanelOpen(false);
setSelectedJob(null);
}}
/>
) : ( ) : (
<Box <Box
sx={{ sx={{

View File

@ -18,18 +18,16 @@ import { Add, WorkOutline } from '@mui/icons-material';
import PersonIcon from '@mui/icons-material/Person'; import PersonIcon from '@mui/icons-material/Person';
import WorkIcon from '@mui/icons-material/Work'; import WorkIcon from '@mui/icons-material/Work';
import AssessmentIcon from '@mui/icons-material/Assessment'; import AssessmentIcon from '@mui/icons-material/Assessment';
import { JobMatchAnalysis } from 'components/JobMatchAnalysis'; import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { Candidate, Job, SkillAssessment } from 'types/types'; import { JobMatchAnalysis, JobMatchScore, JobAnalysisScore } from 'components/JobMatchAnalysis';
import { Candidate, Job } from 'types/types';
import { BackstoryPageProps } from 'components/BackstoryTab'; import { BackstoryPageProps } from 'components/BackstoryTab';
import { useAuth } from 'hooks/AuthContext'; import { useAuth } from 'hooks/AuthContext';
import { useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext'; import { useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext';
import { CandidateInfo } from 'components/ui/CandidateInfo';
import { Scrollable } from 'components/Scrollable';
import { CandidatePicker } from 'components/ui/CandidatePicker'; import { CandidatePicker } from 'components/ui/CandidatePicker';
import { JobCreator } from 'components/JobCreator'; import { JobCreator } from 'components/JobCreator';
import { LoginRestricted } from 'components/ui/LoginRestricted'; import { LoginRestricted } from 'components/ui/LoginRestricted';
import { ResumeGenerator } from 'components/ResumeGenerator'; import { ResumeGenerator } from 'components/ResumeGenerator';
import { JobInfo } from 'components/ui/JobInfo';
import { JobsView } from 'components/ui/JobsView'; import { JobsView } from 'components/ui/JobsView';
function WorkAddIcon(): JSX.Element { function WorkAddIcon(): JSX.Element {
@ -62,18 +60,10 @@ function WorkAddIcon(): JSX.Element {
interface AnalysisState { interface AnalysisState {
job: Job | null; job: Job | null;
candidate: Candidate | null; candidate: Candidate | null;
analysis: SkillAssessment[] | null; analysis: JobAnalysisScore | null;
resume: string | null; resume: string | null;
} }
interface StepData {
index: number;
label: string;
requiredState: string[];
title: string;
icon: React.ReactNode;
}
const initialState: AnalysisState = { const initialState: AnalysisState = {
job: null, job: null,
candidate: null, candidate: null,
@ -81,24 +71,6 @@ const initialState: AnalysisState = {
resume: null, resume: null,
}; };
// Steps in our process
const steps: StepData[] = [
{ requiredState: [], title: 'Job Selection', icon: <WorkIcon /> },
{ requiredState: ['job'], title: 'Select Candidate', icon: <PersonIcon /> },
{
requiredState: ['job', 'candidate'],
title: 'Job Analysis',
icon: <WorkIcon />,
},
{
requiredState: ['job', 'candidate', 'analysis'],
title: 'Generated Resume',
icon: <AssessmentIcon />,
},
].map((item, index) => {
return { ...item, index, label: item.title.toLowerCase().replace(/ /g, '-') };
});
const capitalize = (str: string): string => { const capitalize = (str: string): string => {
return str.charAt(0).toUpperCase() + str.slice(1); return str.charAt(0).toUpperCase() + str.slice(1);
}; };
@ -110,53 +82,73 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate(); const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const { selectedJob, setSelectedJob } = useSelectedJob(); const { selectedJob, setSelectedJob } = useSelectedJob();
const [activeStep, setActiveStep] = useState<StepData>(steps[0]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [jobTab, setJobTab] = useState<string>('select'); const [jobTab, setJobTab] = useState<string>('select');
const [analysisState, setAnalysisState] = useState<AnalysisState | null>(null); const [analysisState, setAnalysisState] = useState<AnalysisState>({
...initialState,
candidate: selectedCandidate,
job: selectedJob,
});
const [canAdvance, setCanAdvance] = useState<boolean>(false); const [canAdvance, setCanAdvance] = useState<boolean>(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); // const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const [activeStep, setActiveStep] = useState<number>(user === null ? 0 : 1);
const maxStep = 4;
const canAccessStep = useCallback( const getMissingStepRequirement = useCallback(
(step: StepData) => { (step: number) => {
if (!analysisState) { switch (step) {
return; 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 return null;
const missing = step.requiredState.find(f => !(analysisState as any)[f]);
return missing;
}, },
[analysisState] [analysisState]
); );
useEffect(() => { useEffect(() => {
if (analysisState !== null) { /* Prevent recusrive state war */
if (analysisState.candidate === selectedCandidate && analysisState.job === selectedJob) {
return; return;
} }
const analysis = { const analysis = {
...initialState, ...initialState,
candidate: selectedCandidate, candidate: selectedCandidate,
job: selectedJob, job: selectedJob,
}; };
setAnalysisState(analysis); setAnalysisState(analysis);
for (let i = steps.length - 1; i >= 0; i--) { }, [analysisState, selectedCandidate, selectedJob, setActiveStep, getMissingStepRequirement]);
// 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]);
useEffect(() => { useEffect(() => {
if (activeStep.index === steps.length - 1) { if (activeStep === maxStep) {
setCanAdvance(false); setCanAdvance(false);
return; return;
} }
const blocked = canAccessStep(steps[activeStep.index + 1]); const blocked = getMissingStepRequirement(activeStep + 1);
if (blocked) { if (blocked) {
setCanAdvance(false); setCanAdvance(false);
} else { } else {
@ -168,58 +160,51 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
behavior: 'smooth', behavior: 'smooth',
}); });
} }
}, [setCanAdvance, analysisState, activeStep, canAccessStep]); }, [setCanAdvance, analysisState, activeStep, getMissingStepRequirement]);
const handleNext = (): void => { const handleNext = (): void => {
if (activeStep.index === steps.length - 1) { if (activeStep === maxStep) {
return; return;
} }
const missing = canAccessStep(steps[activeStep.index + 1]); let nextStep = activeStep;
if (missing) { for (let i = activeStep + 1; i < maxStep; i++) {
setError(`${capitalize(missing)} is necessary before continuing.`); if (getMissingStepRequirement(i)) {
return; break;
}
nextStep = i;
} }
if (nextStep !== activeStep) {
if (activeStep.index < steps.length - 1) { setActiveStep(nextStep);
setActiveStep(prevActiveStep => steps[prevActiveStep.index + 1]);
} }
}; };
const handleBack = (): void => { const handleBack = (): void => {
if (activeStep.index === 0) { if (activeStep === 0) {
return; return;
} }
setActiveStep(prevActiveStep => steps[prevActiveStep.index - 1]); setActiveStep(prevActiveStep => prevActiveStep - 1);
}; };
const moveToStep = (step: number): void => { const moveToStep = (step: number): void => {
const missing = canAccessStep(steps[step]); const missing = getMissingStepRequirement(step);
if (missing) { if (missing) {
setError(`${capitalize(missing)} is needed to access this step.`); setError(`${capitalize(missing)} is needed to access this step.`);
return; return;
} }
setActiveStep(steps[step]); setActiveStep(step);
}; };
const onCandidateSelect = (candidate: Candidate): void => { const onCandidateSelect = (candidate: Candidate): void => {
if (!analysisState) {
return;
}
analysisState.candidate = candidate; analysisState.candidate = candidate;
setAnalysisState({ ...analysisState }); setAnalysisState({ ...analysisState });
setSelectedCandidate(candidate); setSelectedCandidate(candidate);
handleNext();
}; };
const onJobsSelected = (job: Job): void => { const onJobsSelected = (job: Job): void => {
if (!analysisState) {
return;
}
analysisState.job = job; analysisState.job = job;
setAnalysisState({ ...analysisState }); setAnalysisState({ ...analysisState });
setSelectedJob(job); setSelectedJob(job);
handleNext();
}; };
// Render function for the candidate selection step // Render function for the candidate selection step
@ -234,8 +219,25 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
// Render function for the job description step // Render function for the job description step
const renderJobDescription = (): JSX.Element => { const renderJobDescription = (): JSX.Element => {
return ( return (
<Box sx={{ mt: 3, width: '100%' }}> <Box
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}> sx={{
width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
borderBottom: 1,
borderColor: 'divider',
m: 0,
}}
>
<Tabs value={jobTab} onChange={handleTabChange} centered> <Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value="select" icon={<WorkOutline />} label="Select Job" /> <Tab value="select" icon={<WorkOutline />} label="Select Job" />
<Tab value="create" icon={<WorkAddIcon />} label="Create Job" /> <Tab value="create" icon={<WorkAddIcon />} label="Create Job" />
@ -243,7 +245,18 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
</Box> </Box>
{jobTab === 'select' && ( {jobTab === 'select' && (
<JobsView selectable={false} onJobView={onJobsSelected} showDetailsPanel={false} /> <JobsView
sx={{
display: 'flex',
position: 'relative',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
flexGrow: 1,
}}
selectable={false}
onJobView={onJobsSelected}
showDetailsPanel={false}
/>
)} )}
{jobTab === 'create' && user && ( {jobTab === 'create' && user && (
<JobCreator <JobCreator
@ -265,19 +278,19 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
); );
}; };
const onAnalysisComplete = (skills: SkillAssessment[]): void => { const onAnalysisComplete = useCallback(
if (!analysisState) { (analysis: JobAnalysisScore): void => {
return; if (analysis.score === analysisState.analysis?.score) {
} return;
analysisState.analysis = skills; }
setAnalysisState({ ...analysisState }); console.log('Analysis complete:', analysis);
}; setAnalysisState({ ...analysisState, analysis });
},
[analysisState]
);
// Render function for the analysis step // Render function for the analysis step
const renderAnalysis = (): JSX.Element => { const renderAnalysis = (): JSX.Element => {
if (!analysisState) {
return <></>;
}
if (!analysisState.job || !analysisState.candidate) { if (!analysisState.job || !analysisState.candidate) {
return ( return (
<Box> <Box>
@ -289,33 +302,26 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
); );
} }
return ( return (
<Box sx={{ mt: 3 }}> <JobMatchAnalysis
<JobMatchAnalysis variant="small"
variant="small" job={analysisState.job}
job={analysisState.job} candidate={analysisState.candidate}
candidate={analysisState.candidate} onAnalysisComplete={onAnalysisComplete}
onAnalysisComplete={onAnalysisComplete} />
/>
</Box>
); );
}; };
const renderResume = (): JSX.Element => { const renderResume = (): JSX.Element => {
if (!analysisState) {
return <></>;
}
if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) { if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) {
return <></>; return <></>;
} }
return ( return (
<Box sx={{ mt: 3 }}> <ResumeGenerator
<ResumeGenerator job={analysisState.job}
job={analysisState.job} candidate={analysisState.candidate}
candidate={analysisState.candidate} skills={analysisState.analysis.skills}
skills={analysisState.analysis} />
/>
</Box>
); );
}; };
@ -326,102 +332,245 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
flexDirection: 'column', flexDirection: 'column',
height: '100%' /* Restrict to main-container's height */, height: '100%' /* Restrict to main-container's height */,
width: '100%', width: '100%',
minHeight: 0 /* Prevent flex overflow */,
maxHeight: 'min-content',
'& > *:not(.Scrollable)': {
flexShrink: 0 /* Prevent shrinking */,
},
position: 'relative', position: 'relative',
}} }}
> >
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0, gap: 1 }}> <Paper
<Stepper activeStep={activeStep.index} alternativeLabel sx={{ mt: 2, mb: 2 }}> elevation={4}
{steps.map((step, index) => ( sx={{
<Step key={step.index}> display: 'flex',
<StepLabel position: 'relative',
sx={{ cursor: 'pointer' }} m: 0,
onClick={(): void => { borderRadius: 0,
moveToStep(index); mb: 1,
}} p: 0,
slots={{ gap: 1,
stepIcon: (): JSX.Element => ( flexDirection: 'column',
<Avatar }}
key={step.index} >
sx={{ <Stepper
bgcolor: activeStep={activeStep}
activeStep.index >= step.index alternativeLabel
? theme.palette.primary.main sx={{
: theme.palette.grey[300], mt: 1,
color: 'white', mb: 1,
}} fontWeight: 'bold',
> '& .MuiStepLabel-label': {
{step.icon} display: 'flex',
</Avatar> flexDirection: 'column',
), width: '100%',
},
}}
>
<Step key={0}>
<StepLabel
sx={{ cursor: 'pointer' }}
onClick={(): void => {
moveToStep(0);
}}
slots={{
stepIcon: (): JSX.Element => (
<Avatar
sx={{
bgcolor:
activeStep >= 0 ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white',
}}
>
<PersonIcon />
</Avatar>
),
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
'& div': { display: 'flex' },
'& :first-of-type': {
whiteSpace: 'nowrap',
},
}} }}
> >
{step.title} <Box sx={{ mb: 1, justifyContent: 'center' }}>Candidate Selection</Box>
</StepLabel> {user !== null && (
</Step> <Box
))} sx={{
justifySelf: 'flex-start',
flexDirection: 'row',
fontSize: '0.75rem',
gap: 1,
width: '100%',
}}
>
<Box sx={{ flexDirection: 'column', textAlign: 'left' }}>
<Box>Name</Box>
<Box sx={{ fontWeight: 'normal' }}>{user?.fullName}</Box>
</Box>
</Box>
)}
</Box>
</StepLabel>
</Step>
<Step key={1}>
<StepLabel
sx={{ cursor: 'pointer' }}
onClick={(): void => {
moveToStep(1);
}}
slots={{
stepIcon: (): JSX.Element => (
<Avatar
sx={{
bgcolor:
activeStep >= 1 ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white',
}}
>
<WorkIcon />
</Avatar>
),
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
'& div': { display: 'flex' },
'& :first-of-type': {
whiteSpace: 'nowrap',
},
}}
>
<Box sx={{ mb: 1, justifyContent: 'center' }}>Job Selection</Box>
{selectedJob !== null && (
<Box
sx={{
justifySelf: 'flex-start',
flexDirection: 'row',
fontSize: '0.75rem',
gap: 1,
width: '100%',
}}
>
<Box sx={{ flexDirection: 'column', textAlign: 'left' }}>
<Box>Company</Box>
<Box sx={{ fontWeight: 'normal' }}>{selectedJob.company}</Box>
</Box>
<Box sx={{ flexDirection: 'column', textAlign: 'left' }}>
<Box>Title</Box>
<Box sx={{ fontWeight: 'normal' }}>{selectedJob.title}</Box>
</Box>
</Box>
)}
</Box>
</StepLabel>
</Step>
<Step key={2}>
<StepLabel
sx={{ cursor: 'pointer' }}
onClick={(): void => {
moveToStep(2);
}}
slots={{
stepIcon: (): JSX.Element => (
<Avatar
sx={{
bgcolor:
activeStep >= 2 ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white',
}}
>
<AutoAwesomeIcon />
</Avatar>
),
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
'& div': { display: 'flex' },
'& :first-of-type': {
whiteSpace: 'nowrap',
},
}}
>
<Box sx={{ mb: 1, justifyContent: 'center' }}>Job Analysis</Box>
{analysisState.analysis !== null && (
<Box sx={{ justifyContent: 'center' }}>
<JobMatchScore score={analysisState.analysis.score} variant="small" />
</Box>
)}
</Box>
</StepLabel>
</Step>
<Step key={3}>
<StepLabel
sx={{ cursor: 'pointer' }}
onClick={(): void => {
moveToStep(3);
}}
slots={{
stepIcon: (): JSX.Element => (
<Avatar
sx={{
bgcolor:
activeStep >= 3 ? theme.palette.primary.main : theme.palette.grey[300],
color: 'white',
}}
>
<AssessmentIcon />
</Avatar>
),
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
'& div': { display: 'flex' },
'& :first-of-type': {
whiteSpace: 'nowrap',
},
}}
>
<Box sx={{ mb: 1, justifyContent: 'center' }}>Generate Resume</Box>
</Box>
</StepLabel>
</Step>
</Stepper> </Stepper>
<Box sx={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row' }}>
{analysisState && analysisState.job && (
<Box sx={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
{!isMobile && (
<Avatar
sx={{
ml: 1,
mt: 1,
bgcolor: theme.palette.primary.main,
color: 'white',
}}
>
<WorkIcon />
</Avatar>
)}
<JobInfo variant="minimal" job={analysisState.job} />
</Box>
)}
{isMobile && <Box sx={{ display: 'flex', borderBottom: '1px solid lightgrey' }} />}
{!isMobile && <Box sx={{ display: 'flex', borderLeft: '1px solid lightgrey' }} />}
{analysisState && analysisState.candidate && (
<Box sx={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
<CandidateInfo variant="minimal" candidate={analysisState.candidate} sx={{}} />
</Box>
)}
</Box>
</Paper> </Paper>
<Scrollable <Box
ref={scrollRef} ref={scrollRef}
sx={{ sx={{
position: 'relative', position: 'relative',
maxHeight: '100%', minHeight: 0 /* Prevent flex overflow */,
// maxHeight: 'min-content',
width: '100%', width: '100%',
display: 'flex', display: 'flex',
flexGrow: 1, flexGrow: 1,
flex: 1 /* Take remaining space in some-container */, overflowY: 'hidden',
overflowY: 'auto' /* Scroll if content overflows */, m: 0,
p: 0,
}} }}
> >
{activeStep.label === 'job-selection' && renderJobDescription()} {activeStep === 0 && renderCandidateSelection()}
{activeStep.label === 'select-candidate' && renderCandidateSelection()} {activeStep === 1 && renderJobDescription()}
{activeStep.label === 'job-analysis' && renderAnalysis()} {activeStep === 2 && renderAnalysis()}
{activeStep.label === 'generated-resume' && renderResume()} {activeStep === 3 && renderResume()}
</Scrollable> </Box>
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
<Button <Button color="inherit" disabled={activeStep === 0} onClick={handleBack} sx={{ mr: 1 }}>
color="inherit"
disabled={activeStep.index === steps[0].index}
onClick={handleBack}
sx={{ mr: 1 }}
>
Back Back
</Button> </Button>
<Box sx={{ flex: '1 1 auto' }} /> <Box sx={{ flex: '1 1 auto' }} />
{activeStep.index === steps[steps.length - 1].index ? ( {activeStep === maxStep ? (
<Button <Button
disabled={!canAdvance} disabled={!canAdvance}
onClick={(): void => { onClick={(): void => {
@ -433,7 +582,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
</Button> </Button>
) : ( ) : (
<Button disabled={!canAdvance} onClick={handleNext} variant="contained"> <Button disabled={!canAdvance} onClick={handleNext} variant="contained">
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'} {activeStep === maxStep - 1 ? 'Done' : 'Next'}
</Button> </Button>
)} )}
</Box> </Box>

View File

@ -545,6 +545,22 @@ class ApiClient {
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate'); return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
} }
async getJobAnalysis(job: Types.Job, candidate: Types.Candidate): Promise<Types.JobAnalysis> {
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<Types.JobAnalysis>(response, 'JobAnalysis');
}
async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> { async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> {
const request = formatApiRequest(updates); const request = formatApiRequest(updates);
const response = await fetch(`${this.baseUrl}/candidates/${id}`, { const response = await fetch(`${this.baseUrl}/candidates/${id}`, {

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models // Generated TypeScript types from Pydantic models
// Source: src/backend/models.py // Source: src/backend/models.py
// Generated on: 2025-06-19T22:17:35.101284 // Generated on: 2025-07-01T21:24:10.743667
// DO NOT EDIT MANUALLY - This file is auto-generated // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================
@ -721,6 +721,12 @@ export interface Job {
details?: JobDetails; details?: JobDetails;
} }
export interface JobAnalysis {
jobId: string;
candidateId: string;
skills: Array<SkillAssessment>;
}
export interface JobApplication { export interface JobApplication {
id?: string; id?: string;
jobId: string; jobId: string;
@ -1042,6 +1048,7 @@ export interface SkillAssessment {
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
ragResults?: Array<ChromaDBGetResponse>; ragResults?: Array<ChromaDBGetResponse>;
matchScore: number;
} }
export interface SocialLink { export interface SocialLink {
@ -1666,6 +1673,19 @@ export function convertJobFromApi(data: any): Job {
details: data.details ? convertJobDetailsFromApi(data.details) : undefined, 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 * Convert JobApplication from API response
* Date fields: appliedDate, updatedDate * Date fields: appliedDate, updatedDate
@ -1973,6 +1993,8 @@ export function convertFromApi<T>(data: any, modelType: string): T {
return convertInterviewScheduleFromApi(data) as T; return convertInterviewScheduleFromApi(data) as T;
case 'Job': case 'Job':
return convertJobFromApi(data) as T; return convertJobFromApi(data) as T;
case 'JobAnalysis':
return convertJobAnalysisFromApi(data) as T;
case 'JobApplication': case 'JobApplication':
return convertJobApplicationFromApi(data) as T; return convertJobApplicationFromApi(data) as T;
case 'JobDetails': case 'JobDetails':

View File

@ -74,13 +74,14 @@ class GenerateResume(Agent):
# Build the system prompt # Build the system prompt
system_prompt = f"""You are a professional resume writer with expertise in highlighting candidate strengths and experiences. 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: ## CANDIDATE INFORMATION:
Name: {self.user.full_name} Name: {self.user.full_name}
Email: {self.user.email or 'N/A'} Email: {self.user.email or "N/A"}
Phone: {self.user.phone or 'N/A'} Phone: {self.user.phone or "N/A"}
{f'Location: {json.dumps(self.user.location.model_dump())}' if self.user.location else ''} {f"Location: {json.dumps(self.user.location.model_dump())}" if self.user.location else ""}
## SKILL ASSESSMENT RESULTS: ## 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. 5. Use action verbs and quantifiable achievements where possible.
6. Maintain a professional tone throughout. 6. Maintain a professional tone throughout.
7. Be concise and impactful - the resume should be 1-2 pages MAXIMUM. 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: If SKILL ASSESSMENT RESULTS or EXPERIENCE EVIDENCE sections are empty:
- Do not create fictional work history - Do not create fictional work history

View File

@ -126,7 +126,6 @@ class ChromaDBGetResponse(BaseModel):
umap_embedding_2d: Optional[List[float]] = Field(default=None, alias=str("umapEmbedding2D")) umap_embedding_2d: Optional[List[float]] = Field(default=None, alias=str("umapEmbedding2D"))
umap_embedding_3d: Optional[List[float]] = Field(default=None, alias=str("umapEmbedding3D")) umap_embedding_3d: Optional[List[float]] = Field(default=None, alias=str("umapEmbedding3D"))
class SkillAssessment(BaseModel): class SkillAssessment(BaseModel):
candidate_id: str = Field(..., alias=str("candidateId")) candidate_id: str = Field(..., alias=str("candidateId"))
skill: str = Field(..., alias=str("skill"), description="The skill being assessed") 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")) 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")) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias=str("updatedAt"))
rag_results: List[ChromaDBGetResponse] = Field(default_factory=list, alias=str("ragResults")) 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) 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): class ApiMessageType(str, Enum):
BINARY = "binary" BINARY = "binary"

View File

@ -51,6 +51,7 @@ from models import (
DocumentType, DocumentType,
DocumentUpdateRequest, DocumentUpdateRequest,
Job, Job,
JobAnalysis,
JobRequirements, JobRequirements,
CreateCandidateRequest, CreateCandidateRequest,
Candidate, Candidate,
@ -1436,6 +1437,66 @@ async def get_candidate_chat_summary(
return JSONResponse(status_code=500, content=create_error_response("SUMMARY_ERROR", str(e))) 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") @router.post("/{candidate_id}/skill-match")
async def get_candidate_skill_match( async def get_candidate_skill_match(
candidate_id: str = Path(...), candidate_id: str = Path(...),

View File

@ -2,7 +2,7 @@ import defines
import re import re
import subprocess import subprocess
import math import math
from models import SystemInfo from models import GPUInfo, SystemInfo
def get_installed_ram(): def get_installed_ram():
@ -12,11 +12,12 @@ def get_installed_ram():
match = re.search(r"MemTotal:\s+(\d+)", meminfo) match = re.search(r"MemTotal:\s+(\d+)", meminfo)
if match: if match:
return f"{math.floor(int(match.group(1)) / 1000**2)}GB" # Convert KB to GB return f"{math.floor(int(match.group(1)) / 1000**2)}GB" # Convert KB to GB
return "RAM information not found"
except Exception as e: except Exception as e:
return f"Error retrieving RAM: {e}" return f"Error retrieving RAM: {e}"
def get_graphics_cards(): def get_graphics_cards() -> list[GPUInfo]:
gpus = [] gpus = []
try: try:
# Run the ze-monitor utility # Run the ze-monitor utility
@ -55,8 +56,8 @@ def get_graphics_cards():
continue continue
return gpus return gpus
except Exception as e: except Exception:
return f"Error retrieving GPU info: {e}" return gpus
def get_cpu_info(): def get_cpu_info():
@ -67,6 +68,7 @@ def get_cpu_info():
cores_match = re.findall(r"processor\s+:\s+\d+", cpuinfo) cores_match = re.findall(r"processor\s+:\s+\d+", cpuinfo)
if model_match and cores_match: if model_match and cores_match:
return f"{model_match.group(1)} with {len(cores_match)} cores" return f"{model_match.group(1)} with {len(cores_match)} cores"
return "CPU information not found"
except Exception as e: except Exception as e:
return f"Error retrieving CPU info: {e}" return f"Error retrieving CPU info: {e}"