""" ATS scoring engine. Score weights (from PRD §9.2): keyword_coverage 30% semantic_similarity 25% skills_overlap 20% experience_alignment 15% resume_quality 10% Each component is normalised to 0–100 before weighting. """ from __future__ import annotations import time from dataclasses import dataclass from app.services.keyword_extractor import KeywordResult, extract_keywords from app.services.resume_parser import ParsedResume, parse_resume from app.services.embedding_service import compute_similarity WEIGHTS = { "keyword_coverage": 0.30, "semantic_similarity": 0.25, "skills_overlap": 0.20, "experience_alignment": 0.15, "resume_quality": 0.10, } @dataclass class ComponentScores: keyword_coverage: int # 0–100 semantic_similarity: int # 0–100 skills_overlap: int # 0–100 experience_alignment: int # 0–100 resume_quality: int # 0–100 def overall(self) -> int: raw = ( self.keyword_coverage * WEIGHTS["keyword_coverage"] + self.semantic_similarity * WEIGHTS["semantic_similarity"] + self.skills_overlap * WEIGHTS["skills_overlap"] + self.experience_alignment * WEIGHTS["experience_alignment"] + self.resume_quality * WEIGHTS["resume_quality"] ) return round(raw) @dataclass class SectionNote: section: str status: str # "strong" | "ok" | "weak" | "missing" note: str score: int # 0–100 heuristic @dataclass class ATSResult: overall_score: int components: ComponentScores keywords: KeywordResult parsed: ParsedResume section_notes: list[SectionNote] latency_ms: int def run_ats_scoring( resume_text: str, jd_text: str, target_role: str = "", ) -> ATSResult: """Orchestrate the full scoring pipeline.""" t0 = time.perf_counter() # ── 1. Parse ────────────────────────────────────────────────────────── parsed = parse_resume(resume_text) # ── 2. Keyword extraction ───────────────────────────────────────────── kw = extract_keywords(resume_text, jd_text) # ── 3. Compute component scores ─────────────────────────────────────── components = _compute_components(parsed, kw, resume_text, jd_text) # ── 4. Section notes ────────────────────────────────────────────────── section_notes = _build_section_notes(parsed, kw, components) overall = components.overall() latency_ms = round((time.perf_counter() - t0) * 1000) return ATSResult( overall_score=overall, components=components, keywords=kw, parsed=parsed, section_notes=section_notes, latency_ms=latency_ms, ) # ── Component scorers ───────────────────────────────────────────────────── def _compute_components( parsed: ParsedResume, kw: KeywordResult, resume_text: str, jd_text: str, ) -> ComponentScores: # Keyword coverage: matched / total JD keywords total_jd = len(kw.jd_keywords) kw_score = round((len(kw.matched) / max(total_jd, 1)) * 100) # Semantic similarity: cosine via embeddings (0–1 → 0–100) sem_raw = compute_similarity(resume_text, jd_text) sem_score = round(sem_raw * 100) # Skills overlap: resume skills ∩ JD keywords / JD keywords res_skill_set = {s.lower() for s in parsed.skills_list} jd_kw_set = {k.lower() for k in kw.jd_keywords} overlap = len(res_skill_set & jd_kw_set) skills_score = round((overlap / max(len(jd_kw_set), 1)) * 100) # Boost if resume has a skills section at all if parsed.skills_list: skills_score = min(100, skills_score + 10) # Experience alignment: heuristics on bullets & action verbs exp_score = _score_experience(parsed) # Resume quality: action verbs, quantification, section coverage qual_score = _score_quality(parsed) return ComponentScores( keyword_coverage=min(100, kw_score), semantic_similarity=min(100, sem_score), skills_overlap=min(100, skills_score), experience_alignment=min(100, exp_score), resume_quality=min(100, qual_score), ) def _score_experience(parsed: ParsedResume) -> int: """Heuristic score for experience depth.""" score = 30 # base — they have some text if not parsed.experience_raw: return 20 bullets = parsed.experience_bullets # Each bullet up to 8 is worth points score += min(len(bullets), 8) * 4 # up to +32 score += min(parsed.action_verb_count, 6) * 3 # up to +18 score += min(parsed.quantified_bullet_count, 4) * 5 # up to +20 return min(100, score) def _score_quality(parsed: ParsedResume) -> int: """Resume quality signals.""" score = 20 sections_present = sum([ bool(parsed.contact), bool(parsed.summary), bool(parsed.skills_raw), bool(parsed.experience_raw), bool(parsed.projects_raw), bool(parsed.education_raw), bool(parsed.certifications_raw), ]) score += sections_present * 8 # up to +56 score += min(parsed.action_verb_count, 3) * 4 # up to +12 score += min(parsed.quantified_bullet_count, 3) * 4 # up to +12 return min(100, score) # ── Section notes ───────────────────────────────────────────────────────── def _build_section_notes( parsed: ParsedResume, kw: KeywordResult, comp: ComponentScores, ) -> list[SectionNote]: notes: list[SectionNote] = [] def _add(section: str, present: bool, strong_note: str, weak_note: str, missing_note: str, score: int) -> None: if not present: notes.append(SectionNote(section, "missing", missing_note, max(0, score - 30))) elif score >= 70: notes.append(SectionNote(section, "strong", strong_note, score)) elif score >= 45: notes.append(SectionNote(section, "ok", weak_note, score)) else: notes.append(SectionNote(section, "weak", weak_note, score)) _add( "Contact", bool(parsed.contact), "Name, email, and links detected.", "Contact block found but may be missing phone or LinkedIn.", "No contact information detected.", 90 if parsed.contact else 0, ) _add( "Summary", bool(parsed.summary), "Professional summary present.", "Summary found but consider aligning it more tightly to the role.", "No summary or objective detected — consider adding one.", 70 if parsed.summary else 0, ) skills_score = comp.skills_overlap _add( "Skills", bool(parsed.skills_list), f"{len(parsed.skills_list)} skills detected; good overlap with JD.", f"{len(parsed.skills_list)} skills detected; {len(kw.missing)} JD keywords missing.", "No skills section detected.", skills_score, ) exp_score = comp.experience_alignment _add( "Experience", bool(parsed.experience_raw), f"{len(parsed.experience_bullets)} bullets; {parsed.quantified_bullet_count} quantified.", f"{len(parsed.experience_bullets)} bullets found; add measurable outcomes.", "No experience section detected.", exp_score, ) _add( "Projects", bool(parsed.projects_raw), "Projects section with relevant work detected.", "Projects present but could be better aligned to JD requirements.", "No projects section — strongly recommended for student profiles.", 75 if parsed.projects_raw else 0, ) _add( "Education", bool(parsed.education_raw), "Education section parsed cleanly.", "Education detected; ensure GPA/honours included if relevant.", "No education section detected.", 80 if parsed.education_raw else 0, ) _add( "Certifications", bool(parsed.certifications_raw), "Certifications listed — strong ATS signal.", "Certifications present.", "No certifications — consider adding role-relevant ones.", 85 if parsed.certifications_raw else 0, ) return notes