Restructuring top level UI
This commit is contained in:
parent
4f4187eba4
commit
3a21f2e510
@ -112,15 +112,22 @@ 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 }, {
|
||||||
|
onError: (error) => {
|
||||||
|
console.error(error);
|
||||||
|
setSnack(error.content, 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
const result = await controller.promise;
|
const result = await controller.promise;
|
||||||
|
if (result && result.document) {
|
||||||
setDocuments(prev => [...prev, result.document]);
|
setDocuments(prev => [...prev, result.document]);
|
||||||
setSnack(`Document uploaded: ${file.name}`, 'success');
|
setSnack(`Document uploaded: ${file.name}`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
// Reset file input
|
// Reset file input
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
setSnack('Failed to upload document', 'error');
|
setSnack('Failed to upload document', 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,20 +154,20 @@ const DocumentManager = (props: BackstoryElementProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle RAG flag toggle
|
// Handle RAG flag toggle
|
||||||
const handleRAGToggle = async (document: Types.Document, includeInRAG: boolean) => {
|
const handleRAGToggle = async (document: Types.Document, includeInRag: boolean) => {
|
||||||
try {
|
try {
|
||||||
document.options = { includeInRAG };
|
document.options = { includeInRag };
|
||||||
// Call API to update RAG flag
|
// Call API to update RAG flag
|
||||||
await apiClient.updateCandidateDocument(document);
|
await apiClient.updateCandidateDocument(document);
|
||||||
|
|
||||||
setDocuments(prev =>
|
setDocuments(prev =>
|
||||||
prev.map(doc =>
|
prev.map(doc =>
|
||||||
doc.id === document.id
|
doc.id === document.id
|
||||||
? { ...doc, includeInRAG }
|
? { ...doc, includeInRag }
|
||||||
: doc
|
: doc
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setSnack(`Document ${includeInRAG ? 'included in' : 'excluded from'} RAG`, 'success');
|
setSnack(`Document ${includeInRag ? 'included in' : 'excluded from'} RAG`, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSnack('Failed to update RAG setting', 'error');
|
setSnack('Failed to update RAG setting', 'error');
|
||||||
}
|
}
|
||||||
@ -292,7 +299,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
|
|||||||
size="small"
|
size="small"
|
||||||
color={getFileTypeColor(doc.type)}
|
color={getFileTypeColor(doc.type)}
|
||||||
/>
|
/>
|
||||||
{doc.options?.includeInRAG && (
|
{doc.options?.includeInRag && (
|
||||||
<Chip
|
<Chip
|
||||||
label="RAG"
|
label="RAG"
|
||||||
size="small"
|
size="small"
|
||||||
@ -311,7 +318,7 @@ const DocumentManager = (props: BackstoryElementProps) => {
|
|||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
checked={doc.options?.includeInRAG}
|
checked={doc.options?.includeInRag}
|
||||||
onChange={(e) => handleRAGToggle(doc, e.target.checked)}
|
onChange={(e) => handleRAGToggle(doc, e.target.checked)}
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
|
@ -44,6 +44,7 @@ import * as Types from 'types/types';
|
|||||||
import { StyledMarkdown } from './StyledMarkdown';
|
import { StyledMarkdown } from './StyledMarkdown';
|
||||||
import { JobInfo } from './ui/JobInfo';
|
import { JobInfo } from './ui/JobInfo';
|
||||||
import { Scrollable } from './Scrollable';
|
import { Scrollable } from './Scrollable';
|
||||||
|
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon';
|
||||||
|
|
||||||
const VisuallyHiddenInput = styled('input')({
|
const VisuallyHiddenInput = styled('input')({
|
||||||
clip: 'rect(0 0 0 0)',
|
clip: 'rect(0 0 0 0)',
|
||||||
@ -71,42 +72,6 @@ const UploadBox = styled(Box)(({ theme }) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
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) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'converting':
|
|
||||||
return <SyncAlt color="primary" />;
|
|
||||||
case 'heartbeat':
|
|
||||||
return <Favorite color="error" />;
|
|
||||||
case 'system':
|
|
||||||
return <Settings color="action" />;
|
|
||||||
case 'info':
|
|
||||||
return <Info color="info" />;
|
|
||||||
case 'searching':
|
|
||||||
return <Search color="primary" />;
|
|
||||||
case 'generating':
|
|
||||||
return <AutoFixHigh color="secondary" />;
|
|
||||||
case 'generating_image':
|
|
||||||
return <Image color="primary" />;
|
|
||||||
case 'thinking':
|
|
||||||
return <Psychology color="secondary" />;
|
|
||||||
case 'tooling':
|
|
||||||
return <Build color="action" />;
|
|
||||||
default:
|
|
||||||
return <Info color="action" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
interface JobCreatorProps extends BackstoryElementProps {
|
interface JobCreatorProps extends BackstoryElementProps {
|
||||||
onSave?: (job: Types.Job) => void;
|
onSave?: (job: Types.Job) => void;
|
||||||
}
|
}
|
||||||
@ -125,7 +90,7 @@ const JobCreator = (props: JobCreatorProps) => {
|
|||||||
const [summary, setSummary] = useState<string>('');
|
const [summary, setSummary] = useState<string>('');
|
||||||
const [job, setJob] = useState<Types.Job | null>(null);
|
const [job, setJob] = useState<Types.Job | null>(null);
|
||||||
const [jobStatus, setJobStatus] = useState<string>('');
|
const [jobStatus, setJobStatus] = useState<string>('');
|
||||||
const [jobStatusIcon, setJobStatusIcon] = useState<JSX.Element>(<></>);
|
const [jobStatusType, setJobStatusType] = useState<Types.ApiActivityType | null>(null);
|
||||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -140,7 +105,7 @@ const JobCreator = (props: JobCreatorProps) => {
|
|||||||
const jobStatusHandlers = {
|
const jobStatusHandlers = {
|
||||||
onStatus: (status: Types.ChatMessageStatus) => {
|
onStatus: (status: Types.ChatMessageStatus) => {
|
||||||
console.log('status:', status.content);
|
console.log('status:', status.content);
|
||||||
setJobStatusIcon(getIcon(status.activity));
|
setJobStatusType(status.activity);
|
||||||
setJobStatus(status.content);
|
setJobStatus(status.content);
|
||||||
},
|
},
|
||||||
onMessage: (jobMessage: Types.JobRequirementsMessage) => {
|
onMessage: (jobMessage: Types.JobRequirementsMessage) => {
|
||||||
@ -152,7 +117,7 @@ const JobCreator = (props: JobCreatorProps) => {
|
|||||||
setSummary(job.summary || '');
|
setSummary(job.summary || '');
|
||||||
setJobTitle(job.title || '');
|
setJobTitle(job.title || '');
|
||||||
setJobRequirements(job.requirements || null);
|
setJobRequirements(job.requirements || null);
|
||||||
setJobStatusIcon(<></>);
|
setJobStatusType(null);
|
||||||
setJobStatus('');
|
setJobStatus('');
|
||||||
},
|
},
|
||||||
onError: (error: Types.ChatMessageError) => {
|
onError: (error: Types.ChatMessageError) => {
|
||||||
@ -161,7 +126,7 @@ const JobCreator = (props: JobCreatorProps) => {
|
|||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
},
|
},
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
setJobStatusIcon(<></>);
|
setJobStatusType(null);
|
||||||
setJobStatus('');
|
setJobStatus('');
|
||||||
setIsProcessing(false);
|
setIsProcessing(false);
|
||||||
}
|
}
|
||||||
@ -421,7 +386,7 @@ const JobCreator = (props: JobCreatorProps) => {
|
|||||||
{(jobStatus || isProcessing) && (
|
{(jobStatus || isProcessing) && (
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
<StatusBox>
|
<StatusBox>
|
||||||
{jobStatusIcon}
|
{jobStatusType && <StatusIcon type={jobStatusType} />}
|
||||||
<Typography variant="body2" sx={{ ml: 1 }}>
|
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||||
{jobStatus || 'Processing...'}
|
{jobStatus || 'Processing...'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -30,6 +30,8 @@ import { StyledMarkdown } from './StyledMarkdown';
|
|||||||
import { Scrollable } from './Scrollable';
|
import { Scrollable } from './Scrollable';
|
||||||
import { useAppState } from 'hooks/GlobalContext';
|
import { useAppState } from 'hooks/GlobalContext';
|
||||||
import * as Types from 'types/types';
|
import * as Types from 'types/types';
|
||||||
|
import JsonView from '@uiw/react-json-view';
|
||||||
|
import { VectorVisualizer } from './VectorVisualizer';
|
||||||
|
|
||||||
interface JobAnalysisProps extends BackstoryPageProps {
|
interface JobAnalysisProps extends BackstoryPageProps {
|
||||||
job: Job;
|
job: Job;
|
||||||
@ -106,6 +108,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
|||||||
|
|
||||||
const initialSkillMatches: SkillMatch[] = requirements.map(req => ({
|
const initialSkillMatches: SkillMatch[] = requirements.map(req => ({
|
||||||
skill: req.requirement,
|
skill: req.requirement,
|
||||||
|
skillModified: req.requirement,
|
||||||
candidateId: candidate.id || "",
|
candidateId: candidate.id || "",
|
||||||
domain: req.domain,
|
domain: req.domain,
|
||||||
status: 'waiting' as const,
|
status: 'waiting' as const,
|
||||||
@ -487,6 +490,13 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
|
|||||||
<Typography paragraph>
|
<Typography paragraph>
|
||||||
{match.description}
|
{match.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{/* { match.ragResults && match.ragResults.length !== 0 && <>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
RAG Information
|
||||||
|
</Typography>
|
||||||
|
<VectorVisualizer inline rag={match.ragResults[0]} />
|
||||||
|
</>
|
||||||
|
} */}
|
||||||
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
@ -27,7 +27,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
interface VectorVisualizerProps extends BackstoryPageProps {
|
interface VectorVisualizerProps extends BackstoryPageProps {
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
rag?: any;
|
rag?: Types.ChromaDBGetResponse;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Metadata {
|
interface Metadata {
|
||||||
@ -37,11 +37,16 @@ interface Metadata {
|
|||||||
distance?: number;
|
distance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyQuerySet = {
|
const emptyQuerySet: Types.ChromaDBGetResponse = {
|
||||||
ids: [],
|
ids: [],
|
||||||
documents: [],
|
documents: [],
|
||||||
metadatas: [],
|
metadatas: [],
|
||||||
embeddings: [],
|
embeddings: [],
|
||||||
|
distances: [],
|
||||||
|
name: "Empty",
|
||||||
|
size: 0,
|
||||||
|
dimensions: 2,
|
||||||
|
query: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PlotData {
|
interface PlotData {
|
||||||
|
@ -19,9 +19,10 @@ const BackstoryLogo = () => {
|
|||||||
letterSpacing: '.2rem',
|
letterSpacing: '.2rem',
|
||||||
color: theme.palette.primary.contrastText,
|
color: theme.palette.primary.contrastText,
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
display: "flex",
|
display: "inline-flex",
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
verticalAlign: "center",
|
||||||
gap: 1,
|
gap: 1,
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
}}
|
}}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React, { JSX } from 'react';
|
import React, { JSX, useActionState, useState } from 'react';
|
||||||
import { Box, Link, Typography, Avatar, Grid, SxProps, CardActions, Chip, Stack, CardHeader } from '@mui/material';
|
import { Box, Link, Typography, Avatar, Grid, SxProps, CardActions, Chip, Stack, CardHeader, Button, styled, LinearProgress, IconButton, Tooltip } from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -8,23 +8,31 @@ import {
|
|||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { useMediaQuery } from '@mui/material';
|
import { useMediaQuery } from '@mui/material';
|
||||||
import { Job, JobFull } from 'types/types';
|
import { Job } from 'types/types';
|
||||||
import { CopyBubble } from "components/CopyBubble";
|
import { CopyBubble } from "components/CopyBubble";
|
||||||
import { rest } from 'lodash';
|
import { rest } from 'lodash';
|
||||||
import { AIBanner } from 'components/ui/AIBanner';
|
import { AIBanner } from 'components/ui/AIBanner';
|
||||||
import { useAuth } from 'hooks/AuthContext';
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
import { DeleteConfirmation } from '../DeleteConfirmation';
|
import { DeleteConfirmation } from '../DeleteConfirmation';
|
||||||
import { Build, CheckCircle, Description, Psychology, Star, Work } from '@mui/icons-material';
|
import { Build, CheckCircle, Description, Psychology, Star, Work } from '@mui/icons-material';
|
||||||
|
import ModelTrainingIcon from '@mui/icons-material/ModelTraining';
|
||||||
|
import { StatusIcon, StatusBox } from 'components/ui/StatusIcon';
|
||||||
|
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';
|
||||||
|
|
||||||
interface JobInfoProps {
|
interface JobInfoProps {
|
||||||
job: Job | JobFull;
|
job: Job;
|
||||||
sx?: SxProps;
|
sx?: SxProps;
|
||||||
action?: string;
|
action?: string;
|
||||||
elevation?: number;
|
elevation?: number;
|
||||||
variant?: "small" | "normal" | null
|
variant?: "small" | "normal" | null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
||||||
|
const { setSnack } = useAppState();
|
||||||
const { job } = props;
|
const { job } = props;
|
||||||
const { user, apiClient } = useAuth();
|
const { user, apiClient } = useAuth();
|
||||||
const {
|
const {
|
||||||
@ -36,6 +44,9 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||||
const isAdmin = user?.isAdmin;
|
const isAdmin = user?.isAdmin;
|
||||||
|
const [adminStatus, setAdminStatus] = useState<string | null>(null);
|
||||||
|
const [adminStatusType, setAdminStatusType] = useState<Types.ApiActivityType | null>(null);
|
||||||
|
const [activeJob, setActiveJob] = useState<Types.Job>({ ...job }); /* Copy of job */
|
||||||
|
|
||||||
const deleteJob = async (jobId: string | undefined) => {
|
const deleteJob = async (jobId: string | undefined) => {
|
||||||
if (jobId) {
|
if (jobId) {
|
||||||
@ -43,10 +54,52 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
setActiveJob({ ...job });
|
||||||
|
}
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return <Box>No user loaded.</Box>;
|
return <Box>No user loaded.</Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const newJob = await apiClient.updateJob(job.id || '', {
|
||||||
|
description: activeJob.description,
|
||||||
|
requirements: activeJob.requirements,
|
||||||
|
});
|
||||||
|
job.updatedAt = newJob.updatedAt;
|
||||||
|
setActiveJob(newJob)
|
||||||
|
setSnack('Job updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setAdminStatus("Re-extracting Job information...");
|
||||||
|
const jobStatusHandlers = {
|
||||||
|
onStatus: (status: Types.ChatMessageStatus) => {
|
||||||
|
console.log('status:', status.content);
|
||||||
|
setAdminStatusType(status.activity);
|
||||||
|
setAdminStatus(status.content);
|
||||||
|
},
|
||||||
|
onMessage: (jobMessage: Types.JobRequirementsMessage) => {
|
||||||
|
const newJob: Types.Job = jobMessage.job
|
||||||
|
console.log('onMessage - job', newJob);
|
||||||
|
newJob.id = job.id;
|
||||||
|
newJob.createdAt = job.createdAt;
|
||||||
|
setActiveJob(newJob);
|
||||||
|
},
|
||||||
|
onError: (error: Types.ChatMessageError) => {
|
||||||
|
console.log('onError', error);
|
||||||
|
setAdminStatusType(null);
|
||||||
|
setAdminStatus(null);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
setAdminStatusType(null);
|
||||||
|
setAdminStatus(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
apiClient.createJobFromDescription(activeJob.description, jobStatusHandlers);
|
||||||
|
};
|
||||||
|
|
||||||
const renderRequirementSection = (title: string, items: string[] | undefined, icon: JSX.Element, required = false) => {
|
const renderRequirementSection = (title: string, items: string[] | undefined, icon: JSX.Element, required = false) => {
|
||||||
if (!items || items.length === 0) return null;
|
if (!items || items.length === 0) return null;
|
||||||
|
|
||||||
@ -75,10 +128,10 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderJobRequirements = () => {
|
const renderJobRequirements = () => {
|
||||||
if (!job.requirements) return null;
|
if (!activeJob.requirements) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card elevation={0} sx={{ m: 0, p: 0, mt: 2, }}>
|
<Card elevation={0} sx={{ m: 0, p: 0, mt: 2, background: "transparent !important" }}>
|
||||||
<CardHeader
|
<CardHeader
|
||||||
title="Job Requirements Analysis"
|
title="Job Requirements Analysis"
|
||||||
avatar={<CheckCircle color="success" />}
|
avatar={<CheckCircle color="success" />}
|
||||||
@ -87,49 +140,49 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
|||||||
<CardContent sx={{ p: 0 }}>
|
<CardContent sx={{ p: 0 }}>
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Technical Skills (Required)",
|
"Technical Skills (Required)",
|
||||||
job.requirements.technicalSkills.required,
|
activeJob.requirements.technicalSkills.required,
|
||||||
<Build color="primary" />,
|
<Build color="primary" />,
|
||||||
true
|
true
|
||||||
)}
|
)}
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Technical Skills (Preferred)",
|
"Technical Skills (Preferred)",
|
||||||
job.requirements.technicalSkills.preferred,
|
activeJob.requirements.technicalSkills.preferred,
|
||||||
<Build color="action" />
|
<Build color="action" />
|
||||||
)}
|
)}
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Experience Requirements (Required)",
|
"Experience Requirements (Required)",
|
||||||
job.requirements.experienceRequirements.required,
|
activeJob.requirements.experienceRequirements.required,
|
||||||
<Work color="primary" />,
|
<Work color="primary" />,
|
||||||
true
|
true
|
||||||
)}
|
)}
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Experience Requirements (Preferred)",
|
"Experience Requirements (Preferred)",
|
||||||
job.requirements.experienceRequirements.preferred,
|
activeJob.requirements.experienceRequirements.preferred,
|
||||||
<Work color="action" />
|
<Work color="action" />
|
||||||
)}
|
)}
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Soft Skills",
|
"Soft Skills",
|
||||||
job.requirements.softSkills,
|
activeJob.requirements.softSkills,
|
||||||
<Psychology color="secondary" />
|
<Psychology color="secondary" />
|
||||||
)}
|
)}
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Experience",
|
"Experience",
|
||||||
job.requirements.experience,
|
activeJob.requirements.experience,
|
||||||
<Star color="warning" />
|
<Star color="warning" />
|
||||||
)}
|
)}
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Education",
|
"Education",
|
||||||
job.requirements.education,
|
activeJob.requirements.education,
|
||||||
<Description color="info" />
|
<Description color="info" />
|
||||||
)}
|
)}
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Certifications",
|
"Certifications",
|
||||||
job.requirements.certifications,
|
activeJob.requirements.certifications,
|
||||||
<CheckCircle color="success" />
|
<CheckCircle color="success" />
|
||||||
)}
|
)}
|
||||||
{renderRequirementSection(
|
{renderRequirementSection(
|
||||||
"Preferred Attributes",
|
"Preferred Attributes",
|
||||||
job.requirements.preferredAttributes,
|
activeJob.requirements.preferredAttributes,
|
||||||
<Star color="secondary" />
|
<Star color="secondary" />
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -153,51 +206,92 @@ const JobInfo: React.FC<JobInfoProps> = (props: JobInfoProps) => {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
|
<CardContent sx={{ display: "flex", flexGrow: 1, p: 3, height: '100%', flexDirection: 'column', alignItems: 'stretch', position: "relative" }}>
|
||||||
{variant !== "small" && <>
|
{variant !== "small" && <>
|
||||||
{'location' in job &&
|
{activeJob.details &&
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
<strong>Location:</strong> {job.location.city}, {job.location.state || job.location.country}
|
<strong>Location:</strong> {activeJob.details.location.city}, {activeJob.details.location.state || activeJob.details.location.country}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
{job.title &&
|
{activeJob.title &&
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
<strong>Title:</strong> {job.title}
|
<strong>Title:</strong> {activeJob.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
{job.company &&
|
{activeJob.company &&
|
||||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
<strong>Company:</strong> {job.company}
|
<strong>Company:</strong> {activeJob.company}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
{job.summary && <Typography variant="body2">
|
{activeJob.summary && <Typography variant="body2">
|
||||||
<strong>Summary:</strong> {job.summary}
|
<strong>Summary:</strong> {activeJob.summary}
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
{job.createdAt && <Typography variant="body2">
|
{activeJob.owner && <Typography variant="body2">
|
||||||
<strong>Created:</strong> {job.createdAt.toISOString()}
|
<strong>Created by:</strong> {activeJob.owner.fullName}
|
||||||
</Typography>
|
</Typography>}
|
||||||
|
{activeJob.createdAt &&
|
||||||
|
<Typography variant="caption">Created: {activeJob.createdAt.toISOString()}</Typography>
|
||||||
}
|
}
|
||||||
{ job.owner && <Typography variant="body2">
|
{activeJob.updatedAt &&
|
||||||
<strong>Created by:</strong> {job.owner.fullName}
|
<Typography variant="caption">Updated: {activeJob.updatedAt.toISOString()}</Typography>
|
||||||
</Typography>
|
|
||||||
}
|
}
|
||||||
|
<Typography variant="caption">Job ID: {job.id}</Typography>
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
{renderJobRequirements()}
|
{renderJobRequirements()}
|
||||||
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardActions>
|
|
||||||
{isAdmin &&
|
{isAdmin &&
|
||||||
<DeleteConfirmation
|
<CardActions sx={{ display: "flex", flexDirection: "column", p: 1 }}>
|
||||||
onDelete={() => { deleteJob(job.id); }}
|
<Box sx={{ display: "flex", flexDirection: "row", pl: 1, pr: 1, gap: 1, alignContent: "center", height: "32px" }}>
|
||||||
sx={{ minWidth: 'auto', px: 2, maxHeight: "min-content", color: "red" }}
|
{(job.updatedAt && job.updatedAt.toISOString()) !== (activeJob.updatedAt && activeJob.updatedAt.toISOString()) &&
|
||||||
action="delete"
|
<Tooltip title="Save Job">
|
||||||
label="job"
|
<IconButton
|
||||||
title="Delete job"
|
size="small"
|
||||||
icon=<DeleteIcon />
|
onClick={(e) => { e.stopPropagation(); handleSave(); }}
|
||||||
message={`Are you sure you want to delete ${job.id}? This action cannot be undone.`}
|
>
|
||||||
/>}
|
<SaveIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
<Tooltip title="Delete Job">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteJob(job.id); }}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Reset Job">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleReset(); }}
|
||||||
|
>
|
||||||
|
<RestoreIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Reprocess Job">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleRefresh(); }}
|
||||||
|
>
|
||||||
|
<ModelTrainingIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
{adminStatus &&
|
||||||
|
<Box sx={{ mt: 3 }}>
|
||||||
|
<StatusBox>
|
||||||
|
{adminStatusType && <StatusIcon type={adminStatusType} />}
|
||||||
|
<Typography variant="body2" sx={{ ml: 1 }}>
|
||||||
|
{adminStatus || 'Processing...'}
|
||||||
|
</Typography>
|
||||||
|
</StatusBox>
|
||||||
|
{adminStatus && <LinearProgress sx={{ mt: 1 }} />}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
</CardActions>
|
</CardActions>
|
||||||
|
}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -5,12 +5,12 @@ import Box from '@mui/material/Box';
|
|||||||
|
|
||||||
import { BackstoryElementProps } from 'components/BackstoryTab';
|
import { BackstoryElementProps } from 'components/BackstoryTab';
|
||||||
import { JobInfo } from 'components/ui/JobInfo';
|
import { JobInfo } from 'components/ui/JobInfo';
|
||||||
import { Job, JobFull } from "types/types";
|
import { Job } from "types/types";
|
||||||
import { useAuth } from 'hooks/AuthContext';
|
import { useAuth } from 'hooks/AuthContext';
|
||||||
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
|
import { useAppState, useSelectedJob } from 'hooks/GlobalContext';
|
||||||
|
|
||||||
interface JobPickerProps extends BackstoryElementProps {
|
interface JobPickerProps extends BackstoryElementProps {
|
||||||
onSelect?: (job: JobFull) => void
|
onSelect?: (job: Job) => void
|
||||||
};
|
};
|
||||||
|
|
||||||
const JobPicker = (props: JobPickerProps) => {
|
const JobPicker = (props: JobPickerProps) => {
|
||||||
@ -18,7 +18,7 @@ const JobPicker = (props: JobPickerProps) => {
|
|||||||
const { apiClient } = useAuth();
|
const { apiClient } = useAuth();
|
||||||
const { selectedJob, setSelectedJob } = useSelectedJob();
|
const { selectedJob, setSelectedJob } = useSelectedJob();
|
||||||
const { setSnack } = useAppState();
|
const { setSnack } = useAppState();
|
||||||
const [jobs, setJobs] = useState<JobFull[] | null>(null);
|
const [jobs, setJobs] = useState<Job[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (jobs !== null) {
|
if (jobs !== null) {
|
||||||
@ -27,7 +27,7 @@ const JobPicker = (props: JobPickerProps) => {
|
|||||||
const getJobs = async () => {
|
const getJobs = async () => {
|
||||||
try {
|
try {
|
||||||
const results = await apiClient.getJobs();
|
const results = await apiClient.getJobs();
|
||||||
const jobs: JobFull[] = results.data;
|
const jobs: Job[] = results.data;
|
||||||
jobs.sort((a, b) => {
|
jobs.sort((a, b) => {
|
||||||
let result = a.company?.localeCompare(b.company || '');
|
let result = a.company?.localeCompare(b.company || '');
|
||||||
if (result === 0) {
|
if (result === 0) {
|
||||||
|
59
frontend/src/components/ui/StatusIcon.tsx
Normal file
59
frontend/src/components/ui/StatusIcon.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
SyncAlt,
|
||||||
|
Favorite,
|
||||||
|
Settings,
|
||||||
|
Info,
|
||||||
|
Search,
|
||||||
|
AutoFixHigh,
|
||||||
|
Image,
|
||||||
|
Psychology,
|
||||||
|
Build,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { styled } from '@mui/material/styles';
|
||||||
|
import * as Types from 'types/types';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
interface StatusIconProps {
|
||||||
|
type: Types.ApiActivityType;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 StatusIcon = (props: StatusIconProps) => {
|
||||||
|
const {type} = props;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'converting':
|
||||||
|
return <SyncAlt color="primary" />;
|
||||||
|
case 'heartbeat':
|
||||||
|
return <Favorite color="error" />;
|
||||||
|
case 'system':
|
||||||
|
return <Settings color="action" />;
|
||||||
|
case 'info':
|
||||||
|
return <Info color="info" />;
|
||||||
|
case 'searching':
|
||||||
|
return <Search color="primary" />;
|
||||||
|
case 'generating':
|
||||||
|
return <AutoFixHigh color="secondary" />;
|
||||||
|
case 'generating_image':
|
||||||
|
return <Image color="primary" />;
|
||||||
|
case 'thinking':
|
||||||
|
return <Psychology color="secondary" />;
|
||||||
|
case 'tooling':
|
||||||
|
return <Build color="action" />;
|
||||||
|
default:
|
||||||
|
return <Info color="action" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { StatusIcon, StatusBox };
|
@ -16,6 +16,8 @@ import {
|
|||||||
Bookmark as BookmarkIcon,
|
Bookmark as BookmarkIcon,
|
||||||
BubbleChart,
|
BubbleChart,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
import SchoolIcon from '@mui/icons-material/School';
|
||||||
|
|
||||||
import FaceRetouchingNaturalIcon from '@mui/icons-material/FaceRetouchingNatural';
|
import FaceRetouchingNaturalIcon from '@mui/icons-material/FaceRetouchingNatural';
|
||||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||||
import { BackstoryLogo } from 'components/ui/BackstoryLogo';
|
import { BackstoryLogo } from 'components/ui/BackstoryLogo';
|
||||||
@ -36,6 +38,7 @@ import { NavigationConfig, NavigationItem } from 'types/navigation';
|
|||||||
import { CandidateProfile } from 'pages/candidate/Profile';
|
import { CandidateProfile } from 'pages/candidate/Profile';
|
||||||
import { DocumentManager } from 'components/DocumentManager';
|
import { DocumentManager } from 'components/DocumentManager';
|
||||||
import { VectorVisualizer } from 'components/VectorVisualizer';
|
import { VectorVisualizer } from 'components/VectorVisualizer';
|
||||||
|
import { HowItWorks } from 'pages/HowItWorks';
|
||||||
|
|
||||||
// Beta page components for placeholder routes
|
// Beta page components for placeholder routes
|
||||||
const SearchPage = () => (<BetaPage><Typography variant="h4">Search</Typography></BetaPage>);
|
const SearchPage = () => (<BetaPage><Typography variant="h4">Search</Typography></BetaPage>);
|
||||||
@ -49,16 +52,17 @@ const SettingsPage = () => (<BetaPage><Typography variant="h4">Settings</Typogra
|
|||||||
export const navigationConfig: NavigationConfig = {
|
export const navigationConfig: NavigationConfig = {
|
||||||
items: [
|
items: [
|
||||||
{ id: 'home', label: <BackstoryLogo />, path: '/', component: <HomePage />, userTypes: ['guest', 'candidate', 'employer'], exact: true, },
|
{ id: 'home', label: <BackstoryLogo />, path: '/', component: <HomePage />, userTypes: ['guest', 'candidate', 'employer'], exact: true, },
|
||||||
|
{ id: 'how-it-works', label: 'How It Works', path: '/how-it-works', icon: <SchoolIcon />, component: <HowItWorks />, userTypes: ['guest', 'candidate', 'employer',], },
|
||||||
{ id: 'job-analysis', label: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon />, component: <JobAnalysisPage />, userTypes: ['guest', 'candidate', 'employer',], },
|
{ id: 'job-analysis', label: 'Job Analysis', path: '/job-analysis', icon: <WorkIcon />, component: <JobAnalysisPage />, userTypes: ['guest', 'candidate', 'employer',], },
|
||||||
{ id: 'chat', label: 'Candidate Chat', path: '/chat', icon: <ChatIcon />, component: <CandidateChatPage />, userTypes: ['guest', 'candidate', 'employer',], }, {
|
{ id: 'chat', label: 'Candidate Chat', path: '/chat', icon: <ChatIcon />, component: <CandidateChatPage />, userTypes: ['guest', 'candidate', 'employer',], }, {
|
||||||
id: 'candidate-menu', label: 'Tools', icon: <PersonIcon />, userTypes: ['candidate'], children: [
|
id: 'candidate-menu', label: 'Tools', icon: <PersonIcon />, userTypes: ['candidate'], children: [
|
||||||
{ id: 'candidate-dashboard', label: 'Dashboard', path: '/candidate/dashboard', icon: <DashboardIcon />, component: <CandidateDashboard />, userTypes: ['candidate'] },
|
{ id: 'candidate-dashboard', label: 'Dashboard', path: '/candidate/dashboard', icon: <DashboardIcon />, component: <CandidateDashboard />, userTypes: ['candidate'] },
|
||||||
{ id: 'candidate-profile', label: 'Profile', icon: <PersonIcon />, path: '/candidate/profile', component: <CandidateProfile />, userTypes: ['candidate'] },
|
{ id: 'candidate-profile', label: 'Profile', icon: <PersonIcon />, path: '/candidate/profile', component: <CandidateProfile />, userTypes: ['candidate'] },
|
||||||
|
{ id: 'candidate-docs', label: 'Documents', icon: <BubbleChart />, path: '/candidate/documents', component: <Box sx={{ display: "flex", width: "100%", flexDirection: "column" }}><VectorVisualizer /><DocumentManager /></Box>, userTypes: ['candidate'] },
|
||||||
{ id: 'candidate-qa-setup', label: 'Q&A Setup', icon: <QuizIcon />, path: '/candidate/qa-setup', component: <BetaPage><Box>Candidate q&a setup page</Box></BetaPage>, userTypes: ['candidate'] },
|
{ id: 'candidate-qa-setup', label: 'Q&A Setup', icon: <QuizIcon />, path: '/candidate/qa-setup', component: <BetaPage><Box>Candidate q&a setup page</Box></BetaPage>, userTypes: ['candidate'] },
|
||||||
{ id: 'candidate-analytics', label: 'Analytics', icon: <AnalyticsIcon />, path: '/candidate/analytics', component: <BetaPage><Box>Candidate analytics page</Box></BetaPage>, userTypes: ['candidate'] },
|
{ id: 'candidate-analytics', label: 'Analytics', icon: <AnalyticsIcon />, path: '/candidate/analytics', component: <BetaPage><Box>Candidate analytics page</Box></BetaPage>, userTypes: ['candidate'] },
|
||||||
{ id: 'candidate-job-analysis', label: 'Job Analysis', path: '/candidate/job-analysis', icon: <WorkIcon />, component: <JobAnalysisPage />, userTypes: ['candidate'], },
|
{ id: 'candidate-job-analysis', label: 'Job Analysis', path: '/candidate/job-analysis', icon: <WorkIcon />, component: <JobAnalysisPage />, userTypes: ['candidate'], },
|
||||||
{ id: 'candidate-resumes', label: 'Resumes', icon: <DescriptionIcon />, path: '/candidate/resumes', component: <BetaPage><Box>Candidate resumes page</Box></BetaPage>, userTypes: ['candidate'] },
|
{ id: 'candidate-resumes', label: 'Resumes', icon: <DescriptionIcon />, path: '/candidate/resumes', component: <BetaPage><Box>Candidate resumes page</Box></BetaPage>, userTypes: ['candidate'] },
|
||||||
{ id: 'candidate-content', label: 'Content', icon: <BubbleChart />, path: '/candidate/content', component: <Box sx={{ display: "flex", width: "100%", flexDirection: "column" }}><VectorVisualizer /><DocumentManager /></Box>, userTypes: ['candidate'] },
|
|
||||||
{ id: 'candidate-settings', label: 'Settings', path: '/candidate/settings', icon: <SettingsIcon />, component: <Settings />, userTypes: ['candidate'], },
|
{ id: 'candidate-settings', label: 'Settings', path: '/candidate/settings', icon: <SettingsIcon />, component: <Settings />, userTypes: ['candidate'], },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -75,7 +79,6 @@ export const navigationConfig: NavigationConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
// { id: 'find-candidate', label: 'Find a Candidate', path: '/find-a-candidate', icon: <PersonSearchIcon />, component: <CandidateListingPage />, userTypes: ['guest', 'candidate', 'employer'], },
|
// { id: 'find-candidate', label: 'Find a Candidate', path: '/find-a-candidate', icon: <PersonSearchIcon />, component: <CandidateListingPage />, userTypes: ['guest', 'candidate', 'employer'], },
|
||||||
{ id: 'docs', label: 'Docs', path: '/docs/*', icon: <LibraryBooksIcon />, component: <DocsPage />, userTypes: ['guest', 'candidate', 'employer'], },
|
|
||||||
{
|
{
|
||||||
id: 'admin-menu',
|
id: 'admin-menu',
|
||||||
label: 'Admin',
|
label: 'Admin',
|
||||||
@ -83,6 +86,7 @@ export const navigationConfig: NavigationConfig = {
|
|||||||
userTypes: ['admin'],
|
userTypes: ['admin'],
|
||||||
children: [
|
children: [
|
||||||
{ id: 'generate-candidate', label: 'Generate Candidate', path: '/admin/generate-candidate', icon: <FaceRetouchingNaturalIcon />, component: <GenerateCandidate />, userTypes: ['admin'] },
|
{ id: 'generate-candidate', label: 'Generate Candidate', path: '/admin/generate-candidate', icon: <FaceRetouchingNaturalIcon />, component: <GenerateCandidate />, userTypes: ['admin'] },
|
||||||
|
{ id: 'docs', label: 'Docs', path: '/docs/*', icon: <LibraryBooksIcon />, component: <DocsPage />, userTypes: ['admin'], },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Auth routes (special handling)
|
// Auth routes (special handling)
|
||||||
|
24
frontend/src/pages/HowItWorks.tsx
Normal file
24
frontend/src/pages/HowItWorks.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, Paper, Typography } from '@mui/material';
|
||||||
|
import { BackstoryLogo } from 'components/ui/BackstoryLogo';
|
||||||
|
|
||||||
|
const HowItWorks = () => {
|
||||||
|
return (<Paper sx={{ m: 1, p: 1 }}>
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column" }}>
|
||||||
|
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
|
||||||
|
<Typography>Job Description ⇒</Typography><BackstoryLogo /><Typography>⇒ (Company Info, Job Summary, Job Requirements) ⇒ <strong>Job</strong></Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
|
||||||
|
<Typography>User Content ⇒ </Typography><BackstoryLogo /><Typography>⇒ RAG Vector Database ⇒ <strong>Candidate</strong></Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
|
||||||
|
<Typography><strong>Job</strong> + <strong>Candidate</strong> ⇒ </Typography><BackstoryLogo /><Typography>⇒ <strong>Skill Match</strong></Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", alignContent: "center", verticalAlign: "center", flexDirection: "row" }}>
|
||||||
|
<Typography><strong>Skill Match</strong> + <strong>Candidate</strong> ⇒ </Typography><BackstoryLogo /><Typography>⇒ <strong>Resume</strong></Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HowItWorks };
|
@ -644,9 +644,19 @@ class ApiClient {
|
|||||||
return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
|
return this.handleApiResponseWithConversion<Types.Employer>(response, 'Employer');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Job Methods with Date Conversion
|
// Job Methods with Date Conversion
|
||||||
// ============================
|
// ============================
|
||||||
|
async updateJob(id: string, updates: Partial<Types.Job>): Promise<Types.Job> {
|
||||||
|
const response = await fetch(`${this.baseUrl}/jobs/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: this.defaultHeaders,
|
||||||
|
body: JSON.stringify(formatApiRequest(updates))
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
|
}
|
||||||
|
|
||||||
createJobFromDescription(job_description: string, streamingOptions?: StreamingOptions<Types.JobRequirementsMessage>): StreamingResponse<Types.JobRequirementsMessage> {
|
createJobFromDescription(job_description: string, streamingOptions?: StreamingOptions<Types.JobRequirementsMessage>): StreamingResponse<Types.JobRequirementsMessage> {
|
||||||
const body = JSON.stringify(job_description);
|
const body = JSON.stringify(job_description);
|
||||||
@ -672,7 +682,7 @@ class ApiClient {
|
|||||||
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
return this.handleApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobs(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.JobFull>> {
|
async getJobs(request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
|
||||||
const paginatedRequest = createPaginatedRequest(request);
|
const paginatedRequest = createPaginatedRequest(request);
|
||||||
const params = toUrlParams(formatApiRequest(paginatedRequest));
|
const params = toUrlParams(formatApiRequest(paginatedRequest));
|
||||||
|
|
||||||
@ -680,7 +690,7 @@ class ApiClient {
|
|||||||
headers: this.defaultHeaders
|
headers: this.defaultHeaders
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.handlePaginatedApiResponseWithConversion<Types.JobFull>(response, 'JobFull');
|
return this.handlePaginatedApiResponseWithConversion<Types.Job>(response, 'Job');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getJobsByEmployer(employerId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
|
async getJobsByEmployer(employerId: string, request: Partial<PaginatedRequest> = {}): Promise<PaginatedResponse<Types.Job>> {
|
||||||
|
@ -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-10T02:48:12.087485
|
// Generated on: 2025-06-10T17:14:56.968033
|
||||||
// DO NOT EDIT MANUALLY - This file is auto-generated
|
// DO NOT EDIT MANUALLY - This file is auto-generated
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
@ -526,7 +526,7 @@ export interface DocumentMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentOptions {
|
export interface DocumentOptions {
|
||||||
includeInRAG: boolean;
|
includeInRag: boolean;
|
||||||
isJobDocument?: boolean;
|
isJobDocument?: boolean;
|
||||||
overwrite?: boolean;
|
overwrite?: boolean;
|
||||||
}
|
}
|
||||||
@ -716,6 +716,7 @@ export interface Job {
|
|||||||
requirements?: JobRequirements;
|
requirements?: JobRequirements;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
|
details?: JobDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobApplication {
|
export interface JobApplication {
|
||||||
@ -734,25 +735,14 @@ export interface JobApplication {
|
|||||||
decision?: ApplicationDecision;
|
decision?: ApplicationDecision;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobFull {
|
export interface JobDetails {
|
||||||
id?: string;
|
|
||||||
ownerId: string;
|
|
||||||
ownerType: "candidate" | "employer" | "guest";
|
|
||||||
owner?: BaseUser;
|
|
||||||
title?: string;
|
|
||||||
summary?: string;
|
|
||||||
company?: string;
|
|
||||||
description: string;
|
|
||||||
requirements?: JobRequirements;
|
|
||||||
createdAt?: Date;
|
|
||||||
updatedAt?: Date;
|
|
||||||
location: Location;
|
location: Location;
|
||||||
salaryRange?: SalaryRange;
|
salaryRange?: SalaryRange;
|
||||||
employmentType: "full-time" | "part-time" | "contract" | "internship" | "freelance";
|
employmentType: "full-time" | "part-time" | "contract" | "internship" | "freelance";
|
||||||
datePosted?: Date;
|
datePosted?: Date;
|
||||||
applicationDeadline?: Date;
|
applicationDeadline?: Date;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
applicants?: Array<JobApplication>;
|
applicants?: Array<any>;
|
||||||
department?: string;
|
department?: string;
|
||||||
reportsTo?: string;
|
reportsTo?: string;
|
||||||
benefits?: Array<string>;
|
benefits?: Array<string>;
|
||||||
@ -1010,6 +1000,7 @@ export interface Skill {
|
|||||||
export interface SkillAssessment {
|
export interface SkillAssessment {
|
||||||
candidateId: string;
|
candidateId: string;
|
||||||
skill: string;
|
skill: string;
|
||||||
|
skillModified?: string;
|
||||||
evidenceFound: boolean;
|
evidenceFound: boolean;
|
||||||
evidenceStrength: "strong" | "moderate" | "weak" | "none";
|
evidenceStrength: "strong" | "moderate" | "weak" | "none";
|
||||||
assessment: string;
|
assessment: string;
|
||||||
@ -1017,6 +1008,7 @@ export interface SkillAssessment {
|
|||||||
evidenceDetails?: Array<EvidenceDetail>;
|
evidenceDetails?: Array<EvidenceDetail>;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
|
ragResults?: Array<ChromaDBGetResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SocialLink {
|
export interface SocialLink {
|
||||||
@ -1609,7 +1601,7 @@ export function convertInterviewScheduleFromApi(data: any): InterviewSchedule {
|
|||||||
/**
|
/**
|
||||||
* Convert Job from API response
|
* Convert Job from API response
|
||||||
* Date fields: createdAt, updatedAt
|
* Date fields: createdAt, updatedAt
|
||||||
* Nested models: owner (BaseUser)
|
* Nested models: owner (BaseUser), details (JobDetails)
|
||||||
*/
|
*/
|
||||||
export function convertJobFromApi(data: any): Job {
|
export function convertJobFromApi(data: any): Job {
|
||||||
if (!data) return data;
|
if (!data) return data;
|
||||||
@ -1622,6 +1614,8 @@ export function convertJobFromApi(data: any): Job {
|
|||||||
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
|
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
|
||||||
// Convert nested BaseUser model
|
// Convert nested BaseUser model
|
||||||
owner: data.owner ? convertBaseUserFromApi(data.owner) : undefined,
|
owner: data.owner ? convertBaseUserFromApi(data.owner) : undefined,
|
||||||
|
// Convert nested JobDetails model
|
||||||
|
details: data.details ? convertJobDetailsFromApi(data.details) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@ -1645,29 +1639,20 @@ export function convertJobApplicationFromApi(data: any): JobApplication {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Convert JobFull from API response
|
* Convert JobDetails from API response
|
||||||
* Date fields: createdAt, updatedAt, datePosted, applicationDeadline, featuredUntil
|
* Date fields: datePosted, applicationDeadline, featuredUntil
|
||||||
* Nested models: owner (BaseUser), applicants (JobApplication)
|
|
||||||
*/
|
*/
|
||||||
export function convertJobFullFromApi(data: any): JobFull {
|
export function convertJobDetailsFromApi(data: any): JobDetails {
|
||||||
if (!data) return data;
|
if (!data) return data;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
// Convert createdAt from ISO string to Date
|
|
||||||
createdAt: data.createdAt ? new Date(data.createdAt) : undefined,
|
|
||||||
// Convert updatedAt from ISO string to Date
|
|
||||||
updatedAt: data.updatedAt ? new Date(data.updatedAt) : undefined,
|
|
||||||
// Convert datePosted from ISO string to Date
|
// Convert datePosted from ISO string to Date
|
||||||
datePosted: data.datePosted ? new Date(data.datePosted) : undefined,
|
datePosted: data.datePosted ? new Date(data.datePosted) : undefined,
|
||||||
// Convert applicationDeadline from ISO string to Date
|
// Convert applicationDeadline from ISO string to Date
|
||||||
applicationDeadline: data.applicationDeadline ? new Date(data.applicationDeadline) : undefined,
|
applicationDeadline: data.applicationDeadline ? new Date(data.applicationDeadline) : undefined,
|
||||||
// Convert featuredUntil from ISO string to Date
|
// Convert featuredUntil from ISO string to Date
|
||||||
featuredUntil: data.featuredUntil ? new Date(data.featuredUntil) : undefined,
|
featuredUntil: data.featuredUntil ? new Date(data.featuredUntil) : undefined,
|
||||||
// Convert nested BaseUser model
|
|
||||||
owner: data.owner ? convertBaseUserFromApi(data.owner) : undefined,
|
|
||||||
// Convert nested JobApplication model
|
|
||||||
applicants: data.applicants ? convertJobApplicationFromApi(data.applicants) : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
@ -1906,8 +1891,8 @@ export function convertFromApi<T>(data: any, modelType: string): T {
|
|||||||
return convertJobFromApi(data) as T;
|
return convertJobFromApi(data) as T;
|
||||||
case 'JobApplication':
|
case 'JobApplication':
|
||||||
return convertJobApplicationFromApi(data) as T;
|
return convertJobApplicationFromApi(data) as T;
|
||||||
case 'JobFull':
|
case 'JobDetails':
|
||||||
return convertJobFullFromApi(data) as T;
|
return convertJobDetailsFromApi(data) as T;
|
||||||
case 'JobListResponse':
|
case 'JobListResponse':
|
||||||
return convertJobListResponseFromApi(data) as T;
|
return convertJobListResponseFromApi(data) as T;
|
||||||
case 'JobRequirementsMessage':
|
case 'JobRequirementsMessage':
|
||||||
|
@ -22,6 +22,7 @@ import asyncio
|
|||||||
from datetime import datetime, UTC
|
from datetime import datetime, UTC
|
||||||
from prometheus_client import Counter, Summary, CollectorRegistry # type: ignore
|
from prometheus_client import Counter, Summary, CollectorRegistry # type: ignore
|
||||||
import numpy as np # type: ignore
|
import numpy as np # type: ignore
|
||||||
|
import json_extractor as json_extractor
|
||||||
|
|
||||||
from models import ( ApiActivityType, ChatMessageError, ChatMessageRagSearch, ChatMessageStatus, ChatMessageStreaming, LLMMessage, ChatQuery, ChatMessage, ChatOptions, ChatMessageUser, Tunables, ApiMessageType, ChatSenderType, ApiStatusType, ChatMessageMetaData, Candidate)
|
from models import ( ApiActivityType, ChatMessageError, ChatMessageRagSearch, ChatMessageStatus, ChatMessageStreaming, LLMMessage, ChatQuery, ChatMessage, ChatOptions, ChatMessageUser, Tunables, ApiMessageType, ChatSenderType, ApiStatusType, ChatMessageMetaData, Candidate)
|
||||||
from logger import logger
|
from logger import logger
|
||||||
@ -373,6 +374,7 @@ Content: {content}
|
|||||||
|
|
||||||
rag_metadata = ChromaDBGetResponse(
|
rag_metadata = ChromaDBGetResponse(
|
||||||
name=rag.name,
|
name=rag.name,
|
||||||
|
query=prompt,
|
||||||
query_embedding=query_embedding.tolist(),
|
query_embedding=query_embedding.tolist(),
|
||||||
ids=chroma_results.get("ids", []),
|
ids=chroma_results.get("ids", []),
|
||||||
embeddings=chroma_results.get("embeddings", []),
|
embeddings=chroma_results.get("embeddings", []),
|
||||||
@ -406,7 +408,7 @@ Content: {content}
|
|||||||
temperature=0.7) -> AsyncGenerator[ChatMessageStatus | ChatMessageError | ChatMessageStreaming | ChatMessage, None]:
|
temperature=0.7) -> AsyncGenerator[ChatMessageStatus | ChatMessageError | ChatMessageStreaming | ChatMessage, None]:
|
||||||
|
|
||||||
self.set_optimal_context_size(
|
self.set_optimal_context_size(
|
||||||
llm=llm, model=model, prompt=prompt
|
llm=llm, model=model, prompt=prompt+system_prompt
|
||||||
)
|
)
|
||||||
|
|
||||||
options = ChatOptions(
|
options = ChatOptions(
|
||||||
@ -808,20 +810,16 @@ Content: {content}
|
|||||||
|
|
||||||
# return
|
# return
|
||||||
|
|
||||||
|
def extract_json_blocks(self, text: str, allow_multiple: bool = False) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Extract JSON blocks from text, even if surrounded by markdown or noisy text.
|
||||||
|
If allow_multiple is True, returns all JSON blocks; otherwise, only the first.
|
||||||
|
"""
|
||||||
|
return json_extractor.extract_json_blocks(text, allow_multiple)
|
||||||
|
|
||||||
def extract_json_from_text(self, text: str) -> str:
|
def extract_json_from_text(self, text: str) -> str:
|
||||||
"""Extract JSON string from text that may contain other content."""
|
"""Extract JSON string from text that may contain other content."""
|
||||||
json_pattern = r"```json\s*([\s\S]*?)\s*```"
|
return json_extractor.extract_json_from_text(text)
|
||||||
match = re.search(json_pattern, text)
|
|
||||||
if match:
|
|
||||||
return match.group(1).strip()
|
|
||||||
|
|
||||||
# Try to find JSON without the markdown code block
|
|
||||||
json_pattern = r"({[\s\S]*})"
|
|
||||||
match = re.search(json_pattern, text)
|
|
||||||
if match:
|
|
||||||
return match.group(1).strip()
|
|
||||||
|
|
||||||
raise ValueError("No JSON found in the response")
|
|
||||||
|
|
||||||
def extract_markdown_from_text(self, text: str) -> str:
|
def extract_markdown_from_text(self, text: str) -> str:
|
||||||
"""Extract Markdown string from text that may contain other content."""
|
"""Extract Markdown string from text that may contain other content."""
|
||||||
@ -832,7 +830,5 @@ Content: {content}
|
|||||||
|
|
||||||
raise ValueError("No Markdown found in the response")
|
raise ValueError("No Markdown found in the response")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Register the base agent
|
# Register the base agent
|
||||||
agent_registry.register(Agent._agent_type, Agent)
|
agent_registry.register(Agent._agent_type, Agent)
|
||||||
|
@ -85,6 +85,13 @@ class JobRequirementsAgent(Agent):
|
|||||||
) -> AsyncGenerator[ChatMessage | ChatMessageError, None]:
|
) -> AsyncGenerator[ChatMessage | ChatMessageError, None]:
|
||||||
"""Analyze job requirements from job description."""
|
"""Analyze job requirements from job description."""
|
||||||
system_prompt, prompt = self.create_job_analysis_prompt(prompt)
|
system_prompt, prompt = self.create_job_analysis_prompt(prompt)
|
||||||
|
status_message = ChatMessageStatus(
|
||||||
|
session_id=session_id,
|
||||||
|
content="Analyzing job requirements",
|
||||||
|
activity=ApiActivityType.THINKING
|
||||||
|
)
|
||||||
|
yield status_message
|
||||||
|
logger.info(f"🔍 {status_message.content}")
|
||||||
generated_message = None
|
generated_message = None
|
||||||
async for generated_message in self.llm_one_shot(llm, model, session_id=session_id, prompt=prompt, system_prompt=system_prompt):
|
async for generated_message in self.llm_one_shot(llm, model, session_id=session_id, prompt=prompt, system_prompt=system_prompt):
|
||||||
if generated_message.status == ApiStatusType.ERROR:
|
if generated_message.status == ApiStatusType.ERROR:
|
||||||
|
@ -152,9 +152,19 @@ JSON RESPONSE:"""
|
|||||||
logger.error(f"⚠️ {error_message.content}")
|
logger.error(f"⚠️ {error_message.content}")
|
||||||
yield error_message
|
yield error_message
|
||||||
return
|
return
|
||||||
# Stage 1A: Analyze job requirements
|
|
||||||
|
skill = prompt.strip()
|
||||||
|
if not skill:
|
||||||
|
error_message = ChatMessageError(
|
||||||
|
session_id=session_id,
|
||||||
|
content="Skill cannot be empty."
|
||||||
|
)
|
||||||
|
logger.error(f"⚠️ {error_message.content}")
|
||||||
|
yield error_message
|
||||||
|
return
|
||||||
|
|
||||||
rag_message = None
|
rag_message = None
|
||||||
async for rag_message in self.generate_rag_results(session_id=session_id, prompt=prompt):
|
async for rag_message in self.generate_rag_results(session_id=session_id, prompt=skill):
|
||||||
if rag_message.status == ApiStatusType.ERROR:
|
if rag_message.status == ApiStatusType.ERROR:
|
||||||
yield rag_message
|
yield rag_message
|
||||||
return
|
return
|
||||||
@ -172,7 +182,7 @@ JSON RESPONSE:"""
|
|||||||
|
|
||||||
rag_context = self.get_rag_context(rag_message)
|
rag_context = self.get_rag_context(rag_message)
|
||||||
logger.info(f"🔍 RAG content retrieved {len(rag_context)} bytes of context")
|
logger.info(f"🔍 RAG content retrieved {len(rag_context)} bytes of context")
|
||||||
system_prompt, prompt = self.generate_skill_assessment_prompt(skill=prompt, rag_context=rag_context)
|
system_prompt, prompt = self.generate_skill_assessment_prompt(skill=skill, rag_context=rag_context)
|
||||||
|
|
||||||
skill_message = None
|
skill_message = None
|
||||||
async for skill_message in self.llm_one_shot(llm=llm, model=model, session_id=session_id, prompt=prompt, system_prompt=system_prompt, temperature=0.7):
|
async for skill_message in self.llm_one_shot(llm=llm, model=model, session_id=session_id, prompt=prompt, system_prompt=system_prompt, temperature=0.7):
|
||||||
@ -199,11 +209,13 @@ JSON RESPONSE:"""
|
|||||||
skill_assessment_data = json.loads(json_str).get("skill_assessment", {})
|
skill_assessment_data = json.loads(json_str).get("skill_assessment", {})
|
||||||
skill_assessment = SkillAssessment(
|
skill_assessment = SkillAssessment(
|
||||||
candidate_id=self.user.id,
|
candidate_id=self.user.id,
|
||||||
skill=skill_assessment_data.get("skill", ""),
|
skill=skill,
|
||||||
|
skill_modified=skill_assessment_data.get("skill", ""),
|
||||||
evidence_found=skill_assessment_data.get("evidence_found", False),
|
evidence_found=skill_assessment_data.get("evidence_found", False),
|
||||||
evidence_strength=skill_assessment_data.get("evidence_strength", "NONE").lower(),
|
evidence_strength=skill_assessment_data.get("evidence_strength", "NONE").lower(),
|
||||||
assessment=skill_assessment_data.get("assessment", ""),
|
assessment=skill_assessment_data.get("assessment", ""),
|
||||||
description=skill_assessment_data.get("description", ""),
|
description=skill_assessment_data.get("description", ""),
|
||||||
|
rag_results=rag_message.content,
|
||||||
evidence_details=[
|
evidence_details=[
|
||||||
EvidenceDetail(
|
EvidenceDetail(
|
||||||
source=evidence.get("source", ""),
|
source=evidence.get("source", ""),
|
||||||
@ -215,13 +227,25 @@ JSON RESPONSE:"""
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_message = ChatMessageError(
|
error_message = ChatMessageError(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
content=f"Failed to parse Skill assessment JSON: {str(e)}\n\n{skill_message.content}"
|
content=f"Failed to parse Skill assessment JSON: {str(e)}\n\n{skill_message.content}\n\nJSON:\n{json_str}\n\n"
|
||||||
)
|
)
|
||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
logger.error(f"⚠️ {error_message.content}")
|
logger.error(f"⚠️ {error_message.content}")
|
||||||
yield error_message
|
yield error_message
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# if skill_assessment.evidence_strength == "none":
|
||||||
|
# logger.info("⚠️ No evidence found for skill assessment, returning NONE.")
|
||||||
|
# with open("src/tmp.txt", "w") as f:
|
||||||
|
# f.write(f"Skill: {skill}\n\n")
|
||||||
|
# f.write(f"System prompt:\n{system_prompt}\n\n")
|
||||||
|
# f.write(f"Prompt:\n{prompt}\n\n")
|
||||||
|
# f.write(f"LLM response:\n{skill_message.content}\n\n")
|
||||||
|
# f.write(f"JSON portion:\n{json_str}\n\n")
|
||||||
|
# f.write(f"JSON parsed:\n{json.dumps(skill_assessment_data, indent=2)}\n\n")
|
||||||
|
# f.write(f"Skill assessment data:\n")
|
||||||
|
# f.write(skill_assessment.model_dump_json(indent=2))
|
||||||
|
|
||||||
skill_assessment_message = ChatMessageSkillAssessment(
|
skill_assessment_message = ChatMessageSkillAssessment(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
status=ApiStatusType.DONE,
|
status=ApiStatusType.DONE,
|
||||||
|
@ -294,6 +294,7 @@ class RedisDatabase:
|
|||||||
# Convert to UTC if it's in a different timezone
|
# Convert to UTC if it's in a different timezone
|
||||||
dt = dt.astimezone(timezone.utc)
|
dt = dt.astimezone(timezone.utc)
|
||||||
return dt
|
return dt
|
||||||
|
logger.warning(f"⚠️ No RAG update time found for user {user_id}")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error getting user RAG update time: {e}")
|
logger.error(f"❌ Error getting user RAG update time: {e}")
|
||||||
@ -314,6 +315,7 @@ class RedisDatabase:
|
|||||||
# Store as ISO format with timezone info
|
# Store as ISO format with timezone info
|
||||||
timestamp_str = update_time.isoformat() # This includes timezone
|
timestamp_str = update_time.isoformat() # This includes timezone
|
||||||
await self.redis.set(rag_update_key, timestamp_str)
|
await self.redis.set(rag_update_key, timestamp_str)
|
||||||
|
logger.info(f"✅ User RAG update time set for user {user_id}: {timestamp_str}")
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error setting user RAG update time: {e}")
|
logger.error(f"❌ Error setting user RAG update time: {e}")
|
||||||
@ -412,7 +414,7 @@ class RedisDatabase:
|
|||||||
async def get_documents_by_rag_status(self, candidate_id: str, include_in_rag: bool = True) -> List[Dict]:
|
async def get_documents_by_rag_status(self, candidate_id: str, include_in_rag: bool = True) -> List[Dict]:
|
||||||
"""Get candidate documents filtered by RAG inclusion status"""
|
"""Get candidate documents filtered by RAG inclusion status"""
|
||||||
all_documents = await self.get_candidate_documents(candidate_id)
|
all_documents = await self.get_candidate_documents(candidate_id)
|
||||||
return [doc for doc in all_documents if doc.get("include_in_RAG", False) == include_in_rag]
|
return [doc for doc in all_documents if doc.get("include_in_rag", False) == include_in_rag]
|
||||||
|
|
||||||
async def bulk_update_document_rag_status(self, candidate_id: str, document_ids: List[str], include_in_rag: bool):
|
async def bulk_update_document_rag_status(self, candidate_id: str, document_ids: List[str], include_in_rag: bool):
|
||||||
"""Bulk update RAG status for multiple documents"""
|
"""Bulk update RAG status for multiple documents"""
|
||||||
@ -421,7 +423,7 @@ class RedisDatabase:
|
|||||||
for doc_id in document_ids:
|
for doc_id in document_ids:
|
||||||
doc_data = await self.get_document(doc_id)
|
doc_data = await self.get_document(doc_id)
|
||||||
if doc_data and doc_data.get("candidate_id") == candidate_id:
|
if doc_data and doc_data.get("candidate_id") == candidate_id:
|
||||||
doc_data["include_in_RAG"] = include_in_rag
|
doc_data["include_in_rag"] = include_in_rag
|
||||||
doc_data["updatedAt"] = datetime.now(UTC).isoformat()
|
doc_data["updatedAt"] = datetime.now(UTC).isoformat()
|
||||||
pipe.set(f"document:{doc_id}", self._serialize(doc_data))
|
pipe.set(f"document:{doc_id}", self._serialize(doc_data))
|
||||||
|
|
||||||
|
@ -3,12 +3,12 @@ import weakref
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, Optional, Any
|
from typing import Dict, Optional, Any
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field # type: ignore
|
||||||
|
|
||||||
from models import ( Candidate )
|
from models import ( Candidate )
|
||||||
from .candidate_entity import CandidateEntity
|
from .candidate_entity import CandidateEntity
|
||||||
from database import RedisDatabase
|
from database import RedisDatabase
|
||||||
from prometheus_client import CollectorRegistry
|
from prometheus_client import CollectorRegistry # type: ignore
|
||||||
|
|
||||||
class EntityManager:
|
class EntityManager:
|
||||||
"""Manages lifecycle of CandidateEntity instances"""
|
"""Manages lifecycle of CandidateEntity instances"""
|
||||||
|
97
src/backend/json_extractor.py
Normal file
97
src/backend/json_extractor.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import List, Union
|
||||||
|
|
||||||
|
def extract_json_blocks(text: str, allow_multiple: bool = False) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Extract JSON blocks from text, even if surrounded by markdown or noisy text.
|
||||||
|
If allow_multiple is True, returns all JSON blocks; otherwise, only the first.
|
||||||
|
"""
|
||||||
|
found = []
|
||||||
|
|
||||||
|
# First try to extract from code blocks (most reliable)
|
||||||
|
code_block_pattern = r"```(?:json)?\s*([\s\S]+?)\s*```"
|
||||||
|
for match in re.finditer(code_block_pattern, text):
|
||||||
|
block = match.group(1).strip()
|
||||||
|
try:
|
||||||
|
parsed = json.loads(block)
|
||||||
|
found.append(parsed)
|
||||||
|
if not allow_multiple:
|
||||||
|
return [parsed]
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If no valid code blocks found, look for standalone JSON objects/arrays
|
||||||
|
if not found:
|
||||||
|
standalone_json = _extract_standalone_json(text, allow_multiple)
|
||||||
|
found.extend(standalone_json)
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
raise ValueError("No valid JSON block found in the text")
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
def _extract_standalone_json(text: str, allow_multiple: bool = False) -> List[Union[dict, list]]:
|
||||||
|
"""Extract standalone JSON objects or arrays from text using proper brace counting."""
|
||||||
|
found = []
|
||||||
|
i = 0
|
||||||
|
|
||||||
|
while i < len(text):
|
||||||
|
if text[i] in '{[':
|
||||||
|
# Found potential JSON start
|
||||||
|
json_str = _extract_complete_json_at_position(text, i)
|
||||||
|
if json_str:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(json_str)
|
||||||
|
found.append(parsed)
|
||||||
|
if not allow_multiple:
|
||||||
|
return [parsed]
|
||||||
|
# Move past this JSON block
|
||||||
|
i += len(json_str)
|
||||||
|
continue
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
def _extract_complete_json_at_position(text: str, start_pos: int) -> str:
|
||||||
|
"""
|
||||||
|
Extract a complete JSON object or array starting at the given position.
|
||||||
|
Uses proper brace/bracket counting and string escape handling.
|
||||||
|
"""
|
||||||
|
if start_pos >= len(text) or text[start_pos] not in '{[':
|
||||||
|
return ""
|
||||||
|
|
||||||
|
start_char = text[start_pos]
|
||||||
|
end_char = '}' if start_char == '{' else ']'
|
||||||
|
|
||||||
|
count = 1
|
||||||
|
i = start_pos + 1
|
||||||
|
in_string = False
|
||||||
|
escape_next = False
|
||||||
|
|
||||||
|
while i < len(text) and count > 0:
|
||||||
|
char = text[i]
|
||||||
|
|
||||||
|
if escape_next:
|
||||||
|
escape_next = False
|
||||||
|
elif char == '\\' and in_string:
|
||||||
|
escape_next = True
|
||||||
|
elif char == '"' and not escape_next:
|
||||||
|
in_string = not in_string
|
||||||
|
elif not in_string:
|
||||||
|
if char == start_char:
|
||||||
|
count += 1
|
||||||
|
elif char == end_char:
|
||||||
|
count -= 1
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
return text[start_pos:i]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def extract_json_from_text(text: str) -> str:
|
||||||
|
"""Extract JSON string from text that may contain other content."""
|
||||||
|
return json.dumps(extract_json_blocks(text, allow_multiple=False)[0])
|
@ -70,6 +70,7 @@ import entities
|
|||||||
from email_service import VerificationEmailRateLimiter, email_service
|
from email_service import VerificationEmailRateLimiter, email_service
|
||||||
from device_manager import DeviceManager
|
from device_manager import DeviceManager
|
||||||
import agents
|
import agents
|
||||||
|
from entities.candidate_entity import CandidateEntity
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
# Import Pydantic models
|
# Import Pydantic models
|
||||||
@ -82,7 +83,7 @@ from models import (
|
|||||||
Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse, CandidateAI,
|
Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse, CandidateAI,
|
||||||
|
|
||||||
# Job models
|
# Job models
|
||||||
JobFull, JobApplication, ApplicationStatus,
|
JobApplication, ApplicationStatus,
|
||||||
|
|
||||||
# Chat models
|
# Chat models
|
||||||
ChatSession, ChatMessage, ChatContext, ChatQuery, ApiStatusType, ChatSenderType, ApiMessageType, ChatContextType,
|
ChatSession, ChatMessage, ChatContext, ChatQuery, ApiStatusType, ChatSenderType, ApiMessageType, ChatContextType,
|
||||||
@ -2121,7 +2122,7 @@ async def upload_candidate_document(
|
|||||||
try:
|
try:
|
||||||
# Parse the JSON string and create DocumentOptions object
|
# Parse the JSON string and create DocumentOptions object
|
||||||
options_dict = json.loads(options_data)
|
options_dict = json.loads(options_data)
|
||||||
options : DocumentOptions = DocumentOptions.model_validate(**options_dict)
|
options : DocumentOptions = DocumentOptions.model_validate(options_dict)
|
||||||
except (json.JSONDecodeError, ValidationError) as e:
|
except (json.JSONDecodeError, ValidationError) as e:
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
iter([json.dumps(ChatMessageError(
|
iter([json.dumps(ChatMessageError(
|
||||||
@ -2178,7 +2179,7 @@ async def upload_candidate_document(
|
|||||||
|
|
||||||
logger.info(f"📁 Received file upload: filename='{file.filename}', content_type='{file.content_type}', size='{len(file_content)} bytes'")
|
logger.info(f"📁 Received file upload: filename='{file.filename}', content_type='{file.content_type}', size='{len(file_content)} bytes'")
|
||||||
|
|
||||||
directory = "rag-content" if options.include_in_RAG else "files"
|
directory = "rag-content" if options.include_in_rag else "files"
|
||||||
directory = "jobs" if options.is_job_document else directory
|
directory = "jobs" if options.is_job_document else directory
|
||||||
|
|
||||||
# Ensure the file does not already exist either in 'files' or in 'rag-content'
|
# Ensure the file does not already exist either in 'files' or in 'rag-content'
|
||||||
@ -2332,16 +2333,7 @@ async def upload_candidate_document(
|
|||||||
media_type="text/event-stream"
|
media_type="text/event-stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def create_job_from_content(database: RedisDatabase, current_user: Candidate, content: str):
|
async def reformat_as_markdown(database: RedisDatabase, candidate_entity: CandidateEntity, content: str):
|
||||||
status_message = ChatMessageStatus(
|
|
||||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
|
||||||
content=f"Initiating connection with {current_user.first_name}'s AI agent...",
|
|
||||||
activity=ApiActivityType.INFO
|
|
||||||
)
|
|
||||||
yield status_message
|
|
||||||
await asyncio.sleep(0) # Let the status message propagate
|
|
||||||
|
|
||||||
async with entities.get_candidate_entity(candidate=current_user) 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:
|
||||||
error_message = ChatMessageError(
|
error_message = ChatMessageError(
|
||||||
@ -2350,30 +2342,6 @@ async def create_job_from_content(database: RedisDatabase, current_user: Candida
|
|||||||
)
|
)
|
||||||
yield error_message
|
yield error_message
|
||||||
return
|
return
|
||||||
message = None
|
|
||||||
status_message = ChatMessageStatus(
|
|
||||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
|
||||||
content=f"Analyzing document for company and requirement details...",
|
|
||||||
activity=ApiActivityType.SEARCHING
|
|
||||||
)
|
|
||||||
yield status_message
|
|
||||||
|
|
||||||
async for message in chat_agent.generate(
|
|
||||||
llm=llm_manager.get_llm(),
|
|
||||||
model=defines.model,
|
|
||||||
session_id=MOCK_UUID,
|
|
||||||
prompt=content
|
|
||||||
):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if not message or not isinstance(message, JobRequirementsMessage):
|
|
||||||
error_message = ChatMessageError(
|
|
||||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
|
||||||
content="Job extraction did not convert successfully"
|
|
||||||
)
|
|
||||||
yield error_message
|
|
||||||
return
|
|
||||||
|
|
||||||
status_message = ChatMessageStatus(
|
status_message = ChatMessageStatus(
|
||||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
content=f"Reformatting job description as markdown...",
|
content=f"Reformatting job description as markdown...",
|
||||||
@ -2381,7 +2349,7 @@ async def create_job_from_content(database: RedisDatabase, current_user: Candida
|
|||||||
)
|
)
|
||||||
yield status_message
|
yield status_message
|
||||||
|
|
||||||
job_requirements : JobRequirementsMessage = message
|
message = None
|
||||||
async for message in chat_agent.llm_one_shot(
|
async for message in chat_agent.llm_one_shot(
|
||||||
llm=llm_manager.get_llm(),
|
llm=llm_manager.get_llm(),
|
||||||
model=defines.model,
|
model=defines.model,
|
||||||
@ -2403,13 +2371,73 @@ Return only the markdown content, no other text. Make sure all content is includ
|
|||||||
yield error_message
|
yield error_message
|
||||||
return
|
return
|
||||||
chat_message : ChatMessage = message
|
chat_message : ChatMessage = message
|
||||||
markdown = chat_message.content
|
|
||||||
try:
|
try:
|
||||||
markdown = chat_agent.extract_markdown_from_text(chat_message.content)
|
chat_message.content = chat_agent.extract_markdown_from_text(chat_message.content)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
job_requirements.job.description = markdown
|
logger.info(f"✅ Successfully converted content to markdown")
|
||||||
logger.info(f"✅ Successfully saved job requirements job {job_requirements.id}")
|
yield chat_message
|
||||||
|
return
|
||||||
|
|
||||||
|
async def create_job_from_content(database: RedisDatabase, current_user: Candidate, content: str):
|
||||||
|
status_message = ChatMessageStatus(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content=f"Initiating connection with {current_user.first_name}'s AI agent...",
|
||||||
|
activity=ApiActivityType.INFO
|
||||||
|
)
|
||||||
|
yield status_message
|
||||||
|
await asyncio.sleep(0) # Let the status message propagate
|
||||||
|
|
||||||
|
async with entities.get_candidate_entity(candidate=current_user) as candidate_entity:
|
||||||
|
message = None
|
||||||
|
async for message in reformat_as_markdown(database, candidate_entity, content):
|
||||||
|
# Only yield one final DONE message
|
||||||
|
if message.status != ApiStatusType.DONE:
|
||||||
|
yield message
|
||||||
|
if not message or not isinstance(message, ChatMessage):
|
||||||
|
error_message = ChatMessageError(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content="Failed to reformat job description"
|
||||||
|
)
|
||||||
|
yield error_message
|
||||||
|
return
|
||||||
|
markdown_message = message
|
||||||
|
|
||||||
|
chat_agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.JOB_REQUIREMENTS)
|
||||||
|
if not chat_agent:
|
||||||
|
error_message = ChatMessageError(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content="No agent found for job requirements chat type"
|
||||||
|
)
|
||||||
|
yield error_message
|
||||||
|
return
|
||||||
|
status_message = ChatMessageStatus(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content=f"Analyzing document for company and requirement details...",
|
||||||
|
activity=ApiActivityType.SEARCHING
|
||||||
|
)
|
||||||
|
yield status_message
|
||||||
|
|
||||||
|
message = None
|
||||||
|
async for message in chat_agent.generate(
|
||||||
|
llm=llm_manager.get_llm(),
|
||||||
|
model=defines.model,
|
||||||
|
session_id=MOCK_UUID,
|
||||||
|
prompt=markdown_message.content
|
||||||
|
):
|
||||||
|
if message.status != ApiStatusType.DONE:
|
||||||
|
yield message
|
||||||
|
|
||||||
|
if not message or not isinstance(message, JobRequirementsMessage):
|
||||||
|
error_message = ChatMessageError(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content="Job extraction did not convert successfully"
|
||||||
|
)
|
||||||
|
yield error_message
|
||||||
|
return
|
||||||
|
|
||||||
|
job_requirements : JobRequirementsMessage = message
|
||||||
|
logger.info(f"✅ Successfully generated job requirements for job {job_requirements.id}")
|
||||||
yield job_requirements
|
yield job_requirements
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -2619,7 +2647,7 @@ async def get_document_content(
|
|||||||
content=create_error_response("FORBIDDEN", "Cannot access another candidate's document")
|
content=create_error_response("FORBIDDEN", "Cannot access another candidate's document")
|
||||||
)
|
)
|
||||||
|
|
||||||
file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.options.include_in_RAG else "files", document.originalName)
|
file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.options.include_in_rag else "files", document.originalName)
|
||||||
file_path = pathlib.Path(file_path)
|
file_path = pathlib.Path(file_path)
|
||||||
if not document.type in [DocumentType.TXT, DocumentType.MARKDOWN]:
|
if not document.type in [DocumentType.TXT, DocumentType.MARKDOWN]:
|
||||||
file_path = file_path.with_suffix('.md')
|
file_path = file_path.with_suffix('.md')
|
||||||
@ -2694,7 +2722,7 @@ async def update_document(
|
|||||||
content=create_error_response("FORBIDDEN", "Cannot update another candidate's document")
|
content=create_error_response("FORBIDDEN", "Cannot update another candidate's document")
|
||||||
)
|
)
|
||||||
update_options = updates.options if updates.options else DocumentOptions()
|
update_options = updates.options if updates.options else DocumentOptions()
|
||||||
if document.options.include_in_RAG != update_options.include_in_RAG:
|
if document.options.include_in_rag != update_options.include_in_rag:
|
||||||
# If RAG status is changing, we need to handle file movement
|
# If RAG status is changing, we need to handle file movement
|
||||||
rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content")
|
rag_dir = os.path.join(defines.user_dir, candidate.username, "rag-content")
|
||||||
file_dir = os.path.join(defines.user_dir, candidate.username, "files")
|
file_dir = os.path.join(defines.user_dir, candidate.username, "files")
|
||||||
@ -2703,7 +2731,7 @@ async def update_document(
|
|||||||
rag_path = os.path.join(rag_dir, document.originalName)
|
rag_path = os.path.join(rag_dir, document.originalName)
|
||||||
file_path = os.path.join(file_dir, document.originalName)
|
file_path = os.path.join(file_dir, document.originalName)
|
||||||
|
|
||||||
if update_options.include_in_RAG:
|
if update_options.include_in_rag:
|
||||||
src = pathlib.Path(file_path)
|
src = pathlib.Path(file_path)
|
||||||
dst = pathlib.Path(rag_path)
|
dst = pathlib.Path(rag_path)
|
||||||
# Move to RAG directory
|
# Move to RAG directory
|
||||||
@ -2731,8 +2759,8 @@ async def update_document(
|
|||||||
update_dict = {}
|
update_dict = {}
|
||||||
if updates.filename is not None:
|
if updates.filename is not None:
|
||||||
update_dict["filename"] = updates.filename.strip()
|
update_dict["filename"] = updates.filename.strip()
|
||||||
if update_options.include_in_RAG is not None:
|
if update_options.include_in_rag is not None:
|
||||||
update_dict["include_in_RAG"] = update_options.include_in_RAG
|
update_dict["include_in_rag"] = update_options.include_in_rag
|
||||||
|
|
||||||
if not update_dict:
|
if not update_dict:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@ -2802,7 +2830,7 @@ async def delete_document(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Delete file from disk
|
# Delete file from disk
|
||||||
file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.options.include_in_RAG else "files", document.originalName)
|
file_path = os.path.join(defines.user_dir, candidate.username, "rag-content" if document.options.include_in_rag else "files", document.originalName)
|
||||||
file_path = pathlib.Path(file_path)
|
file_path = pathlib.Path(file_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -3264,9 +3292,6 @@ async def create_candidate_job(
|
|||||||
is_employer = isinstance(current_user, Employer)
|
is_employer = isinstance(current_user, Employer)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if is_employer:
|
|
||||||
job = JobFull.model_validate(job_data)
|
|
||||||
else:
|
|
||||||
job = Job.model_validate(job_data)
|
job = Job.model_validate(job_data)
|
||||||
|
|
||||||
# Add required fields
|
# Add required fields
|
||||||
@ -3286,6 +3311,50 @@ async def create_candidate_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.patch("/jobs/{job_id}")
|
||||||
|
async def update_job(
|
||||||
|
job_id: str = Path(...),
|
||||||
|
updates: Dict[str, Any] = Body(...),
|
||||||
|
current_user = Depends(get_current_user),
|
||||||
|
database: RedisDatabase = Depends(get_database)
|
||||||
|
):
|
||||||
|
"""Update a candidate"""
|
||||||
|
try:
|
||||||
|
job_data = await database.get_job(job_id)
|
||||||
|
if not job_data:
|
||||||
|
logger.warning(f"⚠️ Job not found for update: {job_data}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content=create_error_response("NOT_FOUND", "Job not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
job = Job.model_validate(job_data)
|
||||||
|
|
||||||
|
# Check authorization (user can only update their own profile)
|
||||||
|
if current_user.is_admin is False and job.owner_id != current_user.id:
|
||||||
|
logger.warning(f"⚠️ Unauthorized update attempt by user {current_user.id} on job {job_id}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=403,
|
||||||
|
content=create_error_response("FORBIDDEN", "Cannot update another user's job")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply updates
|
||||||
|
updates["updatedAt"] = datetime.now(UTC).isoformat()
|
||||||
|
logger.info(f"🔄 Updating job {job_id} with data: {updates}")
|
||||||
|
job_dict = job.model_dump()
|
||||||
|
job_dict.update(updates)
|
||||||
|
updated_job = Job.model_validate(job_dict)
|
||||||
|
await database.set_job(job_id, updated_job.model_dump())
|
||||||
|
|
||||||
|
return create_success_response(updated_job.model_dump(by_alias=True))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Update job error: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content=create_error_response("UPDATE_FAILED", str(e))
|
||||||
|
)
|
||||||
|
|
||||||
@api_router.post("/jobs/from-content")
|
@api_router.post("/jobs/from-content")
|
||||||
async def create_job_from_description(
|
async def create_job_from_description(
|
||||||
content: str = Body(...),
|
content: str = Body(...),
|
||||||
@ -3306,7 +3375,14 @@ async def create_job_from_description(
|
|||||||
|
|
||||||
logger.info(f"📁 Received file content: size='{len(content)} bytes'")
|
logger.info(f"📁 Received file content: size='{len(content)} bytes'")
|
||||||
|
|
||||||
|
last_yield_was_streaming = False
|
||||||
async for message in create_job_from_content(database=database, current_user=current_user, content=content):
|
async for message in create_job_from_content(database=database, current_user=current_user, content=content):
|
||||||
|
if message.status != ApiStatusType.STREAMING:
|
||||||
|
last_yield_was_streaming = False
|
||||||
|
else:
|
||||||
|
if last_yield_was_streaming:
|
||||||
|
continue
|
||||||
|
last_yield_was_streaming = True
|
||||||
logger.info(f"📄 Yielding job creation message status: {message.status}")
|
logger.info(f"📄 Yielding job creation message status: {message.status}")
|
||||||
yield message
|
yield message
|
||||||
return
|
return
|
||||||
@ -3526,9 +3602,6 @@ async def get_jobs(
|
|||||||
all_jobs_data = await database.get_all_jobs()
|
all_jobs_data = await database.get_all_jobs()
|
||||||
jobs_list = []
|
jobs_list = []
|
||||||
for job in all_jobs_data.values():
|
for job in all_jobs_data.values():
|
||||||
if job.get("user_type") == "employer":
|
|
||||||
jobs_list.append(JobFull.model_validate(job))
|
|
||||||
else:
|
|
||||||
jobs_list.append(Job.model_validate(job))
|
jobs_list.append(Job.model_validate(job))
|
||||||
|
|
||||||
paginated_jobs, total = filter_and_paginate(
|
paginated_jobs, total = filter_and_paginate(
|
||||||
@ -4537,12 +4610,12 @@ def get_endpoint_rate_limiter(rate_limiter: RateLimiter = Depends(get_rate_limit
|
|||||||
@api_router.post("/candidates/{candidate_id}/skill-match")
|
@api_router.post("/candidates/{candidate_id}/skill-match")
|
||||||
async def get_candidate_skill_match(
|
async def get_candidate_skill_match(
|
||||||
candidate_id: str = Path(...),
|
candidate_id: str = Path(...),
|
||||||
requirement: str = Body(...),
|
skill: str = Body(...),
|
||||||
current_user = Depends(get_current_user_or_guest),
|
current_user = Depends(get_current_user_or_guest),
|
||||||
database: RedisDatabase = Depends(get_database)
|
database: RedisDatabase = Depends(get_database)
|
||||||
) -> StreamingResponse:
|
) -> StreamingResponse:
|
||||||
|
|
||||||
"""Get skill match for a candidate against a requirement with caching"""
|
"""Get skill match for a candidate against a skill with caching"""
|
||||||
async def message_stream_generator():
|
async def message_stream_generator():
|
||||||
candidate_data = await database.get_candidate(candidate_id)
|
candidate_data = await database.get_candidate(candidate_id)
|
||||||
if not candidate_data:
|
if not candidate_data:
|
||||||
@ -4555,18 +4628,21 @@ async def get_candidate_skill_match(
|
|||||||
|
|
||||||
candidate = Candidate.model_validate(candidate_data)
|
candidate = Candidate.model_validate(candidate_data)
|
||||||
|
|
||||||
# Create cache key for this specific candidate + requirement combination
|
# Create cache key for this specific candidate + skill combination
|
||||||
requirement_hash = hashlib.md5(requirement.encode()).hexdigest()[:8]
|
skill_hash = hashlib.md5(skill.lower().encode()).hexdigest()[:8]
|
||||||
cache_key = f"skill_match:{candidate.id}:{requirement_hash}"
|
cache_key = f"skill_match:{candidate.id}:{skill_hash}"
|
||||||
|
|
||||||
# Get cached assessment if it exists
|
# Get cached assessment if it exists
|
||||||
assessment : SkillAssessment | None = await database.get_cached_skill_match(cache_key)
|
assessment : SkillAssessment | None = await database.get_cached_skill_match(cache_key)
|
||||||
|
|
||||||
|
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}). Regenerating...")
|
||||||
|
assessment = None
|
||||||
|
|
||||||
# Determine if we need to regenerate the assessment
|
# Determine if we need to regenerate the assessment
|
||||||
cached_date = None
|
|
||||||
if assessment:
|
if assessment:
|
||||||
# Get the latest RAG data update time for the current user
|
# Get the latest RAG data update time for the current user
|
||||||
user_rag_update_time = await database.get_user_rag_update_time(current_user.id)
|
user_rag_update_time = await database.get_user_rag_update_time(candidate.id)
|
||||||
|
|
||||||
updated = assessment.updated_at if "updated_at" in assessment else assessment.created_at
|
updated = assessment.updated_at if "updated_at" in assessment else assessment.created_at
|
||||||
# Check if cached result is still valid
|
# Check if cached result is still valid
|
||||||
@ -4575,14 +4651,11 @@ async def get_candidate_skill_match(
|
|||||||
logger.info(f"🔄 Out-of-date cached entry for {candidate.username} skill {assessment.skill}")
|
logger.info(f"🔄 Out-of-date cached entry for {candidate.username} skill {assessment.skill}")
|
||||||
assessment = None
|
assessment = None
|
||||||
else:
|
else:
|
||||||
cached_date = updated
|
logger.info(f"✅ Using cached skill match for {candidate.username} skill {assessment.skill}: {cache_key}")
|
||||||
else:
|
else:
|
||||||
logger.info(f"💾 No cached skill match data: {cache_key}, {candidate.id}, {requirement}")
|
logger.info(f"💾 No cached skill match data: {cache_key}, {candidate.id}, {skill}")
|
||||||
|
|
||||||
if assessment:
|
if assessment:
|
||||||
logger.info(f"✅ Found cached skill match for candidate {candidate.username} against requirement: {requirement}")
|
|
||||||
logger.info(f"💾 Cached skill match data: {assessment.evidence_strength}")
|
|
||||||
|
|
||||||
# Return cached assessment
|
# Return cached assessment
|
||||||
skill_message = ChatMessageSkillAssessment(
|
skill_message = ChatMessageSkillAssessment(
|
||||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
@ -4592,7 +4665,7 @@ async def get_candidate_skill_match(
|
|||||||
yield skill_message
|
yield skill_message
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"🔍 Generating skill match for candidate {candidate.username} against requirement: {requirement}")
|
logger.info(f"🔍 Generating skill match for candidate {candidate.username} for skill: {skill}")
|
||||||
|
|
||||||
async with entities.get_candidate_entity(candidate=candidate) as candidate_entity:
|
async with entities.get_candidate_entity(candidate=candidate) as candidate_entity:
|
||||||
agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.SKILL_MATCH)
|
agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.SKILL_MATCH)
|
||||||
@ -4610,11 +4683,15 @@ async def get_candidate_skill_match(
|
|||||||
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=requirement,
|
prompt=skill,
|
||||||
):
|
):
|
||||||
if generated_message.status == ApiStatusType.ERROR:
|
if generated_message.status == ApiStatusType.ERROR:
|
||||||
|
error_message = ChatMessageError(
|
||||||
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
|
content=f"AI generation error: {generated_message.content}"
|
||||||
|
)
|
||||||
logger.error(f"❌ AI generation error: {generated_message.content}")
|
logger.error(f"❌ AI generation error: {generated_message.content}")
|
||||||
yield f"data: {json.dumps({'status': 'error'})}\n\n"
|
yield error_message
|
||||||
return
|
return
|
||||||
|
|
||||||
# If the message is not done, convert it to a ChatMessageBase to remove
|
# If the message is not done, convert it to a ChatMessageBase to remove
|
||||||
@ -4634,7 +4711,7 @@ async def get_candidate_skill_match(
|
|||||||
if final_message is None:
|
if final_message is None:
|
||||||
error_message = ChatMessageError(
|
error_message = ChatMessageError(
|
||||||
sessionId=MOCK_UUID, # No session ID for document uploads
|
sessionId=MOCK_UUID, # No session ID for document uploads
|
||||||
content=f"No skill match found for the given requirement"
|
content=f"No match found for the given skill"
|
||||||
)
|
)
|
||||||
yield error_message
|
yield error_message
|
||||||
return
|
return
|
||||||
@ -4658,8 +4735,8 @@ async def get_candidate_skill_match(
|
|||||||
return
|
return
|
||||||
|
|
||||||
await database.cache_skill_match(cache_key, assessment)
|
await database.cache_skill_match(cache_key, assessment)
|
||||||
logger.info(f"💾 Cached new skill match for candidate {candidate.id}")
|
logger.info(f"💾 Cached new skill match for candidate {candidate.id} as {cache_key}")
|
||||||
logger.info(f"✅ Skill match found for candidate {candidate.id}: {assessment.evidence_strength}")
|
logger.info(f"✅ Skill match: {assessment.evidence_strength} {skill}")
|
||||||
yield skill_match
|
yield skill_match
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -98,9 +98,26 @@ class EvidenceDetail(BaseModel):
|
|||||||
"populate_by_name": True, # Allow both field names and aliases
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ChromaDBGetResponse(BaseModel):
|
||||||
|
# Chroma fields
|
||||||
|
ids: List[str] = []
|
||||||
|
embeddings: List[List[float]] = []
|
||||||
|
documents: List[str] = []
|
||||||
|
metadatas: List[Dict[str, Any]] = []
|
||||||
|
distances: List[float] = []
|
||||||
|
# Additional fields
|
||||||
|
name: str = ""
|
||||||
|
size: int = 0
|
||||||
|
dimensions: int = 2 | 3
|
||||||
|
query: str = ""
|
||||||
|
query_embedding: Optional[List[float]] = Field(default=None, alias="queryEmbedding")
|
||||||
|
umap_embedding_2d: Optional[List[float]] = Field(default=None, alias="umapEmbedding2D")
|
||||||
|
umap_embedding_3d: Optional[List[float]] = Field(default=None, alias="umapEmbedding3D")
|
||||||
|
|
||||||
class SkillAssessment(BaseModel):
|
class SkillAssessment(BaseModel):
|
||||||
candidate_id: str = Field(..., alias='candidateId')
|
candidate_id: str = Field(..., alias='candidateId')
|
||||||
skill: str = Field(..., alias="skill", description="The skill being assessed")
|
skill: str = Field(..., alias="skill", description="The skill being assessed")
|
||||||
|
skill_modified: Optional[str] = Field(default="", alias="skillModified", description="The skill rephrased by LLM during skill match")
|
||||||
evidence_found: bool = Field(..., alias="evidenceFound", description="Whether evidence was found for the skill")
|
evidence_found: bool = Field(..., alias="evidenceFound", description="Whether evidence was found for the skill")
|
||||||
evidence_strength: SkillStrength = Field(..., alias="evidenceStrength", description="Strength of evidence found for the skill")
|
evidence_strength: SkillStrength = Field(..., alias="evidenceStrength", description="Strength of evidence found for the skill")
|
||||||
assessment: str = Field(..., alias="assessment", description="Short (one to two sentence) assessment of the candidate's proficiency with the skill")
|
assessment: str = Field(..., alias="assessment", description="Short (one to two sentence) assessment of the candidate's proficiency with the skill")
|
||||||
@ -108,6 +125,7 @@ class SkillAssessment(BaseModel):
|
|||||||
evidence_details: List[EvidenceDetail] = Field(default_factory=list, alias="evidenceDetails", description="List of evidence details supporting the skill assessment")
|
evidence_details: List[EvidenceDetail] = Field(default_factory=list, alias="evidenceDetails", description="List of evidence details supporting the skill assessment")
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias='createdAt')
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias='createdAt')
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias='updatedAt')
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias='updatedAt')
|
||||||
|
rag_results: List[ChromaDBGetResponse] = Field(default_factory=list, alias="ragResults")
|
||||||
model_config = {
|
model_config = {
|
||||||
"populate_by_name": True, # Allow both field names and aliases
|
"populate_by_name": True, # Allow both field names and aliases
|
||||||
}
|
}
|
||||||
@ -523,7 +541,7 @@ class DocumentType(str, Enum):
|
|||||||
IMAGE = "image"
|
IMAGE = "image"
|
||||||
|
|
||||||
class DocumentOptions(BaseModel):
|
class DocumentOptions(BaseModel):
|
||||||
include_in_RAG: bool = Field(default=True, alias="includeInRAG")
|
include_in_rag: bool = Field(default=True, alias="includeInRag")
|
||||||
is_job_document: Optional[bool] = Field(default=False, alias="isJobDocument")
|
is_job_document: Optional[bool] = Field(default=False, alias="isJobDocument")
|
||||||
overwrite: Optional[bool] = Field(default=False, alias="overwrite")
|
overwrite: Optional[bool] = Field(default=False, alias="overwrite")
|
||||||
model_config = {
|
model_config = {
|
||||||
@ -680,23 +698,7 @@ class JobRequirements(BaseModel):
|
|||||||
"populate_by_name": True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
}
|
}
|
||||||
|
|
||||||
class Job(BaseModel):
|
class JobDetails(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
|
||||||
owner_id: str = Field(..., alias="ownerId")
|
|
||||||
owner_type: UserType = Field(..., alias="ownerType")
|
|
||||||
owner: Optional[BaseUser] = None
|
|
||||||
title: Optional[str]
|
|
||||||
summary: Optional[str]
|
|
||||||
company: Optional[str]
|
|
||||||
description: str
|
|
||||||
requirements: Optional[JobRequirements]
|
|
||||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt")
|
|
||||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt")
|
|
||||||
model_config = {
|
|
||||||
"populate_by_name": True # Allow both field names and aliases
|
|
||||||
}
|
|
||||||
|
|
||||||
class JobFull(Job):
|
|
||||||
location: Location
|
location: Location
|
||||||
salary_range: Optional[SalaryRange] = Field(None, alias="salaryRange")
|
salary_range: Optional[SalaryRange] = Field(None, alias="salaryRange")
|
||||||
employment_type: EmploymentType = Field(..., alias="employmentType")
|
employment_type: EmploymentType = Field(..., alias="employmentType")
|
||||||
@ -712,6 +714,24 @@ class JobFull(Job):
|
|||||||
views: int = 0
|
views: int = 0
|
||||||
application_count: int = Field(0, alias="applicationCount")
|
application_count: int = Field(0, alias="applicationCount")
|
||||||
|
|
||||||
|
class Job(BaseModel):
|
||||||
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
|
owner_id: str = Field(..., alias="ownerId")
|
||||||
|
owner_type: UserType = Field(..., alias="ownerType")
|
||||||
|
owner: Optional[BaseUser] = None
|
||||||
|
title: Optional[str]
|
||||||
|
summary: Optional[str]
|
||||||
|
company: Optional[str]
|
||||||
|
description: str
|
||||||
|
requirements: Optional[JobRequirements]
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="createdAt")
|
||||||
|
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC), alias="updatedAt")
|
||||||
|
details: Optional[JobDetails] = Field(None, alias="details")
|
||||||
|
model_config = {
|
||||||
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class InterviewFeedback(BaseModel):
|
class InterviewFeedback(BaseModel):
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
interview_id: str = Field(..., alias="interviewId")
|
interview_id: str = Field(..., alias="interviewId")
|
||||||
@ -765,22 +785,6 @@ class JobApplication(BaseModel):
|
|||||||
"populate_by_name": True # Allow both field names and aliases
|
"populate_by_name": True # Allow both field names and aliases
|
||||||
}
|
}
|
||||||
|
|
||||||
class ChromaDBGetResponse(BaseModel):
|
|
||||||
# Chroma fields
|
|
||||||
ids: List[str] = []
|
|
||||||
embeddings: List[List[float]] = []
|
|
||||||
documents: List[str] = []
|
|
||||||
metadatas: List[Dict[str, Any]] = []
|
|
||||||
distances: List[float] = []
|
|
||||||
# Additional fields
|
|
||||||
name: str = ""
|
|
||||||
size: int = 0
|
|
||||||
dimensions: int = 2 | 3
|
|
||||||
query: str = ""
|
|
||||||
query_embedding: Optional[List[float]] = Field(default=None, alias="queryEmbedding")
|
|
||||||
umap_embedding_2d: Optional[List[float]] = Field(default=None, alias="umapEmbedding2D")
|
|
||||||
umap_embedding_3d: Optional[List[float]] = Field(default=None, alias="umapEmbedding3D")
|
|
||||||
|
|
||||||
class GuestSessionResponse(BaseModel):
|
class GuestSessionResponse(BaseModel):
|
||||||
"""Response for guest session creation"""
|
"""Response for guest session creation"""
|
||||||
access_token: str = Field(..., alias="accessToken")
|
access_token: str = Field(..., alias="accessToken")
|
||||||
@ -1270,4 +1274,4 @@ Candidate.update_forward_refs()
|
|||||||
Employer.update_forward_refs()
|
Employer.update_forward_refs()
|
||||||
ChatSession.update_forward_refs()
|
ChatSession.update_forward_refs()
|
||||||
JobApplication.update_forward_refs()
|
JobApplication.update_forward_refs()
|
||||||
JobFull.update_forward_refs()
|
Job.update_forward_refs()
|
@ -309,7 +309,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
|||||||
include=["embeddings", "documents", "metadatas"]
|
include=["embeddings", "documents", "metadatas"]
|
||||||
))
|
))
|
||||||
if not self._umap_collection or not len(self._umap_collection.embeddings):
|
if not self._umap_collection or not len(self._umap_collection.embeddings):
|
||||||
logging.warning("No embeddings found in the collection.")
|
logging.warning("⚠️ No embeddings found in the collection.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# During initialization
|
# During initialization
|
||||||
@ -455,7 +455,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler):
|
|||||||
meta["chunk_end"] = end
|
meta["chunk_end"] = end
|
||||||
return "".join(lines[start:end])
|
return "".join(lines[start:end])
|
||||||
except:
|
except:
|
||||||
logging.warning(f"Unable to open {source_file}")
|
logging.warning(f"⚠️ Unable to open {source_file}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Cosine Distance Equivalent Similarity Retrieval Characteristics
|
# Cosine Distance Equivalent Similarity Retrieval Characteristics
|
||||||
|
Loading…
x
Reference in New Issue
Block a user