Refactored job analysis sequence
This commit is contained in:
parent
e0992e77b2
commit
aa6be077e6
@ -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
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
useMediaQuery,
|
||||
Button,
|
||||
Paper,
|
||||
SxProps,
|
||||
} from '@mui/material';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
@ -26,12 +27,18 @@ import { BackstoryPageProps } from './BackstoryTab';
|
||||
import { Job } from 'types/types';
|
||||
import * as Types from 'types/types';
|
||||
import { JobInfo } from './ui/JobInfo';
|
||||
import { Scrollable } from 'components/Scrollable';
|
||||
|
||||
interface JobAnalysisScore {
|
||||
score: number;
|
||||
skills: SkillAssessment[];
|
||||
}
|
||||
|
||||
interface JobAnalysisProps extends BackstoryPageProps {
|
||||
job: Job;
|
||||
candidate: Candidate;
|
||||
variant?: 'small' | 'normal';
|
||||
onAnalysisComplete: (skills: SkillAssessment[]) => void;
|
||||
onAnalysisComplete: (analysis: JobAnalysisScore) => void;
|
||||
}
|
||||
|
||||
interface SkillMatch extends SkillAssessment {
|
||||
@ -40,6 +47,111 @@ interface SkillMatch extends SkillAssessment {
|
||||
matchScore: number;
|
||||
}
|
||||
|
||||
const JobMatchScore: React.FC<{ score: number; variant?: 'small' | 'normal'; sx?: SxProps }> = ({
|
||||
variant = 'normal',
|
||||
score,
|
||||
sx = {},
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const getMatchColor = (score: number): string => {
|
||||
if (score >= 80) return theme.palette.success.main;
|
||||
if (score >= 60) return theme.palette.info.main;
|
||||
if (score >= 40) return theme.palette.warning.main;
|
||||
return theme.palette.error.main;
|
||||
};
|
||||
const suffix = variant === 'small' ? '' : ' Match';
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: variant === 'small' ? '8rem' : '10rem',
|
||||
ml: 1,
|
||||
p: 1,
|
||||
gap: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexDirection: variant === 'small' ? 'row' : 'column',
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={
|
||||
score >= 80
|
||||
? `Excellent${suffix}`
|
||||
: score >= 60
|
||||
? `Good${suffix}`
|
||||
: score >= 40
|
||||
? `Partial${suffix}`
|
||||
: `Low${suffix}`
|
||||
}
|
||||
sx={{
|
||||
bgcolor: getMatchColor(score),
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={score}
|
||||
size={variant === 'small' ? 45 : 60}
|
||||
thickness={5}
|
||||
sx={{
|
||||
color: getMatchColor(score),
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}>
|
||||
{`${Math.round(score)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const calculateScore = (skillMatch: SkillAssessment): number => {
|
||||
let score = 0;
|
||||
switch (skillMatch.evidenceStrength.toUpperCase()) {
|
||||
case 'STRONG':
|
||||
score = 100;
|
||||
break;
|
||||
case 'MODERATE':
|
||||
score = 75;
|
||||
break;
|
||||
case 'WEAK':
|
||||
score = 50;
|
||||
break;
|
||||
case 'NONE':
|
||||
score = 0;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
skillMatch.evidenceStrength === 'none' &&
|
||||
skillMatch.evidenceDetails &&
|
||||
skillMatch.evidenceDetails.length > 3
|
||||
) {
|
||||
score = Math.min(skillMatch.evidenceDetails.length * 8, 40);
|
||||
}
|
||||
return score;
|
||||
};
|
||||
|
||||
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => {
|
||||
const { job, candidate, onAnalysisComplete, variant = 'normal' } = props;
|
||||
const { apiClient } = useAuth();
|
||||
@ -49,11 +161,12 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
const [loadingRequirements, setLoadingRequirements] = useState<boolean>(false);
|
||||
const [expanded, setExpanded] = useState<string | false>(false);
|
||||
const [overallScore, setOverallScore] = useState<number>(0);
|
||||
const [startAnalysis, setStartAnalysis] = useState<boolean>(false);
|
||||
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
||||
const [matchStatus, setMatchStatus] = useState<string>('');
|
||||
const [percentage, setPercentage] = useState<number>(0);
|
||||
|
||||
const [analysis, setAnalysis] = useState<JobAnalysisScore | null>(null);
|
||||
const [startAnalysis, setStartAnalysis] = useState<boolean>(true);
|
||||
const [firstRun, setFirstRun] = useState<boolean>(true);
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
|
||||
// Handle accordion expansion
|
||||
@ -155,59 +268,54 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
|
||||
// Fetch match data for each requirement
|
||||
useEffect(() => {
|
||||
if (!startAnalysis || analyzing || !job.requirements) {
|
||||
if (
|
||||
(!startAnalysis && !firstRun) ||
|
||||
analyzing ||
|
||||
!job.requirements ||
|
||||
requirements.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchMatchData = async (skills: SkillAssessment[]): Promise<void> => {
|
||||
if (requirements.length === 0) return;
|
||||
|
||||
// Process requirements one by one
|
||||
const fetchMatchData = async (firstRun: boolean): Promise<void> => {
|
||||
const currentAnalysis = await apiClient.getJobAnalysis(job, candidate);
|
||||
for (let i = 0; i < requirements.length; i++) {
|
||||
try {
|
||||
setSkillMatches(prev => {
|
||||
const updated = [...prev];
|
||||
updated[i] = { ...updated[i], status: 'pending' };
|
||||
return updated;
|
||||
});
|
||||
|
||||
const request = await apiClient.candidateMatchForRequirement(
|
||||
candidate.id || '',
|
||||
requirements[i].requirement,
|
||||
skillMatchHandlers
|
||||
let match: SkillMatch;
|
||||
const existingMatch = currentAnalysis?.skills.find(
|
||||
(match: SkillAssessment) => match.skill === requirements[i].requirement
|
||||
);
|
||||
const result = await request.promise;
|
||||
const skillMatch = result.skillAssessment;
|
||||
skills.push(skillMatch);
|
||||
setMatchStatus('');
|
||||
let matchScore = 0;
|
||||
switch (skillMatch.evidenceStrength.toUpperCase()) {
|
||||
case 'STRONG':
|
||||
matchScore = 100;
|
||||
break;
|
||||
case 'MODERATE':
|
||||
matchScore = 75;
|
||||
break;
|
||||
case 'WEAK':
|
||||
matchScore = 50;
|
||||
break;
|
||||
case 'NONE':
|
||||
matchScore = 0;
|
||||
break;
|
||||
if (existingMatch) {
|
||||
match = {
|
||||
...existingMatch,
|
||||
status: 'complete',
|
||||
matchScore: calculateScore(existingMatch),
|
||||
domain: requirements[i].domain,
|
||||
};
|
||||
} else {
|
||||
setSkillMatches(prev => {
|
||||
const updated = [...prev];
|
||||
updated[i] = { ...updated[i], status: 'pending' };
|
||||
return updated;
|
||||
});
|
||||
|
||||
const request = await apiClient.candidateMatchForRequirement(
|
||||
candidate.id || '',
|
||||
requirements[i].requirement,
|
||||
skillMatchHandlers
|
||||
);
|
||||
const result = await request.promise; /* Wait for the streaming result to complete */
|
||||
const skillMatch = result.skillAssessment;
|
||||
setMatchStatus('');
|
||||
|
||||
match = {
|
||||
...skillMatch,
|
||||
status: 'complete',
|
||||
matchScore: calculateScore(skillMatch),
|
||||
domain: requirements[i].domain,
|
||||
};
|
||||
}
|
||||
if (
|
||||
skillMatch.evidenceStrength === 'none' &&
|
||||
skillMatch.evidenceDetails &&
|
||||
skillMatch.evidenceDetails.length > 3
|
||||
) {
|
||||
matchScore = Math.min(skillMatch.evidenceDetails.length * 8, 40);
|
||||
}
|
||||
const match: SkillMatch = {
|
||||
...skillMatch,
|
||||
status: 'complete',
|
||||
matchScore,
|
||||
domain: requirements[i].domain,
|
||||
};
|
||||
|
||||
setSkillMatches(prev => {
|
||||
const updated = [...prev];
|
||||
updated[i] = match;
|
||||
@ -221,7 +329,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
const newOverallScore =
|
||||
completedMatches.reduce((sum, match) => sum + match.matchScore, 0) /
|
||||
completedMatches.length;
|
||||
setOverallScore(newOverallScore);
|
||||
setOverallScore(Math.round(newOverallScore));
|
||||
}
|
||||
return current;
|
||||
});
|
||||
@ -243,15 +351,14 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
|
||||
setAnalyzing(true);
|
||||
setPercentage(0);
|
||||
const skills: SkillAssessment[] = [];
|
||||
fetchMatchData(skills).then(() => {
|
||||
|
||||
fetchMatchData(firstRun).then(() => {
|
||||
setFirstRun(false);
|
||||
setAnalyzing(false);
|
||||
setStartAnalysis(false);
|
||||
onAnalysisComplete && onAnalysisComplete(skills);
|
||||
});
|
||||
}, [
|
||||
job,
|
||||
onAnalysisComplete,
|
||||
startAnalysis,
|
||||
analyzing,
|
||||
requirements,
|
||||
@ -259,8 +366,30 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
apiClient,
|
||||
candidate.id,
|
||||
skillMatchHandlers,
|
||||
firstRun,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skillMatches.length === 0) {
|
||||
return;
|
||||
}
|
||||
const finishedAnalysis = skillMatches.every(
|
||||
match => match.status === 'complete' || match.status === 'error'
|
||||
);
|
||||
if (!finishedAnalysis) {
|
||||
return;
|
||||
}
|
||||
if (analysis && analysis.score === overallScore) {
|
||||
return; // No change in score, skip setting analysis
|
||||
}
|
||||
const newAnalysis: JobAnalysisScore = {
|
||||
score: overallScore,
|
||||
skills: skillMatches,
|
||||
};
|
||||
setAnalysis(newAnalysis);
|
||||
onAnalysisComplete && onAnalysisComplete(newAnalysis);
|
||||
}, [onAnalysisComplete, skillMatches, overallScore, analysis]);
|
||||
|
||||
// Get color based on match score
|
||||
const getMatchColor = (score: number): string => {
|
||||
if (score >= 80) return theme.palette.success.main;
|
||||
@ -284,7 +413,17 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', m: 0, p: 0 }}>
|
||||
<Scrollable
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
m: 0,
|
||||
p: 0,
|
||||
width: '100%',
|
||||
minHeight: 0,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{variant !== 'small' && <JobInfo job={job} variant="normal" />}
|
||||
|
||||
<Box
|
||||
@ -305,68 +444,11 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{overallScore !== 0 && (
|
||||
<Paper
|
||||
sx={{
|
||||
width: '10rem',
|
||||
ml: 1,
|
||||
p: 1,
|
||||
gap: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Chip
|
||||
label={
|
||||
overallScore >= 80
|
||||
? 'Excellent Match'
|
||||
: overallScore >= 60
|
||||
? 'Good Match'
|
||||
: overallScore >= 40
|
||||
? 'Partial Match'
|
||||
: 'Low Match'
|
||||
}
|
||||
sx={{
|
||||
bgcolor: getMatchColor(overallScore),
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={overallScore}
|
||||
size={60}
|
||||
thickness={5}
|
||||
sx={{
|
||||
color: getMatchColor(overallScore),
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" component="div" sx={{ fontWeight: 'bold' }}>
|
||||
{`${Math.round(overallScore)}%`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
{analyzing && overallScore !== 0 && (
|
||||
<JobMatchScore
|
||||
score={overallScore}
|
||||
sx={{ width: isMobile ? '100%' : 'auto', flexGrow: 1 }}
|
||||
/>
|
||||
)}
|
||||
{analyzing && (
|
||||
<Paper
|
||||
@ -425,7 +507,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
onClick={beginAnalysis}
|
||||
variant="contained"
|
||||
>
|
||||
{analyzing ? 'Assessment in Progress' : 'Start Skill Assessment'}
|
||||
{analyzing ? 'Assessment in Progress' : 'Assess Unknown Skills'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@ -638,8 +720,9 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Scrollable>
|
||||
);
|
||||
};
|
||||
|
||||
export { JobMatchAnalysis };
|
||||
export type { JobAnalysisScore };
|
||||
export { JobMatchAnalysis, JobMatchScore };
|
||||
|
@ -138,22 +138,25 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
|
||||
}, [apiClient, candidate.id, job.id, resume, setSnack, navigate, prompt, systemPrompt]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
<Scrollable
|
||||
className="ResumeGenerator"
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
m: 0,
|
||||
p: 0,
|
||||
width: '100%',
|
||||
minHeight: 0,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{user?.isAdmin && (
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} centered>
|
||||
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
|
||||
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
|
||||
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 1 }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} centered>
|
||||
<Tab disabled={systemPrompt === ''} value="system" icon={<TuneIcon />} label="System" />
|
||||
<Tab disabled={prompt === ''} value="prompt" icon={<InputIcon />} label="Prompt" />
|
||||
<Tab disabled={resume === ''} value="resume" icon={<ArticleIcon />} label="Resume" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{status && (
|
||||
<Box sx={{ mt: 0, mb: 1 }}>
|
||||
@ -191,7 +194,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
|
||||
Save Resume and Edit
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Scrollable>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
@ -29,6 +29,7 @@ import {
|
||||
Alert,
|
||||
Tooltip,
|
||||
Grid,
|
||||
SxProps,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
KeyboardArrowUp as ArrowUpIcon,
|
||||
@ -77,6 +78,7 @@ interface JobsViewProps {
|
||||
showActions?: boolean;
|
||||
showDetailsPanel?: boolean;
|
||||
variant?: 'table' | 'list' | 'responsive';
|
||||
sx?: SxProps;
|
||||
}
|
||||
|
||||
const Transition = React.forwardRef(function Transition(
|
||||
@ -93,7 +95,7 @@ const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?:
|
||||
onClose,
|
||||
inDialog = false,
|
||||
}) => (
|
||||
<Scrollable
|
||||
<Box
|
||||
sx={{
|
||||
p: inDialog ? 2 : 1.5,
|
||||
height: '100%',
|
||||
@ -163,7 +165,7 @@ const JobInfoPanel: React.FC<{ job: Types.Job; onClose?: () => void; inDialog?:
|
||||
</Typography>
|
||||
)} */}
|
||||
</Box>
|
||||
</Scrollable>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const JobsView: React.FC<JobsViewProps> = ({
|
||||
@ -175,6 +177,7 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
showActions = true,
|
||||
showDetailsPanel = true,
|
||||
filter = {},
|
||||
sx = {},
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const { apiClient, user } = useAuth();
|
||||
@ -196,9 +199,11 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
const [sortOrder, setSortOrder] = React.useState<SortOrder>('desc');
|
||||
const [mobileDialogOpen, setMobileDialogOpen] = React.useState(false);
|
||||
const [detailsPanelOpen, setDetailsPanelOpen] = React.useState(showDetailsPanel);
|
||||
|
||||
if (location.pathname.indexOf('/candidate/jobs') === 0) {
|
||||
filter = { ...filter, owner_id: user?.id || '' };
|
||||
}
|
||||
|
||||
const fetchJobs = React.useCallback(
|
||||
async (pageNum = 0, searchTerm = '') => {
|
||||
try {
|
||||
@ -220,11 +225,24 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
}
|
||||
|
||||
const sortedJobs = sortJobs(paginationResponse.data, sortField, sortOrder);
|
||||
setJobs(sortedJobs);
|
||||
setTotal(paginationResponse.total);
|
||||
|
||||
if (sortedJobs.length > 0 && !selectedJob && detailsPanelOpen) {
|
||||
setSelectedJob(sortedJobs[0]);
|
||||
let updated = false;
|
||||
if (jobs.length) {
|
||||
if (sortedJobs.length !== jobs.length) {
|
||||
updated = true;
|
||||
} else {
|
||||
for (let i = 0; i < sortedJobs.length; i++) {
|
||||
if (sortedJobs[i].id !== jobs[i].id) {
|
||||
updated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updated = true;
|
||||
}
|
||||
if (updated) {
|
||||
setJobs(sortedJobs);
|
||||
setTotal(paginationResponse.total);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'An error occurred while fetching jobs');
|
||||
@ -234,10 +252,18 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[limit, sortField, sortOrder, selectedJob, detailsPanelOpen, apiClient]
|
||||
[limit, sortField, sortOrder, apiClient]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (jobs.length > 0 && !selectedJob && detailsPanelOpen) {
|
||||
console.log('Setting selected job from fetchJobs');
|
||||
setSelectedJob(jobs[0]);
|
||||
}
|
||||
}, [jobs, selectedJob, detailsPanelOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
console.log('Fetching jobs with filter:', filter, 'searchQuery:', searchQuery);
|
||||
fetchJobs(0, searchQuery);
|
||||
}, [fetchJobs, searchQuery]);
|
||||
|
||||
@ -274,6 +300,7 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
};
|
||||
|
||||
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
console.log('Handling search change:', event.target.value);
|
||||
const value = event.target.value;
|
||||
setSearchQuery(value);
|
||||
|
||||
@ -290,11 +317,13 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
};
|
||||
|
||||
const handlePageChange = (event: unknown, newPage: number): void => {
|
||||
console.log('Handling page change:', newPage);
|
||||
setPage(newPage);
|
||||
fetchJobs(newPage, searchQuery);
|
||||
};
|
||||
|
||||
const handleRowsPerPageChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
console.log('Handling rows per page change:', event.target.value);
|
||||
const newLimit = parseInt(event.target.value, 10);
|
||||
setLimit(newLimit);
|
||||
setPage(0);
|
||||
@ -335,17 +364,10 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
};
|
||||
|
||||
const handleJobRowClick = (job: Types.Job): void => {
|
||||
/* If not selectable, just view the job */
|
||||
if (!selectable) {
|
||||
setSelectedJob(job);
|
||||
onJobView?.(job);
|
||||
return;
|
||||
}
|
||||
if (isMobile) {
|
||||
setSelectedJob(job);
|
||||
setSelectedJob(job);
|
||||
if (isMobile && showDetailsPanel) {
|
||||
setMobileDialogOpen(true);
|
||||
} else if (detailsPanelOpen) {
|
||||
setSelectedJob(job);
|
||||
} else if (detailsPanelOpen || !isMobile) {
|
||||
setDetailsPanelOpen(true);
|
||||
}
|
||||
onJobView?.(job);
|
||||
@ -477,7 +499,7 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
Updated {getSortIcon('updatedAt')}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
{/* <TableCell>Status</TableCell> */}
|
||||
{showActions && <TableCell align="center">Actions</TableCell>}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@ -550,13 +572,13 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
<TableCell>
|
||||
<Typography variant="body2">{formatDate(job.updatedAt)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{/* <TableCell>
|
||||
<Chip
|
||||
label={job.details?.isActive ? 'Active' : 'Inactive'}
|
||||
color={job.details?.isActive ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableCell> */}
|
||||
{showActions && (
|
||||
<TableCell align="center" onClick={(e): void => e.stopPropagation()}>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
@ -605,7 +627,7 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'row', position: 'relative' }}>
|
||||
<Box sx={{ flexGrow: 1, display: 'flex', flexDirection: 'row', position: 'relative', ...sx }}>
|
||||
<Scrollable
|
||||
sx={{ display: 'flex', flex: 1, flexDirection: 'column', height: '100%', width: '100%' }}
|
||||
>
|
||||
@ -618,7 +640,14 @@ const JobsView: React.FC<JobsViewProps> = ({
|
||||
>
|
||||
<Paper sx={{ flex: 1, ml: 1 }}>
|
||||
{selectedJob ? (
|
||||
<JobInfoPanel job={selectedJob} onClose={(): void => setSelectedJob(null)} />
|
||||
<JobInfoPanel
|
||||
job={selectedJob}
|
||||
onClose={(): void => {
|
||||
console.log('Closing JobInfoPanel');
|
||||
setDetailsPanelOpen(false);
|
||||
setSelectedJob(null);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
|
@ -18,18 +18,16 @@ import { Add, WorkOutline } from '@mui/icons-material';
|
||||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import WorkIcon from '@mui/icons-material/Work';
|
||||
import AssessmentIcon from '@mui/icons-material/Assessment';
|
||||
import { JobMatchAnalysis } from 'components/JobMatchAnalysis';
|
||||
import { Candidate, Job, SkillAssessment } from 'types/types';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import { JobMatchAnalysis, JobMatchScore, JobAnalysisScore } from 'components/JobMatchAnalysis';
|
||||
import { Candidate, Job } from 'types/types';
|
||||
import { BackstoryPageProps } from 'components/BackstoryTab';
|
||||
import { useAuth } from 'hooks/AuthContext';
|
||||
import { useSelectedCandidate, useSelectedJob } from 'hooks/GlobalContext';
|
||||
import { CandidateInfo } from 'components/ui/CandidateInfo';
|
||||
import { Scrollable } from 'components/Scrollable';
|
||||
import { CandidatePicker } from 'components/ui/CandidatePicker';
|
||||
import { JobCreator } from 'components/JobCreator';
|
||||
import { LoginRestricted } from 'components/ui/LoginRestricted';
|
||||
import { ResumeGenerator } from 'components/ResumeGenerator';
|
||||
import { JobInfo } from 'components/ui/JobInfo';
|
||||
import { JobsView } from 'components/ui/JobsView';
|
||||
|
||||
function WorkAddIcon(): JSX.Element {
|
||||
@ -62,18 +60,10 @@ function WorkAddIcon(): JSX.Element {
|
||||
interface AnalysisState {
|
||||
job: Job | null;
|
||||
candidate: Candidate | null;
|
||||
analysis: SkillAssessment[] | null;
|
||||
analysis: JobAnalysisScore | null;
|
||||
resume: string | null;
|
||||
}
|
||||
|
||||
interface StepData {
|
||||
index: number;
|
||||
label: string;
|
||||
requiredState: string[];
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const initialState: AnalysisState = {
|
||||
job: null,
|
||||
candidate: null,
|
||||
@ -81,24 +71,6 @@ const initialState: AnalysisState = {
|
||||
resume: null,
|
||||
};
|
||||
|
||||
// Steps in our process
|
||||
const steps: StepData[] = [
|
||||
{ requiredState: [], title: 'Job Selection', icon: <WorkIcon /> },
|
||||
{ requiredState: ['job'], title: 'Select Candidate', icon: <PersonIcon /> },
|
||||
{
|
||||
requiredState: ['job', 'candidate'],
|
||||
title: 'Job Analysis',
|
||||
icon: <WorkIcon />,
|
||||
},
|
||||
{
|
||||
requiredState: ['job', 'candidate', 'analysis'],
|
||||
title: 'Generated Resume',
|
||||
icon: <AssessmentIcon />,
|
||||
},
|
||||
].map((item, index) => {
|
||||
return { ...item, index, label: item.title.toLowerCase().replace(/ /g, '-') };
|
||||
});
|
||||
|
||||
const capitalize = (str: string): string => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
@ -110,53 +82,73 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate();
|
||||
const { selectedJob, setSelectedJob } = useSelectedJob();
|
||||
|
||||
const [activeStep, setActiveStep] = useState<StepData>(steps[0]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [jobTab, setJobTab] = useState<string>('select');
|
||||
const [analysisState, setAnalysisState] = useState<AnalysisState | null>(null);
|
||||
const [analysisState, setAnalysisState] = useState<AnalysisState>({
|
||||
...initialState,
|
||||
candidate: selectedCandidate,
|
||||
job: selectedJob,
|
||||
});
|
||||
const [canAdvance, setCanAdvance] = useState<boolean>(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
// const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [activeStep, setActiveStep] = useState<number>(user === null ? 0 : 1);
|
||||
const maxStep = 4;
|
||||
|
||||
const canAccessStep = useCallback(
|
||||
(step: StepData) => {
|
||||
if (!analysisState) {
|
||||
return;
|
||||
const getMissingStepRequirement = useCallback(
|
||||
(step: number) => {
|
||||
switch (step) {
|
||||
case 0 /* candidate selection */:
|
||||
break;
|
||||
case 1 /* job selection */:
|
||||
if (!analysisState.candidate) {
|
||||
return 'candidate';
|
||||
}
|
||||
break;
|
||||
case 2 /* job analysis */:
|
||||
if (!analysisState.candidate) {
|
||||
return 'candidate';
|
||||
}
|
||||
if (!analysisState.job) {
|
||||
return 'job';
|
||||
}
|
||||
break;
|
||||
case 3 /* resume generation */:
|
||||
if (!analysisState.candidate) {
|
||||
return 'candidate';
|
||||
}
|
||||
if (!analysisState.job) {
|
||||
return 'job';
|
||||
}
|
||||
if (!analysisState.analysis) {
|
||||
return 'analysis';
|
||||
}
|
||||
break;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const missing = step.requiredState.find(f => !(analysisState as any)[f]);
|
||||
return missing;
|
||||
return null;
|
||||
},
|
||||
[analysisState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (analysisState !== null) {
|
||||
/* Prevent recusrive state war */
|
||||
if (analysisState.candidate === selectedCandidate && analysisState.job === selectedJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
const analysis = {
|
||||
...initialState,
|
||||
candidate: selectedCandidate,
|
||||
job: selectedJob,
|
||||
};
|
||||
setAnalysisState(analysis);
|
||||
for (let i = steps.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const missing = steps[i].requiredState.find(f => !(analysis as any)[f]);
|
||||
if (!missing) {
|
||||
setActiveStep(steps[i]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [analysisState, selectedCandidate, selectedJob, setActiveStep, canAccessStep]);
|
||||
}, [analysisState, selectedCandidate, selectedJob, setActiveStep, getMissingStepRequirement]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeStep.index === steps.length - 1) {
|
||||
if (activeStep === maxStep) {
|
||||
setCanAdvance(false);
|
||||
return;
|
||||
}
|
||||
const blocked = canAccessStep(steps[activeStep.index + 1]);
|
||||
const blocked = getMissingStepRequirement(activeStep + 1);
|
||||
if (blocked) {
|
||||
setCanAdvance(false);
|
||||
} else {
|
||||
@ -168,58 +160,51 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
}, [setCanAdvance, analysisState, activeStep, canAccessStep]);
|
||||
}, [setCanAdvance, analysisState, activeStep, getMissingStepRequirement]);
|
||||
|
||||
const handleNext = (): void => {
|
||||
if (activeStep.index === steps.length - 1) {
|
||||
if (activeStep === maxStep) {
|
||||
return;
|
||||
}
|
||||
const missing = canAccessStep(steps[activeStep.index + 1]);
|
||||
if (missing) {
|
||||
setError(`${capitalize(missing)} is necessary before continuing.`);
|
||||
return;
|
||||
let nextStep = activeStep;
|
||||
for (let i = activeStep + 1; i < maxStep; i++) {
|
||||
if (getMissingStepRequirement(i)) {
|
||||
break;
|
||||
}
|
||||
nextStep = i;
|
||||
}
|
||||
|
||||
if (activeStep.index < steps.length - 1) {
|
||||
setActiveStep(prevActiveStep => steps[prevActiveStep.index + 1]);
|
||||
if (nextStep !== activeStep) {
|
||||
setActiveStep(nextStep);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = (): void => {
|
||||
if (activeStep.index === 0) {
|
||||
if (activeStep === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveStep(prevActiveStep => steps[prevActiveStep.index - 1]);
|
||||
setActiveStep(prevActiveStep => prevActiveStep - 1);
|
||||
};
|
||||
|
||||
const moveToStep = (step: number): void => {
|
||||
const missing = canAccessStep(steps[step]);
|
||||
const missing = getMissingStepRequirement(step);
|
||||
if (missing) {
|
||||
setError(`${capitalize(missing)} is needed to access this step.`);
|
||||
return;
|
||||
}
|
||||
setActiveStep(steps[step]);
|
||||
setActiveStep(step);
|
||||
};
|
||||
|
||||
const onCandidateSelect = (candidate: Candidate): void => {
|
||||
if (!analysisState) {
|
||||
return;
|
||||
}
|
||||
analysisState.candidate = candidate;
|
||||
setAnalysisState({ ...analysisState });
|
||||
setSelectedCandidate(candidate);
|
||||
handleNext();
|
||||
};
|
||||
|
||||
const onJobsSelected = (job: Job): void => {
|
||||
if (!analysisState) {
|
||||
return;
|
||||
}
|
||||
analysisState.job = job;
|
||||
setAnalysisState({ ...analysisState });
|
||||
setSelectedJob(job);
|
||||
handleNext();
|
||||
};
|
||||
|
||||
// Render function for the candidate selection step
|
||||
@ -234,8 +219,25 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
// Render function for the job description step
|
||||
const renderJobDescription = (): JSX.Element => {
|
||||
return (
|
||||
<Box sx={{ mt: 3, width: '100%' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
minHeight: 0 /* Prevent flex overflow */,
|
||||
maxHeight: 'min-content',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
m: 0,
|
||||
}}
|
||||
>
|
||||
<Tabs value={jobTab} onChange={handleTabChange} centered>
|
||||
<Tab value="select" icon={<WorkOutline />} label="Select Job" />
|
||||
<Tab value="create" icon={<WorkAddIcon />} label="Create Job" />
|
||||
@ -243,7 +245,18 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
</Box>
|
||||
|
||||
{jobTab === 'select' && (
|
||||
<JobsView selectable={false} onJobView={onJobsSelected} showDetailsPanel={false} />
|
||||
<JobsView
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
minHeight: 0 /* Prevent flex overflow */,
|
||||
maxHeight: 'min-content',
|
||||
flexGrow: 1,
|
||||
}}
|
||||
selectable={false}
|
||||
onJobView={onJobsSelected}
|
||||
showDetailsPanel={false}
|
||||
/>
|
||||
)}
|
||||
{jobTab === 'create' && user && (
|
||||
<JobCreator
|
||||
@ -265,19 +278,19 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const onAnalysisComplete = (skills: SkillAssessment[]): void => {
|
||||
if (!analysisState) {
|
||||
return;
|
||||
}
|
||||
analysisState.analysis = skills;
|
||||
setAnalysisState({ ...analysisState });
|
||||
};
|
||||
const onAnalysisComplete = useCallback(
|
||||
(analysis: JobAnalysisScore): void => {
|
||||
if (analysis.score === analysisState.analysis?.score) {
|
||||
return;
|
||||
}
|
||||
console.log('Analysis complete:', analysis);
|
||||
setAnalysisState({ ...analysisState, analysis });
|
||||
},
|
||||
[analysisState]
|
||||
);
|
||||
|
||||
// Render function for the analysis step
|
||||
const renderAnalysis = (): JSX.Element => {
|
||||
if (!analysisState) {
|
||||
return <></>;
|
||||
}
|
||||
if (!analysisState.job || !analysisState.candidate) {
|
||||
return (
|
||||
<Box>
|
||||
@ -289,33 +302,26 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<JobMatchAnalysis
|
||||
variant="small"
|
||||
job={analysisState.job}
|
||||
candidate={analysisState.candidate}
|
||||
onAnalysisComplete={onAnalysisComplete}
|
||||
/>
|
||||
</Box>
|
||||
<JobMatchAnalysis
|
||||
variant="small"
|
||||
job={analysisState.job}
|
||||
candidate={analysisState.candidate}
|
||||
onAnalysisComplete={onAnalysisComplete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderResume = (): JSX.Element => {
|
||||
if (!analysisState) {
|
||||
return <></>;
|
||||
}
|
||||
if (!analysisState.job || !analysisState.candidate || !analysisState.analysis) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<ResumeGenerator
|
||||
job={analysisState.job}
|
||||
candidate={analysisState.candidate}
|
||||
skills={analysisState.analysis}
|
||||
/>
|
||||
</Box>
|
||||
<ResumeGenerator
|
||||
job={analysisState.job}
|
||||
candidate={analysisState.candidate}
|
||||
skills={analysisState.analysis.skills}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -326,102 +332,245 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
flexDirection: 'column',
|
||||
height: '100%' /* Restrict to main-container's height */,
|
||||
width: '100%',
|
||||
minHeight: 0 /* Prevent flex overflow */,
|
||||
maxHeight: 'min-content',
|
||||
'& > *:not(.Scrollable)': {
|
||||
flexShrink: 0 /* Prevent shrinking */,
|
||||
},
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Paper elevation={4} sx={{ m: 0, borderRadius: 0, mb: 1, p: 0, gap: 1 }}>
|
||||
<Stepper activeStep={activeStep.index} alternativeLabel sx={{ mt: 2, mb: 2 }}>
|
||||
{steps.map((step, index) => (
|
||||
<Step key={step.index}>
|
||||
<StepLabel
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={(): void => {
|
||||
moveToStep(index);
|
||||
}}
|
||||
slots={{
|
||||
stepIcon: (): JSX.Element => (
|
||||
<Avatar
|
||||
key={step.index}
|
||||
sx={{
|
||||
bgcolor:
|
||||
activeStep.index >= step.index
|
||||
? theme.palette.primary.main
|
||||
: theme.palette.grey[300],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{step.icon}
|
||||
</Avatar>
|
||||
),
|
||||
<Paper
|
||||
elevation={4}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
m: 0,
|
||||
borderRadius: 0,
|
||||
mb: 1,
|
||||
p: 0,
|
||||
gap: 1,
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Stepper
|
||||
activeStep={activeStep}
|
||||
alternativeLabel
|
||||
sx={{
|
||||
mt: 1,
|
||||
mb: 1,
|
||||
fontWeight: 'bold',
|
||||
'& .MuiStepLabel-label': {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Step key={0}>
|
||||
<StepLabel
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={(): void => {
|
||||
moveToStep(0);
|
||||
}}
|
||||
slots={{
|
||||
stepIcon: (): JSX.Element => (
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor:
|
||||
activeStep >= 0 ? theme.palette.primary.main : theme.palette.grey[300],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<PersonIcon />
|
||||
</Avatar>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& div': { display: 'flex' },
|
||||
'& :first-of-type': {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{step.title}
|
||||
</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
<Box sx={{ mb: 1, justifyContent: 'center' }}>Candidate Selection</Box>
|
||||
{user !== null && (
|
||||
<Box
|
||||
sx={{
|
||||
justifySelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
fontSize: '0.75rem',
|
||||
gap: 1,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexDirection: 'column', textAlign: 'left' }}>
|
||||
<Box>Name</Box>
|
||||
<Box sx={{ fontWeight: 'normal' }}>{user?.fullName}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
|
||||
<Step key={1}>
|
||||
<StepLabel
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={(): void => {
|
||||
moveToStep(1);
|
||||
}}
|
||||
slots={{
|
||||
stepIcon: (): JSX.Element => (
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor:
|
||||
activeStep >= 1 ? theme.palette.primary.main : theme.palette.grey[300],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<WorkIcon />
|
||||
</Avatar>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& div': { display: 'flex' },
|
||||
'& :first-of-type': {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 1, justifyContent: 'center' }}>Job Selection</Box>
|
||||
{selectedJob !== null && (
|
||||
<Box
|
||||
sx={{
|
||||
justifySelf: 'flex-start',
|
||||
flexDirection: 'row',
|
||||
fontSize: '0.75rem',
|
||||
gap: 1,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexDirection: 'column', textAlign: 'left' }}>
|
||||
<Box>Company</Box>
|
||||
<Box sx={{ fontWeight: 'normal' }}>{selectedJob.company}</Box>
|
||||
</Box>
|
||||
<Box sx={{ flexDirection: 'column', textAlign: 'left' }}>
|
||||
<Box>Title</Box>
|
||||
<Box sx={{ fontWeight: 'normal' }}>{selectedJob.title}</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
|
||||
<Step key={2}>
|
||||
<StepLabel
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={(): void => {
|
||||
moveToStep(2);
|
||||
}}
|
||||
slots={{
|
||||
stepIcon: (): JSX.Element => (
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor:
|
||||
activeStep >= 2 ? theme.palette.primary.main : theme.palette.grey[300],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<AutoAwesomeIcon />
|
||||
</Avatar>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& div': { display: 'flex' },
|
||||
'& :first-of-type': {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 1, justifyContent: 'center' }}>Job Analysis</Box>
|
||||
{analysisState.analysis !== null && (
|
||||
<Box sx={{ justifyContent: 'center' }}>
|
||||
<JobMatchScore score={analysisState.analysis.score} variant="small" />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
|
||||
<Step key={3}>
|
||||
<StepLabel
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={(): void => {
|
||||
moveToStep(3);
|
||||
}}
|
||||
slots={{
|
||||
stepIcon: (): JSX.Element => (
|
||||
<Avatar
|
||||
sx={{
|
||||
bgcolor:
|
||||
activeStep >= 3 ? theme.palette.primary.main : theme.palette.grey[300],
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<AssessmentIcon />
|
||||
</Avatar>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& div': { display: 'flex' },
|
||||
'& :first-of-type': {
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 1, justifyContent: 'center' }}>Generate Resume</Box>
|
||||
</Box>
|
||||
</StepLabel>
|
||||
</Step>
|
||||
</Stepper>
|
||||
<Box sx={{ display: 'flex', flexDirection: isMobile ? 'column' : 'row' }}>
|
||||
{analysisState && analysisState.job && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
|
||||
{!isMobile && (
|
||||
<Avatar
|
||||
sx={{
|
||||
ml: 1,
|
||||
mt: 1,
|
||||
bgcolor: theme.palette.primary.main,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<WorkIcon />
|
||||
</Avatar>
|
||||
)}
|
||||
<JobInfo variant="minimal" job={analysisState.job} />
|
||||
</Box>
|
||||
)}
|
||||
{isMobile && <Box sx={{ display: 'flex', borderBottom: '1px solid lightgrey' }} />}
|
||||
{!isMobile && <Box sx={{ display: 'flex', borderLeft: '1px solid lightgrey' }} />}
|
||||
{analysisState && analysisState.candidate && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', width: '100%' }}>
|
||||
<CandidateInfo variant="minimal" candidate={analysisState.candidate} sx={{}} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
<Scrollable
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
maxHeight: '100%',
|
||||
minHeight: 0 /* Prevent flex overflow */,
|
||||
// maxHeight: 'min-content',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
flex: 1 /* Take remaining space in some-container */,
|
||||
overflowY: 'auto' /* Scroll if content overflows */,
|
||||
overflowY: 'hidden',
|
||||
m: 0,
|
||||
p: 0,
|
||||
}}
|
||||
>
|
||||
{activeStep.label === 'job-selection' && renderJobDescription()}
|
||||
{activeStep.label === 'select-candidate' && renderCandidateSelection()}
|
||||
{activeStep.label === 'job-analysis' && renderAnalysis()}
|
||||
{activeStep.label === 'generated-resume' && renderResume()}
|
||||
</Scrollable>
|
||||
{activeStep === 0 && renderCandidateSelection()}
|
||||
{activeStep === 1 && renderJobDescription()}
|
||||
{activeStep === 2 && renderAnalysis()}
|
||||
{activeStep === 3 && renderResume()}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'row', pt: 2 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
disabled={activeStep.index === steps[0].index}
|
||||
onClick={handleBack}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<Button color="inherit" disabled={activeStep === 0} onClick={handleBack} sx={{ mr: 1 }}>
|
||||
Back
|
||||
</Button>
|
||||
<Box sx={{ flex: '1 1 auto' }} />
|
||||
|
||||
{activeStep.index === steps[steps.length - 1].index ? (
|
||||
{activeStep === maxStep ? (
|
||||
<Button
|
||||
disabled={!canAdvance}
|
||||
onClick={(): void => {
|
||||
@ -433,7 +582,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = () => {
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={!canAdvance} onClick={handleNext} variant="contained">
|
||||
{activeStep.index === steps.length - 1 ? 'Done' : 'Next'}
|
||||
{activeStep === maxStep - 1 ? 'Done' : 'Next'}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
@ -545,6 +545,22 @@ class ApiClient {
|
||||
return this.handleApiResponseWithConversion<Types.Candidate>(response, 'Candidate');
|
||||
}
|
||||
|
||||
async getJobAnalysis(job: Types.Job, candidate: Types.Candidate): Promise<Types.JobAnalysis> {
|
||||
const data: Types.JobAnalysis = {
|
||||
jobId: job.id || '',
|
||||
candidateId: candidate.id || '',
|
||||
skills: [],
|
||||
};
|
||||
const request = formatApiRequest(data);
|
||||
const response = await fetch(`${this.baseUrl}/candidates/job-analysis`, {
|
||||
method: 'POST',
|
||||
headers: this.defaultHeaders,
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
return this.handleApiResponseWithConversion<Types.JobAnalysis>(response, 'JobAnalysis');
|
||||
}
|
||||
|
||||
async updateCandidate(id: string, updates: Partial<Types.Candidate>): Promise<Types.Candidate> {
|
||||
const request = formatApiRequest(updates);
|
||||
const response = await fetch(`${this.baseUrl}/candidates/${id}`, {
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Generated TypeScript types from Pydantic models
|
||||
// Source: src/backend/models.py
|
||||
// Generated on: 2025-06-19T22:17:35.101284
|
||||
// Generated on: 2025-07-01T21:24:10.743667
|
||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||
|
||||
// ============================
|
||||
@ -721,6 +721,12 @@ export interface Job {
|
||||
details?: JobDetails;
|
||||
}
|
||||
|
||||
export interface JobAnalysis {
|
||||
jobId: string;
|
||||
candidateId: string;
|
||||
skills: Array<SkillAssessment>;
|
||||
}
|
||||
|
||||
export interface JobApplication {
|
||||
id?: string;
|
||||
jobId: string;
|
||||
@ -1042,6 +1048,7 @@ export interface SkillAssessment {
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
ragResults?: Array<ChromaDBGetResponse>;
|
||||
matchScore: number;
|
||||
}
|
||||
|
||||
export interface SocialLink {
|
||||
@ -1666,6 +1673,19 @@ export function convertJobFromApi(data: any): Job {
|
||||
details: data.details ? convertJobDetailsFromApi(data.details) : undefined,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convert JobAnalysis from API response
|
||||
* Nested models: skills (SkillAssessment)
|
||||
*/
|
||||
export function convertJobAnalysisFromApi(data: any): JobAnalysis {
|
||||
if (!data) return data;
|
||||
|
||||
return {
|
||||
...data,
|
||||
// Convert nested SkillAssessment model
|
||||
skills: data.skills.map((item: any) => convertSkillAssessmentFromApi(item)),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Convert JobApplication from API response
|
||||
* Date fields: appliedDate, updatedDate
|
||||
@ -1973,6 +1993,8 @@ export function convertFromApi<T>(data: any, modelType: string): T {
|
||||
return convertInterviewScheduleFromApi(data) as T;
|
||||
case 'Job':
|
||||
return convertJobFromApi(data) as T;
|
||||
case 'JobAnalysis':
|
||||
return convertJobAnalysisFromApi(data) as T;
|
||||
case 'JobApplication':
|
||||
return convertJobApplicationFromApi(data) as T;
|
||||
case 'JobDetails':
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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(...),
|
||||
|
@ -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}"
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user