607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Paper,
|
|
Accordion,
|
|
AccordionSummary,
|
|
AccordionDetails,
|
|
CircularProgress,
|
|
Grid,
|
|
Chip,
|
|
Divider,
|
|
Card,
|
|
CardContent,
|
|
useTheme,
|
|
LinearProgress,
|
|
useMediaQuery,
|
|
Button,
|
|
} from '@mui/material';
|
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
import ErrorIcon from '@mui/icons-material/Error';
|
|
import PendingIcon from '@mui/icons-material/Pending';
|
|
import WarningIcon from '@mui/icons-material/Warning';
|
|
import {
|
|
Candidate,
|
|
ChatMessage,
|
|
ChatMessageError,
|
|
ChatMessageStatus,
|
|
ChatMessageStreaming,
|
|
ChatMessageUser,
|
|
ChatSession,
|
|
EvidenceDetail,
|
|
JobRequirements,
|
|
SkillAssessment,
|
|
SkillStatus,
|
|
} from 'types/types';
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import { BackstoryPageProps } from './BackstoryTab';
|
|
import { Job } from 'types/types';
|
|
import { StyledMarkdown } from './StyledMarkdown';
|
|
import { Scrollable } from './Scrollable';
|
|
import { useAppState } from 'hooks/GlobalContext';
|
|
import * as Types from 'types/types';
|
|
import JsonView from '@uiw/react-json-view';
|
|
import { VectorVisualizer } from './VectorVisualizer';
|
|
import { JobInfo } from './ui/JobInfo';
|
|
|
|
interface JobAnalysisProps extends BackstoryPageProps {
|
|
job: Job;
|
|
candidate: Candidate;
|
|
variant?: 'small' | 'normal';
|
|
onAnalysisComplete: (skills: SkillAssessment[]) => void;
|
|
}
|
|
|
|
const defaultMessage: ChatMessage = {
|
|
status: 'done',
|
|
type: 'text',
|
|
sessionId: '',
|
|
timestamp: new Date(),
|
|
content: '',
|
|
role: 'assistant',
|
|
metadata: null as any,
|
|
};
|
|
|
|
interface SkillMatch extends SkillAssessment {
|
|
domain: string;
|
|
status: SkillStatus;
|
|
matchScore: number;
|
|
}
|
|
|
|
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => {
|
|
const { job, candidate, onAnalysisComplete, variant = 'normal' } = props;
|
|
const { apiClient } = useAuth();
|
|
const { setSnack } = useAppState();
|
|
const theme = useTheme();
|
|
const [requirements, setRequirements] = useState<{ requirement: string; domain: string }[]>([]);
|
|
const [skillMatches, setSkillMatches] = useState<SkillMatch[]>([]);
|
|
const [creatingSession, setCreatingSession] = useState<boolean>(false);
|
|
const [loadingRequirements, setLoadingRequirements] = useState<boolean>(false);
|
|
const [expanded, setExpanded] = useState<string | false>(false);
|
|
const [overallScore, setOverallScore] = useState<number>(0);
|
|
const [requirementsSession, setRequirementsSession] = useState<ChatSession | null>(null);
|
|
const [statusMessage, setStatusMessage] = useState<ChatMessage | null>(null);
|
|
const [startAnalysis, setStartAnalysis] = useState<boolean>(false);
|
|
const [analyzing, setAnalyzing] = useState<boolean>(false);
|
|
const [matchStatus, setMatchStatus] = useState<string>('');
|
|
const [matchStatusType, setMatchStatusType] = useState<Types.ApiActivityType | null>(null);
|
|
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
|
|
// Handle accordion expansion
|
|
const handleAccordionChange =
|
|
(panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
|
setExpanded(isExpanded ? panel : false);
|
|
};
|
|
|
|
const initializeRequirements = (job: Job) => {
|
|
if (!job || !job.requirements) {
|
|
return;
|
|
}
|
|
const requirements: { requirement: string; domain: string }[] = [];
|
|
if (job.requirements?.technicalSkills) {
|
|
job.requirements.technicalSkills.required?.forEach(req =>
|
|
requirements.push({
|
|
requirement: req,
|
|
domain: 'Technical Skills (required)',
|
|
})
|
|
);
|
|
job.requirements.technicalSkills.preferred?.forEach(req =>
|
|
requirements.push({
|
|
requirement: req,
|
|
domain: 'Technical Skills (preferred)',
|
|
})
|
|
);
|
|
}
|
|
if (job.requirements?.experienceRequirements) {
|
|
job.requirements.experienceRequirements.required?.forEach(req =>
|
|
requirements.push({ requirement: req, domain: 'Experience (required)' })
|
|
);
|
|
job.requirements.experienceRequirements.preferred?.forEach(req =>
|
|
requirements.push({
|
|
requirement: req,
|
|
domain: 'Experience (preferred)',
|
|
})
|
|
);
|
|
}
|
|
if (job.requirements?.softSkills) {
|
|
job.requirements.softSkills.forEach(req =>
|
|
requirements.push({ requirement: req, domain: 'Soft Skills' })
|
|
);
|
|
}
|
|
if (job.requirements?.experience) {
|
|
job.requirements.experience.forEach(req =>
|
|
requirements.push({ requirement: req, domain: 'Experience' })
|
|
);
|
|
}
|
|
if (job.requirements?.education) {
|
|
job.requirements.education.forEach(req =>
|
|
requirements.push({ requirement: req, domain: 'Education' })
|
|
);
|
|
}
|
|
if (job.requirements?.certifications) {
|
|
job.requirements.certifications.forEach(req =>
|
|
requirements.push({ requirement: req, domain: 'Certifications' })
|
|
);
|
|
}
|
|
if (job.requirements?.preferredAttributes) {
|
|
job.requirements.preferredAttributes.forEach(req =>
|
|
requirements.push({ requirement: req, domain: 'Preferred Attributes' })
|
|
);
|
|
}
|
|
|
|
const initialSkillMatches: SkillMatch[] = requirements.map(req => ({
|
|
skill: req.requirement,
|
|
skillModified: req.requirement,
|
|
candidateId: candidate.id || '',
|
|
domain: req.domain,
|
|
status: 'waiting' as const,
|
|
assessment: '',
|
|
description: '',
|
|
evidenceFound: false,
|
|
evidenceStrength: 'none',
|
|
evidenceDetails: [],
|
|
matchScore: 0,
|
|
}));
|
|
|
|
setRequirements(requirements);
|
|
setSkillMatches(initialSkillMatches);
|
|
setStatusMessage(null);
|
|
setLoadingRequirements(false);
|
|
setOverallScore(0);
|
|
};
|
|
|
|
useEffect(() => {
|
|
initializeRequirements(job);
|
|
}, [job]);
|
|
|
|
const skillMatchHandlers = {
|
|
onStatus: (status: Types.ChatMessageStatus) => {
|
|
setMatchStatusType(status.activity);
|
|
setMatchStatus(status.content.toLowerCase());
|
|
},
|
|
};
|
|
|
|
// Fetch match data for each requirement
|
|
useEffect(() => {
|
|
if (!startAnalysis || analyzing || !job.requirements) {
|
|
return;
|
|
}
|
|
|
|
const fetchMatchData = async (skills: SkillAssessment[]) => {
|
|
if (requirements.length === 0) return;
|
|
|
|
// Process requirements one by one
|
|
for (let i = 0; i < requirements.length; i++) {
|
|
try {
|
|
setSkillMatches(prev => {
|
|
const updated = [...prev];
|
|
updated[i] = { ...updated[i], status: 'pending' };
|
|
return updated;
|
|
});
|
|
|
|
const request: any = await apiClient.candidateMatchForRequirement(
|
|
candidate.id || '',
|
|
requirements[i].requirement,
|
|
skillMatchHandlers
|
|
);
|
|
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 (
|
|
skillMatch.evidenceStrength == 'NONE' &&
|
|
skillMatch.citations &&
|
|
skillMatch.citations.length > 3
|
|
) {
|
|
matchScore = Math.min(skillMatch.citations.length * 8, 40);
|
|
}
|
|
const match: SkillMatch = {
|
|
...skillMatch,
|
|
status: 'complete',
|
|
matchScore,
|
|
domain: requirements[i].domain,
|
|
};
|
|
setSkillMatches(prev => {
|
|
const updated = [...prev];
|
|
updated[i] = match;
|
|
return updated;
|
|
});
|
|
|
|
// Update overall score
|
|
setSkillMatches(current => {
|
|
const completedMatches = current.filter(match => match.status === 'complete');
|
|
if (completedMatches.length > 0) {
|
|
const newOverallScore =
|
|
completedMatches.reduce((sum, match) => sum + match.matchScore, 0) /
|
|
completedMatches.length;
|
|
setOverallScore(newOverallScore);
|
|
}
|
|
return current;
|
|
});
|
|
} catch (error) {
|
|
console.error(`Error fetching match for requirement ${requirements[i]}:`, error);
|
|
setSkillMatches(prev => {
|
|
const updated = [...prev];
|
|
updated[i] = {
|
|
...updated[i],
|
|
status: 'error',
|
|
assessment: 'Failed to analyze this requirement.',
|
|
};
|
|
return updated;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
setAnalyzing(true);
|
|
const skills: SkillAssessment[] = [];
|
|
fetchMatchData(skills).then(() => {
|
|
setAnalyzing(false);
|
|
setStartAnalysis(false);
|
|
onAnalysisComplete && onAnalysisComplete(skills);
|
|
});
|
|
}, [job, onAnalysisComplete, startAnalysis, analyzing, requirements, loadingRequirements]);
|
|
|
|
// Get color based on match score
|
|
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;
|
|
};
|
|
|
|
// Get icon based on status
|
|
const getStatusIcon = (status: string, score: number) => {
|
|
if (status === 'pending' || status === 'waiting') return <PendingIcon />;
|
|
if (status === 'error') return <ErrorIcon color="error" />;
|
|
if (score >= 70) return <CheckCircleIcon color="success" />;
|
|
if (score >= 40) return <WarningIcon color="warning" />;
|
|
return <ErrorIcon color="error" />;
|
|
};
|
|
|
|
const beginAnalysis = () => {
|
|
initializeRequirements(job);
|
|
setStartAnalysis(true);
|
|
};
|
|
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', m: 0, p: 0 }}>
|
|
{variant !== 'small' && <JobInfo job={job} variant="normal" />}
|
|
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
mb: isMobile ? 1 : 2,
|
|
gap: 1,
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: isMobile ? 'column' : 'row',
|
|
flexGrow: 1,
|
|
gap: 1,
|
|
}}
|
|
>
|
|
{overallScore !== 0 && (
|
|
<>
|
|
<Typography variant="h5" component="h2" sx={{ mr: 2 }}>
|
|
Overall Match:
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
position: 'relative',
|
|
display: 'inline-flex',
|
|
mr: 2,
|
|
}}
|
|
>
|
|
<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>
|
|
<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>
|
|
<Button
|
|
sx={{ marginLeft: 'auto' }}
|
|
disabled={analyzing || startAnalysis}
|
|
onClick={beginAnalysis}
|
|
variant="contained"
|
|
>
|
|
{analyzing ? 'Assessment in Progress' : 'Start Skill Assessment'}
|
|
</Button>
|
|
</Box>
|
|
|
|
{loadingRequirements ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
|
<CircularProgress />
|
|
<Typography variant="h6" sx={{ ml: 2 }}>
|
|
Analyzing job requirements...
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
<Box>
|
|
<Typography variant="h5" component="h2" gutterBottom>
|
|
Requirements Analysis
|
|
</Typography>
|
|
|
|
{skillMatches.map((match, index) => (
|
|
<Accordion
|
|
key={index}
|
|
expanded={expanded === `panel${index}`}
|
|
onChange={handleAccordionChange(`panel${index}`)}
|
|
sx={{
|
|
mb: 2,
|
|
border: '1px solid',
|
|
borderColor:
|
|
match.status === 'complete'
|
|
? getMatchColor(match.matchScore)
|
|
: theme.palette.divider,
|
|
}}
|
|
>
|
|
<AccordionSummary
|
|
expandIcon={<ExpandMoreIcon />}
|
|
aria-controls={`panel${index}bh-content`}
|
|
id={`panel${index}bh-header`}
|
|
sx={{
|
|
bgcolor:
|
|
match.status === 'complete'
|
|
? `${getMatchColor(match.matchScore)}22` // Add transparency
|
|
: 'inherit',
|
|
}}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
width: '100%',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
{getStatusIcon(match.status, match.matchScore)}
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: 0,
|
|
p: 0,
|
|
m: 0,
|
|
}}
|
|
>
|
|
<Typography
|
|
sx={{
|
|
ml: 1,
|
|
mb: 0,
|
|
fontWeight: 'medium',
|
|
marginBottom: '0px !important',
|
|
}}
|
|
>
|
|
{match.skill}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ ml: 1, fontWeight: 'light' }}>
|
|
{match.domain}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
{match.status === 'complete' ? (
|
|
<Chip
|
|
label={`${match.matchScore}% Match`}
|
|
size="small"
|
|
sx={{
|
|
bgcolor: getMatchColor(match.matchScore),
|
|
color: 'white',
|
|
minWidth: 90,
|
|
}}
|
|
/>
|
|
) : match.status === 'waiting' ? (
|
|
<Chip
|
|
label="Waiting..."
|
|
size="small"
|
|
sx={{
|
|
bgcolor: 'rgb(189, 173, 85)',
|
|
color: 'white',
|
|
minWidth: 90,
|
|
}}
|
|
/>
|
|
) : match.status === 'pending' ? (
|
|
<Chip
|
|
label="Analyzing..."
|
|
size="small"
|
|
sx={{
|
|
bgcolor: theme.palette.grey[400],
|
|
color: 'white',
|
|
minWidth: 90,
|
|
}}
|
|
/>
|
|
) : (
|
|
<Chip
|
|
label="Error"
|
|
size="small"
|
|
sx={{
|
|
bgcolor: theme.palette.error.main,
|
|
color: 'white',
|
|
minWidth: 90,
|
|
}}
|
|
/>
|
|
)}
|
|
</Box>
|
|
</AccordionSummary>
|
|
|
|
<AccordionDetails>
|
|
{match.status === 'pending' ? (
|
|
<Box sx={{ width: '100%', p: 2 }}>
|
|
<LinearProgress />
|
|
<Typography sx={{ mt: 2 }}>
|
|
Analyzing candidate's match for this requirement... {matchStatus}
|
|
</Typography>
|
|
</Box>
|
|
) : match.status === 'error' ? (
|
|
<Typography color="error">
|
|
{match.assessment || 'An error occurred while analyzing this requirement.'}
|
|
</Typography>
|
|
) : (
|
|
<Box>
|
|
<Typography variant="h6" gutterBottom>
|
|
Assessment
|
|
</Typography>
|
|
|
|
<Typography paragraph sx={{ mb: 3 }}>
|
|
{match.assessment}
|
|
</Typography>
|
|
|
|
<Typography variant="h6" gutterBottom>
|
|
Supporting Evidence
|
|
</Typography>
|
|
{match.evidenceDetails && match.evidenceDetails.length > 0 ? (
|
|
match.evidenceDetails.map((evidence, evndex) => (
|
|
<Card
|
|
key={evndex}
|
|
variant="outlined"
|
|
sx={{
|
|
mb: 2,
|
|
borderLeft: '4px solid',
|
|
borderColor: theme.palette.primary.main,
|
|
}}
|
|
>
|
|
<CardContent>
|
|
<Typography
|
|
variant="body1"
|
|
component="div"
|
|
sx={{ mb: 1, fontStyle: 'italic' }}
|
|
>
|
|
"{evidence.quote}"
|
|
</Typography>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'flex-start',
|
|
flexDirection: 'column',
|
|
}}
|
|
>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Relevance: {evidence.context}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Source: {evidence.source}
|
|
</Typography>
|
|
{/* <Chip
|
|
size="small"
|
|
label={`Relevance: ${citation.relevance}%`}
|
|
sx={{
|
|
bgcolor: theme.palette.grey[200],
|
|
}}
|
|
/> */}
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
) : (
|
|
<Typography color="text.secondary">
|
|
No specific evidence found in candidate's profile.
|
|
</Typography>
|
|
)}
|
|
<Typography variant="h6" gutterBottom>
|
|
Skill description
|
|
</Typography>
|
|
<Typography paragraph>{match.description}</Typography>
|
|
{/* { match.ragResults && match.ragResults.length !== 0 && <>
|
|
<Typography variant="h6" gutterBottom>
|
|
RAG Information
|
|
</Typography>
|
|
<VectorVisualizer inline rag={match.ragResults[0]} />
|
|
</>
|
|
} */}
|
|
</Box>
|
|
)}
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { JobMatchAnalysis };
|