diff --git a/apps/backend/app/routers/resumes.py b/apps/backend/app/routers/resumes.py index 2fbbf35f9..fa98c47de 100644 --- a/apps/backend/app/routers/resumes.py +++ b/apps/backend/app/routers/resumes.py @@ -22,6 +22,8 @@ logger = logging.getLogger(__name__) from app.schemas import ( + ATSScore, + ATSSubScores, GenerateContentResponse, ImproveResumeConfirmRequest, ImproveResumeRequest, @@ -55,6 +57,7 @@ verify_diff_result, ) from app.services.refiner import refine_resume, calculate_keyword_match +from app.services.ats import compute_ats_score from app.schemas.refinement import RefinementConfig from app.services.cover_letter import ( generate_cover_letter, @@ -428,6 +431,43 @@ def _preserve_personal_info( return result, warnings +def _build_ats_score( + improved_data: dict[str, Any], + job_keywords: dict[str, Any], + refinement_result: Any, + refinement_successful: bool, +) -> ATSScore | None: + """Build ATSScore from refinement result and resume data.""" + try: + kw_analysis = ( + refinement_result.keyword_analysis + if refinement_successful and refinement_result is not None + else None + ) + final_match = ( + refinement_result.final_match_percentage + if refinement_successful and refinement_result is not None + else calculate_keyword_match(improved_data, job_keywords) + ) + ats_raw = compute_ats_score( + refined_resume=improved_data, + job_keywords=job_keywords, + keyword_match_percentage=final_match, + missing_keywords=kw_analysis.non_injectable_keywords if kw_analysis else [], + injectable_keywords=kw_analysis.injectable_keywords if kw_analysis else [], + ) + return ATSScore( + overall_score=ats_raw["overall_score"], + sub_scores=ATSSubScores(**ats_raw["sub_scores"]), + missing_keywords=ats_raw["missing_keywords"], + injectable_keywords=ats_raw["injectable_keywords"], + recommendations=ats_raw["recommendations"], + ) + except Exception as e: + logger.warning("ATS score computation failed", exc_info=True) + return None + + def _calculate_diff_from_resume( resume: dict[str, Any], improved_data: dict[str, Any], @@ -908,6 +948,7 @@ async def _improve_preview_flow( # Multi-pass refinement: keyword injection, AI phrase removal, alignment validation refinement_stats: RefinementStats | None = None + refinement_result = None refinement_attempted = False refinement_successful = False try: @@ -1016,6 +1057,12 @@ async def _improve_preview_flow( diff_summary=diff_summary, detailed_changes=detailed_changes, refinement_stats=refinement_stats, + ats_score=_build_ats_score( + improved_data, + job_keywords, + refinement_result, + refinement_successful, + ), warnings=response_warnings, refinement_attempted=refinement_attempted, refinement_successful=refinement_successful, @@ -1266,6 +1313,7 @@ async def improve_resume_endpoint( # Multi-pass refinement: keyword injection, AI phrase removal, alignment validation refinement_stats: RefinementStats | None = None + refinement_result = None refinement_attempted = False refinement_successful = False try: @@ -1402,6 +1450,12 @@ async def improve_resume_endpoint( diff_summary=diff_summary, detailed_changes=detailed_changes, refinement_stats=refinement_stats, + ats_score=_build_ats_score( + improved_data, + job_keywords, + refinement_result, + refinement_successful, + ), warnings=response_warnings, refinement_attempted=refinement_attempted, refinement_successful=refinement_successful, diff --git a/apps/backend/app/schemas/__init__.py b/apps/backend/app/schemas/__init__.py index 243de4be2..dfb03b41e 100644 --- a/apps/backend/app/schemas/__init__.py +++ b/apps/backend/app/schemas/__init__.py @@ -2,6 +2,8 @@ from app.schemas.models import ( AdditionalInfo, + ATSScore, + ATSSubScores, ApiKeyProviderStatus, ApiKeysUpdateRequest, ApiKeysUpdateResponse, @@ -95,6 +97,8 @@ "ResumeChange", "ResumeDiffSummary", "ResumeFieldDiff", + "ATSScore", + "ATSSubScores", "RefinementStats", "LLMConfigRequest", "LLMConfigResponse", diff --git a/apps/backend/app/schemas/models.py b/apps/backend/app/schemas/models.py index e66a4fb64..493e5e747 100644 --- a/apps/backend/app/schemas/models.py +++ b/apps/backend/app/schemas/models.py @@ -483,6 +483,47 @@ class ResumeDiffSummary(BaseModel): high_risk_changes: int # High-risk additions +class ATSSubScores(BaseModel): + """Individual component scores that make up the ATS overall score.""" + + keyword_match: float = Field( + default=0.0, ge=0.0, le=100.0, description="Keyword match % (0–100)" + ) + skills_coverage: float = Field( + default=0.0, ge=0.0, le=100.0, description="JD skills matched in resume (0–100)" + ) + section_completeness: float = Field( + default=0.0, + ge=0.0, + le=100.0, + description="Key resume sections present (0–100)", + ) + + +class ATSScore(BaseModel): + """ATS-style score breakdown for a resume against a job description.""" + + overall_score: float = Field( + default=0.0, + ge=0.0, + le=100.0, + description="Weighted composite ATS score (0–100)", + ) + sub_scores: ATSSubScores = Field(default_factory=ATSSubScores) + missing_keywords: list[str] = Field( + default_factory=list, + description="Job keywords absent from the tailored resume", + ) + injectable_keywords: list[str] = Field( + default_factory=list, + description="Missing keywords that exist in the master resume and can be safely added", + ) + recommendations: list[str] = Field( + default_factory=list, + description="Actionable suggestions to improve the ATS score", + ) + + class RefinementStats(BaseModel): """Statistics from the multi-pass refinement process.""" @@ -530,6 +571,9 @@ class ImproveResumeData(BaseModel): # Refinement metadata (multi-pass refinement stats) refinement_stats: "RefinementStats | None" = None + # ATS score breakdown + ats_score: "ATSScore | None" = None + # Warning and status fields for transparency warnings: list[str] = Field(default_factory=list) refinement_attempted: bool = False diff --git a/apps/backend/app/services/ats.py b/apps/backend/app/services/ats.py new file mode 100644 index 000000000..3d3dff2aa --- /dev/null +++ b/apps/backend/app/services/ats.py @@ -0,0 +1,217 @@ +"""ATS score computation utilities. + +Calculates an ATS-style breakdown score from already-processed resume and job data: + - keyword_match: final keyword match % from the refinement pipeline + - skills_coverage: overlap between resume technical skills and JD required skills + - section_completeness: presence of essential resume sections (local, no LLM) + +The overall_score is a weighted composite of the three sub-scores. +""" + +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + +# Weights must sum to 1.0 +_WEIGHTS = { + "keyword_match": 0.55, + "skills_coverage": 0.25, + "section_completeness": 0.20, +} + +# Patterns to detect resume section headings +_SECTION_PATTERNS = { + "summary": ["summary", "objective", "profile", "about"], + "experience": ["experience", "work history", "employment"], + "education": ["education", "academic", "degree"], + "skills": ["skills", "technologies", "competencies", "technical"], +} + + +def _extract_all_text(data: dict[str, Any]) -> str: + """Flatten all string values from a resume dict into a single text block.""" + parts: list[str] = [] + + def _walk(obj: Any) -> None: + if isinstance(obj, str): + parts.append(obj) + elif isinstance(obj, list): + for item in obj: + _walk(item) + elif isinstance(obj, dict): + for v in obj.values(): + _walk(v) + + _walk(data) + return " ".join(parts) + + +def _keyword_in_text(keyword: str, text_lower: str) -> bool: + """Whole-word match against pre-lowercased text to avoid false positives. + + Args: + keyword: The keyword to search for (will be lowercased internally). + text_lower: Full text that has already been lowercased by the caller. + """ + escaped = re.escape(keyword.strip().lower()) + if not escaped: + return False + return bool(re.search(rf"(? float: + """Return skills coverage score (0–100). + + Checks how many required_skills / preferred_skills from the JD appear + in the resume's technicalSkills list (falls back to full-text search). + """ + jd_skills: list[str] = [] + jd_skills.extend(job_keywords.get("required_skills", [])) + jd_skills.extend(job_keywords.get("preferred_skills", [])) + + if not jd_skills: + return 0.0 + + resume_skills: list[str] = ( + resume.get("additional", {}).get("technicalSkills", []) or [] + ) + resume_text = _extract_all_text(resume).lower() + resume_skills_lower = {s.lower() for s in resume_skills if isinstance(s, str)} + + matched = 0 + for skill in jd_skills: + if not isinstance(skill, str): + continue + skill_lower = skill.lower() + # Direct skill list match or whole-word text match (resume_text is pre-lowercased) + if skill_lower in resume_skills_lower or _keyword_in_text(skill, resume_text): + matched += 1 + + return min(100.0, (matched / len(jd_skills)) * 100) + + +def _compute_section_completeness(resume: dict[str, Any]) -> float: + """Return section completeness score (0–100). + + Checks the structured resume dict for the presence of key sections. + If no structured sections are detected, falls back to scanning all + extracted text for common section heading keywords. + """ + found = 0 + + # Structured-data fast path + if resume.get("summary"): + found += 1 + if resume.get("workExperience"): + found += 1 + if resume.get("education"): + found += 1 + skills = resume.get("additional", {}).get("technicalSkills", []) + if skills: + found += 1 + + # If none of the structured checks fired, fall back to text scanning + if found == 0: + text = _extract_all_text(resume).lower() + for patterns in _SECTION_PATTERNS.values(): + if any(p in text for p in patterns): + found += 1 + + total = len(_SECTION_PATTERNS) # 4 + return (found / total) * 100 + + +def _generate_recommendations( + keyword_score: float, + skills_score: float, + section_score: float, + missing_keywords: list[str], + injectable_keywords: list[str], +) -> list[str]: + tips: list[str] = [] + + if keyword_score < 60 and missing_keywords: + top = ", ".join(missing_keywords[:5]) + tips.append(f"Add these high-priority missing keywords: {top}.") + + if injectable_keywords: + top_injectable = ", ".join(injectable_keywords[:5]) + tips.append( + f"The following skills are in your master resume but not in this tailored version — consider adding them: {top_injectable}." + ) + + if skills_score < 60: + tips.append( + "Expand your Skills section to include more of the tools and technologies listed in the job description." + ) + + if section_score < 75: + tips.append( + "Make sure your resume includes all key sections: Summary, Work Experience, Education, and Skills." + ) + + if keyword_score >= 80 and skills_score >= 80: + tips.append( + "Strong keyword and skills alignment. Consider quantifying your achievements with metrics and numbers." + ) + + if not tips: + tips.append( + "Your resume is well-aligned with the job description. Review for any niche certifications or tools to add." + ) + + return tips + + +def compute_ats_score( + refined_resume: dict[str, Any], + job_keywords: dict[str, Any], + keyword_match_percentage: float, + missing_keywords: list[str], + injectable_keywords: list[str], +) -> dict[str, Any]: + """Compute the ATS score breakdown dict. + + Args: + refined_resume: The fully refined resume data dict. + job_keywords: Extracted JD keywords dict (required_skills, preferred_skills, …). + keyword_match_percentage: Final keyword match % from refiner.calculate_keyword_match. + missing_keywords: Keywords absent from the tailored resume (non-injectable). + injectable_keywords: Keywords absent but present in the master resume. + + Returns: + Dict with overall_score, sub_scores, missing_keywords, + injectable_keywords, and recommendations. + """ + kw_score = min(100.0, max(0.0, keyword_match_percentage)) + sk_score = _compute_skills_coverage(refined_resume, job_keywords) + sec_score = _compute_section_completeness(refined_resume) + + overall = ( + kw_score * _WEIGHTS["keyword_match"] + + sk_score * _WEIGHTS["skills_coverage"] + + sec_score * _WEIGHTS["section_completeness"] + ) + + return { + "overall_score": round(overall, 1), + "sub_scores": { + "keyword_match": round(kw_score, 1), + "skills_coverage": round(sk_score, 1), + "section_completeness": round(sec_score, 1), + }, + "missing_keywords": missing_keywords[:10], + "injectable_keywords": injectable_keywords[:10], + "recommendations": _generate_recommendations( + kw_score, + sk_score, + sec_score, + missing_keywords, + injectable_keywords, + ), + } diff --git a/apps/frontend/app/(default)/tailor/page.tsx b/apps/frontend/app/(default)/tailor/page.tsx index 35ec87c3d..b8ae1434d 100644 --- a/apps/frontend/app/(default)/tailor/page.tsx +++ b/apps/frontend/app/(default)/tailor/page.tsx @@ -19,6 +19,7 @@ import { useStatusCache } from '@/lib/context/status-cache'; import { Loader2, ArrowLeft, AlertTriangle, Settings } from 'lucide-react'; import { useTranslations } from '@/lib/i18n'; import { DiffPreviewModal } from '@/components/tailor/diff-preview-modal'; +import { ATSScoreCard } from '@/components/tailor/ats-score-card'; import { ConfirmDialog } from '@/components/ui/confirm-dialog'; export default function TailorPage() { @@ -456,6 +457,13 @@ export default function TailorPage() { + {/* ATS Score Breakdown — shown once a preview result is available */} + {pendingResult?.data?.ats_score && ( +
+ +
+ )} + {/* Diff preview modal */} {showDiffModal && pendingResult && ( = { + keyword_match: 'Keyword Match', + skills_coverage: 'Skills Coverage', + section_completeness: 'Section Completeness', +}; + +function scoreColor(value: number): string { + if (value >= 80) return 'text-green-400'; + if (value >= 60) return 'text-yellow-400'; + return 'text-red-400'; +} + +function barColor(value: number): string { + if (value >= 80) return 'bg-green-500'; + if (value >= 60) return 'bg-yellow-500'; + return 'bg-red-500'; +} + +function clampWidth(value: number): number { + return Number.isFinite(value) ? Math.min(Math.max(value, 0), 100) : 0; +} + +function SubScoreRow({ label, value }: { label: string; value: number }) { + return ( +
+
+ {label} + + {Number.isFinite(value) ? value.toFixed(1) : '—'}% + +
+
+
+
+
+ ); +} + +export function ATSScoreCard({ atsScore }: ATSScoreCardProps) { + const { overall_score, sub_scores, missing_keywords, injectable_keywords, recommendations } = + atsScore; + + return ( +
+ {/* Header */} +
+

ATS Score Breakdown

+
+ + {overall_score.toFixed(1)} + + /100 +
+
+ + {/* Overall bar */} +
+
+
+ + {/* Sub-score breakdown */} +
+ {Object.entries(sub_scores).map(([key, value]) => ( + + ))} +
+ + {/* Missing keywords */} + {missing_keywords.length > 0 && ( +
+

+ Missing Keywords +

+
+ {missing_keywords.map((kw, i) => ( + + {kw} + + ))} +
+
+ )} + + {/* Injectable keywords */} + {injectable_keywords.length > 0 && ( +
+

+ Safe to Add (in your master resume) +

+
+ {injectable_keywords.map((kw, i) => ( + + {kw} + + ))} +
+
+ )} + + {/* Recommendations */} + {recommendations.length > 0 && ( +
+

+ Recommendations +

+
    + {recommendations.map((tip, i) => ( +
  • + + {tip} +
  • + ))} +
+
+ )} +
+ ); +}