Refresh maintains selected entities

This commit is contained in:
James Ketr 2025-06-04 10:31:26 -07:00
parent a912e4d24c
commit 4f7b2f3e6a
13 changed files with 398 additions and 267 deletions

View File

@ -125,7 +125,6 @@ services:
context: .
dockerfile: Dockerfile
target: ollama
#image: ollama
container_name: ollama
restart: "always"
env_file:

View File

@ -24,12 +24,7 @@ import { Candidate, ChatMessage, ChatMessageBase, ChatMessageUser, ChatSession,
import { useAuth } from 'hooks/AuthContext';
import { BackstoryPageProps } from './BackstoryTab';
import { toCamelCase } from 'types/conversion';
interface Job {
title: string;
description: string;
}
import { Job } from 'types/types';
interface JobAnalysisProps extends BackstoryPageProps {
job: Job;
@ -48,7 +43,6 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
} = props
const { apiClient } = useAuth();
const theme = useTheme();
const [jobRequirements, setJobRequirements] = useState<JobRequirements | null>(null);
const [requirements, setRequirements] = useState<{ requirement: string, domain: string }[]>([]);
const [skillMatches, setSkillMatches] = useState<SkillMatch[]>([]);
const [creatingSession, setCreatingSession] = useState<boolean>(false);
@ -89,7 +83,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
// Fetch initial requirements
useEffect(() => {
if (!job.description || !requirementsSession || loadingRequirements || jobRequirements) {
if (!job.description || !requirementsSession || loadingRequirements) {
return;
}
@ -101,22 +95,36 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
onMessage: (msg: ChatMessage) => {
console.log(`onMessage: ${msg.type}`, msg);
if (msg.type === "response") {
const incoming: any = toCamelCase<JobRequirements>(JSON.parse(msg.content || ''));
const requirements: { requirement: string, domain: string }[] = ['technicalSkills', 'experienceRequirements'].flatMap((domain) => {
return ['required', 'preferred'].flatMap((level) => {
return incoming[domain][level].map((s: string) => { return { requirement: s, domain: domain }; });
})
});
['softSkills', 'experience', 'education', 'certifications', 'preferredAttributes'].forEach(domain => {
if (incoming[domain]) {
incoming[domain].forEach((s: string) => requirements.push({ requirement: s, domain: domain }));
}
});
const job: Job = toCamelCase<Job>(JSON.parse(msg.content || ''));
const requirements: { requirement: string, domain: string }[] = [];
if (job.requirements?.technicalSkills) {
job.requirements.technicalSkills.required?.forEach(req => requirements.push({ requirement: req, domain: 'Technical Skills (required)' }));
job.requirements.technicalSkills.preferred?.forEach(req => requirements.push({ requirement: req, domain: 'Technical Skills (preferred)' }));
}
if (job.requirements?.experienceRequirements) {
job.requirements.experienceRequirements.required?.forEach(req => requirements.push({ requirement: req, domain: 'Experience (required)' }));
job.requirements.experienceRequirements.preferred?.forEach(req => requirements.push({ requirement: req, domain: 'Experience (preferred)' }));
}
if (job.requirements?.softSkills) {
job.requirements.softSkills.forEach(req => requirements.push({ requirement: req, domain: 'Soft Skills' }));
}
if (job.requirements?.experience) {
job.requirements.experience.forEach(req => requirements.push({ requirement: req, domain: 'Experience' }));
}
if (job.requirements?.education) {
job.requirements.education.forEach(req => requirements.push({ requirement: req, domain: 'Education' }));
}
if (job.requirements?.certifications) {
job.requirements.certifications.forEach(req => requirements.push({ requirement: req, domain: 'Certifications' }));
}
if (job.requirements?.preferredAttributes) {
job.requirements.preferredAttributes.forEach(req => requirements.push({ requirement: req, domain: 'Preferred Attributes' }));
}
const initialSkillMatches = requirements.map(req => ({
requirement: req.requirement,
domain: req.domain,
status: 'pending' as const,
status: 'waiting' as const,
matchScore: 0,
assessment: '',
description: '',
@ -168,6 +176,12 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
// Process requirements one by one
for (let i = 0; i < requirements.length; i++) {
try {
setSkillMatches(prev => {
const updated = [...prev];
updated[i] = { ...updated[i], status: 'pending' };
return updated;
});
const result: any = await apiClient.candidateMatchForRequirement(candidate.id || '', requirements[i].requirement);
const skillMatch = result.skillMatch;
let matchScore: number = 0;
@ -177,7 +191,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
case "WEAK": matchScore = 50; break;
case "NONE": matchScore = 0; break;
}
if (skillMatch.evidenceStrength == "NONE" && skillMatch.citations.length > 3) {
if (skillMatch.evidenceStrength == "NONE" && skillMatch.citations && skillMatch.citations.length > 3) {
matchScore = Math.min(skillMatch.citations.length * 8, 40);
}
const match: SkillMatch = {
@ -234,7 +248,7 @@ const JobMatchAnalysis: React.FC<JobAnalysisProps> = (props: JobAnalysisProps) =
// Get icon based on status
const getStatusIcon = (status: string, score: number) => {
if (status === 'pending') return <PendingIcon />;
if (status === 'pending' || status === 'waiting') return <PendingIcon />;
if (status === 'error') return <ErrorIcon color="error" />;
if (score >= 70) return <CheckCircleIcon color="success" />;
if (score >= 40) return <WarningIcon color="warning" />;

View File

@ -1,5 +1,41 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import * as Types from 'types/types';
import { useAuth } from 'hooks/AuthContext';
// ============================
// Local Storage Keys
// ============================
const STORAGE_KEYS = {
SELECTED_CANDIDATE_ID: 'selectedCandidateId',
SELECTED_JOB_ID: 'selectedJobId',
SELECTED_EMPLOYER_ID: 'selectedEmployerId'
} as const;
// ============================
// Local Storage Utilities
// ============================
function getStoredId(key: string): string | null {
try {
return localStorage.getItem(key);
} catch (error) {
console.warn('Failed to read from localStorage:', error);
return null;
}
}
function setStoredId(key: string, id: string | null): void {
try {
if (id) {
localStorage.setItem(key, id);
} else {
localStorage.removeItem(key);
}
} catch (error) {
console.warn('Failed to write to localStorage:', error);
}
}
// ============================
// App State Interface
@ -9,6 +45,7 @@ export interface AppState {
selectedCandidate: Types.Candidate | null;
selectedJob: Types.Job | null;
selectedEmployer: Types.Employer | null;
isInitializing: boolean;
// Add more global state as needed:
// currentView: string;
// filters: Record<string, any>;
@ -30,13 +67,118 @@ export type AppStateContextType = AppState & AppStateActions;
// ============================
export function useAppStateLogic(): AppStateContextType {
const { apiClient } = useAuth();
const [selectedCandidate, setSelectedCandidateState] = useState<Types.Candidate | null>(null);
const [selectedJob, setSelectedJobState] = useState<Types.Job | null>(null);
const [selectedEmployer, setSelectedEmployerState] = useState<Types.Employer | null>(null);
const [isInitializing, setIsInitializing] = useState<boolean>(true);
// ============================
// Initialization Effect
// ============================
useEffect(() => {
const initializeFromStorage = async () => {
setIsInitializing(true);
try {
// Get stored IDs
const candidateId = getStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID);
const jobId = getStoredId(STORAGE_KEYS.SELECTED_JOB_ID);
const employerId = getStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID);
// Restore entities in parallel if IDs exist
const promises: Promise<void>[] = [];
if (candidateId) {
promises.push(
(async () => {
try {
// Assuming apiClient.getCandidate exists
const candidate = await apiClient.getCandidate(candidateId);
if (candidate) {
setSelectedCandidateState(candidate);
console.log('Restored candidate from storage:', candidate);
} else {
// Data not available, clear stored ID
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null);
console.log('Candidate not found, cleared from storage');
}
} catch (error) {
console.warn('Failed to restore candidate:', error);
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null);
}
})()
);
}
if (jobId) {
promises.push(
(async () => {
try {
// Assuming apiClient.getJob exists
const job = await apiClient.getJob(jobId);
if (job) {
setSelectedJobState(job);
console.log('Restored job from storage:', job);
} else {
// Data not available, clear stored ID
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
console.log('Job not found, cleared from storage');
}
} catch (error) {
console.warn('Failed to restore job:', error);
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
}
})()
);
}
if (employerId) {
promises.push(
(async () => {
try {
// Assuming apiClient.getEmployer exists
const employer = await apiClient.getEmployer(employerId);
if (employer) {
setSelectedEmployerState(employer);
console.log('Restored employer from storage:', employer);
} else {
// Data not available, clear stored ID
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
console.log('Employer not found, cleared from storage');
}
} catch (error) {
console.warn('Failed to restore employer:', error);
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
}
})()
);
}
// Wait for all restoration attempts to complete
await Promise.all(promises);
} catch (error) {
console.error('Error during app state initialization:', error);
} finally {
setIsInitializing(false);
}
};
initializeFromStorage();
}, []);
// ============================
// State Setters with Persistence
// ============================
const setSelectedCandidate = useCallback((candidate: Types.Candidate | null) => {
setSelectedCandidateState(candidate);
// Persist ID to localStorage
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, candidate?.id || null);
if (candidate) {
console.log('Selected candidate:', candidate);
} else {
@ -47,6 +189,9 @@ export function useAppStateLogic(): AppStateContextType {
const setSelectedJob = useCallback((job: Types.Job | null) => {
setSelectedJobState(job);
// Persist ID to localStorage
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, job?.id || null);
if (job) {
console.log('Selected job:', job);
} else {
@ -57,6 +202,9 @@ export function useAppStateLogic(): AppStateContextType {
const setSelectedEmployer = useCallback((employer: Types.Employer | null) => {
setSelectedEmployerState(employer);
// Persist ID to localStorage
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, employer?.id || null);
if (employer) {
console.log('Selected employer:', employer);
} else {
@ -68,6 +216,12 @@ export function useAppStateLogic(): AppStateContextType {
setSelectedCandidateState(null);
setSelectedJobState(null);
setSelectedEmployerState(null);
// Clear all from localStorage
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null);
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
console.log('Cleared all selections');
}, []);
@ -75,6 +229,7 @@ export function useAppStateLogic(): AppStateContextType {
selectedCandidate,
selectedJob,
selectedEmployer,
isInitializing,
setSelectedCandidate,
setSelectedJob,
setSelectedEmployer,
@ -135,6 +290,14 @@ export function useSelectedEmployer() {
return { selectedEmployer, setSelectedEmployer };
}
/**
* Hook to check if the app is still initializing
*/
export function useAppInitializing() {
const { isInitializing } = useAppState();
return isInitializing;
}
// ============================
// Development Utilities
// ============================
@ -148,12 +311,23 @@ export function useAppStateDebug() {
useEffect(() => {
if (process.env.NODE_ENV === 'development') {
console.group('🔍 App State Debug');
console.log('Is Initializing:', appState.isInitializing);
console.log('Selected Candidate:', appState.selectedCandidate);
console.log('Selected Job:', appState.selectedJob);
console.log('Selected Employer:', appState.selectedEmployer);
console.groupEnd();
}
}, [appState.selectedCandidate, appState.selectedJob, appState.selectedEmployer]);
}, [appState.selectedCandidate, appState.selectedJob, appState.selectedEmployer, appState.isInitializing]);
return appState;
}
/**
* Utility to manually clear all localStorage for this app (development/debugging)
*/
export function clearAppLocalStorage() {
setStoredId(STORAGE_KEYS.SELECTED_CANDIDATE_ID, null);
setStoredId(STORAGE_KEYS.SELECTED_JOB_ID, null);
setStoredId(STORAGE_KEYS.SELECTED_EMPLOYER_ID, null);
console.log('Cleared all app localStorage');
}

View File

@ -46,6 +46,7 @@ import { ComingSoon } from 'components/ui/ComingSoon';
const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps) => {
const theme = useTheme();
const { user } = useAuth();
const navigate = useNavigate();
const { selectedCandidate, setSelectedCandidate } = useSelectedCandidate()
const { setSnack, submitQuery } = props;
const backstoryProps = { setSnack, submitQuery };
@ -53,6 +54,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
const [activeStep, setActiveStep] = useState(0);
const [jobDescription, setJobDescription] = useState('');
const [jobTitle, setJobTitle] = useState('');
const [company, setCompany] = useState('');
const [jobLocation, setJobLocation] = useState('');
const [analysisStarted, setAnalysisStarted] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -60,6 +62,9 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
const { apiClient } = useAuth();
const [candidates, setCandidates] = useState<Candidate[] | null>(null);
const user_type = user?.userType || 'guest';
const user_id = user?.id || '';
useEffect(() => {
if (candidates !== null || selectedCandidate) {
return;
@ -96,7 +101,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
// Steps in our process
const steps = [
{ index: 1, label: 'Job Description', icon: <WorkIcon /> },
{ index: 1, label: 'Job Selection', icon: <WorkIcon /> },
{ index: 2, label: 'AI Analysis', icon: <WorkIcon /> },
{ index: 3, label: 'Generated Resume', icon: <AssessmentIcon /> }
];
@ -104,124 +109,6 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
steps.unshift({ index: 0, label: 'Select Candidate', icon: <PersonIcon /> })
}
const fetchMatchForRequirement = async (requirement: string): Promise<any> => {
// Create different mock responses based on the requirement
const mockResponses: Record<string, any> = {
"5+ years of React development experience": {
requirement: "5+ years of React development experience",
status: "complete",
matchScore: 85,
assessment: "The candidate demonstrates extensive React experience spanning over 6 years, with a strong portfolio of complex applications and deep understanding of React's component lifecycle and hooks.",
citations: [
{
text: "Led frontend development team of 5 engineers to rebuild our customer portal using React and TypeScript, resulting in 40% improved performance and 30% reduction in bugs.",
source: "Resume, Work Experience",
relevance: 95
},
{
text: "Developed and maintained reusable React component library used across 12 different products within the organization.",
source: "Resume, Work Experience",
relevance: 90
},
{
text: "I've been working with React since 2017, building everything from small widgets to enterprise applications.",
source: "Cover Letter",
relevance: 85
}
]
},
"Strong TypeScript skills": {
requirement: "Strong TypeScript skills",
status: "complete",
matchScore: 90,
assessment: "The candidate shows excellent TypeScript proficiency through their work history and personal projects. They have implemented complex type systems and demonstrate an understanding of advanced TypeScript features.",
citations: [
{
text: "Converted a legacy JavaScript codebase of 100,000+ lines to TypeScript, implementing strict type checking and reducing runtime errors by 70%.",
source: "Resume, Projects",
relevance: 98
},
{
text: "Created comprehensive TypeScript interfaces for our GraphQL API, ensuring type safety across the entire application stack.",
source: "Resume, Technical Skills",
relevance: 95
}
]
},
"Experience with RESTful APIs": {
requirement: "Experience with RESTful APIs",
status: "complete",
matchScore: 75,
assessment: "The candidate has good experience with RESTful APIs, having both consumed and designed them. They understand REST principles but have less documented experience with API versioning and caching strategies.",
citations: [
{
text: "Designed and implemented a RESTful API serving over 1M requests daily with a focus on performance and scalability.",
source: "Resume, Technical Projects",
relevance: 85
},
{
text: "Worked extensively with third-party APIs including Stripe, Twilio, and Salesforce to integrate payment processing and communication features.",
source: "Resume, Work Experience",
relevance: 70
}
]
},
"Knowledge of state management solutions (Redux, Context API)": {
requirement: "Knowledge of state management solutions (Redux, Context API)",
status: "complete",
matchScore: 65,
assessment: "The candidate has moderate experience with state management, primarily using Redux. There is less evidence of Context API usage, which could indicate a knowledge gap in more modern React state management approaches.",
citations: [
{
text: "Implemented Redux for global state management in an e-commerce application, handling complex state logic for cart, user preferences, and product filtering.",
source: "Resume, Skills",
relevance: 80
},
{
text: "My experience includes working with state management libraries like Redux and MobX.",
source: "Cover Letter",
relevance: 60
}
]
},
"Experience with CI/CD pipelines": {
requirement: "Experience with CI/CD pipelines",
status: "complete",
matchScore: 40,
assessment: "The candidate shows limited experience with CI/CD pipelines. While they mention some exposure to Jenkins and GitLab CI, there is insufficient evidence of setting up or maintaining comprehensive CI/CD workflows.",
citations: [
{
text: "Familiar with CI/CD tools including Jenkins and GitLab CI.",
source: "Resume, Skills",
relevance: 40
}
]
},
"Cloud platform experience (AWS, Azure, GCP)": {
requirement: "Cloud platform experience (AWS, Azure, GCP)",
status: "complete",
matchScore: 30,
assessment: "The candidate demonstrates minimal experience with cloud platforms. There is a brief mention of AWS S3 and Lambda, but no substantial evidence of deeper cloud architecture knowledge or experience with Azure or GCP.",
citations: [
{
text: "Used AWS S3 for file storage and Lambda for image processing in a photo sharing application.",
source: "Resume, Projects",
relevance: 35
}
]
}
};
// Return a promise that resolves with the mock data after a delay
return new Promise((resolve) => {
// Different requirements resolve at different speeds to simulate real-world analysis
const delay = Math.random() * 5000 + 2000; // 2-7 seconds
setTimeout(() => {
resolve(mockResponses[requirement]);
}, delay);
});
};
// Navigation handlers
const handleNext = () => {
if (activeStep === 0 && !selectedCandidate) {
@ -230,7 +117,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
}
if (activeStep === 1) {
if ((/*(extraInfo && !jobTitle) || */!jobDescription)) {
if (!jobDescription) {
setError('Please provide job description before continuing.');
return;
}
@ -338,12 +225,9 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
</Paper>
);
const extraInfo = false;
// Render function for the job description step
const renderJobDescription = () => (
<Paper elevation={3} sx={{ p: 3, mt: 3, mb: 4, borderRadius: 2 }}>
{extraInfo && <>
<Typography variant="h5" gutterBottom>
Enter Job Details
</Typography>
@ -361,6 +245,18 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Company"
variant="outlined"
value={company}
onChange={(e) => setCompany(e.target.value)}
required
margin="normal"
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
@ -371,23 +267,21 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
margin="normal"
/>
</Grid>
</Grid>
</>
}
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2, mb: 1 }}>
<Typography variant="subtitle1" sx={{ mr: 2 }}>
Job Description
Job Selection
</Typography>
{extraInfo && <Button
<Button
variant="outlined"
startIcon={<FileUploadIcon />}
size="small"
onClick={() => setOpenUploadDialog(true)}
>
Upload
</Button>}
</Button>
</Box>
<TextField
@ -419,7 +313,7 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
<Box sx={{ mt: 3 }}>
{selectedCandidate && (
<JobMatchAnalysis
job={{ title: jobTitle, description: jobDescription }}
job={{ title: jobTitle, description: jobDescription, company: company, ownerId: user_id, ownerType: user_type }}
candidate={selectedCandidate}
{...backstoryProps}
/>
@ -434,14 +328,14 @@ const JobAnalysisPage: React.FC<BackstoryPageProps> = (props: BackstoryPageProps
);
// If no user is logged in, show message
if (!user) {
if (!user?.id) {
return (
<Container maxWidth="md">
<Paper elevation={3} sx={{ p: 4, mt: 4, textAlign: 'center' }}>
<Typography variant="h5" gutterBottom>
Please log in to access candidate analysis
</Typography>
<Button variant="contained" color="primary" sx={{ mt: 2 }}>
<Button variant="contained" onClick={() => { navigate('/login'); }} color="primary" sx={{ mt: 2 }}>
Log In
</Button>
</Paper>

View File

@ -24,9 +24,9 @@ const CandidateRoute: React.FC<CandidateRouteProps> = (props: CandidateRouteProp
if (candidate?.username === username || !username) {
return;
}
const getCandidate = async (username: string) => {
const getCandidate = async (reference: string) => {
try {
const result : Candidate = await apiClient.getCandidate(username);
const result: Candidate = await apiClient.getCandidate(reference);
setCandidate(result);
navigate('/chat');
} catch {

View File

@ -506,8 +506,9 @@ class ApiClient {
// Candidate Methods with Date Conversion
// ============================
async getCandidate(username: string): Promise<Types.Candidate> {
const response = await fetch(`${this.baseUrl}/candidates/${username}`, {
// reference can be candidateId, username, or email
async getCandidate(reference: string): Promise<Types.Candidate> {
const response = await fetch(`${this.baseUrl}/candidates/${reference}`, {
headers: this.defaultHeaders
});

View File

@ -1,6 +1,6 @@
// Generated TypeScript types from Pydantic models
// Source: src/backend/models.py
// Generated on: 2025-06-04T05:16:43.020718
// Generated on: 2025-06-04T17:02:08.242818
// DO NOT EDIT MANUALLY - This file is auto-generated
// ============================
@ -577,26 +577,13 @@ export interface InterviewSchedule {
export interface Job {
id?: string;
title: string;
ownerId: string;
ownerType: "candidate" | "employer" | "guest";
title?: string;
summary?: string;
company?: string;
description: string;
responsibilities: Array<string>;
requirements: Array<string>;
preferredSkills?: Array<string>;
employerId: string;
location: Location;
salaryRange?: SalaryRange;
employmentType: "full-time" | "part-time" | "contract" | "internship" | "freelance";
datePosted: Date;
applicationDeadline?: Date;
isActive: boolean;
applicants?: Array<JobApplication>;
department?: string;
reportsTo?: string;
benefits?: Array<string>;
visaSponsorship?: boolean;
featuredUntil?: Date;
views: number;
applicationCount: number;
requirements?: JobRequirements;
}
export interface JobApplication {
@ -615,6 +602,31 @@ export interface JobApplication {
decision?: ApplicationDecision;
}
export interface JobFull {
id?: string;
ownerId: string;
ownerType: "candidate" | "employer" | "guest";
title?: string;
summary?: string;
company?: string;
description: string;
requirements?: JobRequirements;
location: Location;
salaryRange?: SalaryRange;
employmentType: "full-time" | "part-time" | "contract" | "internship" | "freelance";
datePosted: Date;
applicationDeadline?: Date;
isActive: boolean;
applicants?: Array<JobApplication>;
department?: string;
reportsTo?: string;
benefits?: Array<string>;
visaSponsorship?: boolean;
featuredUntil?: Date;
views: number;
applicationCount: number;
}
export interface JobListResponse {
success: boolean;
data?: Array<Job>;
@ -1224,23 +1236,6 @@ export function convertInterviewScheduleFromApi(data: any): InterviewSchedule {
endDate: new Date(data.endDate),
};
}
/**
* Convert Job from API response, parsing date fields
* Date fields: datePosted, applicationDeadline, featuredUntil
*/
export function convertJobFromApi(data: any): Job {
if (!data) return data;
return {
...data,
// Convert datePosted from ISO string to Date
datePosted: new Date(data.datePosted),
// Convert applicationDeadline from ISO string to Date
applicationDeadline: data.applicationDeadline ? new Date(data.applicationDeadline) : undefined,
// Convert featuredUntil from ISO string to Date
featuredUntil: data.featuredUntil ? new Date(data.featuredUntil) : undefined,
};
}
/**
* Convert JobApplication from API response, parsing date fields
* Date fields: appliedDate, updatedDate
@ -1256,6 +1251,23 @@ export function convertJobApplicationFromApi(data: any): JobApplication {
updatedDate: new Date(data.updatedDate),
};
}
/**
* Convert JobFull from API response, parsing date fields
* Date fields: datePosted, applicationDeadline, featuredUntil
*/
export function convertJobFullFromApi(data: any): JobFull {
if (!data) return data;
return {
...data,
// Convert datePosted from ISO string to Date
datePosted: new Date(data.datePosted),
// Convert applicationDeadline from ISO string to Date
applicationDeadline: data.applicationDeadline ? new Date(data.applicationDeadline) : undefined,
// Convert featuredUntil from ISO string to Date
featuredUntil: data.featuredUntil ? new Date(data.featuredUntil) : undefined,
};
}
/**
* Convert MessageReaction from API response, parsing date fields
* Date fields: timestamp
@ -1378,10 +1390,10 @@ export function convertFromApi<T>(data: any, modelType: string): T {
return convertInterviewFeedbackFromApi(data) as T;
case 'InterviewSchedule':
return convertInterviewScheduleFromApi(data) as T;
case 'Job':
return convertJobFromApi(data) as T;
case 'JobApplication':
return convertJobApplicationFromApi(data) as T;
case 'JobFull':
return convertJobFullFromApi(data) as T;
case 'MessageReaction':
return convertMessageReactionFromApi(data) as T;
case 'RAGConfiguration':

View File

@ -25,7 +25,7 @@ from models import Candidate
_agents: List[Agent] = []
def get_or_create_agent(agent_type: str, prometheus_collector: CollectorRegistry, user: Optional[Candidate]=None, **kwargs) -> Agent:
def get_or_create_agent(agent_type: str, prometheus_collector: CollectorRegistry, user: Optional[Candidate]=None) -> Agent:
"""
Get or create and append a new agent of the specified type, ensuring only one agent per type exists.
@ -39,16 +39,17 @@ def get_or_create_agent(agent_type: str, prometheus_collector: CollectorRegistry
Raises:
ValueError: If no matching agent type is found or if a agent of this type already exists.
"""
# Check if a agent with the given agent_type already exists
for agent in _agents:
if agent.agent_type == agent_type:
return agent
# Check if a global (non-user) agent with the given agent_type already exists
if not user:
for agent in _agents:
if agent.agent_type == agent_type:
return agent
# Find the matching subclass
for agent_cls in Agent.__subclasses__():
if agent_cls.model_fields["agent_type"].default == agent_type:
# Create the agent instance with provided kwargs
agent = agent_cls(agent_type=agent_type, user=user, prometheus_collector=prometheus_collector, **kwargs)
agent = agent_cls(agent_type=agent_type, user=user, prometheus_collector=prometheus_collector)
# if agent.agent_persist: # If an agent is not set to persist, do not add it to the list
_agents.append(agent)
return agent

View File

@ -40,13 +40,17 @@ class JobRequirementsAgent(Agent):
## INSTRUCTIONS:
1. Analyze ONLY the job description provided.
2. Extract and categorize all requirements and preferences.
3. DO NOT consider any candidate information - this is a pure job analysis task.
2. Extract company information, job title, and all requirements.
3. Extract and categorize all requirements and preferences.
4. DO NOT consider any candidate information - this is a pure job analysis task.
## OUTPUT FORMAT:
```json
{
{
"company_name": "Company Name",
"job_title": "Job Title",
"job_summary": "Brief summary of the job",
"job_requirements": {
"technical_skills": {
"required": ["skill1", "skill2"],
@ -133,9 +137,15 @@ class JobRequirementsAgent(Agent):
json_str = self.extract_json_from_text(generated_message.content)
job_requirements : JobRequirements | None = None
job_requirements_data = ""
company_name = ""
job_summary = ""
job_title = ""
try:
job_requirements_data = json.loads(json_str)
job_requirements_data = job_requirements_data.get("job_requirements", None)
job_title = job_requirements_data.get("job_title", "")
company_name = job_requirements_data.get("company_name", "")
job_summary = job_requirements_data.get("job_summary", "")
job_requirements = JobRequirements.model_validate(job_requirements_data)
if not job_requirements:
raise ValueError("Job requirements data is empty or invalid.")
@ -160,7 +170,13 @@ class JobRequirementsAgent(Agent):
return
status_message.status = ChatStatusType.DONE
status_message.type = ChatMessageType.RESPONSE
status_message.content = json.dumps(job_requirements.model_dump(mode="json", exclude_unset=True))
job_data = {
"company": company_name,
"title": job_title,
"summary": job_summary,
"requirements": job_requirements.model_dump(mode="json", exclude_unset=True)
}
status_message.content = json.dumps(job_data)
yield status_message
logger.info(f"✅ Job requirements analysis completed successfully.")

View File

@ -168,7 +168,7 @@ JSON RESPONSE:"""
user_message.content = prompt
skill_assessment = None
async for skill_assessment in self.llm_one_shot(llm=llm, model=model, user_message=user_message, system_prompt=system_prompt, temperature=0.1):
async for skill_assessment in self.llm_one_shot(llm=llm, model=model, user_message=user_message, system_prompt=system_prompt, temperature=0.7):
if skill_assessment.status == ChatStatusType.ERROR:
status_message.status = ChatStatusType.ERROR
status_message.content = skill_assessment.content

View File

@ -20,11 +20,13 @@ from logger import logger
import agents as agents
from models import (Tunables, CandidateQuestion, ChatMessageUser, ChatMessage, RagEntry, ChatMessageType, ChatMessageMetaData, ChatStatusType, Candidate, ChatContextType)
from llm_manager import llm_manager
from agents.base import Agent
class CandidateEntity(Candidate):
model_config = {"arbitrary_types_allowed": True} # Allow ChromaDBFileWatcher, etc
# Internal instance members
CandidateEntity__agents: List[Agent] = []
CandidateEntity__observer: Optional[Any] = Field(default=None, exclude=True)
CandidateEntity__file_watcher: Optional[ChromaDBFileWatcher] = Field(default=None, exclude=True)
CandidateEntity__prometheus_collector: Optional[CollectorRegistry] = Field(
@ -63,7 +65,7 @@ class CandidateEntity(Candidate):
# Check if file exists
return user_info_path.is_file()
def get_or_create_agent(self, agent_type: ChatContextType, **kwargs) -> agents.Agent:
def get_or_create_agent(self, agent_type: ChatContextType) -> agents.Agent:
"""
Get or create an agent of the specified type for this candidate.
@ -74,11 +76,17 @@ class CandidateEntity(Candidate):
Returns:
The created agent instance.
"""
# Only instantiate one agent of each type per user
for agent in self.CandidateEntity__agents:
if agent.agent_type == agent_type:
return agent
return agents.get_or_create_agent(
agent_type=agent_type,
user=self,
prometheus_collector=self.prometheus_collector,
**kwargs)
prometheus_collector=self.prometheus_collector
)
# Wrapper properties that map into file_watcher
@property

View File

@ -64,13 +64,13 @@ import agents
# =============================
from models import (
# API
LoginRequest, CreateCandidateRequest, CreateEmployerRequest,
Job, LoginRequest, CreateCandidateRequest, CreateEmployerRequest,
# User models
Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse, CandidateAI,
# Job models
Job, JobApplication, ApplicationStatus,
JobFull, JobApplication, ApplicationStatus,
# Chat models
ChatSession, ChatMessage, ChatContext, ChatQuery, ChatStatusType, ChatMessageBase, ChatMessageUser, ChatSenderType, ChatMessageType, ChatContextType,
@ -2609,28 +2609,24 @@ async def confirm_password_reset(
# ============================
@api_router.post("/jobs")
async def create_job(
async def create_candidate_job(
job_data: Dict[str, Any] = Body(...),
current_user = Depends(get_current_user),
database: RedisDatabase = Depends(get_database)
):
"""Create a new job"""
is_employer = isinstance(current_user, Employer)
try:
# Verify user is an employer
if not isinstance(current_user, Employer):
return JSONResponse(
status_code=403,
content=create_error_response("FORBIDDEN", "Only employers can create jobs")
)
if is_employer:
job = JobFull.model_validate(job_data)
else:
job = Job.model_validate(job_data)
# Add required fields
job_data["id"] = str(uuid.uuid4())
job_data["datePosted"] = datetime.now(UTC).isoformat()
job_data["views"] = 0
job_data["applicationCount"] = 0
job_data["employerId"] = current_user.id
job.id = str(uuid.uuid4())
job.owner_id = current_user.id
job = Job.model_validate(job_data)
await database.set_job(job.id, job.model_dump())
return create_success_response(job.model_dump(by_alias=True, exclude_unset=True))
@ -2687,7 +2683,12 @@ async def get_jobs(
# Get all jobs from Redis
all_jobs_data = await database.get_all_jobs()
jobs_list = [Job.model_validate(data) for data in all_jobs_data.values() if data.get("is_active", True)]
jobs_list = []
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))
paginated_jobs, total = filter_and_paginate(
jobs_list, page, limit, sortBy, sortOrder, filter_dict
@ -2822,33 +2823,40 @@ async def post_candidate_rag_search(
content=create_error_response("SUMMARY_ERROR", str(e))
)
@api_router.get("/candidates/{username}")
# reference can be candidateId, username, or email
@api_router.get("/candidates/{reference}")
async def get_candidate(
username: str = Path(...),
reference: str = Path(...),
database: RedisDatabase = Depends(get_database)
):
"""Get a candidate by username"""
try:
# Normalize reference to lowercase for case-insensitive search
query_lower = reference.lower()
all_candidates_data = await database.get_all_candidates()
candidates_list = [Candidate.model_validate(data) for data in all_candidates_data.values()]
if not all_candidates_data:
logger.warning(f"⚠️ No candidates found in database")
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "No candidates found")
)
candidate_data = None
for candidate in all_candidates_data.values():
if (candidate.get("id", "").lower() == query_lower or
candidate.get("username", "").lower() == query_lower or
candidate.get("email", "").lower() == query_lower):
candidate_data = candidate
break
# Normalize username to lowercase for case-insensitive search
query_lower = username.lower()
# Filter by search query
candidates_list = [
c for c in candidates_list
if (query_lower == c.email.lower() or
query_lower == c.username.lower())
]
if not len(candidates_list):
if not candidate_data:
logger.warning(f"⚠️ Candidate not found for reference: {reference}")
return JSONResponse(
status_code=404,
content=create_error_response("NOT_FOUND", "Candidate not found")
)
candidate_data = candidates_list[0]
candidate = Candidate.model_validate(candidate_data) if not candidate_data.get("is_AI") else CandidateAI.model_validate(candidate_data)
return create_success_response(candidate.model_dump(by_alias=True, exclude_unset=True))
@ -3368,8 +3376,8 @@ async def get_candidate_skill_match(
candidate = Candidate.model_validate(candidate_data)
logger.info(f"🔍 Running skill match for candidate {candidate.id} against requirement: {requirement}")
async with entities.get_candidate_entity(candidate=candidate) as candidate_entity:
logger.info(f"🔍 Running skill match for candidate {candidate_entity.username} against requirement: {requirement}")
agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.SKILL_MATCH)
if not agent:
return JSONResponse(

View File

@ -108,18 +108,7 @@ class SkillMatch(BaseModel):
"populate_by_name": True # Allow both field names and aliases
}
class JobRequirements(BaseModel):
technical_skills: Requirements = Field(..., alias="technicalSkills")
experience_requirements: Requirements = Field(..., alias="experienceRequirements")
soft_skills: Optional[List[str]] = Field(default_factory=list, alias="softSkills")
experience: Optional[List[str]] = []
education: Optional[List[str]] = []
certifications: Optional[List[str]] = []
preferred_attributes: Optional[List[str]] = Field(None, alias="preferredAttributes")
model_config = {
"populate_by_name": True # Allow both field names and aliases
}
class ChatMessageType(str, Enum):
ERROR = "error"
GENERATING = "generating"
@ -650,14 +639,32 @@ class AuthResponse(BaseModel):
"populate_by_name": True # Allow both field names and aliases
}
class JobRequirements(BaseModel):
technical_skills: Requirements = Field(..., alias="technicalSkills")
experience_requirements: Requirements = Field(..., alias="experienceRequirements")
soft_skills: Optional[List[str]] = Field(default_factory=list, alias="softSkills")
experience: Optional[List[str]] = []
education: Optional[List[str]] = []
certifications: Optional[List[str]] = []
preferred_attributes: Optional[List[str]] = Field(None, alias="preferredAttributes")
model_config = {
"populate_by_name": True # Allow both field names and aliases
}
class Job(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
title: str
owner_id: str = Field(..., alias="ownerId")
owner_type: UserType = Field(..., alias="ownerType")
title: Optional[str]
summary: Optional[str]
company: Optional[str]
description: str
responsibilities: List[str]
requirements: List[str]
preferred_skills: Optional[List[str]] = Field(None, alias="preferredSkills")
employer_id: str = Field(..., alias="employerId")
requirements: Optional[JobRequirements]
model_config = {
"populate_by_name": True # Allow both field names and aliases
}
class JobFull(Job):
location: Location
salary_range: Optional[SalaryRange] = Field(None, alias="salaryRange")
employment_type: EmploymentType = Field(..., alias="employmentType")
@ -672,9 +679,6 @@ class Job(BaseModel):
featured_until: Optional[datetime] = Field(None, alias="featuredUntil")
views: int = 0
application_count: int = Field(0, alias="applicationCount")
model_config = {
"populate_by_name": True # Allow both field names and aliases
}
class InterviewFeedback(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@ -1061,4 +1065,4 @@ Candidate.update_forward_refs()
Employer.update_forward_refs()
ChatSession.update_forward_refs()
JobApplication.update_forward_refs()
Job.update_forward_refs()
JobFull.update_forward_refs()