Almost working through automatic flow

This commit is contained in:
James Ketr 2025-06-05 16:03:26 -07:00
parent 504985a06b
commit 1a13d41f28
9 changed files with 555 additions and 218 deletions

View File

@ -112,9 +112,9 @@ const DocumentManager = (props: BackstoryElementProps) => {
try { try {
// Upload file (replace with actual API call) // Upload file (replace with actual API call)
const controller = apiClient.uploadCandidateDocument(file, { includeInRAG: true, isJobDocument: false }); const controller = apiClient.uploadCandidateDocument(file, { includeInRAG: true, isJobDocument: false });
const newDocument = await controller.promise; const result = await controller.promise;
setDocuments(prev => [...prev, newDocument]); setDocuments(prev => [...prev, result.document]);
setSnack(`Document uploaded: ${file.name}`, 'success'); setSnack(`Document uploaded: ${file.name}`, 'success');
// Reset file input // Reset file input

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, JSX } from 'react'; import React, { useState, useEffect, useRef, JSX } from 'react';
import { import {
Box, Box,
Button, Button,
@ -6,7 +6,6 @@ import {
Paper, Paper,
TextField, TextField,
Grid, Grid,
InputAdornment,
Dialog, Dialog,
DialogTitle, DialogTitle,
DialogContent, DialogContent,
@ -14,7 +13,15 @@ import {
DialogActions, DialogActions,
IconButton, IconButton,
useTheme, useTheme,
useMediaQuery useMediaQuery,
Chip,
Divider,
Card,
CardContent,
CardHeader,
LinearProgress,
Stack,
Alert
} from '@mui/material'; } from '@mui/material';
import { import {
SyncAlt, SyncAlt,
@ -25,7 +32,14 @@ import {
AutoFixHigh, AutoFixHigh,
Image, Image,
Psychology, Psychology,
Build Build,
CloudUpload,
Description,
Business,
LocationOn,
Work,
CheckCircle,
Star
} from '@mui/icons-material'; } from '@mui/icons-material';
import { styled } from '@mui/material/styles'; import { styled } from '@mui/material/styles';
import DescriptionIcon from '@mui/icons-material/Description'; import DescriptionIcon from '@mui/icons-material/Description';
@ -37,7 +51,6 @@ import { BackstoryElementProps } from './BackstoryTab';
import { LoginRequired } from 'components/ui/LoginRequired'; import { LoginRequired } from 'components/ui/LoginRequired';
import * as Types from 'types/types'; import * as Types from 'types/types';
import { StreamingResponse } from 'services/api-client';
const VisuallyHiddenInput = styled('input')({ const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)', clip: 'rect(0 0 0 0)',
@ -51,52 +64,85 @@ const VisuallyHiddenInput = styled('input')({
width: 1, width: 1,
}); });
const UploadBox = styled(Box)(({ theme }) => ({
border: `2px dashed ${theme.palette.primary.main}`,
borderRadius: theme.shape.borderRadius * 2,
padding: theme.spacing(4),
textAlign: 'center',
backgroundColor: theme.palette.action.hover,
transition: 'all 0.3s ease',
cursor: 'pointer',
'&:hover': {
backgroundColor: theme.palette.action.selected,
borderColor: theme.palette.primary.dark,
},
}));
const StatusBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1, 2),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
minHeight: 48,
}));
const getIcon = (type: Types.ApiActivityType) => { const getIcon = (type: Types.ApiActivityType) => {
switch (type) { switch (type) {
case 'converting': case 'converting':
return <SyncAlt />; return <SyncAlt color="primary" />;
case 'heartbeat': case 'heartbeat':
return <Favorite />; return <Favorite color="error" />;
case 'system': case 'system':
return <Settings />; return <Settings color="action" />;
case 'info': case 'info':
return <Info />; return <Info color="info" />;
case 'searching': case 'searching':
return <Search />; return <Search color="primary" />;
case 'generating': case 'generating':
return <AutoFixHigh />; return <AutoFixHigh color="secondary" />;
case 'generating_image': case 'generating_image':
return <Image />; return <Image color="primary" />;
case 'thinking': case 'thinking':
return <Psychology />; return <Psychology color="secondary" />;
case 'tooling': case 'tooling':
return <Build />; return <Build color="action" />;
default: default:
return <Info />; // fallback icon return <Info color="action" />;
} }
} };
const JobManagement = (props: BackstoryElementProps) => { const JobManagement = (props: BackstoryElementProps) => {
const { user, apiClient } = useAuth(); const { user, apiClient } = useAuth();
const { selectedCandidate } = useSelectedCandidate() const { selectedCandidate } = useSelectedCandidate();
const { selectedJob, setSelectedJob } = useSelectedJob() const { selectedJob, setSelectedJob } = useSelectedJob();
const { setSnack, submitQuery } = props; const { setSnack, submitQuery } = props;
const backstoryProps = { setSnack, submitQuery }; const backstoryProps = { setSnack, submitQuery };
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const isTablet = useMediaQuery(theme.breakpoints.down('md'));
const [openUploadDialog, setOpenUploadDialog] = useState<boolean>(false); const [openUploadDialog, setOpenUploadDialog] = useState<boolean>(false);
const [jobDescription, setJobDescription] = useState<string>(''); const [jobDescription, setJobDescription] = useState<string>('');
const [jobRequirements, setJobRequirements] = useState<Types.JobRequirements | null>(null);
const [jobTitle, setJobTitle] = useState<string>(''); const [jobTitle, setJobTitle] = useState<string>('');
const [company, setCompany] = useState<string>(''); const [company, setCompany] = useState<string>('');
const [summary, setSummary] = useState<string>('');
const [jobLocation, setJobLocation] = useState<string>(''); const [jobLocation, setJobLocation] = useState<string>('');
const [jobId, setJobId] = useState<string>(''); const [jobId, setJobId] = useState<string>('');
const [jobStatus, setJobStatus] = useState<string>(''); const [jobStatus, setJobStatus] = useState<string>('');
const [jobStatusIcon, setJobStatusIcon] = useState<JSX.Element>(<></>); const [jobStatusIcon, setJobStatusIcon] = useState<JSX.Element>(<></>);
const [isProcessing, setIsProcessing] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
}, [jobTitle, jobDescription, company]); }, [jobTitle, jobDescription, company]);
const fileInputRef = useRef<HTMLInputElement>(null);
if (!user?.id) { if (!user?.id) {
return ( return (
<LoginRequired asset="candidate analysis" /> <LoginRequired asset="candidate analysis" />
@ -105,39 +151,52 @@ const JobManagement = (props: BackstoryElementProps) => {
const jobStatusHandlers = { const jobStatusHandlers = {
onStatus: (status: Types.ChatMessageStatus) => { onStatus: (status: Types.ChatMessageStatus) => {
console.log('status:', status.content);
setJobStatusIcon(getIcon(status.activity)); setJobStatusIcon(getIcon(status.activity));
setJobStatus(status.content); setJobStatus(status.content);
}, },
onMessage: (job: Types.Job) => { onMessage: (job: Types.Job) => {
console.log('onMessage - job', job); console.log('onMessage - job', job);
setCompany(job.company || '');
setJobDescription(job.description); setJobDescription(job.description);
setSummary(job.summary || '');
setJobTitle(job.title || ''); setJobTitle(job.title || '');
setJobRequirements(job.requirements || null);
setJobStatusIcon(<></>);
setJobStatus('');
}, },
onError: (error: Types.ChatMessageError) => { onError: (error: Types.ChatMessageError) => {
console.log('onError', error); console.log('onError', error);
setSnack(error.content, "error"); setSnack(error.content, "error");
setIsProcessing(false);
}, },
onComplete: () => { onComplete: () => {
setJobStatusIcon(<></>); setJobStatusIcon(<></>);
setJobStatus(''); setJobStatus('');
setIsProcessing(false);
} }
}; };
const documentStatusHandlers = { const documentStatusHandlers = {
...jobStatusHandlers, ...jobStatusHandlers,
onMessage: (document: Types.Document) => { onMessage: (document: Types.DocumentMessage) => {
if ('document' in document) {
console.log('onMessage - document', document); console.log('onMessage - document', document);
const job: Types.Job = document as any; setJobDescription(document.content || '');
setJobDescription(job.description); } else if ('requirements' in document) {
setJobTitle(job.title || ''); console.log('onMessage - document (as job)', document);
jobStatusHandlers.onMessage(document);
} }
setJobStatusIcon(<></>);
setJobStatus('');
} }
};
const handleJobUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleJobUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) { if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]; const file = e.target.files[0];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
let docType : Types.DocumentType | null = null; let docType: Types.DocumentType | null = null;
switch (fileExtension.substring(1)) { switch (fileExtension.substring(1)) {
case "pdf": case "pdf":
docType = "pdf"; docType = "pdf";
@ -159,85 +218,240 @@ const JobManagement = (props: BackstoryElementProps) => {
} }
try { try {
// Upload file (replace with actual API call) setIsProcessing(true);
const controller : StreamingResponse<Types.Document> = apiClient.uploadCandidateDocument(file, { isJobDocument: true}, documentStatusHandlers); setJobDescription('');
const document : Types.Document | null = await controller.promise; setJobTitle('');
setJobRequirements(null);
setSummary('');
const controller = apiClient.uploadCandidateDocument(file, { isJobDocument: true, overwrite: true }, documentStatusHandlers);
const document = await controller.promise;
if (!document) { if (!document) {
return; return;
} }
console.log(`Document id: ${document.id}`) console.log(`Document id: ${document.id}`);
e.target.value = ''; e.target.value = '';
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setSnack('Failed to upload document', 'error'); setSnack('Failed to upload document', 'error');
setIsProcessing(false);
} }
} }
}; };
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const renderRequirementSection = (title: string, items: string[] | undefined, icon: JSX.Element, required = false) => {
if (!items || items.length === 0) return null;
return (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1.5 }}>
{icon}
<Typography variant="subtitle1" sx={{ ml: 1, fontWeight: 600 }}>
{title}
</Typography>
{required && <Chip label="Required" size="small" color="error" sx={{ ml: 1 }} />}
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{items.map((item, index) => (
<Chip
key={index}
label={item}
variant="outlined"
size="small"
sx={{ mb: 1 }}
/>
))}
</Stack>
</Box>
);
};
const renderJobRequirements = () => {
if (!jobRequirements) return null;
return (
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Requirements Analysis"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>
{renderRequirementSection(
"Technical Skills (Required)",
jobRequirements.technicalSkills.required,
<Build color="primary" />,
true
)}
{renderRequirementSection(
"Technical Skills (Preferred)",
jobRequirements.technicalSkills.preferred,
<Build color="action" />
)}
{renderRequirementSection(
"Experience Requirements (Required)",
jobRequirements.experienceRequirements.required,
<Work color="primary" />,
true
)}
{renderRequirementSection(
"Experience Requirements (Preferred)",
jobRequirements.experienceRequirements.preferred,
<Work color="action" />
)}
{renderRequirementSection(
"Soft Skills",
jobRequirements.softSkills,
<Psychology color="secondary" />
)}
{renderRequirementSection(
"Experience",
jobRequirements.experience,
<Star color="warning" />
)}
{renderRequirementSection(
"Education",
jobRequirements.education,
<Description color="info" />
)}
{renderRequirementSection(
"Certifications",
jobRequirements.certifications,
<CheckCircle color="success" />
)}
{renderRequirementSection(
"Preferred Attributes",
jobRequirements.preferredAttributes,
<Star color="secondary" />
)}
</CardContent>
</Card>
);
};
const handleSave = async () => { const handleSave = async () => {
const job : Types.Job = { const newJob: Types.Job = {
ownerId: user?.id || '', ownerId: user?.id || '',
ownerType: 'candidate', ownerType: 'candidate',
description: jobDescription, description: jobDescription,
company: company,
summary: summary,
title: jobTitle, title: jobTitle,
} requirements: jobRequirements || undefined
apiClient.createJob(job, jobStatusHandlers); };
} setIsProcessing(true);
const job = await apiClient.createJob(newJob);
setIsProcessing(false);
setSelectedJob(job);
};
const handleExtractRequirements = () => {
// Implement requirements extraction logic here
setIsProcessing(true);
// This would call your API to extract requirements from the job description
};
const renderJobCreation = () => { const renderJobCreation = () => {
if (!user) { if (!user) {
return <Box>You must </Box> return <Box>You must be logged in</Box>;
} }
return (<>
<Paper elevation={3} sx={{ p: 3, pt: 1, mt: 0, mb: 4, borderRadius: 2 }}> return (
<Grid size={{ xs: 12 }}> <Box sx={{ maxWidth: 1200, mx: 'auto', p: { xs: 2, sm: 3 } }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', mt: 0, mb: 1, flexDirection: "column" }}> {/* Upload Section */}
<Typography variant="subtitle1" sx={{ mr: 2 }}> <Card elevation={3} sx={{ mb: 4 }}>
Job Selection <CardHeader
title="Job Information"
subheader="Upload a job description or enter details manually"
avatar={<Work color="primary" />}
/>
<CardContent>
<Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<CloudUpload sx={{ mr: 1 }} />
Upload Job Description
</Typography>
<UploadBox onClick={handleUploadClick}>
<CloudUpload sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Drop your job description here
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Supported formats: PDF, DOCX, TXT, MD
</Typography> </Typography>
<Box sx={{display: "flex", flexDirection: "column"}}>
<Button <Button
component="label"
variant="contained" variant="contained"
startIcon={<FileUploadIcon />} startIcon={<FileUploadIcon />}
size={isMobile ? "small" : "medium"}> disabled={isProcessing}
Upload // onClick={handleUploadClick}
>
Choose File
</Button>
</UploadBox>
<VisuallyHiddenInput <VisuallyHiddenInput
ref={fileInputRef}
type="file" type="file"
accept=".txt,.md,.docx,.pdf" accept=".txt,.md,.docx,.pdf"
onChange={handleJobUpload} onChange={handleJobUpload}
/> />
</Button> </Grid>
<Typography variant="caption">Accepted document formats: .pdf, .docx, .txt, or .md</Typography>
</Box>
<Box>{jobStatusIcon} {jobStatus}</Box>
</Box>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center' }}>
<Description sx={{ mr: 1 }} />
Or Enter Manually
</Typography>
<TextField <TextField
fullWidth fullWidth
multiline multiline
rows={12} rows={isMobile ? 8 : 12}
placeholder="Enter the job description here..." placeholder="Paste or type the job description here..."
variant="outlined" variant="outlined"
value={jobDescription} value={jobDescription}
onChange={(e) => setJobDescription(e.target.value)} onChange={(e) => setJobDescription(e.target.value)}
required disabled={isProcessing}
InputProps={{ sx={{ mb: 2 }}
startAdornment: (
<InputAdornment position="start" sx={{ alignSelf: 'flex-start', mt: 1.5 }}>
<DescriptionIcon color="action" />
</InputAdornment>
),
}}
/> />
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}> {jobRequirements === null && jobDescription && (
The job description will be used to extract requirements for candidate matching. <Button
</Typography> variant="outlined"
onClick={handleExtractRequirements}
startIcon={<AutoFixHigh />}
disabled={isProcessing}
fullWidth={isMobile}
>
Extract Requirements
</Button>
)}
</Grid>
</Grid> </Grid>
<Typography variant="h5" gutterBottom>
Enter Job Details
</Typography>
{(jobStatus || isProcessing) && (
<Box sx={{ mt: 3 }}>
<StatusBox>
{jobStatusIcon}
<Typography variant="body2" sx={{ ml: 1 }}>
{jobStatus || 'Processing...'}
</Typography>
</StatusBox>
{isProcessing && <LinearProgress sx={{ mt: 1 }} />}
</Box>
)}
</CardContent>
</Card>
{/* Job Details Section */}
<Card elevation={3} sx={{ mb: 4 }}>
<CardHeader
title="Job Details"
subheader="Enter specific information about the position"
avatar={<Business color="primary" />}
/>
<CardContent>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
@ -247,7 +461,10 @@ const JobManagement = (props: BackstoryElementProps) => {
value={jobTitle} value={jobTitle}
onChange={(e) => setJobTitle(e.target.value)} onChange={(e) => setJobTitle(e.target.value)}
required required
margin="normal" disabled={isProcessing}
InputProps={{
startAdornment: <Work sx={{ mr: 1, color: 'text.secondary' }} />
}}
/> />
</Grid> </Grid>
@ -259,34 +476,75 @@ const JobManagement = (props: BackstoryElementProps) => {
value={company} value={company}
onChange={(e) => setCompany(e.target.value)} onChange={(e) => setCompany(e.target.value)}
required required
margin="normal" disabled={isProcessing}
InputProps={{
startAdornment: <Business sx={{ mr: 1, color: 'text.secondary' }} />
}}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> {/* <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Job Location" label="Job Location"
variant="outlined" variant="outlined"
value={jobLocation} value={jobLocation}
onChange={(e) => setJobLocation(e.target.value)} onChange={(e) => setJobLocation(e.target.value)}
margin="normal" disabled={isProcessing}
InputProps={{
startAdornment: <LocationOn sx={{ mr: 1, color: 'text.secondary' }} />
}}
/> />
</Grid> */}
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'flex-end', height: '100%' }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!jobTitle || !company || !jobDescription || isProcessing}
fullWidth={isMobile}
size="large"
startIcon={<CheckCircle />}
>
Save Job
</Button>
</Box>
</Grid> </Grid>
</Grid> </Grid>
</CardContent>
</Card>
{/* Job Summary */}
{summary !== '' &&
<Card elevation={2} sx={{ mt: 3 }}>
<CardHeader
title="Job Summary"
avatar={<CheckCircle color="success" />}
sx={{ pb: 1 }}
/>
<CardContent sx={{ pt: 0 }}>
{summary}
</CardContent>
</Card>
}
</Paper> {/* Requirements Display */}
{renderJobRequirements()}
</>); </Box>
);
}; };
return ( return (
<Box sx={{display: "flex", flexDirection: isMobile ? "column" : "row", gap: 1, m: 0, p: 0}}> <Box sx={{
{ selectedJob === null && renderJobCreation() } minHeight: '100vh',
{/* { selectedJob !== null && renderJob() } */} backgroundColor: 'background.default',
pt: { xs: 2, sm: 3 }
}}>
{selectedJob === null && renderJobCreation()}
</Box> </Box>
); );
} };
export { JobManagement }; export { JobManagement };

View File

@ -57,6 +57,52 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
setExpanded(isExpanded ? panel : false); setExpanded(isExpanded ? panel : false);
}; };
useEffect(() => {
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 = requirements.map(req => ({
requirement: req.requirement,
domain: req.domain,
status: 'waiting' as const,
matchScore: 0,
assessment: '',
description: '',
citations: []
}));
setRequirements(requirements);
setSkillMatches(initialSkillMatches);
setStatusMessage(null);
setLoadingRequirements(false);
}, [job, setRequirements]);
useEffect(() => { useEffect(() => {
if (requirementsSession || creatingSession) { if (requirementsSession || creatingSession) {
return; return;

View File

@ -89,6 +89,12 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
} }
}, [selectedCandidate, activeStep]); }, [selectedCandidate, activeStep]);
useEffect(() => {
if (selectedJob && activeStep === 1) {
setActiveStep(2);
}
}, [selectedJob, activeStep]);
// Steps in our process // Steps in our process
const steps = [ const steps = [
{ index: 1, label: 'Job Selection', icon: <WorkIcon /> }, { index: 1, label: 'Job Selection', icon: <WorkIcon /> },

View File

@ -622,9 +622,15 @@ class ApiClient {
// Job Methods with Date Conversion // Job Methods with Date Conversion
// ============================ // ============================
createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>, streamingOptions?: StreamingOptions<Types.Job>): StreamingResponse<Types.Job> { async createJob(job: Omit<Types.Job, 'id' | 'datePosted' | 'views' | 'applicationCount'>): Promise<Types.Job> {
const body = JSON.stringify(formatApiRequest(job)); const body = JSON.stringify(formatApiRequest(job));
return this.streamify<Types.Job>(`/jobs`, body, streamingOptions); const response = await fetch(`${this.baseUrl}/jobs`, {
method: 'POST',
headers: this.defaultHeaders,
body: body
});
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
} }
async getJob(id: string): Promise<Types.Job> { async getJob(id: string): Promise<Types.Job> {
@ -824,7 +830,7 @@ class ApiClient {
const document : Types.Document = await controller.promise; const document : Types.Document = await controller.promise;
console.log(`Document id: ${document.id}`) console.log(`Document id: ${document.id}`)
*/ */
uploadCandidateDocument(file: File, options: Types.DocumentOptions, streamingOptions?: StreamingOptions<Types.Document>): StreamingResponse<Types.Document> { uploadCandidateDocument(file: File, options: Types.DocumentOptions, streamingOptions?: StreamingOptions<Types.DocumentMessage>): StreamingResponse<Types.DocumentMessage> {
const convertedOptions = toSnakeCase(options); const convertedOptions = toSnakeCase(options);
const formData = new FormData() const formData = new FormData()
formData.append('file', file); formData.append('file', file);
@ -837,7 +843,7 @@ class ApiClient {
'Authorization': this.defaultHeaders['Authorization'] 'Authorization': this.defaultHeaders['Authorization']
} }
}; };
return this.streamify<Types.Document>('/candidates/documents/upload', formData, streamingOptions); return this.streamify<Types.DocumentMessage>('/candidates/documents/upload', formData, streamingOptions);
// { // {
// method: 'POST', // method: 'POST',
// headers: { // headers: {
@ -1003,12 +1009,6 @@ class ApiClient {
let messageId = ''; let messageId = '';
let finalMessage : T | null = null; let finalMessage : T | null = null;
console.log('streamify: ', {
api,
method,
headers,
body: data
});
const promise = new Promise<T>(async (resolve, reject) => { const promise = new Promise<T>(async (resolve, reject) => {
try { try {
const response = await fetch(`${this.baseUrl}${api}`, { const response = await fetch(`${this.baseUrl}${api}`, {

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models // Generated TypeScript types from Pydantic models
// Source: src/backend/models.py // Source: src/backend/models.py
// Generated on: 2025-06-05T20:17:00.575243 // Generated on: 2025-06-05T22:02:22.004513
// DO NOT EDIT MANUALLY - This file is auto-generated // DO NOT EDIT MANUALLY - This file is auto-generated
// ============================ // ============================
@ -502,11 +502,14 @@ export interface DocumentMessage {
type: "binary" | "text" | "json"; type: "binary" | "text" | "json";
timestamp?: Date; timestamp?: Date;
document: Document; document: Document;
content?: string;
converted: boolean;
} }
export interface DocumentOptions { export interface DocumentOptions {
includeInRAG?: boolean; includeInRAG?: boolean;
isJobDocument?: boolean; isJobDocument?: boolean;
overwrite?: boolean;
} }
export interface DocumentUpdateRequest { export interface DocumentUpdateRequest {

View File

@ -132,21 +132,20 @@ class JobRequirementsAgent(Agent):
yield error_message yield error_message
return return
job_requirements : JobRequirements | None = None requirements = None
job_requirements_data = "" job_requirements_data = ""
company_name = "" company = ""
job_summary = "" summary = ""
job_title = "" title = ""
try: try:
json_str = self.extract_json_from_text(generated_message.content) json_str = self.extract_json_from_text(generated_message.content)
job_requirements_data = json.loads(json_str) requirements_json = json.loads(json_str)
job_requirements_data = job_requirements_data.get("job_requirements", None)
job_title = job_requirements_data.get("job_title", "") company = requirements_json.get("company_name", "")
company_name = job_requirements_data.get("company_name", "") title = requirements_json.get("job_title", "")
job_summary = job_requirements_data.get("job_summary", "") summary = requirements_json.get("job_summary", "")
job_requirements = JobRequirements.model_validate(job_requirements_data) job_requirements_data = requirements_json.get("job_requirements", None)
if not job_requirements: requirements = JobRequirements.model_validate(job_requirements_data)
raise ValueError("Job requirements data is empty or invalid.")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
status_message.status = ApiStatusType.ERROR status_message.status = ApiStatusType.ERROR
status_message.content = f"Failed to parse job requirements JSON: {str(e)}\n\n{job_requirements_data}" status_message.content = f"Failed to parse job requirements JSON: {str(e)}\n\n{job_requirements_data}"
@ -157,6 +156,7 @@ class JobRequirementsAgent(Agent):
status_message.status = ApiStatusType.ERROR status_message.status = ApiStatusType.ERROR
status_message.content = f"Job requirements validation error: {str(e)}\n\n{job_requirements_data}" status_message.content = f"Job requirements validation error: {str(e)}\n\n{job_requirements_data}"
logger.error(f"⚠️ {status_message.content}") logger.error(f"⚠️ {status_message.content}")
logger.error(f"Content: {prompt}")
yield status_message yield status_message
return return
except Exception as e: except Exception as e:
@ -169,14 +169,13 @@ class JobRequirementsAgent(Agent):
job_requirements_message = JobRequirementsMessage( job_requirements_message = JobRequirementsMessage(
session_id=session_id, session_id=session_id,
status=ApiStatusType.DONE, status=ApiStatusType.DONE,
requirements=job_requirements, requirements=requirements,
company=company_name, company=company,
title=job_title, title=title,
summary=job_summary, summary=summary,
description=prompt, description=prompt,
) )
yield job_requirements_message yield job_requirements_message
logger.info(f"✅ Job requirements analysis completed successfully.") logger.info(f"✅ Job requirements analysis completed successfully.")
return return

View File

@ -1730,7 +1730,7 @@ async def upload_candidate_document(
) )
"""Upload a document for the current candidate""" """Upload a document for the current candidate"""
async def upload_stream_generator(): async def upload_stream_generator(file_content):
# Verify user is a candidate # Verify user is a candidate
if current_user.user_type != "candidate": if current_user.user_type != "candidate":
logger.warning(f"⚠️ Unauthorized upload attempt by user type: {current_user.user_type}") logger.warning(f"⚠️ Unauthorized upload attempt by user type: {current_user.user_type}")
@ -1763,6 +1763,7 @@ async def upload_candidate_document(
os.makedirs(dir_path, exist_ok=True) os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, file.filename) file_path = os.path.join(dir_path, file.filename)
if os.path.exists(file_path): if os.path.exists(file_path):
if not options.overwrite:
logger.warning(f"⚠️ File already exists: {file_path}") logger.warning(f"⚠️ File already exists: {file_path}")
error_message = ChatMessageError( error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads session_id=MOCK_UUID, # No session ID for document uploads
@ -1770,6 +1771,14 @@ async def upload_candidate_document(
) )
yield error_message yield error_message
return return
else:
logger.info(f"🔄 Overwriting existing file: {file_path}")
status_message = ChatMessageStatus(
session_id=MOCK_UUID, # No session ID for document uploads
content=f"Overwriting existing file: {file.filename}",
activity=ApiActivityType.INFO
)
yield status_message
# Validate file type # Validate file type
allowed_types = ['.txt', '.md', '.docx', '.pdf', '.png', '.jpg', '.jpeg', '.gif'] allowed_types = ['.txt', '.md', '.docx', '.pdf', '.png', '.jpg', '.jpeg', '.gif']
@ -1818,6 +1827,7 @@ async def upload_candidate_document(
yield error_message yield error_message
return return
converted = False;
if document_type != DocumentType.MARKDOWN and document_type != DocumentType.TXT: if document_type != DocumentType.MARKDOWN and document_type != DocumentType.TXT:
p = pathlib.Path(file_path) p = pathlib.Path(file_path)
p_as_md = p.with_suffix(".md") p_as_md = p.with_suffix(".md")
@ -1828,7 +1838,7 @@ async def upload_candidate_document(
): ):
status_message = ChatMessageStatus( status_message = ChatMessageStatus(
session_id=MOCK_UUID, # No session ID for document uploads session_id=MOCK_UUID, # No session ID for document uploads
content=f"Converting {file.filename} to Markdown format for better processing...", content=f"Converting content from {document_type}...",
activity=ApiActivityType.CONVERTING activity=ApiActivityType.CONVERTING
) )
yield status_message yield status_message
@ -1837,6 +1847,9 @@ async def upload_candidate_document(
md = MarkItDown(enable_plugins=False) # Set to True to enable plugins md = MarkItDown(enable_plugins=False) # Set to True to enable plugins
result = md.convert(file_path, output_format="markdown") result = md.convert(file_path, output_format="markdown")
p_as_md.write_text(result.text_content) p_as_md.write_text(result.text_content)
file_content = result.text_content
converted = True
logger.info(f"✅ Converted {file.filename} to Markdown format: {p_as_md}")
file_path = p_as_md file_path = p_as_md
except Exception as e: except Exception as e:
error_message = ChatMessageError( error_message = ChatMessageError(
@ -1856,21 +1869,23 @@ async def upload_candidate_document(
type=ApiMessageType.JSON, type=ApiMessageType.JSON,
status=ApiStatusType.DONE, status=ApiStatusType.DONE,
document=document_data, document=document_data,
converted=converted,
content=file_content,
) )
yield chat_message yield chat_message
# If this is a job description, process it with the job requirements agent # If this is a job description, process it with the job requirements agent
if options.is_job_document: if not options.is_job_document:
content = None
with open(file_path, "r") as f:
content = f.read()
if not content or len(content) == 0:
error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads
content="Job description file is empty"
)
yield error_message
return return
status_message = ChatMessageStatus(
session_id=MOCK_UUID, # No session ID for document uploads
content=f"Initiating connection with {candidate.first_name}'s AI agent...",
activity=ApiActivityType.INFO
)
yield status_message
await asyncio.sleep(0)
async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: async with entities.get_candidate_entity(candidate=candidate) as candidate_entity:
chat_agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.JOB_REQUIREMENTS) chat_agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.JOB_REQUIREMENTS)
if not chat_agent: if not chat_agent:
@ -1881,14 +1896,21 @@ async def upload_candidate_document(
yield error_message yield error_message
return return
message = None message = None
status_message = ChatMessageStatus(
session_id=MOCK_UUID, # No session ID for document uploads
content=f"Analyzing document for company and requirement details...",
activity=ApiActivityType.SEARCHING
)
yield status_message
await asyncio.sleep(0)
async for message in chat_agent.generate( async for message in chat_agent.generate(
llm=llm_manager.get_llm(), llm=llm_manager.get_llm(),
model=defines.model, model=defines.model,
session_id=MOCK_UUID, session_id=MOCK_UUID,
prompt=content prompt=file_content
): ):
if message.status != ApiStatusType.DONE: pass
yield message
if not message or not isinstance(message, JobRequirementsMessage): if not message or not isinstance(message, JobRequirementsMessage):
error_message = ChatMessageError( error_message = ChatMessageError(
session_id=MOCK_UUID, # No session ID for document uploads session_id=MOCK_UUID, # No session ID for document uploads
@ -1912,7 +1934,7 @@ async def upload_candidate_document(
# return DebugStreamingResponse( # return DebugStreamingResponse(
return StreamingResponse( return StreamingResponse(
to_json(upload_stream_generator()), to_json(upload_stream_generator(file_content)),
media_type="text/event-stream", media_type="text/event-stream",
headers={ headers={
"Cache-Control": "no-cache, no-store, must-revalidate", "Cache-Control": "no-cache, no-store, must-revalidate",

View File

@ -521,6 +521,7 @@ class DocumentType(str, Enum):
class DocumentOptions(BaseModel): class DocumentOptions(BaseModel):
include_in_RAG: Optional[bool] = Field(True, alias="includeInRAG") include_in_RAG: Optional[bool] = Field(True, alias="includeInRAG")
is_job_document: Optional[bool] = Field(False, alias="isJobDocument") is_job_document: Optional[bool] = Field(False, alias="isJobDocument")
overwrite: Optional[bool] = Field(False, alias="overwrite")
model_config = { model_config = {
"populate_by_name": True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
} }
@ -829,6 +830,8 @@ class JobRequirementsMessage(ApiMessage):
class DocumentMessage(ApiMessage): class DocumentMessage(ApiMessage):
type: ApiMessageType = ApiMessageType.JSON type: ApiMessageType = ApiMessageType.JSON
document: Document = Field(..., alias="document") document: Document = Field(..., alias="document")
content: Optional[str] = ""
converted: bool = Field(False, alias="converted")
model_config = { model_config = {
"populate_by_name": True # Allow both field names and aliases "populate_by_name": True # Allow both field names and aliases
} }