Spaces:
Sleeping
Sleeping
| """ | |
| 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, | |
| } | |
| 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) | |
| class SectionNote: | |
| section: str | |
| status: str # "strong" | "ok" | "weak" | "missing" | |
| note: str | |
| score: int # 0β100 heuristic | |
| 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 | |