From 4f7b2f3e6a941792680d1e3dae5b5d6f111254b1 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Wed, 4 Jun 2025 10:31:26 -0700 Subject: [PATCH] Refresh maintains selected entities --- docker-compose.yml | 1 - frontend/src/components/JobMatchAnalysis.tsx | 58 +++--- frontend/src/hooks/GlobalContext.tsx | 176 ++++++++++++++++++- frontend/src/pages/JobAnalysisPage.tsx | 158 +++-------------- frontend/src/routes/CandidateRoute.tsx | 4 +- frontend/src/services/api-client.ts | 5 +- frontend/src/types/types.ts | 90 ++++++---- src/backend/agents/__init__.py | 13 +- src/backend/agents/job_requirements.py | 24 ++- src/backend/agents/skill_match.py | 2 +- src/backend/entities/candidate_entity.py | 14 +- src/backend/main.py | 74 ++++---- src/backend/models.py | 46 ++--- 13 files changed, 398 insertions(+), 267 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0d0255c..5fbc23a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -125,7 +125,6 @@ services: context: . dockerfile: Dockerfile target: ollama - #image: ollama container_name: ollama restart: "always" env_file: diff --git a/frontend/src/components/JobMatchAnalysis.tsx b/frontend/src/components/JobMatchAnalysis.tsx index 494382f..b3dba6f 100644 --- a/frontend/src/components/JobMatchAnalysis.tsx +++ b/frontend/src/components/JobMatchAnalysis.tsx @@ -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 = (props: JobAnalysisProps) = } = props const { apiClient } = useAuth(); const theme = useTheme(); - const [jobRequirements, setJobRequirements] = useState(null); const [requirements, setRequirements] = useState<{ requirement: string, domain: string }[]>([]); const [skillMatches, setSkillMatches] = useState([]); const [creatingSession, setCreatingSession] = useState(false); @@ -89,7 +83,7 @@ const JobMatchAnalysis: React.FC = (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 = (props: JobAnalysisProps) = onMessage: (msg: ChatMessage) => { console.log(`onMessage: ${msg.type}`, msg); if (msg.type === "response") { - const incoming: any = toCamelCase(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(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 = (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 = (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 = (props: JobAnalysisProps) = // Get icon based on status const getStatusIcon = (status: string, score: number) => { - if (status === 'pending') return ; + if (status === 'pending' || status === 'waiting') return ; if (status === 'error') return ; if (score >= 70) return ; if (score >= 40) return ; diff --git a/frontend/src/hooks/GlobalContext.tsx b/frontend/src/hooks/GlobalContext.tsx index ca063c2..bf4bc42 100644 --- a/frontend/src/hooks/GlobalContext.tsx +++ b/frontend/src/hooks/GlobalContext.tsx @@ -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; @@ -30,13 +67,118 @@ export type AppStateContextType = AppState & AppStateActions; // ============================ export function useAppStateLogic(): AppStateContextType { + const { apiClient } = useAuth(); const [selectedCandidate, setSelectedCandidateState] = useState(null); const [selectedJob, setSelectedJobState] = useState(null); const [selectedEmployer, setSelectedEmployerState] = useState(null); + const [isInitializing, setIsInitializing] = useState(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[] = []; + + 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'); } \ No newline at end of file diff --git a/frontend/src/pages/JobAnalysisPage.tsx b/frontend/src/pages/JobAnalysisPage.tsx index 55bd5ee..cab60c0 100644 --- a/frontend/src/pages/JobAnalysisPage.tsx +++ b/frontend/src/pages/JobAnalysisPage.tsx @@ -46,6 +46,7 @@ import { ComingSoon } from 'components/ui/ComingSoon'; const JobAnalysisPage: React.FC = (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 = (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(null); @@ -60,6 +62,9 @@ const JobAnalysisPage: React.FC = (props: BackstoryPageProps const { apiClient } = useAuth(); const [candidates, setCandidates] = useState(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 = (props: BackstoryPageProps // Steps in our process const steps = [ - { index: 1, label: 'Job Description', icon: }, + { index: 1, label: 'Job Selection', icon: }, { index: 2, label: 'AI Analysis', icon: }, { index: 3, label: 'Generated Resume', icon: } ]; @@ -104,124 +109,6 @@ const JobAnalysisPage: React.FC = (props: BackstoryPageProps steps.unshift({ index: 0, label: 'Select Candidate', icon: }) } - const fetchMatchForRequirement = async (requirement: string): Promise => { - // Create different mock responses based on the requirement - const mockResponses: Record = { - "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 = (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 = (props: BackstoryPageProps ); - const extraInfo = false; - // Render function for the job description step const renderJobDescription = () => ( - {extraInfo && <> Enter Job Details @@ -361,6 +245,18 @@ const JobAnalysisPage: React.FC = (props: BackstoryPageProps /> + + setCompany(e.target.value)} + required + margin="normal" + /> + + = (props: BackstoryPageProps margin="normal" /> - - - } + - Job Description + Job Selection - {extraInfo && } + = (props: BackstoryPageProps {selectedCandidate && ( @@ -434,14 +328,14 @@ const JobAnalysisPage: React.FC = (props: BackstoryPageProps ); // If no user is logged in, show message - if (!user) { + if (!user?.id) { return ( Please log in to access candidate analysis - diff --git a/frontend/src/routes/CandidateRoute.tsx b/frontend/src/routes/CandidateRoute.tsx index 1a5c159..745fd9d 100644 --- a/frontend/src/routes/CandidateRoute.tsx +++ b/frontend/src/routes/CandidateRoute.tsx @@ -24,9 +24,9 @@ const CandidateRoute: React.FC = (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 { diff --git a/frontend/src/services/api-client.ts b/frontend/src/services/api-client.ts index e63b831..5a20c97 100644 --- a/frontend/src/services/api-client.ts +++ b/frontend/src/services/api-client.ts @@ -506,8 +506,9 @@ class ApiClient { // Candidate Methods with Date Conversion // ============================ - async getCandidate(username: string): Promise { - const response = await fetch(`${this.baseUrl}/candidates/${username}`, { + // reference can be candidateId, username, or email + async getCandidate(reference: string): Promise { + const response = await fetch(`${this.baseUrl}/candidates/${reference}`, { headers: this.defaultHeaders }); diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 1153a37..5283da4 100644 --- a/frontend/src/types/types.ts +++ b/frontend/src/types/types.ts @@ -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; - requirements: Array; - preferredSkills?: Array; - employerId: string; - location: Location; - salaryRange?: SalaryRange; - employmentType: "full-time" | "part-time" | "contract" | "internship" | "freelance"; - datePosted: Date; - applicationDeadline?: Date; - isActive: boolean; - applicants?: Array; - department?: string; - reportsTo?: string; - benefits?: Array; - 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; + department?: string; + reportsTo?: string; + benefits?: Array; + visaSponsorship?: boolean; + featuredUntil?: Date; + views: number; + applicationCount: number; +} + export interface JobListResponse { success: boolean; data?: Array; @@ -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(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': diff --git a/src/backend/agents/__init__.py b/src/backend/agents/__init__.py index aef8da8..27d6f40 100644 --- a/src/backend/agents/__init__.py +++ b/src/backend/agents/__init__.py @@ -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 diff --git a/src/backend/agents/job_requirements.py b/src/backend/agents/job_requirements.py index b64b5fa..10ac8b1 100644 --- a/src/backend/agents/job_requirements.py +++ b/src/backend/agents/job_requirements.py @@ -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.") diff --git a/src/backend/agents/skill_match.py b/src/backend/agents/skill_match.py index 05bca65..e4b6887 100644 --- a/src/backend/agents/skill_match.py +++ b/src/backend/agents/skill_match.py @@ -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 diff --git a/src/backend/entities/candidate_entity.py b/src/backend/entities/candidate_entity.py index 0cf9bd4..4b0718f 100644 --- a/src/backend/entities/candidate_entity.py +++ b/src/backend/entities/candidate_entity.py @@ -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 diff --git a/src/backend/main.py b/src/backend/main.py index 00c67a6..738b495 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -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( diff --git a/src/backend/models.py b/src/backend/models.py index bf99e2d..bd7d3eb 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -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() \ No newline at end of file +JobFull.update_forward_refs() \ No newline at end of file