447 lines
16 KiB
TypeScript
447 lines
16 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Paper,
|
|
Accordion,
|
|
AccordionSummary,
|
|
AccordionDetails,
|
|
CircularProgress,
|
|
Grid,
|
|
Chip,
|
|
Divider,
|
|
Card,
|
|
CardContent,
|
|
useTheme,
|
|
LinearProgress
|
|
} 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, ChatMessageBase, ChatMessageUser, ChatSession, JobRequirements, SkillMatch } from 'types/types';
|
|
import { useAuth } from 'hooks/AuthContext';
|
|
import { BackstoryPageProps } from './BackstoryTab';
|
|
import { toCamelCase } from 'types/conversion';
|
|
|
|
|
|
interface Job {
|
|
title: string;
|
|
description: string;
|
|
}
|
|
|
|
interface JobAnalysisProps extends BackstoryPageProps {
|
|
job: Job;
|
|
candidate: Candidate;
|
|
}
|
|
|
|
const defaultMessage: ChatMessageUser = {
|
|
type: "preparing", status: "done", sender: "user", sessionId: "", timestamp: new Date(), content: ""
|
|
};
|
|
|
|
const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) => {
|
|
const {
|
|
job,
|
|
candidate,
|
|
setSnack,
|
|
} = props
|
|
const { apiClient } = useAuth();
|
|
const theme = useTheme();
|
|
const [jobRequirements, setJobRequirements] = useState<JobRequirements | null>(null);
|
|
const [requirements, setRequirements] = useState<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);
|
|
|
|
// Handle accordion expansion
|
|
const handleAccordionChange = (panel: string) => (event: React.SyntheticEvent, isExpanded: boolean) => {
|
|
setExpanded(isExpanded ? panel : false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (requirementsSession || creatingSession) {
|
|
return;
|
|
}
|
|
|
|
const createSession = async () => {
|
|
try {
|
|
const session: ChatSession = await apiClient.createCandidateChatSession(
|
|
candidate.username,
|
|
'job_requirements',
|
|
`Generate requirements for ${job.title}`
|
|
);
|
|
setSnack("Job analysis session started");
|
|
setRequirementsSession(session);
|
|
} catch (error) {
|
|
console.log(error);
|
|
setSnack("Unable to create requirements session", "error");
|
|
}
|
|
setCreatingSession(false);
|
|
};
|
|
setCreatingSession(true);
|
|
createSession();
|
|
}, [requirementsSession, apiClient, candidate]);
|
|
|
|
// Fetch initial requirements
|
|
useEffect(() => {
|
|
if (!job.description || !requirementsSession || loadingRequirements || jobRequirements) {
|
|
return;
|
|
}
|
|
|
|
const getRequirements = async () => {
|
|
setLoadingRequirements(true);
|
|
try {
|
|
const chatMessage: ChatMessageUser = { ...defaultMessage, sessionId: requirementsSession.id || '', content: job.description };
|
|
apiClient.sendMessageStream(chatMessage, {
|
|
onMessage: (msg: ChatMessage) => {
|
|
console.log(`onMessage: ${msg.type}`, msg);
|
|
if (msg.type === "response") {
|
|
const incoming: any = toCamelCase<JobRequirements>(JSON.parse(msg.content || ''));
|
|
const requirements: string[] = ['technicalSkills', 'experienceRequirements'].flatMap((type) => {
|
|
return ['required', 'preferred'].flatMap((level) => {
|
|
return incoming[type][level].map((s: string) => s);
|
|
})
|
|
});
|
|
['softSkills', 'experience', 'education', 'certifications', 'preferredAttributes'].forEach(l => {
|
|
if (incoming[l]) {
|
|
incoming[l].forEach((s: string) => requirements.push(s));
|
|
}
|
|
});
|
|
|
|
// Initialize skill matches with pending status
|
|
const initialSkillMatches = requirements.map(req => ({
|
|
requirement: req,
|
|
status: 'pending' as const,
|
|
matchScore: 0,
|
|
assessment: '',
|
|
citations: []
|
|
}));
|
|
|
|
setRequirements(requirements);
|
|
setSkillMatches(initialSkillMatches);
|
|
setStatusMessage(null);
|
|
setLoadingRequirements(false);
|
|
}
|
|
},
|
|
onError: (error: string | ChatMessageBase) => {
|
|
console.log("onError:", error);
|
|
// Type-guard to determine if this is a ChatMessageBase or a string
|
|
if (typeof error === "object" && error !== null && "content" in error) {
|
|
setSnack(error.content || 'Error obtaining requirements from job description.', "error");
|
|
} else {
|
|
setSnack(error as string, "error");
|
|
}
|
|
setLoadingRequirements(false);
|
|
},
|
|
onStreaming: (chunk: ChatMessageBase) => {
|
|
// console.log("onStreaming:", chunk);
|
|
},
|
|
onStatusChange: (status: string) => {
|
|
console.log(`onStatusChange: ${status}`);
|
|
},
|
|
onComplete: () => {
|
|
console.log("onComplete");
|
|
setStatusMessage(null);
|
|
setLoadingRequirements(false);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to send message:', error);
|
|
setLoadingRequirements(false);
|
|
}
|
|
};
|
|
|
|
getRequirements();
|
|
}, [job, requirementsSession]);
|
|
|
|
// Fetch match data for each requirement
|
|
useEffect(() => {
|
|
const fetchMatchData = async () => {
|
|
if (requirements.length === 0) return;
|
|
|
|
// Process requirements one by one
|
|
for (let i = 0; i < requirements.length; i++) {
|
|
try {
|
|
const match: SkillMatch = await apiClient.candidateMatchForRequirement(candidate.id || '', requirements[i]);
|
|
console.log(match);
|
|
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;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
if (!loadingRequirements) {
|
|
fetchMatchData();
|
|
}
|
|
}, [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') 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" />;
|
|
};
|
|
|
|
return (
|
|
<Box>
|
|
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
|
|
<Grid container spacing={2}>
|
|
<Grid size={{ xs: 12 }} sx={{ textAlign: 'center', mb: 2 }}>
|
|
<Typography variant="h4" component="h1" gutterBottom>
|
|
Job Match Analysis
|
|
</Typography>
|
|
<Divider sx={{ mb: 2 }} />
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Typography variant="h6" component="h2">
|
|
Job: {job.title}
|
|
</Typography>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, md: 6 }}>
|
|
<Typography variant="h6" component="h2">
|
|
Candidate: {candidate.fullName}
|
|
</Typography>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12 }} sx={{ mt: 2 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
|
<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>
|
|
</Grid>
|
|
</Grid>
|
|
</Paper>
|
|
|
|
{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)}
|
|
<Typography sx={{ ml: 1, fontWeight: 'medium' }}>
|
|
{match.requirement}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{match.status === 'complete' ? (
|
|
<Chip
|
|
label={`${match.matchScore}% Match`}
|
|
size="small"
|
|
sx={{
|
|
bgcolor: getMatchColor(match.matchScore),
|
|
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...
|
|
</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.citations && match.citations.length > 0 ? (
|
|
match.citations.map((citation, citIndex) => (
|
|
<Card
|
|
key={citIndex}
|
|
variant="outlined"
|
|
sx={{
|
|
mb: 2,
|
|
borderLeft: '4px solid',
|
|
borderColor: theme.palette.primary.main,
|
|
}}
|
|
>
|
|
<CardContent>
|
|
<Typography variant="body1" component="div" sx={{ mb: 1, fontStyle: 'italic' }}>
|
|
"{citation.text}"
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Source: {citation.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>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</AccordionDetails>
|
|
</Accordion>
|
|
))}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export { JobMatchAnalysis }; |