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