Spaces:
Running
Running
| from __future__ import annotations | |
| import json | |
| import logging | |
| from typing import Any | |
| from src.core.config import get_llm_client, get_settings | |
| from src.core.models import ( | |
| MatchRecommendation, | |
| MatchResult, | |
| Profile, | |
| Rationale, | |
| SkillDetail, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| class RationaleGenerator: | |
| def __init__(self) -> None: | |
| self._client = None | |
| settings = get_settings() | |
| self.model = settings.openai_model | |
| def client(self): | |
| if self._client is None: | |
| try: | |
| self._client = get_llm_client() | |
| except Exception: | |
| logger.warning("LLM client unavailable for rationale generator") | |
| self._client = None | |
| return self._client | |
| async def generate( | |
| self, match: MatchResult, profile: Profile, job_requirements: dict, | |
| ) -> Rationale: | |
| try: | |
| if self.client is None: | |
| raise RuntimeError("LLM client unavailable") | |
| prompt = self._build_prompt(match, profile, job_requirements) | |
| from langchain_core.messages import HumanMessage | |
| messages = [ | |
| HumanMessage( | |
| content="Generate a candidate evaluation report for a recruiter. " | |
| f"Output valid JSON only.\n\n{prompt}" | |
| ), | |
| ] | |
| response = await self.client.ainvoke(messages) | |
| content = response.content if hasattr(response, "content") else str(response) | |
| return self._parse_response(content) | |
| except Exception as e: | |
| logger.warning(f"Rationale LLM failed, using template: {e}") | |
| return self._template_rationale(match, profile) | |
| async def generate_batch( | |
| self, matches: list[MatchResult], profiles: dict[str, Profile], | |
| job_requirements: dict[str, Any], | |
| ) -> list[Rationale]: | |
| results: list[Rationale] = [] | |
| for m in matches: | |
| profile = profiles.get(m.profile_id) | |
| if profile is None: | |
| continue | |
| rationale = await self.generate(m, profile, job_requirements) | |
| results.append(rationale) | |
| return results | |
| def _build_prompt( | |
| self, match: MatchResult, profile: Profile, job_requirements: dict, | |
| ) -> str: | |
| skills_text = ", ".join(s.name for s in profile.skills) | |
| return ( | |
| f"Candidate: {match.name}\n" | |
| f"Title: {match.current_title or 'N/A'}\n" | |
| f"Company: {match.current_company or 'N/A'}\n" | |
| f"Experience: {match.experience_years or 0} years\n" | |
| f"Skills: {skills_text}\n" | |
| f"Location: {match.location or 'N/A'}\n" | |
| f"Overall Score: {match.scores.overall:.2f}\n" | |
| f"Skill Match: {match.scores.skill_match:.2f}\n" | |
| f"Experience Match: {match.scores.experience_match:.2f}\n" | |
| f"Matched Skills: {', '.join(match.matched_skills)}\n" | |
| f"Missing Skills: {', '.join(match.missing_skills)}\n" | |
| ) | |
| def _parse_response(self, response: str) -> Rationale: | |
| try: | |
| data = json.loads(response) | |
| return Rationale( | |
| summary=data.get("summary", ""), | |
| strengths=data.get("strengths", []), | |
| gaps=data.get("gaps", []), | |
| skill_details=[ | |
| SkillDetail(**sd) for sd in data.get("skill_details", []) | |
| ], | |
| experience_analysis=data.get("experience_analysis", ""), | |
| recommendation=MatchRecommendation(data.get("recommendation", "good_match")), | |
| ) | |
| except (json.JSONDecodeError, TypeError, ValueError): | |
| return Rationale(summary="Could not parse rationale response from LLM.") | |
| def _template_rationale(self, match: MatchResult, profile: Profile | None) -> Rationale: | |
| matched = set(match.matched_skills) | |
| missing = set(match.missing_skills) | |
| skill_details = [ | |
| SkillDetail( | |
| skill=s, | |
| required=True, | |
| found=s in matched, | |
| proficiency_match=s in matched, | |
| evidence=s in matched and "Found in profile" or "Not found in profile", | |
| ) | |
| for s in list(matched) + list(missing) | |
| ] | |
| strengths = [] | |
| if matched: | |
| top5 = ", ".join(list(matched)[:5]) | |
| strengths.append(f"Matches {len(matched)} required skills: {top5}") | |
| if match.experience_years and match.scores.experience_match >= 0.6: | |
| yrs = match.experience_years | |
| strengths.append(f"Experience level ({yrs} years) meets requirements") | |
| if match.current_company: | |
| strengths.append(f"Currently at {match.current_company}") | |
| gaps = [] | |
| if missing: | |
| gaps.append(f"Missing {len(missing)} required skills: {', '.join(list(missing)[:5])}") | |
| if match.scores.experience_match < 0.5 and match.experience_years: | |
| gaps.append(f"Experience ({match.experience_years} years) may be insufficient") | |
| if match.scores.overall >= 0.7: | |
| recommendation = MatchRecommendation.STRONG | |
| elif match.scores.overall >= 0.5: | |
| recommendation = MatchRecommendation.GOOD | |
| elif match.scores.overall >= 0.3: | |
| recommendation = MatchRecommendation.POTENTIAL | |
| else: | |
| recommendation = MatchRecommendation.WEAK | |
| return Rationale( | |
| summary=( | |
| f"{match.name} is a{'n' if recommendation == MatchRecommendation.STRONG else ''} " | |
| f"{recommendation.value.replace('_', ' ')} " | |
| f"with {len(matched)} matching skills " | |
| f"and {match.experience_years or 0} years of experience." | |
| ), | |
| strengths=strengths, | |
| gaps=gaps, | |
| skill_details=skill_details, | |
| experience_analysis=( | |
| f"Candidate has {match.experience_years or 0} years of experience" | |
| f"{' at ' + match.current_company if match.current_company else ''}." | |
| ), | |
| recommendation=recommendation, | |
| ) | |