backstory/frontend/src/components/JobMatchAnalysis.tsx

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 };