Malware-detection / app /services /ats_scoring.py
kumar-aditya's picture
Upload 60 files
c8f4a46 verified
Raw
History Blame Contribute Delete
8.81 kB
"""
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