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:
- internal
ports:
- 7860:7860 # gradio port for testing
- 8912:8911 # FastAPI React server
volumes:
- ./cache:/root/.cache # Persist all models and GPU kernel cache

View File

@ -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 (
<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 { job, candidate, onAnalysisComplete, variant = 'normal' } = props;
const { apiClient } = useAuth();
@ -49,11 +161,12 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
const [loadingRequirements, setLoadingRequirements] = useState<boolean>(false);
const [expanded, setExpanded] = useState<string | false>(false);
const [overallScore, setOverallScore] = useState<number>(0);
const [startAnalysis, setStartAnalysis] = useState<boolean>(false);
const [analyzing, setAnalyzing] = useState<boolean>(false);
const [matchStatus, setMatchStatus] = useState<string>('');
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'));
// Handle accordion expansion
@ -155,59 +268,54 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (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<void> => {
if (requirements.length === 0) return;
// Process requirements one by one
const fetchMatchData = async (firstRun: boolean): Promise<void> => {
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<JobAnalysisProps> = (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<JobAnalysisProps> = (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<JobAnalysisProps> = (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<JobAnalysisProps> = (props: JobAnalysisProps) =
};
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" />}
<Box
@ -305,68 +444,11 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
gap: 1,
}}
>
{overallScore !== 0 && (
<Paper
sx={{
width: '10rem',
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 && overallScore !== 0 && (
<JobMatchScore
score={overallScore}
sx={{ width: isMobile ? '100%' : 'auto', flexGrow: 1 }}
/>
)}
{analyzing && (
<Paper
@ -425,7 +507,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
onClick={beginAnalysis}
variant="contained"
>
{analyzing ? 'Assessment in Progress' : 'Start Skill Assessment'}
{analyzing ? 'Assessment in Progress' : 'Assess Unknown Skills'}
</Button>
</Box>
@ -638,8 +720,9 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
))}
</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]);
return (
<Box
<Scrollable
className="ResumeGenerator"
sx={{
display: 'flex',
flexDirection: 'column',
m: 0,
p: 0,
width: '100%',
minHeight: 0,
position: 'relative',
}}
>
{user?.isAdmin && (
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs>
</Box>
)}
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
<Tabs value={tabValue} onChange={handleTabChange} centered>
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" />
</Tabs>
</Box>
{status && (
<Box sx={{ mt: 0, mb: 1 }}>
@ -191,7 +194,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
Save Resume and Edit
</Button>
)}
</Box>
</Scrollable>
);
};

View File

@ -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,
}) => (
<Scrollable
<Box
sx={{
p: inDialog ? 2 : 1.5,
height: '100%',
@ -163,7 +165,7 @@ const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?:
</Typography>
)} */}
</Box>
</Scrollable>
</Box>
);
const JobsView: React.FC<JobsViewProps> = ({
@ -175,6 +177,7 @@ const JobsView: React.FC<JobsViewProps> = ({
showActions = true,
showDetailsPanel = true,
filter = {},
sx = {},
}) => {
const theme = useTheme();
const { apiClient, user } = useAuth();
@ -196,9 +199,11 @@ const JobsView: React.FC<JobsViewProps> = ({
const [sortOrder, setSortOrder] = React.useState<SortOrder>('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<JobsViewProps> = ({
}
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<JobsViewProps> = ({
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<JobsViewProps> = ({
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
console.log('Handling search change:', event.target.value);
const value = event.target.value;
setSearchQuery(value);
@ -290,11 +317,13 @@ const JobsView: React.FC<JobsViewProps> = ({
};
const handlePageChange = (event: unknown, newPage: number): void => {
console.log('Handling page change:', newPage);
setPage(newPage);
fetchJobs(newPage, searchQuery);
};
const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>): 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<JobsViewProps> = ({
};
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<JobsViewProps> = ({
Updated {getSortIcon('updatedAt')}
</Box>
</TableCell>
<TableCell>Status</TableCell>
{/* <TableCell>Status</TableCell> */}
{showActions && <TableCell align="center">Actions</TableCell>}
</TableRow>
</TableHead>
@ -550,13 +572,13 @@ const JobsView: React.FC<JobsViewProps> = ({
<TableCell>
<Typography variant="body2">{formatDate(job.updatedAt)}</Typography>
</TableCell>
<TableCell>
{/* <TableCell>
<Chip
label={job.details?.isActive ? 'Active' : 'Inactive'}
color={job.details?.isActive ? 'success' : 'default'}
size="small"
/>
</TableCell>
</TableCell> */}
{showActions && (
<TableCell align="center" onClick={(e): void => e.stopPropagation()}>
<Box sx={{ display: 'flex', gap: 0.5 }}>
@ -605,7 +627,7 @@ const JobsView: React.FC<JobsViewProps> = ({
);
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'row', position: 'relative' }}>
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', position: 'relative', ...sx }}>
<Scrollable
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 }}>
{selectedJob ? (
<JobInfoPanel job={selectedJob} onClose={(): void => setSelectedJob(null)} />
<JobInfoPanel
job={selectedJob}
onClose={(): void => {
console.log('Closing JobInfoPanel');
setDetailsPanelOpen(false);
setSelectedJob(null);
}}
/>
) : (
<Box
sx={{

View File

@ -18,18 +18,16 @@ import { Add, WorkOutline } from '@mui/icons-material';
import PersonIcon from '@mui/icons-material/Person';
import WorkIcon from '@mui/icons-material/Work';
import AssessmentIcon from '@mui/icons-material/Assessment';
import { JobMatchAnalysis } from 'components/JobMatchAnalysis';
import { Candidate, Job, SkillAssessment } from 'types/types';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import { JobMatchAnalysis, JobMatchScore, JobAnalysisScore } from 'components/JobMatchAnalysis';
import { Candidate, Job } from 'types/types';
import { BackstoryPageProps } from 'components/BackstoryTab';
import { useAuth } from 'hooks/AuthContext';
import { useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext';
import { CandidateInfo } from 'components/ui/CandidateInfo';
import { Scrollable } from 'components/Scrollable';
import { CandidatePicker } from 'components/ui/CandidatePicker';
import { JobCreator } from 'components/JobCreator';
import { LoginRestricted } from 'components/ui/LoginRestricted';
import { ResumeGenerator } from 'components/ResumeGenerator';
import { JobInfo } from 'components/ui/JobInfo';
import { JobsView } from 'components/ui/JobsView';
function WorkAddIcon(): JSX.Element {
@ -62,18 +60,10 @@ function WorkAddIcon(): JSX.Element {
interface AnalysisState {
job: Job | null;
candidate: Candidate | null;
analysis: SkillAssessment[] | null;
analysis: JobAnalysisScore | null;
resume: string | null;
}
interface StepData {
index: number;
label: string;
requiredState: string[];
title: string;
icon: React.ReactNode;
}
const initialState: AnalysisState = {
job: null,
candidate: null,
@ -81,24 +71,6 @@ const initialState: AnalysisState = {
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 => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
@ -110,53 +82,73 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
const { selectedJob, setSelectedJob } = useSelectedJob();
const [activeStep, setActiveStep] = useState<StepData>(steps[0]);
const [error, setError] = useState<string | null>(null);
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 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(
(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<BackstoryPageProps> = () => {
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<BackstoryPageProps> = () => {
// Render function for the job description step
const renderJobDescription = (): JSX.Element => {
return (
<Box sx={{ mt: 3, width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Box
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>
<Tab value="select" icon={<WorkOutline />} label="Select Job" />
<Tab value="create" icon={<WorkAddIcon />} label="Create Job" />
@ -243,7 +245,18 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
</Box>
{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 && (
<JobCreator
@ -265,19 +278,19 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
);
};
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 (
<Box>
@ -289,33 +302,26 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
);
}
return (
<Box sx={{ mt: 3 }}>
<JobMatchAnalysis
variant="small"
job={analysisState.job}
candidate={analysisState.candidate}
onAnalysisComplete={onAnalysisComplete}
/>
</Box>
<JobMatchAnalysis
variant="small"
job={analysisState.job}
candidate={analysisState.candidate}
onAnalysisComplete={onAnalysisComplete}
/>
);
};
const renderResume = (): JSX.Element => {
if (!analysisState) {
return <></>;
}
if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) {
return <></>;
}
return (
<Box sx={{ mt: 3 }}>
<ResumeGenerator
job={analysisState.job}
candidate={analysisState.candidate}
skills={analysisState.analysis}
/>
</Box>
<ResumeGenerator
job={analysisState.job}
candidate={analysisState.candidate}
skills={analysisState.analysis.skills}
/>
);
};
@ -326,102 +332,245 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
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',
}}
>
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0, gap: 1 }}>
<Stepper activeStep={activeStep.index} alternativeLabel sx={{ mt: 2, mb: 2 }}>
{steps.map((step, index) => (
<Step key={step.index}>
<StepLabel
sx={{ cursor: 'pointer' }}
onClick={(): void => {
moveToStep(index);
}}
slots={{
stepIcon: (): JSX.Element => (
<Avatar
key={step.index}
sx={{
bgcolor:
activeStep.index >= step.index
? theme.palette.primary.main
: theme.palette.grey[300],
color: 'white',
}}
>
{step.icon}
</Avatar>
),
<Paper
elevation={4}
sx={{
display: 'flex',
position: 'relative',
m: 0,
borderRadius: 0,
mb: 1,
p: 0,
gap: 1,
flexDirection: 'column',
}}
>
<Stepper
activeStep={activeStep}
alternativeLabel
sx={{
mt: 1,
mb: 1,
fontWeight: 'bold',
'& .MuiStepLabel-label': {
display: 'flex',
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}
</StepLabel>
</Step>
))}
<Box sx={{ mb: 1, justifyContent: 'center' }}>Candidate Selection</Box>
{user !== null && (
<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>
<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>
<Scrollable
<Box
ref={scrollRef}
sx={{
position: 'relative',
maxHeight: '100%',
minHeight: 0 /* Prevent flex overflow */,
// maxHeight: 'min-content',
width: '100%',
display: 'flex',
flexGrow: 1,
flex: 1 /* Take remaining space in some-container */,
overflowY: 'auto' /* Scroll if content overflows */,
overflowY: 'hidden',
m: 0,
p: 0,
}}
>
{activeStep.label === 'job-selection' && renderJobDescription()}
{activeStep.label === 'select-candidate' && renderCandidateSelection()}
{activeStep.label === 'job-analysis' && renderAnalysis()}
{activeStep.label === 'generated-resume' && renderResume()}
</Scrollable>
{activeStep === 0 && renderCandidateSelection()}
{activeStep === 1 && renderJobDescription()}
{activeStep === 2 && renderAnalysis()}
{activeStep === 3 && renderResume()}
</Box>
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
<Button
color="inherit"
disabled={activeStep.index === steps[0].index}
onClick={handleBack}
sx={{ mr: 1 }}
>
<Button color="inherit" disabled={activeStep === 0} onClick={handleBack} sx={{ mr: 1 }}>
Back
</Button>
<Box sx={{ flex: '1 1 auto' }} />
{activeStep.index === steps[steps.length - 1].index ? (
{activeStep === maxStep ? (
<Button
disabled={!canAdvance}
onClick={(): void => {
@ -433,7 +582,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
</Button>
) : (
<Button disabled={!canAdvance} onClick={handleNext} variant="contained">
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'}
{activeStep === maxStep - 1 ? 'Done' : 'Next'}
</Button>
)}
</Box>

View File

@ -545,6 +545,22 @@ class ApiClient {
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> {
const request = formatApiRequest(updates);
const response = await fetch(`${this.baseUrl}/candidates/${id}`, {

View File

@ -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<SkillAssessment>;
}
export interface JobApplication {
id?: string;
jobId: string;
@ -1042,6 +1048,7 @@ export interface SkillAssessment {
createdAt?: Date;
updatedAt?: Date;
ragResults?: Array<ChromaDBGetResponse>;
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<T>(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':

View File

@ -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

View File

@ -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"

View File

@ -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(...),

View File

@ -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}"