Refactored job analysis sequence
This commit is contained in:
parent
e0992e77b2
commit
aa6be077e6
@ -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
|
||||||
|
@ -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 };
|
||||||
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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={{
|
||||||
|
@ -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>
|
||||||
|
@ -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}`, {
|
||||||
|
@ -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':
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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(...),
|
||||||
|
@ -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}"
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user