Added JobViewer

This commit is contained in:
James Ketr 2025-06-11 23:04:43 -07:00
parent 53e0d4aafb
commit e0ed154476
11 changed files with 479 additions and 33 deletions

View File

@ -278,10 +278,13 @@ const JobCreator = (props: JobCreatorProps) => {
updatedAt: new Date(),
};
setIsProcessing(true);
const jobMessage = await apiClient.createJob(newJob);
const job = await apiClient.createJob(newJob);
setIsProcessing(false);
const job: Types.Job = jobMessage.job;
onSave ? onSave(job) : setSelectedJob(job);
if (!job) {
setSnack('Failed to save job', 'error');
return;
}
onSave && onSave(job);
};
const handleExtractRequirements = async () => {
@ -305,7 +308,8 @@ const JobCreator = (props: JobCreatorProps) => {
const renderJobCreation = () => {
return (
<Box sx={{
mx: 'auto', p: { xs: 2, sm: 3 },
width: "100%",
p: 1
}}>
{/* Upload Section */}
<Card elevation={3} sx={{ mb: 4 }}>
@ -477,8 +481,33 @@ const JobCreator = (props: JobCreatorProps) => {
}}>
{job === null && renderJobCreation()}
{job &&
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end', height: '100%' }}>
<Box sx={{
display: "flex", flexDirection: "column",
height: "100%", /* Restrict to main-container's height */
width: "100%",
minHeight: 0,/* Prevent flex overflow */
maxHeight: "min-content",
position: "relative",
}}>
<Box sx={{
display: "flex",
flexDirection: "row",
flexGrow: 1,
gap: 1,
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",
border: "3px solid purple",
}}>
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><JobInfo job={job} /></Scrollable>
<Scrollable sx={{ display: "flex", flexGrow: 1, position: "relative", maxHeight: "30rem" }}><StyledMarkdown content={job.description} /></Scrollable>
</Box>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end' }}>
<Button
variant="contained"
onClick={handleSave}
@ -490,10 +519,7 @@ const JobCreator = (props: JobCreatorProps) => {
Save Job
</Button>
</Box>
<Box sx={{ display: "flex", flexDirection: "row", flexShrink: 1, gap: 1 }}>
<Paper elevation={1} sx={{ p: 1, m: 1, flexGrow: 1 }}><Scrollable><JobInfo job={job} /></Scrollable></Paper>
<Paper elevation={1} sx={{ p: 1, m: 1, flexGrow: 1 }}><Scrollable><StyledMarkdown content={job.description} /></Scrollable></Paper>
</Box>
</Box>
}
</Box>

View File

@ -20,6 +20,8 @@ import ArticleIcon from '@mui/icons-material/Article';
import { StatusBox, StatusIcon } from './ui/StatusIcon';
import { CopyBubble } from './CopyBubble';
import { useAppState } from 'hooks/GlobalContext';
import { StreamingOptions } from 'services/api-client';
import { setDefaultResultOrder } from 'dns';
interface ResumeGeneratorProps {
job: Job;
@ -43,6 +45,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
const [tabValue, setTabValue] = useState<string>('resume');
const [status, setStatus] = useState<string>('');
const [statusType, setStatusType] = useState<Types.ApiActivityType | null>(null);
const [error, setError] = useState<Types.ChatMessageError | null>(null);
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);
@ -58,7 +61,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
setStatusType("thinking");
setStatus("Starting resume generation...");
const generateResumeHandlers = {
const generateResumeHandlers: StreamingOptions<Types.ChatMessageResume> = {
onMessage: (message: Types.ChatMessageResume) => {
setSystemPrompt(message.systemPrompt || '');
setPrompt(message.prompt || '');
@ -79,11 +82,17 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
},
onComplete: () => {
onComplete && onComplete(resume);
}
},
onError: (error: Types.ChatMessageError) => {
console.log('error:', error);
setStatusType(null);
setStatus(error.content);
setError(error);
},
};
const generateResume = async () => {
const request: any = await apiClient.generateResume(candidate.id || '', skills, generateResumeHandlers);
const request: any = await apiClient.generateResume(candidate.id || '', job.id || '', generateResumeHandlers);
const result = await request.promise;
};
@ -112,7 +121,7 @@ const ResumeGenerator: React.FC<ResumeGeneratorProps> = (props: ResumeGeneratorP
{status || 'Processing...'}
</Typography>
</StatusBox>
{status && <LinearProgress sx={{ mt: 1 }} />}
{status && !error && <LinearProgress sx={{ mt: 1 }} />}
</Box>}
<Paper elevation={3} sx={{ p: 3, m: 1, mt: 0 }}><Scrollable autoscroll sx={{ display: "flex", flexGrow: 1, position: "relative" }}>

View File

@ -29,6 +29,7 @@ const Scrollable = forwardRef((props: ScrollableProps, ref) => {
p: 0,
flexGrow: 1,
overflow: 'auto',
position: 'relative',
// backgroundColor: '#F5F5F5',
...sx,
}}

View File

@ -21,16 +21,16 @@ import RestoreIcon from '@mui/icons-material/Restore';
import SaveIcon from '@mui/icons-material/Save';
import * as Types from "types/types";
import { useAppState } from 'hooks/GlobalContext';
import { StyledMarkdown } from 'components/StyledMarkdown';
interface JobInfoProps {
job: Job;
sx?: SxProps;
action?: string;
elevation?: number;
variant?: "minimal" | "small" | "normal" | null
variant?: "minimal" | "small" | "normal" | "all" | null
};
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
const { setSnack } = useAppState();
const { job } = props;
@ -50,8 +50,15 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
// State for description expansion
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [shouldShowMoreButton, setShouldShowMoreButton] = useState(false);
const [deleted, setDeleted] = useState<boolean>(false);
const descriptionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (job && job.id !== activeJob?.id) {
setActiveJob(job);
}
}, [job, activeJob, setActiveJob]);
// Check if description needs truncation
useEffect(() => {
if (descriptionRef.current && job.summary) {
@ -212,8 +219,11 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
borderStyle: 'solid',
transition: 'all 0.3s ease',
flexDirection: "column",
...sx,
minWidth: 0,
opacity: deleted ? 0.5 : 1.0,
backgroundColor: deleted ? theme.palette.action.disabledBackground : theme.palette.background.paper,
pointerEvents: deleted ? "none" : "auto",
...sx,
}}
{...rest}
>
@ -304,6 +314,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
}
<Typography variant="caption">Job ID: {job.id}</Typography>
</>}
{variant === 'all' && <StyledMarkdown content={activeJob.description} />}
{(variant !== 'small' && variant !== 'minimal') && <><Divider />{renderJobRequirements()}</>}
@ -324,7 +335,7 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
<Tooltip title="Delete Job">
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); deleteJob(job.id); }}
onClick={(e) => { e.stopPropagation(); deleteJob(job.id); setDeleted(true) }}
>
<DeleteIcon />
</IconButton>

View File

@ -50,7 +50,7 @@ const JobPicker = (props: JobPickerProps) => {
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", justifyContent: "center" }}>
{jobs?.map((j, i) =>
<Paper key={`${j.id}`}
onClick={() => { onSelect ? onSelect(j) : setSelectedJob(j); }}
onClick={() => { console.log('Selected job', j); onSelect && onSelect(j) }}
sx={{ cursor: "pointer" }}>
<JobInfo variant="small"
sx={{

View File

@ -0,0 +1,318 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Typography,
FormControl,
Select,
MenuItem,
InputLabel,
Chip,
Divider,
IconButton,
Tooltip
} from '@mui/material';
import {
KeyboardArrowUp as ArrowUpIcon,
KeyboardArrowDown as ArrowDownIcon,
Business as BusinessIcon,
Work as WorkIcon,
Schedule as ScheduleIcon
} from '@mui/icons-material';
import { JobInfo } from 'components/ui/JobInfo';
import { Job } from "types/types";
import { useAuth } from 'hooks/AuthContext';
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
import { Navigate, useNavigate, useParams } from 'react-router-dom';
type SortField = 'updatedAt' | 'createdAt' | 'company' | 'title';
type SortOrder = 'asc' | 'desc';
interface JobViewerProps {
onSelect?: (job: Job) => void;
}
const JobViewer: React.FC<JobViewerProps> = ({ onSelect }) => {
const navigate = useNavigate();
const { apiClient } = useAuth();
const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack } = useAppState();
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [sortField, setSortField] = useState<SortField>('updatedAt');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const { jobId } = useParams<{ jobId?: string }>();
useEffect(() => {
const getJobs = async () => {
setLoading(true);
try {
const results = await apiClient.getJobs();
const jobsData: Job[] = results.data || [];
setJobs(jobsData);
if (jobId) {
const job = jobsData.find(j => j.id === jobId);
if (job) {
setSelectedJob(job);
onSelect?.(job);
return;
}
}
// Auto-select first job if none selected
if (jobsData.length > 0 && !selectedJob) {
const firstJob = sortJobs(jobsData, sortField, sortOrder)[0];
setSelectedJob(firstJob);
onSelect?.(firstJob);
}
} catch (err) {
setSnack("Failed to load jobs: " + err);
} finally {
setLoading(false);
}
};
getJobs();
}, [apiClient, setSnack]);
const sortJobs = (jobsList: Job[], field: SortField, order: SortOrder): Job[] => {
return [...jobsList].sort((a, b) => {
let aValue: any;
let bValue: any;
switch (field) {
case 'updatedAt':
aValue = a.updatedAt?.getTime() || 0;
bValue = b.updatedAt?.getTime() || 0;
break;
case 'createdAt':
aValue = a.createdAt?.getTime() || 0;
bValue = b.createdAt?.getTime() || 0;
break;
case 'company':
aValue = a.company?.toLowerCase() || '';
bValue = b.company?.toLowerCase() || '';
break;
case 'title':
aValue = a.title?.toLowerCase() || '';
bValue = b.title?.toLowerCase() || '';
break;
default:
return 0;
}
if (aValue < bValue) return order === 'asc' ? -1 : 1;
if (aValue > bValue) return order === 'asc' ? 1 : -1;
return 0;
});
};
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortOrder('desc');
}
};
const handleJobSelect = (job: Job) => {
setSelectedJob(job);
onSelect?.(job);
navigate(`/candidate/jobs/${job.id}`);
};
const sortedJobs = sortJobs(jobs, sortField, sortOrder);
const formatDate = (date: Date | undefined) => {
if (!date) return 'N/A';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(date);
};
const getSortIcon = (field: SortField) => {
if (sortField !== field) return null;
return sortOrder === 'asc' ? <ArrowUpIcon fontSize="small" /> : <ArrowDownIcon fontSize="small" />;
};
return (
<Box sx={{ display: 'flex', height: '100%', gap: 2, p: 2, position: 'relative' }}>
{/* Left Panel - Job List */}
<Paper sx={{ width: '50%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Typography variant="h6" gutterBottom>
Jobs ({jobs.length})
</Typography>
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Sort by</InputLabel>
<Select
value={`${sortField}-${sortOrder}`}
label="Sort by"
onChange={(e) => {
const [field, order] = e.target.value.split('-') as [SortField, SortOrder];
setSortField(field);
setSortOrder(order);
}}
>
<MenuItem value="updatedAt-desc">Updated (Newest)</MenuItem>
<MenuItem value="updatedAt-asc">Updated (Oldest)</MenuItem>
<MenuItem value="createdAt-desc">Created (Newest)</MenuItem>
<MenuItem value="createdAt-asc">Created (Oldest)</MenuItem>
<MenuItem value="company-asc">Company (A-Z)</MenuItem>
<MenuItem value="company-desc">Company (Z-A)</MenuItem>
<MenuItem value="title-asc">Title (A-Z)</MenuItem>
<MenuItem value="title-desc">Title (Z-A)</MenuItem>
</Select>
</FormControl>
</Box>
<TableContainer sx={{ flex: 1, overflow: 'auto' }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell
sx={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('company')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BusinessIcon fontSize="small" />
Company
{getSortIcon('company')}
</Box>
</TableCell>
<TableCell
sx={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('title')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WorkIcon fontSize="small" />
Title
{getSortIcon('title')}
</Box>
</TableCell>
<TableCell
sx={{ cursor: 'pointer', userSelect: 'none' }}
onClick={() => handleSort('updatedAt')}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ScheduleIcon fontSize="small" />
Updated
{getSortIcon('updatedAt')}
</Box>
</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{sortedJobs.map((job) => (
<TableRow
key={job.id}
hover
selected={selectedJob?.id === job.id}
onClick={() => handleJobSelect(job)}
sx={{
cursor: 'pointer',
'&.Mui-selected': {
backgroundColor: 'action.selected',
}
}}
>
<TableCell>
<Typography variant="body2" fontWeight="medium">
{job.company || 'N/A'}
</Typography>
{job.details?.location && (
<Typography variant="caption" color="text.secondary">
{job.details.location.city}, {job.details.location.state || job.details.location.country}
</Typography>
)}
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="medium">
{job.title || 'N/A'}
</Typography>
{job.details?.employmentType && (
<Chip
label={job.details.employmentType}
size="small"
variant="outlined"
sx={{ mt: 0.5, fontSize: '0.7rem', height: 20 }}
/>
)}
</TableCell>
<TableCell>
<Typography variant="body2">
{formatDate(job.updatedAt)}
</Typography>
{job.createdAt && (
<Typography variant="caption" color="text.secondary">
Created: {formatDate(job.createdAt)}
</Typography>
)}
</TableCell>
<TableCell>
<Chip
label={job.details?.isActive ? "Active" : "Inactive"}
color={job.details?.isActive ? "success" : "default"}
size="small"
variant="outlined"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
{/* Right Panel - Job Details */}
<Paper sx={{ width: '50%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ p: 2, borderBottom: 1, borderColor: 'divider' }}>
<Typography variant="h6">
Job Details
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'auto', p: 2 }}>
{selectedJob ? (
<JobInfo
job={selectedJob}
variant="all"
sx={{
border: 'none',
boxShadow: 'none',
backgroundColor: 'transparent'
}}
/>
) : (
<Box sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
color: 'text.secondary'
}}>
<Typography variant="body1">
Select a job to view details
</Typography>
</Box>
)}
</Box>
</Paper>
</Box>
);
};
export { JobViewer };

View File

@ -43,6 +43,7 @@ import { VectorVisualizer } from "components/VectorVisualizer";
import { DocumentManager } from "components/DocumentManager";
import { useAuth } from "hooks/AuthContext";
import { useNavigate } from "react-router-dom";
import { JobViewer } from "components/ui/JobViewer";
// Beta page components for placeholder routes
const BackstoryPage = () => (
@ -162,6 +163,17 @@ export const navigationConfig: NavigationConfig = {
showInNavigation: false,
showInUserMenu: true,
},
{
id: "candidate-jobs",
label: "Jobs",
icon: <WorkIcon />,
path: "/candidate/jobs/:jobId?",
component: <JobViewer />,
userTypes: ["candidate"],
userMenuGroup: "profile",
showInNavigation: false,
showInUserMenu: true,
},
{
id: "candidate-docs",
label: "Content",

View File

@ -221,7 +221,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
// Render function for the job description step
const renderJobDescription = () => {
return (<Box sx={{ mt: 3 }}>
return (<Box sx={{ mt: 3, width: "100%" }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}>
<Tabs value={jobTab} onChange={handleTabChange} centered>
<Tab value='select' icon={<WorkOutline />} label="Select Job" />

View File

@ -663,7 +663,7 @@ class ApiClient {
return this.streamify<Types.JobRequirementsMessage>('/jobs/from-content', body, streamingOptions, "JobRequirementsMessage");
}
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.JobRequirementsMessage> {
async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
const body = JSON.stringify(formatApiRequest(job));
const response = await fetch(`${this.baseUrl}/jobs`, {
method: 'POST',
@ -671,7 +671,7 @@ class ApiClient {
body: body
});
return this.handleApiResponseWithConversion<Types.JobRequirementsMessage>(response, 'JobRequirementsMessage');
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
}
async getJob(id: string): Promise<Types.Job> {
@ -920,14 +920,14 @@ class ApiClient {
return this.streamify<Types.DocumentMessage>(`/jobs/requirements/${jobId}`, null, streamingOptions, "DocumentMessage");
}
generateResume(candidateId: string, skills: Types.SkillAssessment[], streamingOptions?: StreamingOptions<Types.ChatMessageResume>): StreamingResponse<Types.ChatMessageResume> {
const body = JSON.stringify(skills);
generateResume(candidateId: string, jobId: string, streamingOptions?: StreamingOptions<Types.ChatMessageResume>): StreamingResponse<Types.ChatMessageResume> {
streamingOptions = {
...streamingOptions,
headers: this.defaultHeaders,
};
return this.streamify<Types.ChatMessageResume>(`/candidates/${candidateId}/generate-resume`, body, streamingOptions, "ChatMessageResume");
return this.streamify<Types.ChatMessageResume>(`/candidates/${candidateId}/${jobId}/generate-resume`, null, streamingOptions, "ChatMessageResume");
}
candidateMatchForRequirement(candidate_id: string, requirement: string,
streamingOptions?: StreamingOptions<Types.ChatMessageSkillAssessment>)
: StreamingResponse<Types.ChatMessageSkillAssessment> {

View File

@ -130,6 +130,10 @@ Phone: {self.user.phone or 'N/A'}
## INSTRUCTIONS:
CRITICAL: Do NOT invent, fabricate, or assume any information not explicitly provided.
If information is missing, state what is missing rather than creating fictional details.
When sections lack data, output "Information not provided" or use placeholder text.
1. Create a professional resume that emphasizes the candidate's strongest skills and most relevant experiences.
2. Format the resume in a clean, concise, and modern style that will pass ATS systems.
3. Include these sections:
@ -145,6 +149,18 @@ Phone: {self.user.phone or 'N/A'}
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.
If SKILL ASSESSMENT RESULTS or EXPERIENCE EVIDENCE sections are empty:
- Do not create fictional work history
- Do not invent specific companies, dates, or achievements
- Instead, create a template with placeholders like [Company Name], [Start Date - End Date]
- Include a note: "Template requires candidate input for: [missing sections]"
IF sufficient experience data exists:
Create full professional experience section
ELSE:
Output: "Professional Experience section requires candidate input"
Provide template format only
## OUTPUT FORMAT:
Provide the resume in clean markdown format, ready for the candidate to use.

View File

@ -50,6 +50,7 @@ import os
import backstory_traceback
from rate_limiter import RateLimiter, RateLimitResult, RateLimitConfig
from background_tasks import BackgroundTaskManager
from get_requirements_list import get_requirements_list
# =============================
# Import custom modules
@ -4609,6 +4610,13 @@ def get_endpoint_rate_limiter(rate_limiter: RateLimiter = Depends(get_rate_limit
return endpoint_rate_limiter
def get_skill_cache_key(candidate_id: str, skill: str) -> str:
"""Generate a unique cache key for skill match"""
# Create cache key for this specific candidate + skill combination
skill_hash = hashlib.md5(skill.lower().encode()).hexdigest()[:8]
return f"skill_match:{candidate_id}:{skill_hash}"
@api_router.post("/candidates/{candidate_id}/skill-match")
async def get_candidate_skill_match(
candidate_id: str = Path(...),
@ -4630,9 +4638,7 @@ async def get_candidate_skill_match(
candidate = Candidate.model_validate(candidate_data)
# Create cache key for this specific candidate + skill combination
skill_hash = hashlib.md5(skill.lower().encode()).hexdigest()[:8]
cache_key = f"skill_match:{candidate.id}:{skill_hash}"
cache_key = get_skill_cache_key(candidate.id, skill)
# Get cached assessment if it exists
assessment : SkillAssessment | None = await database.get_cached_skill_match(cache_key)
@ -4905,28 +4911,75 @@ async def get_candidate_job_score(
},
})
@api_router.post("/candidates/{candidate_id}/generate-resume")
@api_router.post("/candidates/{candidate_id}/{job_id}/generate-resume")
async def generate_resume(
candidate_id: str = Path(...),
skills: List[SkillAssessment] = Body(...),
job_id: str = Path(...),
current_user = Depends(get_current_user_or_guest),
database: RedisDatabase = Depends(get_database)
) -> StreamingResponse:
skills: List[SkillAssessment] = []
"""Get skill match for a candidate against a requirement with caching"""
async def message_stream_generator():
logger.info(f"🔍 Looking up candidate and job details for {candidate_id}/{job_id}")
candidate_data = await database.get_candidate(candidate_id)
if not candidate_data:
logger.error(f"❌ Candidate with ID '{candidate_id}' not found")
error_message = ChatMessageError(
sessionId=MOCK_UUID, # No session ID for document uploads
content=f"Candidate with ID '{candidate_id}' not found"
)
yield error_message
return
candidate = Candidate.model_validate(candidate_data)
job_data = await database.get_job(job_id)
if not job_data:
logger.error(f"❌ Job with ID '{job_id}' not found")
error_message = ChatMessageError(
sessionId=MOCK_UUID, # No session ID for document uploads
content=f"Job with ID '{job_id}' not found"
)
yield error_message
return
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.")
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}")
uninitalized = True
break
if assessment and assessment.skill.lower() != skill.lower():
logger.warning(f"❌ Cached skill match for {candidate.username} does not match requested skill: {assessment.skill} != {skill} ({cache_key}).")
uninitalized = True
break
logger.info(f"✅ Assessment found for {candidate.username} skill {assessment.skill}: {cache_key}")
skills.append(assessment)
if uninitalized:
logger.error("❌ Uninitialized skill match data, cannot generate resume")
error_message = ChatMessageError(
sessionId=MOCK_UUID, # No session ID for document uploads
content=f"Uninitialized skill match data, cannot generate resume"
)
yield error_message
return
candidate = Candidate.model_validate(candidate_data)
logger.info(f"🔍 Generating resume for candidate {candidate.username}")
logger.info(f"🔍 Generating resume for candidate {candidate.username}, job {job.id}, with {len(skills)} skills.")
async with entities.get_candidate_entity(candidate=candidate) as candidate_entity:
agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.GENERATE_RESUME)