From a197535beafd8e8cb3315b40ad5a0156224d26dd Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Mon, 9 Jun 2025 16:14:32 -0700 Subject: [PATCH] Resume generation almost working --- frontend/src/components/ResumeGenerator.tsx | 87 +++++-- frontend/src/components/layout/Header.tsx | 2 +- frontend/src/components/ui/LoginRequired.tsx | 2 +- frontend/src/pages/JobAnalysisPage.tsx | 7 - frontend/src/services/api-client.ts | 8 + frontend/src/types/types.ts | 35 ++- src/backend/agents/generate_persona.py | 8 +- src/backend/agents/generate_resume.py | 198 ++++++++++++++ src/backend/agents/job_requirements.py | 6 +- src/backend/agents/skill_match.py | 2 +- src/backend/database.py | 5 +- src/backend/main.py | 261 ++++++++++++++++++- src/backend/models.py | 28 +- src/backend/rag/rag.py | 7 +- 14 files changed, 595 insertions(+), 61 deletions(-) create mode 100644 src/backend/agents/generate_resume.py diff --git a/frontend/src/components/ResumeGenerator.tsx b/frontend/src/components/ResumeGenerator.tsx index 5391268..08b53d3 100644 --- a/frontend/src/components/ResumeGenerator.tsx +++ b/frontend/src/components/ResumeGenerator.tsx @@ -1,12 +1,21 @@ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useRef, useEffect } from 'react'; import { Tabs, Tab, Box, Button, + Paper, + Typography, } from '@mui/material'; import { Job, Candidate, SkillAssessment } from "types/types"; -import JsonView from '@uiw/react-json-view'; +import { Scrollable } from './Scrollable'; +import { useAuth } from 'hooks/AuthContext'; +import * as Types from 'types/types'; +import { StyledMarkdown } from './StyledMarkdown'; +import { Message } from './Message'; +import InputIcon from '@mui/icons-material/Input'; +import TuneIcon from '@mui/icons-material/Tune'; +import ArticleIcon from '@mui/icons-material/Article'; interface ResumeGeneratorProps { job: Job; @@ -15,31 +24,75 @@ interface ResumeGeneratorProps { onComplete?: (resume: string) => void; } +const defaultMessage: Types.ChatMessageStatus = { + status: "done", type: "text", sessionId: "", timestamp: new Date(), content: "", activity: 'info' +}; + const ResumeGenerator: React.FC = (props: ResumeGeneratorProps) => { const { job, candidate, skills, onComplete } = props; - const [resume, setResume] = useState('Generated resume goes here...'); + const { apiClient } = useAuth(); + const [resume, setResume] = useState(''); + const [prompt, setPrompt] = useState(''); + const [systemPrompt, setSystemPrompt] = useState(''); const [generating, setGenerating] = useState(false); + const [statusMessage, setStatusMessage] = useState(null); + const [tabValue, setTabValue] = useState('resume'); + + + + const handleTabChange = (event: React.SyntheticEvent, newValue: string) => { + setTabValue(newValue); + } // State for editing job description - const generateResume = () => { - setResume('Generation begins...'); - setGenerating(true); - setTimeout(() => { - setGenerating(false); - setResume('Generation complete'); - onComplete && onComplete(resume); - }, 3000); + const generateResumeHandlers = { + onStatus: (status: Types.ChatMessageStatus) => { + setStatusMessage({...defaultMessage, content: status.content.toLowerCase}); + }, + onStreaming: (chunk: Types.ChatMessageStreaming) =>{ + setResume(chunk.content); + }, + onComplete: () => { + setStatusMessage(null); + } }; + useEffect(() => { + if (!job || !candidate || !skills || resume || generating) { + return; + } + const generateResume = async () => { + setGenerating(true); + const request : any = await apiClient.generateResume(candidate.id || '', skills, generateResumeHandlers); + const result = await request.promise; + setSystemPrompt(result.systemPrompt) + setPrompt(result.prompt) + setResume(result.resume) + setGenerating(false); + }; + generateResume() + }, [job, candidate, apiClient, resume, skills, generating]); + return ( - - - {resume} - - + sx={{ + display: "flex", + flexDirection: "column", + }}> + + + } label="System" /> + } label="Prompt" /> + } label="Resume" /> + + + { statusMessage && } + + { tabValue === 'system' &&
{systemPrompt}
} + { tabValue === 'prompt' &&
{prompt}
} + { tabValue === 'resume' && } +
) diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 810d4df..508261d 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -257,7 +257,7 @@ const Header: React.FC = (props: HeaderProps) => { // Render desktop navigation with dropdowns const renderDesktopNavigation = () => { return ( - + :last-of-type": { marginRight: "auto"} }}> {navigationItems.map((item, index) => { const hasChildren = item.children && item.children.length > 0; const isActive = isCurrentPath(item) || hasActiveChild(item); diff --git a/frontend/src/components/ui/LoginRequired.tsx b/frontend/src/components/ui/LoginRequired.tsx index 13abe92..f41c9e2 100644 --- a/frontend/src/components/ui/LoginRequired.tsx +++ b/frontend/src/components/ui/LoginRequired.tsx @@ -8,7 +8,7 @@ import { import { useNavigate } from 'react-router-dom'; interface LoginRequiredProps { -asset: string; + asset: string; } const LoginRequired = (props: LoginRequiredProps) => { const { asset } = props; diff --git a/frontend/src/pages/JobAnalysisPage.tsx b/frontend/src/pages/JobAnalysisPage.tsx index 4d24c9e..e68d629 100644 --- a/frontend/src/pages/JobAnalysisPage.tsx +++ b/frontend/src/pages/JobAnalysisPage.tsx @@ -228,13 +228,6 @@ const JobAnalysisPage: React.FC = (props: BackstoryPageProps ); - // If no user is logged in, show message - if (!user?.id) { - return ( - - ); - } - return ( (`/jobs/requirements/${jobId}`, null, streamingOptions); } + generateResume(candidateId: string, skills: Types.SkillAssessment[], streamingOptions?: StreamingOptions): StreamingResponse { + const body = JSON.stringify(skills); + streamingOptions = { + ...streamingOptions, + headers: this.defaultHeaders, + }; + return this.streamify(`/candidates/${candidateId}/generate-resume`, body, streamingOptions); + } candidateMatchForRequirement(candidate_id: string, requirement: string, streamingOptions?: StreamingOptions) : StreamingResponse { diff --git a/frontend/src/types/types.ts b/frontend/src/types/types.ts index 652bba6..95b6691 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-09T17:45:24.922154 +// Generated on: 2025-06-09T20:36:06.432367 // DO NOT EDIT MANUALLY - This file is auto-generated // ============================ @@ -19,7 +19,7 @@ export type ApiStatusType = "streaming" | "status" | "done" | "error"; export type ApplicationStatus = "applied" | "reviewing" | "interview" | "offer" | "rejected" | "accepted" | "withdrawn"; -export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "generate_image" | "rag_search" | "skill_match"; +export type ChatContextType = "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match"; export type ChatSenderType = "user" | "assistant" | "system" | "information" | "warning" | "error"; @@ -282,7 +282,7 @@ export interface Certification { } export interface ChatContext { - type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "generate_image" | "rag_search" | "skill_match"; + type: "job_search" | "job_requirements" | "candidate_chat" | "interview_prep" | "resume_review" | "general" | "generate_persona" | "generate_profile" | "generate_resume" | "generate_image" | "rag_search" | "skill_match"; relatedEntityId?: string; relatedEntityType?: "job" | "candidate" | "employer"; additionalContext?: Record; @@ -341,6 +341,20 @@ export interface ChatMessageRagSearch { content: Array; } +export interface ChatMessageResume { + id?: string; + sessionId: string; + senderId?: string; + status: "streaming" | "status" | "done" | "error"; + type: "binary" | "text" | "json"; + timestamp?: Date; + role: "user" | "assistant" | "system" | "information" | "warning" | "error"; + content: string; + tunables?: Tunables; + metadata: ChatMessageMetaData; + resume: string; +} + export interface ChatMessageSkillAssessment { id?: string; sessionId: string; @@ -1267,6 +1281,19 @@ export function convertChatMessageRagSearchFromApi(data: any): ChatMessageRagSea timestamp: data.timestamp ? new Date(data.timestamp) : undefined, }; } +/** + * Convert ChatMessageResume from API response, parsing date fields + * Date fields: timestamp + */ +export function convertChatMessageResumeFromApi(data: any): ChatMessageResume { + if (!data) return data; + + return { + ...data, + // Convert timestamp from ISO string to Date + timestamp: data.timestamp ? new Date(data.timestamp) : undefined, + }; +} /** * Convert ChatMessageSkillAssessment from API response, parsing date fields * Date fields: timestamp @@ -1678,6 +1705,8 @@ export function convertFromApi(data: any, modelType: string): T { return convertChatMessageErrorFromApi(data) as T; case 'ChatMessageRagSearch': return convertChatMessageRagSearchFromApi(data) as T; + case 'ChatMessageResume': + return convertChatMessageResumeFromApi(data) as T; case 'ChatMessageSkillAssessment': return convertChatMessageSkillAssessmentFromApi(data) as T; case 'ChatMessageStatus': diff --git a/src/backend/agents/generate_persona.py b/src/backend/agents/generate_persona.py index 7fee083..67d4cf6 100644 --- a/src/backend/agents/generate_persona.py +++ b/src/backend/agents/generate_persona.py @@ -1,6 +1,6 @@ from __future__ import annotations from datetime import UTC, datetime -from pydantic import model_validator, Field, BaseModel +from pydantic import model_validator, Field, BaseModel # type: ignore from typing import ( Dict, Literal, @@ -23,7 +23,7 @@ import asyncio import time import os import random -from names_dataset import NameDataset, NameWrapper +from names_dataset import NameDataset, NameWrapper # type: ignore from .base import Agent, agent_registry, LLMMessage from models import ApiActivityType, Candidate, ChatMessage, ChatMessageError, ChatMessageMetaData, ApiMessageType, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, ChatOptions, ChatSenderType, ApiStatusType, Tunables @@ -128,7 +128,7 @@ logger = logging.getLogger(__name__) class EthnicNameGenerator: def __init__(self): try: - from names_dataset import NameDataset + from names_dataset import NameDataset # type: ignore self.nd = NameDataset() except ImportError: logger.error("NameDataset not available. Please install: pip install names-dataset") @@ -292,7 +292,7 @@ class EthnicNameGenerator: return names class GeneratePersona(Agent): - agent_type: Literal["generate_persona"] = "generate_persona" + agent_type: Literal["generate_persona"] = "generate_persona" # type: ignore _agent_type: ClassVar[str] = agent_type # Add this for registration agent_persist: bool = False diff --git a/src/backend/agents/generate_resume.py b/src/backend/agents/generate_resume.py new file mode 100644 index 0000000..565c0d4 --- /dev/null +++ b/src/backend/agents/generate_resume.py @@ -0,0 +1,198 @@ +from __future__ import annotations +from pydantic import model_validator, Field # type: ignore +from typing import ( + Dict, + Literal, + ClassVar, + Any, + AsyncGenerator, + List, + Optional +# override +) # NOTE: You must import Optional for late binding to work +import inspect +import re +import json +import traceback +import asyncio +import time +import asyncio +import numpy as np # type: ignore + +from logger import logger +from .base import Agent, agent_registry +from models import (ApiActivityType, ApiStatusType, Candidate, ChatMessage, ChatMessageError, ChatMessageResume, ChatMessageStatus, JobRequirements, JobRequirementsMessage, SkillAssessment, SkillStrength, Tunables) + +class GenerateResume(Agent): + agent_type: Literal["generate_resume"] = "generate_resume" # type: ignore + _agent_type: ClassVar[str] = agent_type # Add this for registration + + def generate_resume_prompt( + self, + skills: List[SkillAssessment] + ): + """ + Generate a professional resume based on skill assessment results + + Parameters: + - candidate_name (str): The candidate's full name + - candidate_contact_info (dict): Contact details like email, phone, location + - skill_assessment_results (list): List of individual skill assessment results from LLM queries + - original_resume (str): Original resume text for reference + + Returns: + - str: System prompt for generating a professional resume + """ + if not self.user: + raise ValueError("User must be bound to agent") + + # Extract and organize skill assessment data + skills_by_strength = { + SkillStrength.STRONG: [], + SkillStrength.MODERATE: [], + SkillStrength.WEAK: [], + SkillStrength.NONE: [] + } + + experience_evidence = {} + + # Process each skill assessment + for assessment in skills: + skill = assessment.skill + strength = assessment.evidence_strength + + # Add to appropriate strength category + if skill and strength in skills_by_strength: + skills_by_strength[strength].append(skill) + + # Collect experience evidence + for evidence in assessment.evidence_details: + source = evidence.source + if source: + if source not in experience_evidence: + experience_evidence[source] = [] + + experience_evidence[source].append( + { + "skill": skill, + "quote": evidence.quote, + "context": evidence.context + } + ) + + # Build the system prompt + system_prompt = f"""You are a professional resume writer with expertise in highlighting candidate strengths and experiences. +Create a polished, concise, and ATS-friendly resume for the candidate based on the assessment data provided. + +## CANDIDATE INFORMATION: +Name: {self.user.full_name} +Email: {self.user.email or 'N/A'} +Phone: {self.user.phone or 'N/A'} +{f'Location: {json.dumps(self.user.location.model_dump())}' if self.user.location else ''} + +## SKILL ASSESSMENT RESULTS: +""" + + if len(skills_by_strength[SkillStrength.STRONG]): + system_prompt += f"""\ + +### Strong Skills (prominent in resume): +{", ".join(skills_by_strength[SkillStrength.STRONG])} +""" + + if len(skills_by_strength[SkillStrength.MODERATE]): + system_prompt += f"""\ + +### Moderate Skills (demonstrated in resume): +{", ".join(skills_by_strength[SkillStrength.MODERATE])} +""" + + if len(skills_by_strength[SkillStrength.WEAK]): + system_prompt += f"""\ + +### Weaker Skills (mentioned or implied): +{", ".join(skills_by_strength[SkillStrength.WEAK])} +""" + + system_prompt += """\ + +## EXPERIENCE EVIDENCE: +""" + + # Add experience evidence by source/position + for source, evidences in experience_evidence.items(): + system_prompt += f"\n### {source}:\n" + for evidence in evidences: + system_prompt += f"- {evidence['skill']}: {evidence['context']}\n" + + # Add instructions for the resume creation + system_prompt += """\ + +## INSTRUCTIONS: + +1. Create a professional resume that emphasizes the candidate's strongest skills and most relevant experiences. +2. Format the resume in a clean, concise, and modern style that will pass ATS systems. +3. Include these sections: + - Professional Summary (highlight strongest skills and experience level) + - Skills (organized by strength) + - Professional Experience (focus on achievements and evidence of the skill) + - If present in material, provide an Education section + - If present in material, provide a Certifications section + - Additional sections as appropriate +4. Use action verbs and quantifiable achievements where possible. +5. Maintain a professional tone throughout. +6. Be concise and impactful - the resume should be 1-2 pages MAXIMUM. +7. Ensure all information is accurate to the original resume - do not embellish or fabricate experiences. + +## OUTPUT FORMAT: +Provide the resume in clean markdown format, ready for the candidate to use. + +""" + prompt = "Create a tailored professional resume that highlights candidate's skills and experience most relevant to the job requirements. Format it in clean, ATS-friendly markdown. Provide ONLY the resume with no commentary before or after." + return system_prompt, prompt + + async def generate_resume( + self, llm: Any, model: str, session_id: str, skills: List[SkillAssessment] + ) -> AsyncGenerator[ChatMessage | ChatMessageError, None]: + # Stage 1A: Analyze job requirements + status_message = ChatMessageStatus( + session_id=session_id, + content = f"Analyzing job requirements", + activity=ApiActivityType.THINKING + ) + yield status_message + + system_prompt, prompt = self.generate_resume_prompt(skills=skills) + + generated_message = None + async for generated_message in self.llm_one_shot(llm=llm, model=model, session_id=session_id, prompt=prompt, system_prompt=system_prompt): + if generated_message.status == ApiStatusType.ERROR: + yield generated_message + return + if generated_message.status != ApiStatusType.DONE: + yield generated_message + + if not generated_message: + error_message = ChatMessageError( + session_id=session_id, + content="Job requirements analysis failed to generate a response." + ) + logger.error(f"⚠️ {error_message.content}") + yield error_message + return + + resume_message = ChatMessageResume( + session_id=session_id, + status=ApiStatusType.DONE, + content="Resume generation completed successfully.", + metadata=generated_message.metadata, + resume=generated_message.content, + prompt=prompt, + system_prompt=system_prompt, + ) + yield resume_message + logger.info(f"✅ Resume generation completed successfully.") + return + +# Register the base agent +agent_registry.register(GenerateResume._agent_type, GenerateResume) diff --git a/src/backend/agents/job_requirements.py b/src/backend/agents/job_requirements.py index b25b48c..1eb79ea 100644 --- a/src/backend/agents/job_requirements.py +++ b/src/backend/agents/job_requirements.py @@ -1,5 +1,5 @@ from __future__ import annotations -from pydantic import model_validator, Field +from pydantic import model_validator, Field # type: ignore from typing import ( Dict, Literal, @@ -16,7 +16,7 @@ import json import asyncio import time import asyncio -import numpy as np +import numpy as np # type: ignore from .base import Agent, agent_registry, LLMMessage from models import ApiActivityType, Candidate, ChatMessage, ChatMessageError, ChatMessageMetaData, ApiMessageType, ChatMessageStatus, ChatMessageUser, ChatOptions, ChatSenderType, ApiStatusType, JobRequirements, JobRequirementsMessage, Tunables @@ -26,7 +26,7 @@ import defines import backstory_traceback as traceback class JobRequirementsAgent(Agent): - agent_type: Literal["job_requirements"] = "job_requirements" + agent_type: Literal["job_requirements"] = "job_requirements" # type: ignore _agent_type: ClassVar[str] = agent_type # Add this for registration # Stage 1A: Job Analysis Implementation diff --git a/src/backend/agents/skill_match.py b/src/backend/agents/skill_match.py index c3222e4..2cc7f96 100644 --- a/src/backend/agents/skill_match.py +++ b/src/backend/agents/skill_match.py @@ -170,8 +170,8 @@ JSON RESPONSE:""" yield error_message return - logger.info(f"🔍 RAG content retrieved: {len(rag_message.content)} bytes") rag_context = self.get_rag_context(rag_message) + 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) skill_message = None diff --git a/src/backend/database.py b/src/backend/database.py index 7993036..f631ba5 100644 --- a/src/backend/database.py +++ b/src/backend/database.py @@ -272,9 +272,8 @@ class RedisDatabase: """Cache skill match assessment""" try: # Cache for 1 hour by default - await self.redis.setex( + await self.redis.set( cache_key, - 3600, json.dumps(assessment.model_dump(mode='json', by_alias=True), default=str) # Serialize with datetime handling ) logger.debug(f"💾 Skill match cached: {cache_key}") @@ -349,7 +348,7 @@ class RedisDatabase: try: # This assumes all candidates belonging to this user need cache invalidation # You might need to adjust the pattern based on how you associate candidates with users - pattern = f"skill_match:*" + pattern = f"skill_match:{user_id}:*" keys = await self.redis.keys(pattern) # Filter keys that belong to candidates owned by this user diff --git a/src/backend/main.py b/src/backend/main.py index f859f7b..2a4573f 100644 --- a/src/backend/main.py +++ b/src/backend/main.py @@ -1,3 +1,4 @@ +import hashlib import time from fastapi import FastAPI, HTTPException, Depends, Query, Path, Body, status, APIRouter, Request, BackgroundTasks, File, UploadFile, Form# type: ignore from fastapi.middleware.cors import CORSMiddleware # type: ignore @@ -75,7 +76,7 @@ import agents # ============================= from models import ( # API - MOCK_UUID, ApiActivityType, ChatMessageError, ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, DocumentMessage, DocumentOptions, Job, JobRequirementsMessage, LoginRequest, CreateCandidateRequest, CreateEmployerRequest, + MOCK_UUID, ApiActivityType, ChatMessageError, ChatMessageResume, ChatMessageSkillAssessment, ChatMessageStatus, ChatMessageStreaming, ChatMessageUser, DocumentMessage, DocumentOptions, Job, JobRequirements, JobRequirementsMessage, LoginRequest, CreateCandidateRequest, CreateEmployerRequest, # User models Candidate, Employer, BaseUserWithType, BaseUser, Guest, Authentication, AuthResponse, CandidateAI, @@ -4518,17 +4519,18 @@ async def get_candidate_skill_match( candidate = Candidate.model_validate(candidate_data) # Create cache key for this specific candidate + requirement combination - cache_key = f"skill_match:{candidate_id}:{hash(requirement)}" + requirement_hash = hashlib.md5(requirement.encode()).hexdigest()[:8] + cache_key = f"skill_match:{candidate.id}:{requirement_hash}" # Get cached assessment if it exists assessment : SkillAssessment | None = await database.get_cached_skill_match(cache_key) - - # 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) - + # Determine if we need to regenerate the assessment cached_date = None if assessment: + # 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) + updated = assessment.updated_at if "updated_at" in assessment else assessment.created_at # Check if cached result is still valid # Regenerate if user's RAG data was updated after cache date @@ -4537,7 +4539,9 @@ async def get_candidate_skill_match( assessment = None else: cached_date = updated - + else: + logger.info(f"💾 No cached skill match data: {cache_key}, {candidate.id}, {requirement}") + 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}") @@ -4656,7 +4660,250 @@ async def get_candidate_skill_match( ).model_dump(mode='json', by_alias=True))]), media_type="text/event-stream" ) + +@api_router.post("/candidates/job-score") +async def get_candidate_job_score( + job_requirements: JobRequirements = Body(...), + skills: List[SkillAssessment] = Body(...), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +) -> StreamingResponse: + # Initialize counters + required_skills_total = 0 + required_skills_matched = 0 + preferred_skills_total = 0 + preferred_skills_matched = 0 + + # Count required technical skills + tech_required = job_requirements.technical_skills.required + required_skills_total += len(tech_required) + + # Count preferred technical skills + tech_preferred = job_requirements.technical_skills.preferred + preferred_skills_total += len(tech_preferred) + + # Count required experience + exp_required = job_requirements.experience_requirements.required + required_skills_total += len(exp_required) + + # Count preferred experience + exp_preferred = job_requirements.experience_requirements.preferred + preferred_skills_total += len(exp_preferred) + + # Education requirements count toward required + edu_required = job_requirements.education or [] + required_skills_total += len(edu_required) + + # Soft skills count toward preferred + soft_skills = job_requirements.soft_skills or [] + preferred_skills_total += len(soft_skills) + + # Industry knowledge counts toward preferred + certifications = job_requirements.certifications or [] + preferred_skills_total += len(certifications) + + preferred_attributes = job_requirements.preferred_attributes or [] + preferred_skills_total += len(preferred_attributes) + + # Check matches in assessment results + for assessment in skills: + evidence_found = assessment.evidence_found + evidence_strength = assessment.evidence_strength + + # Consider STRONG and MODERATE evidence as matches + is_match = evidence_found and evidence_strength in ["STRONG", "MODERATE"] + + if not is_match: + continue + + # Loop through each of the job requirements categories + # and see if the skill matches the required or preferred skills + if assessment.skill in tech_required: + required_skills_matched += 1 + elif assessment.skill in tech_preferred: + preferred_skills_matched += 1 + elif assessment.skill in exp_required: + required_skills_matched += 1 + elif assessment.skill in exp_preferred: + preferred_skills_matched += 1 + elif assessment.skill in edu_required: + required_skills_matched += 1 + elif assessment.skill in soft_skills: + preferred_skills_matched += 1 + elif assessment.skill in certifications: + preferred_skills_matched += 1 + elif assessment.skill in preferred_attributes: + preferred_skills_matched += 1 + # If no skills were found, return empty statistics + if required_skills_total == 0 and preferred_skills_total == 0: + return create_success_response({ + "required_skills": { + "total": 0, + "matched": 0, + "percentage": 0.0, + }, + "preferred_skills": { + "total": 0, + "matched": 0, + "percentage": 0.0, + }, + "overall_match": { + "total": 0, + "matched": 0, + "percentage": 0.0, + }, + }) + + # Calculate percentages + required_match_percent = ( + (required_skills_matched / required_skills_total * 100) + if required_skills_total > 0 + else 0 + ) + preferred_match_percent = ( + (preferred_skills_matched / preferred_skills_total * 100) + if preferred_skills_total > 0 + else 0 + ) + overall_total = required_skills_total + preferred_skills_total + overall_matched = required_skills_matched + preferred_skills_matched + overall_match_percent = ( + (overall_matched / overall_total * 100) if overall_total > 0 else 0 + ) + + return create_success_response({ + "required_skills": { + "total": required_skills_total, + "matched": required_skills_matched, + "percentage": round(required_match_percent, 1), + }, + "preferred_skills": { + "total": preferred_skills_total, + "matched": preferred_skills_matched, + "percentage": round(preferred_match_percent, 1), + }, + "overall_match": { + "total": overall_total, + "matched": overall_matched, + "percentage": round(overall_match_percent, 1), + }, + }) + +@api_router.post("/candidates/{candidate_id}/generate-resume") +async def generate_resume( + candidate_id: str = Path(...), + skills: List[SkillAssessment] = Body(...), + current_user = Depends(get_current_user_or_guest), + database: RedisDatabase = Depends(get_database) +) -> StreamingResponse: + + """Get skill match for a candidate against a requirement with caching""" + async def message_stream_generator(): + candidate_data = await database.get_candidate(candidate_id) + if not candidate_data: + error_message = ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content=f"Candidate with ID '{candidate_id}' not found" + ) + yield error_message + return + + candidate = Candidate.model_validate(candidate_data) + + logger.info(f"🔍 Generating resume for candidate {candidate.username}") + + async with entities.get_candidate_entity(candidate=candidate) as candidate_entity: + agent = candidate_entity.get_or_create_agent(agent_type=ChatContextType.GENERATE_RESUME) + if not agent: + error_message = ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content=f"No skill match agent found for this candidate" + ) + yield error_message + return + # Generate new skill match + final_message = None + async for generated_message in agent.generate_resume( + llm=llm_manager.get_llm(), + model=defines.model, + session_id=MOCK_UUID, + skills=skills, + ): + if generated_message.status == ApiStatusType.ERROR: + logger.error(f"❌ AI generation error: {generated_message.content}") + yield f"data: {json.dumps({'status': 'error'})}\n\n" + return + + # If the message is not done, convert it to a ChatMessageBase to remove + # metadata and other unnecessary fields for streaming + if generated_message.status != ApiStatusType.DONE: + if not isinstance(generated_message, ChatMessageStreaming) and not isinstance(generated_message, ChatMessageStatus): + raise TypeError( + f"Expected ChatMessageStreaming or ChatMessageStatus, got {type(generated_message)}" + ) + yield generated_message# Convert to ChatMessageBase for streaming + + # Store reference to the complete AI message + if generated_message.status == ApiStatusType.DONE: + final_message = generated_message + break + + if final_message is None: + error_message = ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content=f"No skill match found for the given requirement" + ) + yield error_message + return + + if not isinstance(final_message, ChatMessageResume): + error_message = ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content=f"Skill match response is not valid" + ) + yield error_message + return + + resume : ChatMessageResume = final_message + yield resume + return + + try: + async def to_json(method): + try: + async for message in method: + json_data = message.model_dump(mode='json', by_alias=True) + json_str = json.dumps(json_data) + yield f"data: {json_str}\n\n".encode("utf-8") + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"Error in to_json conversion: {e}") + return + + return StreamingResponse( + to_json(message_stream_generator()), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Nginx + "X-Content-Type-Options": "nosniff", + "Access-Control-Allow-Origin": "*", # Adjust for your CORS needs + "Transfer-Encoding": "chunked", + }, + ) + except Exception as e: + logger.error(backstory_traceback.format_exc()) + logger.error(f"❌ Document upload error: {e}") + return StreamingResponse( + iter([json.dumps(ChatMessageError( + sessionId=MOCK_UUID, # No session ID for document uploads + content="Failed to generate skill assessment" + ).model_dump(mode='json', by_alias=True))]), + media_type="text/event-stream" + ) + @rate_limited(guest_per_minute=5, user_per_minute=30, admin_per_minute=100) @api_router.get("/candidates/{username}/chat-sessions") async def get_candidate_chat_sessions( diff --git a/src/backend/models.py b/src/backend/models.py index 3563ea4..563abd5 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -78,16 +78,6 @@ class ChatSenderType(str, Enum): WARNING = "warning" ERROR = "error" -class Requirements(BaseModel): - required: List[str] = Field(default_factory=list) - preferred: List[str] = Field(default_factory=list) - - @model_validator(mode='before') - def validate_requirements(cls, values): - if not isinstance(values, dict): - raise ValueError("Requirements must be a dictionary with 'required' and 'preferred' keys.") - return values - class SkillStatus(str, Enum): PENDING = "pending" COMPLETE = "complete" @@ -142,6 +132,7 @@ class ChatContextType(str, Enum): GENERAL = "general" GENERATE_PERSONA = "generate_persona" GENERATE_PROFILE = "generate_profile" + GENERATE_RESUME = "generate_resume" GENERATE_IMAGE = "generate_image" RAG_SEARCH = "rag_search" SKILL_MATCH = "skill_match" @@ -667,6 +658,16 @@ class GuestCleanupRequest(BaseModel): "populate_by_name": True } +class Requirements(BaseModel): + required: List[str] = Field(default_factory=list) + preferred: List[str] = Field(default_factory=list) + + @model_validator(mode='before') + def validate_requirements(cls, values): + if not isinstance(values, dict): + raise ValueError("Requirements must be a dictionary with 'required' and 'preferred' keys.") + return values + class JobRequirements(BaseModel): technical_skills: Requirements = Field(..., alias="technicalSkills") experience_requirements: Requirements = Field(..., alias="experienceRequirements") @@ -1010,6 +1011,13 @@ class ChatMessageSkillAssessment(ChatMessageUser): metadata: ChatMessageMetaData = Field(default=ChatMessageMetaData()) skill_assessment: SkillAssessment = Field(..., alias="skillAssessment") +class ChatMessageResume(ChatMessageUser): + role: ChatSenderType = ChatSenderType.ASSISTANT + metadata: ChatMessageMetaData = Field(default=ChatMessageMetaData()) + resume: str = Field(..., alias="resume") + system_prompt: Optional[str] = Field(None, alias="systemPrompt") + prompt: Optional[str] = Field(None, alias="prompt") + class GPUInfo(BaseModel): name: str memory: int diff --git a/src/backend/rag/rag.py b/src/backend/rag/rag.py index af7c5ce..0a6d3c1 100644 --- a/src/backend/rag/rag.py +++ b/src/backend/rag/rag.py @@ -391,8 +391,7 @@ class ChromaDBFileWatcher(FileSystemEventHandler): raise # Log diagnostics - logging.info(f"Input text: {text}") - logging.info(f"Embedding shape: {embedding.shape}, First 5 values: {embedding[:5]}") + logging.debug(f"Embedding shape: {embedding.shape}, First 5 values: {embedding[:5]}") # Check for invalid embeddings if embedding.size == 0 or np.any(np.isnan(embedding)) or np.any(np.isinf(embedding)): @@ -402,12 +401,12 @@ class ChromaDBFileWatcher(FileSystemEventHandler): # Check normalization norm = np.linalg.norm(embedding) is_normalized = np.allclose(norm, 1.0, atol=1e-3) - logging.info(f"Embedding norm: {norm}, Is normalized: {is_normalized}") + logging.debug(f"Embedding norm: {norm}, Is normalized: {is_normalized}") # Normalize if needed if not is_normalized: embedding = embedding / norm - logging.info("Embedding normalized manually.") + logging.debug("Embedding normalized manually.") return embedding