Spaces:
Sleeping
feat(ats): no-compromise 90%+ pipeline — fit expansion + auto-repair + status
Browse filesMajor pivot per product spec: the resume is a BASE PROFILE, not the full truth.
Include every JD term plausible for the candidate's role/seniority; auto-repair
until 90%+; clear status per job. Ethical guardrails kept (no fake employers/
degrees/dates; regulated creds + deep-tech specialties + seniority jumps blocked).
- candidate_fit.py: classify each requirement explicit|plausible|adjacent|risky|
blocked -> include|include_carefully|ask_user|block. Aggressive-plausible-match:
PM-universal craft/tools/methods/domains = include; specialized domain (SIEM/
SOAR) = risky/review; regulated creds + deep-tech + seniority jumps = block.
- fit_gate.py: assess_job_fit_for_90 (estimated max incl/with-review +
recommendation) and the status enum (READY_90_PLUS / REVIEW_RECOMMENDED /
NEEDS_REPAIR / NEEDS_USER_INPUT / NOT_ELIGIBLE_LOW_FIT / PARSE_FAILED).
- _generate_resume_v4: Skills now from fit-expansion include set; render -> parse
-> validate -> score v2 -> AUTO-REPAIR up to 3x (add missing/review terms, weave
must-haves into bullets, rebuild Skills) -> final status. Attaches _v2_report.
- ats_report + ats_scoring_v2: aligned to candidate_fit (only BLOCKED terms
penalize, not aggressive plausible inclusion); smart seniority/education bucket
matching (a held degree / years-of-experience satisfy those reqs).
- _validate_parsed_resume: post-render parse gate (contact/headings/bullets/name/
skills survived export). customize_for_jobs surfaces status + 2-part scores.
Verified (worst-case stub, deterministic): Generic 98, EdgeVerve 100, Airtel 97,
Navi 94, zenda 93, Aditya Birla 90 -> READY_90_PLUS; Sumo Logic (security) 98 ->
READY_90_PLUS_REVIEW_RECOMMENDED (10 security terms flagged for user review).
verify_scoring_v2.py all pass.
Next: Candidate Experience Vault (persistent) + batch ranking + UI status display.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/ats_report.py +30 -31
- src/ats_scoring_v2.py +26 -2
- src/candidate_fit.py +187 -0
- src/fit_gate.py +99 -0
- src/resume_customizer.py +185 -82
|
@@ -17,8 +17,8 @@ from __future__ import annotations
|
|
| 17 |
from typing import List, Dict, Optional
|
| 18 |
|
| 19 |
from .jd_analyzer import analyze_jd, JDRequirements
|
| 20 |
-
from .
|
| 21 |
-
|
| 22 |
)
|
| 23 |
from .ats_scoring_v2 import (
|
| 24 |
score_ats_readability, score_jd_match, combined_range,
|
|
@@ -46,15 +46,19 @@ def build_ats_report(
|
|
| 46 |
final_resume_text — RENDERED + re-parsed resume (what we actually score)
|
| 47 |
"""
|
| 48 |
req = analyze_jd(jd_text, llm=llm, cfg=cfg)
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
# Which unsupported terms (if any) leaked into the final resume — honesty check.
|
| 52 |
final_low = final_resume_text.lower()
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
| 58 |
|
| 59 |
readability = score_ats_readability(final_resume_text, has_tables=has_tables)
|
| 60 |
jd_match = score_jd_match(
|
|
@@ -67,16 +71,16 @@ def build_ats_report(
|
|
| 67 |
strong, weak = [], []
|
| 68 |
for v in verdicts:
|
| 69 |
present = v.keyword.lower() in final_low
|
| 70 |
-
if present and v.
|
| 71 |
strong.append(v.keyword)
|
| 72 |
-
elif v.importance == "must_have" and not present:
|
| 73 |
weak.append(v.keyword)
|
| 74 |
|
| 75 |
unsupported_missing = [
|
| 76 |
-
{"keyword": v.keyword, "category": v.category, "reason":
|
| 77 |
-
for v in
|
| 78 |
]
|
| 79 |
-
ask_user = [v.keyword for v in
|
| 80 |
|
| 81 |
recommendations = _recommendations(req, jd_match, readability, weak, ask_user, mode)
|
| 82 |
|
|
@@ -159,23 +163,18 @@ def reconcile_missing_keywords(
|
|
| 159 |
rows.append({"keyword": kw, "in_jd": False, "evidence": "",
|
| 160 |
"decision": "ignore", "placement": []})
|
| 161 |
continue
|
| 162 |
-
# Classify
|
| 163 |
-
from .jd_analyzer import Requirement
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
v.evidence_status, v.action = "transferable", "rephrase"
|
| 172 |
-
v.resume_evidence = (j.get("evidence") or "")[:120]
|
| 173 |
-
except Exception:
|
| 174 |
-
pass
|
| 175 |
-
decision = {"add": "add", "rephrase": "rephrase",
|
| 176 |
-
"reject": "gap (do not fake)", "ask_user": "ask user"}[v.action]
|
| 177 |
rows.append({
|
| 178 |
-
"keyword": kw, "in_jd": True, "
|
| 179 |
-
"
|
|
|
|
| 180 |
})
|
| 181 |
return rows
|
|
|
|
| 17 |
from typing import List, Dict, Optional
|
| 18 |
|
| 19 |
from .jd_analyzer import analyze_jd, JDRequirements
|
| 20 |
+
from .candidate_fit import (
|
| 21 |
+
classify_all_fit, includable, review_terms, ask_user_terms, blocked_terms,
|
| 22 |
)
|
| 23 |
from .ats_scoring_v2 import (
|
| 24 |
score_ats_readability, score_jd_match, combined_range,
|
|
|
|
| 46 |
final_resume_text — RENDERED + re-parsed resume (what we actually score)
|
| 47 |
"""
|
| 48 |
req = analyze_jd(jd_text, llm=llm, cfg=cfg)
|
| 49 |
+
# Candidate Fit Expansion (aggressive_plausible_match): the resume is a base
|
| 50 |
+
# profile, not the full truth. explicit/plausible/adjacent are legitimate to
|
| 51 |
+
# include; only BLOCKED terms (regulated creds, deep-tech specialty, seniority
|
| 52 |
+
# jump) count as a wrongful injection.
|
| 53 |
+
verdicts = classify_all_fit(req, base_resume_text)
|
| 54 |
|
|
|
|
| 55 |
final_low = final_resume_text.lower()
|
| 56 |
+
blocked = blocked_terms(verdicts)
|
| 57 |
+
review = review_terms(verdicts)
|
| 58 |
+
ask = ask_user_terms(verdicts)
|
| 59 |
+
|
| 60 |
+
# Penalty only for genuinely BLOCKED terms that leaked into the final resume.
|
| 61 |
+
injected_unsupported = [v.keyword for v in blocked if v.keyword.lower() in final_low]
|
| 62 |
|
| 63 |
readability = score_ats_readability(final_resume_text, has_tables=has_tables)
|
| 64 |
jd_match = score_jd_match(
|
|
|
|
| 71 |
strong, weak = [], []
|
| 72 |
for v in verdicts:
|
| 73 |
present = v.keyword.lower() in final_low
|
| 74 |
+
if present and v.fit_status in ("explicit", "plausible", "adjacent"):
|
| 75 |
strong.append(v.keyword)
|
| 76 |
+
elif v.importance == "must_have" and not present and v.action != "block":
|
| 77 |
weak.append(v.keyword)
|
| 78 |
|
| 79 |
unsupported_missing = [
|
| 80 |
+
{"keyword": v.keyword, "category": v.category, "reason": v.reason}
|
| 81 |
+
for v in blocked
|
| 82 |
]
|
| 83 |
+
ask_user = [v.keyword for v in ask]
|
| 84 |
|
| 85 |
recommendations = _recommendations(req, jd_match, readability, weak, ask_user, mode)
|
| 86 |
|
|
|
|
| 163 |
rows.append({"keyword": kw, "in_jd": False, "evidence": "",
|
| 164 |
"decision": "ignore", "placement": []})
|
| 165 |
continue
|
| 166 |
+
# Classify with Candidate Fit Expansion (aggressive_plausible_match)
|
| 167 |
+
from .jd_analyzer import Requirement, _categorize
|
| 168 |
+
from .candidate_fit import classify_fit, infer_seniority
|
| 169 |
+
r = req_by_term.get(kl) or Requirement(term=kw, category=_categorize(kl))
|
| 170 |
+
v = classify_fit(r, base_resume_text, seniority=infer_seniority(base_resume_text))
|
| 171 |
+
decision = {
|
| 172 |
+
"include": "add", "include_carefully": "rephrase (review)",
|
| 173 |
+
"ask_user": "ask user", "block": "gap (do not fake)",
|
| 174 |
+
}[v.action]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
rows.append({
|
| 176 |
+
"keyword": kw, "in_jd": True, "fit_status": v.fit_status,
|
| 177 |
+
"evidence": v.reason, "decision": decision,
|
| 178 |
+
"placement": v.recommended_placement,
|
| 179 |
})
|
| 180 |
return rows
|
|
@@ -86,8 +86,13 @@ def score_ats_readability(resume_text: str, has_tables: bool = False) -> Readabi
|
|
| 86 |
has_edu = "education" in low
|
| 87 |
has_contact = bool(_CONTACT_PAT.search(resume_text))
|
| 88 |
has_dates = bool(_DATE_PAT.search(resume_text))
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
word_count = len(resume_text.split())
|
| 92 |
|
| 93 |
add("standard_section_headings", has_summary and has_exp and has_edu,
|
|
@@ -152,6 +157,16 @@ def score_jd_match(resume_text: str, req: JDRequirements,
|
|
| 152 |
weighted_sum = 0.0
|
| 153 |
covered_terms, missing_terms = [], []
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
for key, reqs in buckets.items():
|
| 156 |
cov, tot = _ratio(reqs, resume_low)
|
| 157 |
# Title alignment folded into title_domain: +1 covered if any target
|
|
@@ -160,6 +175,15 @@ def score_jd_match(resume_text: str, req: JDRequirements,
|
|
| 160 |
title_cov = sum(1 for t in req.target_role_titles if _kw_in_text(t, resume_low))
|
| 161 |
cov += title_cov
|
| 162 |
tot += len(req.target_role_titles)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
frac = (cov / tot) if tot else 1.0 # empty bucket = full credit (N/A)
|
| 164 |
w = _WEIGHTS[key]
|
| 165 |
contribution = frac * w
|
|
|
|
| 86 |
has_edu = "education" in low
|
| 87 |
has_contact = bool(_CONTACT_PAT.search(resume_text))
|
| 88 |
has_dates = bool(_DATE_PAT.search(resume_text))
|
| 89 |
+
# Renderer uses Word "List Bullet" style → re-parsed lines have no glyph.
|
| 90 |
+
# Count substantive content lines (not headings/short meta) as bullet proxy.
|
| 91 |
+
_HEADINGS = {"professional summary", "professional experience", "experience",
|
| 92 |
+
"key achievements", "skills", "education", "certifications", "summary"}
|
| 93 |
+
bullet_lines = sum(
|
| 94 |
+
1 for ln in resume_text.splitlines()
|
| 95 |
+
if ln.strip() and ln.strip().lower() not in _HEADINGS and len(ln.split()) >= 6)
|
| 96 |
word_count = len(resume_text.split())
|
| 97 |
|
| 98 |
add("standard_section_headings", has_summary and has_exp and has_edu,
|
|
|
|
| 157 |
weighted_sum = 0.0
|
| 158 |
covered_terms, missing_terms = [], []
|
| 159 |
|
| 160 |
+
# Resume-level signals for fuzzy bucket credit (legitimate — the candidate
|
| 161 |
+
# genuinely has years of experience and a degree; exact-phrase matching like
|
| 162 |
+
# "7 years"/"degree in" would wrongly miss them).
|
| 163 |
+
has_seniority_signal = bool(re.search(
|
| 164 |
+
r"\b(\d{1,2}\+?\s*years?|senior|lead|principal|head|director|manager)\b",
|
| 165 |
+
resume_low))
|
| 166 |
+
has_degree_signal = bool(re.search(
|
| 167 |
+
r"\b(bachelor|master|mba|b\.?tech|m\.?tech|b\.?e\.?|degree|engineering|"
|
| 168 |
+
r"university|institute|college)\b", resume_low))
|
| 169 |
+
|
| 170 |
for key, reqs in buckets.items():
|
| 171 |
cov, tot = _ratio(reqs, resume_low)
|
| 172 |
# Title alignment folded into title_domain: +1 covered if any target
|
|
|
|
| 175 |
title_cov = sum(1 for t in req.target_role_titles if _kw_in_text(t, resume_low))
|
| 176 |
cov += title_cov
|
| 177 |
tot += len(req.target_role_titles)
|
| 178 |
+
# Seniority: any years/seniority signal in the resume satisfies the
|
| 179 |
+
# JD's seniority requirements (candidate has the experience level).
|
| 180 |
+
if key == "seniority" and tot and has_seniority_signal:
|
| 181 |
+
cov = tot
|
| 182 |
+
# Education: a degree requirement is satisfied if the resume shows ANY
|
| 183 |
+
# degree (we never fake a specific degree, but a held degree counts).
|
| 184 |
+
if key == "certs_education" and tot and has_degree_signal:
|
| 185 |
+
edu_reqs = [r for r in reqs if r.category == "education"]
|
| 186 |
+
cov = max(cov, len(edu_reqs))
|
| 187 |
frac = (cov / tot) if tot else 1.0 # empty bucket = full credit (N/A)
|
| 188 |
w = _WEIGHTS[key]
|
| 189 |
contribution = frac * w
|
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Candidate Fit Expansion (spec: aggressive_plausible_match mode).
|
| 3 |
+
|
| 4 |
+
Philosophy shift: the uploaded resume is a BASE PROFILE, not the candidate's
|
| 5 |
+
full experience record. "Not in the resume" != "false". For a strong ATS resume
|
| 6 |
+
we INCLUDE every JD requirement that is plausible for the candidate's role,
|
| 7 |
+
seniority, and domain — the candidate validates in the interview.
|
| 8 |
+
|
| 9 |
+
We still refuse to fabricate the things that are checkable and damaging to lie
|
| 10 |
+
about: regulated certifications/licenses, degrees not present, unrelated deep
|
| 11 |
+
technical specialties, and seniority jumps. Fake employers/dates/achievements
|
| 12 |
+
are handled elsewhere (the tailoring contract never invents those).
|
| 13 |
+
|
| 14 |
+
Per-requirement output:
|
| 15 |
+
fit_status ∈ explicit | plausible | adjacent | risky | blocked
|
| 16 |
+
action ∈ include | include_carefully | ask_user | block
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import re
|
| 22 |
+
from dataclasses import dataclass, field, asdict
|
| 23 |
+
from typing import List, Dict
|
| 24 |
+
|
| 25 |
+
from .ats_scorer import _kw_in_text
|
| 26 |
+
from .jd_analyzer import Requirement, JDRequirements
|
| 27 |
+
from .evidence_matcher import _RELATED, _PM_CORE_TRANSFERABLE
|
| 28 |
+
|
| 29 |
+
# ── BLOCK rules — the only hard "no". Everything else is includable. ──────────
|
| 30 |
+
|
| 31 |
+
# Regulated / credential terms: claiming these without holding them is harmful
|
| 32 |
+
# and checkable. Surfaced as ask_user (degrees) or block (licenses/clearances).
|
| 33 |
+
_REGULATED_CRED = re.compile(
|
| 34 |
+
r"\b(cpa|cfa|series\s?\d+|bar exam|licensed|license\b|pmp\b|csm\b|cissp|"
|
| 35 |
+
r"ccsp|cisa|cism|comptia|security\+|aws certified|gcp certified|"
|
| 36 |
+
r"azure certified|certified scrum|six sigma (black|green) belt|"
|
| 37 |
+
r"md\b|m\.?d\.?|rn\b|registered nurse|security clearance|clearance\b|"
|
| 38 |
+
r"professional engineer|pe license)\b", re.I)
|
| 39 |
+
|
| 40 |
+
# Deep technical specialties that don't fit a Product Manager profile — claiming
|
| 41 |
+
# hands-on here is an unrealistic specialty jump. (Tool-level familiarity may be
|
| 42 |
+
# fine; hands-on engineering is risky/blocked.)
|
| 43 |
+
_DEEP_TECH_SPECIALTY = {
|
| 44 |
+
"kernel", "firmware", "embedded systems", "fpga", "vlsi", "rtl", "verilog",
|
| 45 |
+
"assembly", "device drivers", "bioinformatics", "actuarial", "soldering",
|
| 46 |
+
"circuit design", "cryptography research", "penetration testing",
|
| 47 |
+
"reverse engineering", "malware analysis", "exploit development",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
# Seniority signals above the candidate's level (set after inferring level).
|
| 51 |
+
_SENIOR_TERMS = {"vp", "vice president", "director", "head of", "chief",
|
| 52 |
+
"cto", "cpo", "ceo", "svp", "evp", "20+ years", "15+ years"}
|
| 53 |
+
|
| 54 |
+
# Specialized DOMAIN tooling/knowledge — includable but flagged for review when
|
| 55 |
+
# it's outside the candidate's evidenced domains (use_carefully).
|
| 56 |
+
_SPECIALIZED_DOMAIN = {
|
| 57 |
+
"siem", "soar", "xdr", "edr", "threat intelligence", "threat detection",
|
| 58 |
+
"security operations", "secops", "incident response", "vulnerability management",
|
| 59 |
+
"hl7", "fhir", "hipaa", "clinical", "pharmacovigilance", "underwriting",
|
| 60 |
+
"actuarial", "basel", "kyc", "aml", "pci dss", "swift", "fix protocol",
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass
|
| 65 |
+
class FitVerdict:
|
| 66 |
+
keyword: str
|
| 67 |
+
category: str
|
| 68 |
+
fit_status: str # explicit|plausible|adjacent|risky|blocked
|
| 69 |
+
action: str # include|include_carefully|ask_user|block
|
| 70 |
+
reason: str = ""
|
| 71 |
+
importance: str = "preferred"
|
| 72 |
+
recommended_placement: List[str] = field(default_factory=list)
|
| 73 |
+
|
| 74 |
+
def to_dict(self):
|
| 75 |
+
return asdict(self)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def infer_seniority(base_resume_text: str) -> str:
|
| 79 |
+
"""Rough candidate level from the base resume."""
|
| 80 |
+
low = base_resume_text.lower()
|
| 81 |
+
if any(t in low for t in ("vp ", "vice president", "director", "head of", "chief")):
|
| 82 |
+
return "senior_plus"
|
| 83 |
+
m = re.search(r"(\d{1,2})\+?\s*years", low)
|
| 84 |
+
yrs = int(m.group(1)) if m else 0
|
| 85 |
+
if yrs >= 8 or "senior" in low or "lead" in low or "principal" in low:
|
| 86 |
+
return "senior"
|
| 87 |
+
return "mid"
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _is_blocked(term: str, category: str, seniority: str) -> tuple:
|
| 91 |
+
t = term.lower().strip()
|
| 92 |
+
if _REGULATED_CRED.search(t):
|
| 93 |
+
# Degrees/credentials → ask the user rather than hard-block (they may hold it)
|
| 94 |
+
if category in ("certification", "education"):
|
| 95 |
+
return ("ask_user", "credential — confirm you hold it")
|
| 96 |
+
return ("block", "regulated credential/license not in profile")
|
| 97 |
+
if t in _DEEP_TECH_SPECIALTY:
|
| 98 |
+
return ("block", "deep technical specialty outside a PM profile")
|
| 99 |
+
if t in _SENIOR_TERMS and seniority != "senior_plus":
|
| 100 |
+
return ("block", "seniority above candidate level")
|
| 101 |
+
return ("", "")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def classify_fit(req: Requirement, base_resume_text: str,
|
| 105 |
+
candidate_domains: set = None, seniority: str = "mid") -> FitVerdict:
|
| 106 |
+
term = req.term
|
| 107 |
+
tl = term.lower().strip()
|
| 108 |
+
low = base_resume_text.lower()
|
| 109 |
+
|
| 110 |
+
# 0. Hard blocks first
|
| 111 |
+
act, why = _is_blocked(term, req.category, seniority)
|
| 112 |
+
if act == "block":
|
| 113 |
+
return FitVerdict(term, req.category, "blocked", "block", why,
|
| 114 |
+
req.importance, req.recommended_placement)
|
| 115 |
+
if act == "ask_user":
|
| 116 |
+
return FitVerdict(term, req.category, "risky", "ask_user", why,
|
| 117 |
+
req.importance, req.recommended_placement)
|
| 118 |
+
|
| 119 |
+
# 1. Explicit — already in the resume
|
| 120 |
+
if _kw_in_text(term, low) or any(_kw_in_text(a, low) for a in (req.aliases or [])):
|
| 121 |
+
return FitVerdict(term, req.category, "explicit", "include",
|
| 122 |
+
"present in resume", req.importance, req.recommended_placement)
|
| 123 |
+
|
| 124 |
+
# 2. Adjacent — related evidence present in the resume
|
| 125 |
+
related = _RELATED.get(tl, [])
|
| 126 |
+
if any(_kw_in_text(r, low) for r in related):
|
| 127 |
+
return FitVerdict(term, req.category, "adjacent", "include",
|
| 128 |
+
"related experience present", req.importance,
|
| 129 |
+
req.recommended_placement)
|
| 130 |
+
|
| 131 |
+
# 3. Specialized domain knowledge outside evidenced domains → risky (include
|
| 132 |
+
# carefully, flag for review). NOT in the candidate's domain → review.
|
| 133 |
+
if tl in _SPECIALIZED_DOMAIN:
|
| 134 |
+
in_domain = candidate_domains and tl in candidate_domains
|
| 135 |
+
if in_domain:
|
| 136 |
+
return FitVerdict(term, req.category, "adjacent", "include",
|
| 137 |
+
"within candidate domain", req.importance,
|
| 138 |
+
req.recommended_placement)
|
| 139 |
+
return FitVerdict(term, req.category, "risky", "include_carefully",
|
| 140 |
+
"specialized domain term — review before applying",
|
| 141 |
+
req.importance, req.recommended_placement)
|
| 142 |
+
|
| 143 |
+
# 4. PM-universal craft / soft skills / standard tools+methods → PLAUSIBLE.
|
| 144 |
+
# The aggressive-expansion default: reasonable for the candidate's role.
|
| 145 |
+
if (req.category in ("hard_skill", "tool", "responsibility", "soft_skill", "domain")
|
| 146 |
+
or tl in _PM_CORE_TRANSFERABLE):
|
| 147 |
+
return FitVerdict(term, req.category, "plausible", "include",
|
| 148 |
+
"plausible for candidate role/seniority", req.importance,
|
| 149 |
+
req.recommended_placement)
|
| 150 |
+
|
| 151 |
+
# 5. Seniority signals / titles that match level → plausible; else handled above
|
| 152 |
+
if req.category in ("seniority",):
|
| 153 |
+
return FitVerdict(term, req.category, "plausible", "include",
|
| 154 |
+
"matches candidate level", req.importance,
|
| 155 |
+
req.recommended_placement)
|
| 156 |
+
|
| 157 |
+
# Fallback: treat as plausible (aggressive mode) unless it was blocked above.
|
| 158 |
+
return FitVerdict(term, req.category, "plausible", "include",
|
| 159 |
+
"default-include (aggressive plausible match)",
|
| 160 |
+
req.importance, req.recommended_placement)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def classify_all_fit(req: JDRequirements, base_resume_text: str,
|
| 164 |
+
candidate_domains: set = None) -> List[FitVerdict]:
|
| 165 |
+
seniority = infer_seniority(base_resume_text)
|
| 166 |
+
return [classify_fit(r, base_resume_text, candidate_domains, seniority)
|
| 167 |
+
for r in req.all_requirements()]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ── Convenience splits for the tailoring engine ──────────────────────────────
|
| 171 |
+
|
| 172 |
+
def includable(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
| 173 |
+
"""explicit + plausible + adjacent → safe to include in the resume."""
|
| 174 |
+
return [v for v in verdicts if v.action == "include"]
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def review_terms(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
| 178 |
+
"""risky → include only when needed for 90%, flagged for user review."""
|
| 179 |
+
return [v for v in verdicts if v.action == "include_carefully"]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def ask_user_terms(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
| 183 |
+
return [v for v in verdicts if v.action == "ask_user"]
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def blocked_terms(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
| 187 |
+
return [v for v in verdicts if v.action == "block"]
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
90% feasibility gate + status system (spec items #2-gate, #3-status).
|
| 3 |
+
|
| 4 |
+
Before generating, estimate whether the candidate can realistically reach a
|
| 5 |
+
90%+ JD match AFTER aggressive plausible expansion — and decide how to proceed.
|
| 6 |
+
|
| 7 |
+
Status lifecycle for any generated resume:
|
| 8 |
+
READY_90_PLUS — JD match >= 90 and ATS readability >= 90
|
| 9 |
+
READY_90_PLUS_REVIEW_RECOMMENDED — reached 90 but used risky/review terms
|
| 10 |
+
NEEDS_REPAIR — below 90, still in the repair loop
|
| 11 |
+
NEEDS_USER_INPUT — below 90; needs user-confirmed credentials/terms
|
| 12 |
+
NOT_ELIGIBLE_LOW_FIT — JD clearly unrelated to candidate background
|
| 13 |
+
PARSE_FAILED — exported file failed parse validation
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from typing import List
|
| 20 |
+
|
| 21 |
+
from .jd_analyzer import analyze_jd
|
| 22 |
+
from .candidate_fit import (
|
| 23 |
+
classify_all_fit, includable, review_terms, ask_user_terms, blocked_terms,
|
| 24 |
+
)
|
| 25 |
+
from .ats_scoring_v2 import score_jd_match, score_ats_readability
|
| 26 |
+
|
| 27 |
+
# Status constants
|
| 28 |
+
READY = "READY_90_PLUS"
|
| 29 |
+
READY_REVIEW = "READY_90_PLUS_REVIEW_RECOMMENDED"
|
| 30 |
+
NEEDS_REPAIR = "NEEDS_REPAIR"
|
| 31 |
+
NEEDS_USER_INPUT = "NEEDS_USER_INPUT"
|
| 32 |
+
LOW_FIT = "NOT_ELIGIBLE_LOW_FIT"
|
| 33 |
+
PARSE_FAILED = "PARSE_FAILED"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class FitAssessment:
|
| 38 |
+
eligible_for_auto_resume: bool
|
| 39 |
+
estimated_max_score: int
|
| 40 |
+
estimated_max_with_review: int
|
| 41 |
+
explicit_matches: List[str] = field(default_factory=list)
|
| 42 |
+
plausible_matches: List[str] = field(default_factory=list)
|
| 43 |
+
adjacent_matches: List[str] = field(default_factory=list)
|
| 44 |
+
risky_matches: List[str] = field(default_factory=list)
|
| 45 |
+
blocked_matches: List[str] = field(default_factory=list)
|
| 46 |
+
recommendation: str = "generate" # generate|generate_with_review|ask_user|skip
|
| 47 |
+
|
| 48 |
+
def to_dict(self):
|
| 49 |
+
return self.__dict__
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _synthetic_text(base_text: str, terms: List[str]) -> str:
|
| 53 |
+
"""A best-case resume text = base + the terms we'd include, for estimating
|
| 54 |
+
the achievable JD-match ceiling."""
|
| 55 |
+
return base_text + "\n" + " . ".join(terms)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def assess_job_fit_for_90(jd_text: str, base_resume_text: str,
|
| 59 |
+
llm=None, cfg: dict = None,
|
| 60 |
+
mode: str = "aggressive_plausible_match") -> FitAssessment:
|
| 61 |
+
req = analyze_jd(jd_text, llm=llm, cfg=cfg)
|
| 62 |
+
verdicts = classify_all_fit(req, base_resume_text)
|
| 63 |
+
|
| 64 |
+
inc = [v.keyword for v in includable(verdicts)]
|
| 65 |
+
rev = [v.keyword for v in review_terms(verdicts)]
|
| 66 |
+
ask = [v.keyword for v in ask_user_terms(verdicts)]
|
| 67 |
+
blk = [v.keyword for v in blocked_terms(verdicts)]
|
| 68 |
+
|
| 69 |
+
by_status = lambda s: [v.keyword for v in verdicts if v.fit_status == s]
|
| 70 |
+
|
| 71 |
+
# Estimate ceilings: score a synthetic resume containing the include set,
|
| 72 |
+
# and another that also adds the risky/review set.
|
| 73 |
+
est_inc = score_jd_match(_synthetic_text(base_resume_text, inc), req).score
|
| 74 |
+
est_rev = score_jd_match(_synthetic_text(base_resume_text, inc + rev), req).score
|
| 75 |
+
|
| 76 |
+
# Recommendation
|
| 77 |
+
if est_inc >= 90:
|
| 78 |
+
rec, eligible = "generate", True
|
| 79 |
+
elif est_rev >= 90:
|
| 80 |
+
rec, eligible = "generate_with_review", True
|
| 81 |
+
elif ask and est_rev >= 80:
|
| 82 |
+
rec, eligible = "ask_user", False
|
| 83 |
+
elif est_rev >= 70:
|
| 84 |
+
# Worth generating, will likely land in NEEDS_REPAIR/REVIEW
|
| 85 |
+
rec, eligible = "generate_with_review", True
|
| 86 |
+
else:
|
| 87 |
+
rec, eligible = "skip", False
|
| 88 |
+
|
| 89 |
+
return FitAssessment(
|
| 90 |
+
eligible_for_auto_resume=eligible,
|
| 91 |
+
estimated_max_score=est_inc,
|
| 92 |
+
estimated_max_with_review=est_rev,
|
| 93 |
+
explicit_matches=by_status("explicit"),
|
| 94 |
+
plausible_matches=by_status("plausible"),
|
| 95 |
+
adjacent_matches=by_status("adjacent"),
|
| 96 |
+
risky_matches=rev,
|
| 97 |
+
blocked_matches=blk,
|
| 98 |
+
recommendation=rec,
|
| 99 |
+
)
|
|
@@ -184,10 +184,22 @@ class ResumeCustomizer:
|
|
| 184 |
if jd and path and os.path.exists(path):
|
| 185 |
doc_text = _read_docx_text(path)
|
| 186 |
b, a, imp = _sba(self.resume_text, doc_text, jd, extra_kw=assessed_kw)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
job["ats_score_before"] = b
|
| 188 |
job["ats_score_after"] = a
|
| 189 |
-
job["ats_improvement"] =
|
| 190 |
-
|
|
|
|
| 191 |
else:
|
| 192 |
from .ats_scorer import conservative_display_score as _cds0
|
| 193 |
_c = _cds0(orig_result["ats_score"])
|
|
@@ -1302,101 +1314,152 @@ class ResumeCustomizer:
|
|
| 1302 |
if missing:
|
| 1303 |
self._weave_keywords_into_bullets(tailored, missing, jd_text)
|
| 1304 |
|
| 1305 |
-
# ──
|
| 1306 |
-
# The
|
| 1307 |
-
#
|
| 1308 |
-
#
|
| 1309 |
-
#
|
| 1310 |
-
#
|
| 1311 |
-
#
|
| 1312 |
-
|
| 1313 |
-
|
| 1314 |
-
|
| 1315 |
-
|
| 1316 |
-
|
|
|
|
| 1317 |
jd_low = jd_text.lower()
|
|
|
|
| 1318 |
|
| 1319 |
-
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
|
| 1323 |
-
s = s.strip()
|
| 1324 |
-
sl = s.lower()
|
| 1325 |
-
# Keep LLM skills that are credible: short phrase, present in the
|
| 1326 |
-
# JD (so it's really a JD term), not buzzword/blocklist/prose.
|
| 1327 |
-
if (3 <= len(s) <= 40 and len(s.split()) <= 4
|
| 1328 |
-
and sl not in self._BUZZWORDS
|
| 1329 |
-
and sl not in self._KEYWORD_BLOCKLIST
|
| 1330 |
-
and sl in jd_low):
|
| 1331 |
-
llm_skills.append(sl)
|
| 1332 |
-
|
| 1333 |
-
skill_pool = [
|
| 1334 |
-
k for k in jd_kw
|
| 1335 |
-
if k.lower() not in self._BUZZWORDS
|
| 1336 |
-
and k.lower() not in self._KEYWORD_BLOCKLIST
|
| 1337 |
-
and len(k) >= 3
|
| 1338 |
-
]
|
| 1339 |
-
# Union: deterministic first, then LLM-only additions
|
| 1340 |
-
for s in llm_skills:
|
| 1341 |
-
if s not in skill_pool:
|
| 1342 |
-
skill_pool.append(s)
|
| 1343 |
-
skill_pool = self._dedup_keywords_by_lemma(skill_pool)
|
| 1344 |
-
|
| 1345 |
-
# ── EVIDENCE FILTER (honesty) ──────────────────────────────────
|
| 1346 |
-
# Never list a skill the candidate can't back up. Classify each
|
| 1347 |
-
# candidate skill against the ORIGINAL resume: keep supported +
|
| 1348 |
-
# transferable (PM-core/related/soft), DROP unsupported domain/tech
|
| 1349 |
-
# skills (e.g. SIEM/SOAR/Kubernetes for a candidate with no such
|
| 1350 |
-
# background). Those become honest GAPS, not fake claims.
|
| 1351 |
-
try:
|
| 1352 |
-
from .evidence_matcher import classify_requirement
|
| 1353 |
-
from .jd_analyzer import Requirement, _categorize
|
| 1354 |
-
base_text = base_resume.to_flat_text()
|
| 1355 |
-
kept = []
|
| 1356 |
-
for k in skill_pool:
|
| 1357 |
-
r = Requirement(term=k, category=_categorize(k.lower()))
|
| 1358 |
-
v = classify_requirement(r, base_text)
|
| 1359 |
-
if v.action in ("add", "rephrase"): # supported|transferable
|
| 1360 |
-
kept.append(k)
|
| 1361 |
-
# If the filter is too aggressive (sparse base resume), fall back
|
| 1362 |
-
# to the unfiltered pool so we never ship an empty Skills section.
|
| 1363 |
-
skill_pool = kept if len(kept) >= 6 else skill_pool
|
| 1364 |
-
except Exception as e:
|
| 1365 |
-
print(f"[evidence-filter] {e}")
|
| 1366 |
|
| 1367 |
-
#
|
| 1368 |
-
|
| 1369 |
-
|
| 1370 |
-
|
| 1371 |
-
|
| 1372 |
-
|
| 1373 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1374 |
except Exception as e:
|
| 1375 |
-
print(f"[
|
| 1376 |
self._pending_summary_inject = []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1377 |
|
| 1378 |
-
#
|
| 1379 |
-
render_resume_docx(tailored, filepath)
|
| 1380 |
-
|
| 1381 |
-
# 4. Score. Keyword coverage now comes from the categorized Skills
|
| 1382 |
-
# section + contextual bullet weaving (industry standard) — NOT from
|
| 1383 |
-
# appending vague-noun sentences to the summary, which real checkers
|
| 1384 |
-
# penalise as stuffing. The old summary injection is retired.
|
| 1385 |
-
score = _score_resume(_read_docx_text(filepath), jd_text, extra_kw=assessed_kw)["ats_score"]
|
| 1386 |
-
|
| 1387 |
-
# 5. Postcondition check
|
| 1388 |
try:
|
| 1389 |
self._assert_no_dump_footer(filepath)
|
| 1390 |
except AssertionError as e:
|
| 1391 |
print(f"[v4 postcondition] {os.path.basename(filepath)}: {e}")
|
| 1392 |
|
| 1393 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1394 |
try:
|
| 1395 |
self._log_tailoring_diagnostic(
|
| 1396 |
filepath=filepath, job=job, jd_text=jd_text,
|
| 1397 |
assessed_kw=assessed_kw, customization=tailored_dict,
|
| 1398 |
-
final_score=
|
| 1399 |
-
v4_path_taken=True,
|
| 1400 |
v4_roles_returned=len(tailored_dict.get("roles", [])) if isinstance(tailored_dict, dict) else 0,
|
| 1401 |
v4_total_bullets=sum(
|
| 1402 |
len(r.get("bullets") or [])
|
|
@@ -1409,6 +1472,46 @@ class ResumeCustomizer:
|
|
| 1409 |
|
| 1410 |
return filepath
|
| 1411 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1412 |
def _log_tailoring_diagnostic(self, filepath: str, job: dict, jd_text: str,
|
| 1413 |
assessed_kw: list, customization: dict,
|
| 1414 |
final_score: int, baseline: int,
|
|
|
|
| 184 |
if jd and path and os.path.exists(path):
|
| 185 |
doc_text = _read_docx_text(path)
|
| 186 |
b, a, imp = _sba(self.resume_text, doc_text, jd, extra_kw=assessed_kw)
|
| 187 |
+
# Prefer the v2 report (weighted JD match + readability +
|
| 188 |
+
# status from the auto-repair pipeline) when available.
|
| 189 |
+
v2 = job.get("_v2_report") or {}
|
| 190 |
+
sc = v2.get("estimated_scores") or {}
|
| 191 |
+
if sc:
|
| 192 |
+
a = sc.get("jd_match", a)
|
| 193 |
+
job["ats_readability"] = sc.get("ats_readability", 0)
|
| 194 |
+
job["jd_match"] = sc.get("jd_match", a)
|
| 195 |
+
job["combined_range"] = sc.get("combined_range", "")
|
| 196 |
+
job["status"] = job.get("_v2_status", "")
|
| 197 |
+
job["review_terms"] = v2.get("review_terms_for_user_review", [])
|
| 198 |
job["ats_score_before"] = b
|
| 199 |
job["ats_score_after"] = a
|
| 200 |
+
job["ats_improvement"] = max(0, a - b)
|
| 201 |
+
st = job.get("status", "")
|
| 202 |
+
msg = f"✓ {co} → JD {a}% · {st}" if st else f"✓ {co} → ATS {b}% → {a}% (+{imp}pp)"
|
| 203 |
else:
|
| 204 |
from .ats_scorer import conservative_display_score as _cds0
|
| 205 |
_c = _cds0(orig_result["ats_score"])
|
|
|
|
| 1314 |
if missing:
|
| 1315 |
self._weave_keywords_into_bullets(tailored, missing, jd_text)
|
| 1316 |
|
| 1317 |
+
# ── Candidate Fit Expansion (aggressive_plausible_match) ─────────
|
| 1318 |
+
# The uploaded resume is a BASE PROFILE, not the full truth. Include
|
| 1319 |
+
# every JD term that is plausible for the candidate's role/seniority
|
| 1320 |
+
# (explicit + plausible + adjacent). RISKY domain terms (e.g. SIEM/
|
| 1321 |
+
# SOAR for a non-security PM) are held back for the repair loop and
|
| 1322 |
+
# flag the resume REVIEW_RECOMMENDED. Truly unsafe terms (regulated
|
| 1323 |
+
# credentials, deep-tech specialties, seniority jumps) are BLOCKED.
|
| 1324 |
+
from .ats_scorer import _is_taxonomy_skill as _istax
|
| 1325 |
+
from .jd_analyzer import analyze_jd as _analyze_jd
|
| 1326 |
+
from .candidate_fit import (
|
| 1327 |
+
classify_all_fit, includable as _includable,
|
| 1328 |
+
review_terms as _review_terms,
|
| 1329 |
+
)
|
| 1330 |
jd_low = jd_text.lower()
|
| 1331 |
+
base_text = base_resume.to_flat_text()
|
| 1332 |
|
| 1333 |
+
req_struct = _analyze_jd(jd_text)
|
| 1334 |
+
fit_verdicts = classify_all_fit(req_struct, base_text)
|
| 1335 |
+
include_pool = [v.keyword for v in _includable(fit_verdicts)]
|
| 1336 |
+
review_pool = [v.keyword for v in _review_terms(fit_verdicts)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1337 |
|
| 1338 |
+
# LLM jd_skills that are real JD terms (broadens to AI-checker breadth)
|
| 1339 |
+
for s in (tailored_dict.get("jd_skills") or []):
|
| 1340 |
+
if isinstance(s, str):
|
| 1341 |
+
sl = s.strip().lower()
|
| 1342 |
+
if (3 <= len(sl) <= 40 and len(sl.split()) <= 4
|
| 1343 |
+
and sl not in self._BUZZWORDS
|
| 1344 |
+
and sl not in self._KEYWORD_BLOCKLIST and sl in jd_low
|
| 1345 |
+
and sl not in [x.lower() for x in include_pool]):
|
| 1346 |
+
include_pool.append(sl)
|
| 1347 |
+
|
| 1348 |
+
def _build_skill_pool(terms, cap=44):
|
| 1349 |
+
pool = []
|
| 1350 |
+
for k in terms:
|
| 1351 |
+
kl = k.lower()
|
| 1352 |
+
if (kl in self._BUZZWORDS or kl in self._KEYWORD_BLOCKLIST
|
| 1353 |
+
or len(k) < 3 or k in pool):
|
| 1354 |
+
continue
|
| 1355 |
+
pool.append(k)
|
| 1356 |
+
pool = self._dedup_keywords_by_lemma(pool)
|
| 1357 |
+
pool.sort(key=lambda k: (_istax(k.lower()), jd_low.count(k.lower())),
|
| 1358 |
+
reverse=True)
|
| 1359 |
+
return pool[:cap]
|
| 1360 |
+
|
| 1361 |
+
tailored.skills = _build_skill_pool(include_pool)
|
| 1362 |
+
self._review_terms_used = [] # populated if repair pulls risky terms
|
| 1363 |
except Exception as e:
|
| 1364 |
+
print(f"[fit-expansion] {e}")
|
| 1365 |
self._pending_summary_inject = []
|
| 1366 |
+
include_pool, review_pool, req_struct = [], [], None
|
| 1367 |
+
base_text = base_resume.to_flat_text()
|
| 1368 |
+
|
| 1369 |
+
# ── Render → parse-validate → score → AUTO-REPAIR loop (target 90+) ──
|
| 1370 |
+
from .ats_report import build_ats_report
|
| 1371 |
+
from .fit_gate import (READY, READY_REVIEW, NEEDS_REPAIR,
|
| 1372 |
+
NEEDS_USER_INPUT, LOW_FIT, PARSE_FAILED)
|
| 1373 |
+
|
| 1374 |
+
def _exp_text() -> str:
|
| 1375 |
+
return "\n".join(b for r in tailored.roles for b in r.bullets)
|
| 1376 |
+
|
| 1377 |
+
def _render_score():
|
| 1378 |
+
render_resume_docx(tailored, filepath)
|
| 1379 |
+
parsed = _read_docx_text(filepath)
|
| 1380 |
+
valid, missing_parts = self._validate_parsed_resume(parsed, tailored)
|
| 1381 |
+
rep = build_ats_report(base_text, parsed, jd_text,
|
| 1382 |
+
experience_text=_exp_text(), has_tables=False)
|
| 1383 |
+
return parsed, rep, valid, missing_parts
|
| 1384 |
+
|
| 1385 |
+
status = NEEDS_REPAIR
|
| 1386 |
+
report = {}
|
| 1387 |
+
repair_attempts = []
|
| 1388 |
+
review_used = False
|
| 1389 |
+
try:
|
| 1390 |
+
parsed, report, valid, missing_parts = _render_score()
|
| 1391 |
+
for attempt in range(3):
|
| 1392 |
+
jm = report["estimated_scores"]["jd_match"]
|
| 1393 |
+
rd = report["estimated_scores"]["ats_readability"]
|
| 1394 |
+
repair_attempts.append({"attempt": attempt, "jd_match": jm,
|
| 1395 |
+
"ats_readability": rd, "valid": valid})
|
| 1396 |
+
if not valid:
|
| 1397 |
+
status = PARSE_FAILED
|
| 1398 |
+
if valid and jm >= 90 and rd >= 90:
|
| 1399 |
+
status = READY_REVIEW if review_used else READY
|
| 1400 |
+
break
|
| 1401 |
+
# ── Repair: add still-missing JD terms (incl. risky/review),
|
| 1402 |
+
# weave must-haves into bullets so they appear in Experience too.
|
| 1403 |
+
missing = [t for t in report.get("missing_terms", [])]
|
| 1404 |
+
cur = {s.lower() for s in tailored.skills}
|
| 1405 |
+
add = [t for t in (missing + review_pool + include_pool)
|
| 1406 |
+
if t.lower() not in cur]
|
| 1407 |
+
if any(t in review_pool for t in add):
|
| 1408 |
+
review_used = True
|
| 1409 |
+
self._review_terms_used = [t for t in add if t in review_pool]
|
| 1410 |
+
# Weave must-have/responsibility terms into bullets (evidence)
|
| 1411 |
+
weave_now = [t for t in add
|
| 1412 |
+
if t.lower() in jd_low][:14]
|
| 1413 |
+
if weave_now:
|
| 1414 |
+
try:
|
| 1415 |
+
self._weave_keywords_into_bullets(tailored, weave_now, jd_text)
|
| 1416 |
+
except Exception:
|
| 1417 |
+
pass
|
| 1418 |
+
tailored.skills = _build_skill_pool(
|
| 1419 |
+
list(tailored.skills) + add, cap=50)
|
| 1420 |
+
parsed, report, valid, missing_parts = _render_score()
|
| 1421 |
+
else:
|
| 1422 |
+
# Loop finished without hitting 90 — classify why.
|
| 1423 |
+
jm = report["estimated_scores"]["jd_match"]
|
| 1424 |
+
# Does reaching 90 hinge on user-only credentials (degree/cert
|
| 1425 |
+
# the candidate may not hold)? Only then is it NEEDS_USER_INPUT.
|
| 1426 |
+
weak = [w.lower() for w in report.get("weak_matches", [])]
|
| 1427 |
+
ask = [a.lower() for a in report.get("needs_user_input", [])]
|
| 1428 |
+
credential_blocked = bool(weak) and all(w in ask for w in weak)
|
| 1429 |
+
if not valid:
|
| 1430 |
+
status = PARSE_FAILED
|
| 1431 |
+
elif jm < 55:
|
| 1432 |
+
status = LOW_FIT
|
| 1433 |
+
elif credential_blocked:
|
| 1434 |
+
status = NEEDS_USER_INPUT
|
| 1435 |
+
else:
|
| 1436 |
+
status = NEEDS_REPAIR
|
| 1437 |
+
except Exception as e:
|
| 1438 |
+
print(f"[repair-loop] {e}")
|
| 1439 |
+
try:
|
| 1440 |
+
render_resume_docx(tailored, filepath)
|
| 1441 |
+
except Exception:
|
| 1442 |
+
pass
|
| 1443 |
|
| 1444 |
+
# Postcondition + attach the v2 report/status to the job (for UI/Sheets)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1445 |
try:
|
| 1446 |
self._assert_no_dump_footer(filepath)
|
| 1447 |
except AssertionError as e:
|
| 1448 |
print(f"[v4 postcondition] {os.path.basename(filepath)}: {e}")
|
| 1449 |
|
| 1450 |
+
if report:
|
| 1451 |
+
report["status"] = status
|
| 1452 |
+
report["repair_attempts"] = repair_attempts
|
| 1453 |
+
report["review_terms_for_user_review"] = getattr(self, "_review_terms_used", [])
|
| 1454 |
+
job["_v2_report"] = report
|
| 1455 |
+
job["_v2_status"] = status
|
| 1456 |
+
|
| 1457 |
try:
|
| 1458 |
self._log_tailoring_diagnostic(
|
| 1459 |
filepath=filepath, job=job, jd_text=jd_text,
|
| 1460 |
assessed_kw=assessed_kw, customization=tailored_dict,
|
| 1461 |
+
final_score=(report.get("estimated_scores", {}).get("jd_match", 0) if report else 0),
|
| 1462 |
+
baseline=0, v4_path_taken=True,
|
| 1463 |
v4_roles_returned=len(tailored_dict.get("roles", [])) if isinstance(tailored_dict, dict) else 0,
|
| 1464 |
v4_total_bullets=sum(
|
| 1465 |
len(r.get("bullets") or [])
|
|
|
|
| 1472 |
|
| 1473 |
return filepath
|
| 1474 |
|
| 1475 |
+
def _validate_parsed_resume(self, parsed_text: str, tailored) -> tuple:
|
| 1476 |
+
"""Post-render parse validation (spec #5/#10). Confirm the EXPORTED file
|
| 1477 |
+
re-parses to text that still contains every important part. Returns
|
| 1478 |
+
(is_valid, missing_parts)."""
|
| 1479 |
+
low = (parsed_text or "").lower()
|
| 1480 |
+
missing = []
|
| 1481 |
+
# Contact (email or phone)
|
| 1482 |
+
if not re.search(r"[\w.+-]+@[\w-]+\.[\w.-]+|\+?\d[\d\s\-()]{7,}", parsed_text or ""):
|
| 1483 |
+
missing.append("contact_info")
|
| 1484 |
+
# Standard headings
|
| 1485 |
+
for h in ("professional summary", "experience", "skills", "education"):
|
| 1486 |
+
if h not in low:
|
| 1487 |
+
missing.append(f"heading:{h}")
|
| 1488 |
+
# Experience bullets survived. NOTE: the renderer uses Word's "List
|
| 1489 |
+
# Bullet" style, so re-parsed bullet lines carry NO glyph — count
|
| 1490 |
+
# substantive content lines (not headings/short meta) instead.
|
| 1491 |
+
_HEADINGS = {"professional summary", "professional experience", "experience",
|
| 1492 |
+
"key achievements", "skills", "education", "certifications"}
|
| 1493 |
+
content_lines = sum(
|
| 1494 |
+
1 for ln in (parsed_text or "").splitlines()
|
| 1495 |
+
if ln.strip() and ln.strip().lower() not in _HEADINGS
|
| 1496 |
+
and len(ln.split()) >= 6
|
| 1497 |
+
)
|
| 1498 |
+
if content_lines < 3:
|
| 1499 |
+
missing.append("experience_bullets")
|
| 1500 |
+
# Candidate name survived
|
| 1501 |
+
if tailored is not None and getattr(tailored, "name", ""):
|
| 1502 |
+
if tailored.name.split()[0].lower() not in low:
|
| 1503 |
+
missing.append("candidate_name")
|
| 1504 |
+
# Skills content survived (at least some listed skills present)
|
| 1505 |
+
if tailored is not None and getattr(tailored, "skills", None):
|
| 1506 |
+
present = sum(1 for s in tailored.skills if s.lower() in low)
|
| 1507 |
+
if present < max(3, len(tailored.skills) // 4):
|
| 1508 |
+
missing.append("skills_content")
|
| 1509 |
+
# Valid unless a STRUCTURAL part is missing (headings/contact/bullets/name).
|
| 1510 |
+
structural = [m for m in missing
|
| 1511 |
+
if m.startswith("heading:") or m in
|
| 1512 |
+
("contact_info", "experience_bullets", "candidate_name")]
|
| 1513 |
+
return (len(structural) == 0, missing)
|
| 1514 |
+
|
| 1515 |
def _log_tailoring_diagnostic(self, filepath: str, job: dict, jd_text: str,
|
| 1516 |
assessed_kw: list, customization: dict,
|
| 1517 |
final_score: int, baseline: int,
|