Spaces:
Sleeping
feat(ats): AUTO_AGGRESSIVE mode + risk severity levels
Browse filesAutomate the common case, pause only for genuinely risky terms.
- candidate_fit.severity: LOW_RISK_AUTO_INCLUDED / MEDIUM_RISK_REVIEW_RECOMMENDED
/ HIGH_RISK_NEEDS_CONFIRMATION / BLOCKED_DO_NOT_INCLUDE. Helpers auto_terms()
(LOW+MEDIUM) and high_risk_terms().
- Generation: LOW+MEDIUM auto-included (weave/skills/repair all gated to exclude
HIGH+BLOCKED); MEDIUM flags REVIEW_RECOMMENDED. HIGH never auto-woven — a job
that NEEDS them for 90 -> NEEDS_USER_INPUT with the terms listed; else ships
clean without claiming them.
- config.AUTOMATION: automation_mode, review_policy, download_policy.
- Download allowed for READY_90_PLUS + REVIEW_RECOMMENDED (no manual accept
needed); blocked otherwise.
Verified (real resume): 6 PM JDs -> READY CLEAN downloadable; security PM ->
NEEDS_USER_INPUT (confirm SIEM/SOAR, independent 66 without them); backend ->
NEEDS_USER_INPUT/WEAK (independent 81); 12yr -> NEEDS_REPAIR (seniority).
Anti-cheat: security tooling NOT auto-claimed. All 3 suites pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- HISTORY.md +32 -0
- config.py +24 -0
- scripts/verify_90_pipeline.py +5 -1
- scripts/verify_anticheat.py +10 -6
- src/candidate_fit.py +49 -0
- src/resume_customizer.py +65 -54
|
@@ -4,6 +4,38 @@ A running log of everything built, fixed, and changed. Most recent first.
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## 2026-06-19 (7) — Anti-circular validation: independent scorer + anti-cheat
|
| 8 |
|
| 9 |
Addressed the key risk: the 90%+ could be the internal scorer agreeing with our
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## 2026-06-19 (8) — AUTO_AGGRESSIVE mode + risk severity levels
|
| 8 |
+
|
| 9 |
+
Goal: a 100-job batch runs mostly hands-off — automate the common case, pause
|
| 10 |
+
only for genuinely risky terms.
|
| 11 |
+
|
| 12 |
+
- **Risk severity** (`candidate_fit.severity`): every term → LOW_RISK_AUTO_INCLUDED
|
| 13 |
+
/ MEDIUM_RISK_REVIEW_RECOMMENDED / HIGH_RISK_NEEDS_CONFIRMATION /
|
| 14 |
+
BLOCKED_DO_NOT_INCLUDE. LOW = common PM/product/analytics/agile craft, normal
|
| 15 |
+
tools/responsibilities/soft. MEDIUM = domain/industry terms & plausible tools
|
| 16 |
+
not in the base resume. HIGH = specialized platforms (SIEM/SOAR), compliance/
|
| 17 |
+
regulatory, engineering hard skills, seniority-sensitive. BLOCKED = creds/
|
| 18 |
+
licenses/fakes/seniority-jumps/deep specialties.
|
| 19 |
+
- **AUTO_AGGRESSIVE generation**: LOW+MEDIUM auto-included everywhere (weaving,
|
| 20 |
+
skills, repair); MEDIUM flags REVIEW_RECOMMENDED. HIGH-risk terms are NEVER
|
| 21 |
+
auto-woven — if a job NEEDS them to hit 90 it pauses as NEEDS_USER_INPUT and
|
| 22 |
+
lists them for confirmation; otherwise it ships clean without claiming them.
|
| 23 |
+
- **config.AUTOMATION**: `automation_mode="auto_aggressive"`, `review_policy`
|
| 24 |
+
(low→auto, medium→auto+flag, high→ask_user, blocked→exclude), `download_policy`
|
| 25 |
+
(READY/REVIEW allow, everything else block).
|
| 26 |
+
- Download stays allowed for READY_90_PLUS and REVIEW_RECOMMENDED (no manual
|
| 27 |
+
accept/reject needed); blocked for WEAK/NEEDS_INPUT/LOW_FIT/PARSE_FAILED.
|
| 28 |
+
|
| 29 |
+
### Verified (real resume, deterministic)
|
| 30 |
+
6 in-domain PM JDs → READY_90_PLUS CLEAN (0 risk terms, downloadable). Security
|
| 31 |
+
PM → NEEDS_USER_INPUT (independent 66 without security tooling; 7 HIGH terms to
|
| 32 |
+
confirm) — matches the spec's "Cybersecurity PM → confirm SIEM/SOAR" example.
|
| 33 |
+
Backend-eng → NEEDS_USER_INPUT/WEAK (independent 81). Sr-Director-12y →
|
| 34 |
+
NEEDS_REPAIR (seniority fails). Anti-cheat: security tooling NOT auto-claimed
|
| 35 |
+
(auto-claimed=[]). All three regression suites pass.
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
## 2026-06-19 (7) — Anti-circular validation: independent scorer + anti-cheat
|
| 40 |
|
| 41 |
Addressed the key risk: the 90%+ could be the internal scorer agreeing with our
|
|
@@ -182,6 +182,30 @@ ASSESSMENT = {
|
|
| 182 |
"test_jobs_limit": 10, # Max jobs in test mode
|
| 183 |
}
|
| 184 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
# ── ever-jobs integration ─────────────────────────────────────────────────────
|
| 186 |
from src.ever_jobs_bridge.platforms import INDIA_DEFAULT_PLATFORMS
|
| 187 |
|
|
|
|
| 182 |
"test_jobs_limit": 10, # Max jobs in test mode
|
| 183 |
}
|
| 184 |
|
| 185 |
+
# ── Resume automation policy (AUTO_AGGRESSIVE) ─────────────────────────────────
|
| 186 |
+
# Controls how aggressively plausible JD terms are auto-included and which
|
| 187 |
+
# resumes are downloadable, so a 100-job batch runs mostly hands-off.
|
| 188 |
+
AUTOMATION = {
|
| 189 |
+
"automation_mode": "auto_aggressive",
|
| 190 |
+
# What to do per risk severity (see candidate_fit.severity):
|
| 191 |
+
"review_policy": {
|
| 192 |
+
"LOW_RISK_AUTO_INCLUDED": "auto_include",
|
| 193 |
+
"MEDIUM_RISK_REVIEW_RECOMMENDED": "auto_include_with_review_flag",
|
| 194 |
+
"HIGH_RISK_NEEDS_CONFIRMATION": "ask_user",
|
| 195 |
+
"BLOCKED_DO_NOT_INCLUDE": "exclude",
|
| 196 |
+
},
|
| 197 |
+
# Which statuses are downloadable:
|
| 198 |
+
"download_policy": {
|
| 199 |
+
"READY_90_PLUS": "allow",
|
| 200 |
+
"READY_90_PLUS_REVIEW_RECOMMENDED": "allow_with_warning",
|
| 201 |
+
"WEAK_90_INTERNAL_ONLY": "block",
|
| 202 |
+
"NEEDS_REPAIR": "block",
|
| 203 |
+
"NEEDS_USER_INPUT": "block",
|
| 204 |
+
"NOT_ELIGIBLE_LOW_FIT": "block",
|
| 205 |
+
"PARSE_FAILED": "block",
|
| 206 |
+
},
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
# ── ever-jobs integration ─────────────────────────────────────────────────────
|
| 210 |
from src.ever_jobs_bridge.platforms import INDIA_DEFAULT_PLATFORMS
|
| 211 |
|
|
@@ -53,8 +53,12 @@ for jf, co in JOBS:
|
|
| 53 |
status = job.get("_v2_status", "")
|
| 54 |
jd_m, rd = sc.get("jd_match", 0), sc.get("ats_readability", 0)
|
| 55 |
print(f"{jf:<16}{status:<34}{jd_m:>4}{rd:>6}")
|
|
|
|
|
|
|
|
|
|
| 56 |
if jf == "sumo_logic_pm":
|
| 57 |
-
ok = ok and (
|
|
|
|
| 58 |
else:
|
| 59 |
ok = ok and (jd_m >= 90 and rd >= 90 and status == "READY_90_PLUS")
|
| 60 |
|
|
|
|
| 53 |
status = job.get("_v2_status", "")
|
| 54 |
jd_m, rd = sc.get("jd_match", 0), sc.get("ats_readability", 0)
|
| 55 |
print(f"{jf:<16}{status:<34}{jd_m:>4}{rd:>6}")
|
| 56 |
+
# AUTO_AGGRESSIVE: a security PM JD reaches 90 on PM craft alone (security
|
| 57 |
+
# tooling is HIGH-risk and not auto-claimed) → READY is fine; if PM craft
|
| 58 |
+
# alone can't hit 90 it pauses as NEEDS_USER_INPUT. Either is acceptable.
|
| 59 |
if jf == "sumo_logic_pm":
|
| 60 |
+
ok = ok and (status in ("READY_90_PLUS", "READY_90_PLUS_REVIEW_RECOMMENDED",
|
| 61 |
+
"NEEDS_USER_INPUT"))
|
| 62 |
else:
|
| 63 |
ok = ok and (jd_m >= 90 and rd >= 90 and status == "READY_90_PLUS")
|
| 64 |
|
|
@@ -68,13 +68,17 @@ check("internal >= 90", internal >= 90, f"internal={internal}")
|
|
| 68 |
check("independent >= 90", indep >= 90, f"independent={indep}")
|
| 69 |
check("download allowed", job.get("download_allowed") is True)
|
| 70 |
|
| 71 |
-
print("2. Cybersecurity PM ->
|
| 72 |
jd, job, r = gen("sumo_logic_pm", "Sec")
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
print("3. Backend-engineer JD must NOT be CLEAN_90_PLUS for a PM:")
|
| 80 |
jd, job, r = gen("backend_engineer", "Backend")
|
|
|
|
| 68 |
check("independent >= 90", indep >= 90, f"independent={indep}")
|
| 69 |
check("download allowed", job.get("download_allowed") is True)
|
| 70 |
|
| 71 |
+
print("2. Cybersecurity PM -> security tooling is HIGH-risk (confirm), not auto-faked:")
|
| 72 |
jd, job, r = gen("sumo_logic_pm", "Sec")
|
| 73 |
+
_sec = ("siem", "soar", "xdr", "secops", "threat intelligence",
|
| 74 |
+
"threat detection", "security operations")
|
| 75 |
+
_high = [t.lower() for t in r.get("high_risk_terms_for_confirmation", [])]
|
| 76 |
+
check("security terms classified HIGH-risk (need confirmation)",
|
| 77 |
+
any(t in _high for t in _sec), str(_high)[:90])
|
| 78 |
+
parsed_sec = _read_docx_text(os.path.join(rc.output_dir, "Sec.docx")).lower()
|
| 79 |
+
auto_claimed = [t for t in _sec if t in parsed_sec]
|
| 80 |
+
check("security tooling NOT auto-claimed in resume (HIGH not auto-included)",
|
| 81 |
+
not auto_claimed, f"auto-claimed={auto_claimed}")
|
| 82 |
|
| 83 |
print("3. Backend-engineer JD must NOT be CLEAN_90_PLUS for a PM:")
|
| 84 |
jd, job, r = gen("backend_engineer", "Backend")
|
|
@@ -223,6 +223,45 @@ def classify_all_fit(req: JDRequirements, base_resume_text: str,
|
|
| 223 |
for r in req.all_requirements()]
|
| 224 |
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
# ── Convenience splits for the tailoring engine ──────────────────────────────
|
| 227 |
|
| 228 |
def includable(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
|
@@ -230,6 +269,16 @@ def includable(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
|
| 230 |
return [v for v in verdicts if v.action == "include"]
|
| 231 |
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
def review_terms(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
| 234 |
"""risky → include only when needed for 90%, flagged for user review."""
|
| 235 |
return [v for v in verdicts if v.action == "include_carefully"]
|
|
|
|
| 223 |
for r in req.all_requirements()]
|
| 224 |
|
| 225 |
|
| 226 |
+
# ── Risk severity (AUTO_AGGRESSIVE mode) ─────────────────────────────────────
|
| 227 |
+
|
| 228 |
+
LOW = "LOW_RISK_AUTO_INCLUDED"
|
| 229 |
+
MEDIUM = "MEDIUM_RISK_REVIEW_RECOMMENDED"
|
| 230 |
+
HIGH = "HIGH_RISK_NEEDS_CONFIRMATION"
|
| 231 |
+
BLOCKED = "BLOCKED_DO_NOT_INCLUDE"
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def severity(v: FitVerdict) -> str:
|
| 235 |
+
"""Map a fit verdict to a risk severity level.
|
| 236 |
+
|
| 237 |
+
LOW — common PM/product/business/analytics/agile terms, normal tools,
|
| 238 |
+
normal responsibilities, soft skills, anything already in the resume.
|
| 239 |
+
MEDIUM — domain/industry terms & plausible tools not in the base resume
|
| 240 |
+
(auto-include but flag for review).
|
| 241 |
+
HIGH — specialized platforms (SIEM/SOAR), compliance/regulatory terms,
|
| 242 |
+
engineering hard skills, seniority-sensitive, credential-required
|
| 243 |
+
(only used with explicit user confirmation).
|
| 244 |
+
BLOCKED— degrees/certs/licenses/fakes/seniority jumps/deep specialties.
|
| 245 |
+
"""
|
| 246 |
+
if v.action == "block":
|
| 247 |
+
return BLOCKED
|
| 248 |
+
if v.action in ("ask_user", "include_carefully"):
|
| 249 |
+
# specialized-domain / engineering / credential / regulatory → confirm
|
| 250 |
+
return HIGH
|
| 251 |
+
# action == include
|
| 252 |
+
if v.fit_status == "explicit":
|
| 253 |
+
return LOW
|
| 254 |
+
if v.category == "domain":
|
| 255 |
+
return MEDIUM
|
| 256 |
+
if v.fit_status == "adjacent":
|
| 257 |
+
return MEDIUM
|
| 258 |
+
return LOW # plausible common PM craft / tools / methods / soft skills
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def by_severity(verdicts: List[FitVerdict], level: str) -> List[FitVerdict]:
|
| 262 |
+
return [v for v in verdicts if severity(v) == level]
|
| 263 |
+
|
| 264 |
+
|
| 265 |
# ── Convenience splits for the tailoring engine ──────────────────────────────
|
| 266 |
|
| 267 |
def includable(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
|
|
|
| 269 |
return [v for v in verdicts if v.action == "include"]
|
| 270 |
|
| 271 |
|
| 272 |
+
def auto_terms(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
| 273 |
+
"""AUTO_AGGRESSIVE: LOW + MEDIUM are auto-included (MEDIUM flags review)."""
|
| 274 |
+
return [v for v in verdicts if severity(v) in (LOW, MEDIUM)]
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def high_risk_terms(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
| 278 |
+
"""HIGH — only included with user confirmation; gate the job if needed for 90."""
|
| 279 |
+
return [v for v in verdicts if severity(v) == HIGH]
|
| 280 |
+
|
| 281 |
+
|
| 282 |
def review_terms(verdicts: List[FitVerdict]) -> List[FitVerdict]:
|
| 283 |
"""risky → include only when needed for 90%, flagged for user review."""
|
| 284 |
return [v for v in verdicts if v.action == "include_carefully"]
|
|
@@ -1288,56 +1288,53 @@ class ResumeCustomizer:
|
|
| 1288 |
# being woven into bullets as "skills".
|
| 1289 |
from .ats_scorer import (
|
| 1290 |
extract_jd_keywords as _ext_kw, _kw_in_text as _kw_check,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1291 |
)
|
| 1292 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1293 |
jd_kw = _ext_kw(jd_text)
|
| 1294 |
for kw in assessed_kw or []:
|
| 1295 |
if kw and kw.lower() not in jd_kw:
|
| 1296 |
jd_kw.append(kw.lower())
|
| 1297 |
flat = tailored.to_flat_text().lower()
|
| 1298 |
-
missing = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1299 |
if missing:
|
| 1300 |
-
|
| 1301 |
-
# into bullets where relevant — not just a narrow allowlist.
|
| 1302 |
-
# These already passed extract_jd_keywords' meaningful filter
|
| 1303 |
-
# (real nouns/skills, no prose/locations/company names). We only
|
| 1304 |
-
# additionally drop lemmatizer artifacts and vague BUZZWORDS that
|
| 1305 |
-
# real checkers penalise. The blocklist still removes known
|
| 1306 |
-
# company/prose terms.
|
| 1307 |
-
missing = [
|
| 1308 |
-
k for k in missing
|
| 1309 |
-
if len(k) >= 3
|
| 1310 |
-
and not (len(k) >= 5 and k.endswith(("at", "iz", "ic")))
|
| 1311 |
-
and k.lower() not in self._BUZZWORDS
|
| 1312 |
-
and k.lower() not in self._KEYWORD_BLOCKLIST
|
| 1313 |
-
]
|
| 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 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
review_pool = [v.keyword for v in _review_terms(fit_verdicts)
|
| 1340 |
-
if v.category != "seniority"]
|
| 1341 |
|
| 1342 |
# LLM jd_skills that are real JD terms (broadens to AI-checker breadth)
|
| 1343 |
for s in (tailored_dict.get("jd_skills") or []):
|
|
@@ -1429,16 +1426,21 @@ class ResumeCustomizer:
|
|
| 1429 |
# ── Repair. Priority: terms that are MISSING entirely, then
|
| 1430 |
# terms present only in Skills (weave THOSE into bullets so they
|
| 1431 |
# become evidenced and the independent score rises).
|
| 1432 |
-
|
| 1433 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1434 |
cur = {s.lower() for s in tailored.skills}
|
| 1435 |
-
add = [t for t in (missing +
|
| 1436 |
-
if t.lower() not in cur]
|
| 1437 |
-
|
|
|
|
|
|
|
| 1438 |
review_used = True
|
| 1439 |
self._review_terms_used = list(dict.fromkeys(
|
| 1440 |
-
getattr(self, "_review_terms_used", []) +
|
| 1441 |
-
[t for t in add if t in review_pool]))
|
| 1442 |
# Weave evidence into bullets: missing must-haves + skills-only
|
| 1443 |
# terms (the latter directly lifts the independent score).
|
| 1444 |
weave_now = [t for t in (skills_only + missing)
|
|
@@ -1457,19 +1459,24 @@ class ResumeCustomizer:
|
|
| 1457 |
jm = report["estimated_scores"]["jd_match"]
|
| 1458 |
rd = report["estimated_scores"]["ats_readability"]
|
| 1459 |
ind = val.independent_jd_match
|
| 1460 |
-
weak = [w.lower() for w in report.get("weak_matches", [])]
|
| 1461 |
-
ask = [a.lower() for a in report.get("needs_user_input", [])]
|
| 1462 |
-
credential_blocked = bool(weak) and all(w in ask for w in weak)
|
| 1463 |
if not valid:
|
| 1464 |
status = PARSE_FAILED
|
| 1465 |
elif jm >= 90 and rd >= 90 and ind >= 90:
|
| 1466 |
status = READY_REVIEW if review_used else READY
|
| 1467 |
elif jm < 55 or ind < 55:
|
| 1468 |
status = LOW_FIT
|
| 1469 |
-
elif credential_blocked:
|
| 1470 |
-
status = NEEDS_USER_INPUT
|
| 1471 |
else:
|
| 1472 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1473 |
except Exception as e:
|
| 1474 |
print(f"[repair-loop] {e}")
|
| 1475 |
try:
|
|
@@ -1541,6 +1548,10 @@ class ResumeCustomizer:
|
|
| 1541 |
report["download_allowed"] = download_allowed
|
| 1542 |
report["repair_attempts"] = repair_attempts
|
| 1543 |
report["review_terms_for_user_review"] = review_list
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1544 |
job["_v2_report"] = report
|
| 1545 |
job["_v2_status"] = status
|
| 1546 |
job["quality_flag"] = quality
|
|
|
|
| 1288 |
# being woven into bullets as "skills".
|
| 1289 |
from .ats_scorer import (
|
| 1290 |
extract_jd_keywords as _ext_kw, _kw_in_text as _kw_check,
|
| 1291 |
+
_is_taxonomy_skill as _istax,
|
| 1292 |
+
)
|
| 1293 |
+
from .jd_analyzer import analyze_jd as _analyze_jd
|
| 1294 |
+
from .candidate_fit import (
|
| 1295 |
+
classify_all_fit, auto_terms as _auto_terms,
|
| 1296 |
+
high_risk_terms as _high_terms, severity as _severity,
|
| 1297 |
+
MEDIUM as _MED, HIGH as _HIGH, BLOCKED as _BLK,
|
| 1298 |
)
|
| 1299 |
try:
|
| 1300 |
+
jd_low = jd_text.lower()
|
| 1301 |
+
base_text = base_resume.to_flat_text()
|
| 1302 |
+
|
| 1303 |
+
# ── Candidate Fit Expansion (AUTO_AGGRESSIVE) — classify FIRST so we
|
| 1304 |
+
# never weave HIGH-risk / blocked terms anywhere. The uploaded resume
|
| 1305 |
+
# is a BASE PROFILE: auto-include LOW+MEDIUM (MEDIUM flags review);
|
| 1306 |
+
# HIGH-risk (specialized platforms/compliance/engineering/seniority)
|
| 1307 |
+
# are gated; BLOCKED (creds/fakes/seniority-jumps) excluded.
|
| 1308 |
+
req_struct = _analyze_jd(jd_text)
|
| 1309 |
+
fit_verdicts = classify_all_fit(req_struct, base_text)
|
| 1310 |
+
_excluded_kw = {v.keyword.lower() for v in fit_verdicts
|
| 1311 |
+
if _severity(v) in (_HIGH, _BLK)}
|
| 1312 |
+
|
| 1313 |
+
# 2b. Weave AUTO (LOW+MEDIUM) JD keywords missing from the resume into
|
| 1314 |
+
# bullets — never the HIGH-risk / blocked ones.
|
| 1315 |
jd_kw = _ext_kw(jd_text)
|
| 1316 |
for kw in assessed_kw or []:
|
| 1317 |
if kw and kw.lower() not in jd_kw:
|
| 1318 |
jd_kw.append(kw.lower())
|
| 1319 |
flat = tailored.to_flat_text().lower()
|
| 1320 |
+
missing = [
|
| 1321 |
+
k for k in jd_kw
|
| 1322 |
+
if not _kw_check(k, flat)
|
| 1323 |
+
and len(k) >= 3
|
| 1324 |
+
and not (len(k) >= 5 and k.endswith(("at", "iz", "ic")))
|
| 1325 |
+
and k.lower() not in self._BUZZWORDS
|
| 1326 |
+
and k.lower() not in self._KEYWORD_BLOCKLIST
|
| 1327 |
+
and k.lower() not in _excluded_kw
|
| 1328 |
+
]
|
| 1329 |
if missing:
|
| 1330 |
+
self._weave_keywords_into_bullets(tailored, missing, jd_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1331 |
|
| 1332 |
+
_auto_v = [v for v in _auto_terms(fit_verdicts) if v.category != "seniority"]
|
| 1333 |
+
include_pool = [v.keyword for v in _auto_v]
|
| 1334 |
+
medium_set = {v.keyword.lower() for v in _auto_v if _severity(v) == _MED}
|
| 1335 |
+
high_pool = [v.keyword for v in _high_terms(fit_verdicts)
|
| 1336 |
+
if v.category != "seniority"]
|
| 1337 |
+
review_pool = [] # HIGH terms are gated, not auto-woven
|
|
|
|
|
|
|
| 1338 |
|
| 1339 |
# LLM jd_skills that are real JD terms (broadens to AI-checker breadth)
|
| 1340 |
for s in (tailored_dict.get("jd_skills") or []):
|
|
|
|
| 1426 |
# ── Repair. Priority: terms that are MISSING entirely, then
|
| 1427 |
# terms present only in Skills (weave THOSE into bullets so they
|
| 1428 |
# become evidenced and the independent score rises).
|
| 1429 |
+
# AUTO_AGGRESSIVE: never add/weave HIGH-risk or blocked terms.
|
| 1430 |
+
_excl = locals().get("_excluded_kw", set())
|
| 1431 |
+
missing = [t for t in report.get("missing_terms", [])
|
| 1432 |
+
if t.lower() not in _excl]
|
| 1433 |
+
skills_only = [t for t in report.get("skills_only_terms", [])
|
| 1434 |
+
if t.lower() not in _excl]
|
| 1435 |
cur = {s.lower() for s in tailored.skills}
|
| 1436 |
+
add = [t for t in (missing + include_pool)
|
| 1437 |
+
if t.lower() not in cur and t.lower() not in _excl]
|
| 1438 |
+
# Flag review if any MEDIUM-severity term gets included.
|
| 1439 |
+
med_added = [t for t in add if t.lower() in medium_set]
|
| 1440 |
+
if med_added:
|
| 1441 |
review_used = True
|
| 1442 |
self._review_terms_used = list(dict.fromkeys(
|
| 1443 |
+
getattr(self, "_review_terms_used", []) + med_added))
|
|
|
|
| 1444 |
# Weave evidence into bullets: missing must-haves + skills-only
|
| 1445 |
# terms (the latter directly lifts the independent score).
|
| 1446 |
weave_now = [t for t in (skills_only + missing)
|
|
|
|
| 1459 |
jm = report["estimated_scores"]["jd_match"]
|
| 1460 |
rd = report["estimated_scores"]["ats_readability"]
|
| 1461 |
ind = val.independent_jd_match
|
|
|
|
|
|
|
|
|
|
| 1462 |
if not valid:
|
| 1463 |
status = PARSE_FAILED
|
| 1464 |
elif jm >= 90 and rd >= 90 and ind >= 90:
|
| 1465 |
status = READY_REVIEW if review_used else READY
|
| 1466 |
elif jm < 55 or ind < 55:
|
| 1467 |
status = LOW_FIT
|
|
|
|
|
|
|
| 1468 |
else:
|
| 1469 |
+
# Below 90 with LOW+MEDIUM only. Would the HIGH-risk terms
|
| 1470 |
+
# (specialized platforms/compliance/engineering the candidate
|
| 1471 |
+
# must CONFIRM) close the gap? If so, pause for the user.
|
| 1472 |
+
needs_high = False
|
| 1473 |
+
if high_pool:
|
| 1474 |
+
from .ats_scoring_v2 import score_jd_match as _sjm
|
| 1475 |
+
synth = parsed + "\n" + " . ".join(high_pool)
|
| 1476 |
+
if _sjm(synth, req_struct).score >= 90:
|
| 1477 |
+
needs_high = True
|
| 1478 |
+
status = NEEDS_USER_INPUT if needs_high else NEEDS_REPAIR
|
| 1479 |
+
report["high_risk_terms_for_confirmation"] = high_pool
|
| 1480 |
except Exception as e:
|
| 1481 |
print(f"[repair-loop] {e}")
|
| 1482 |
try:
|
|
|
|
| 1548 |
report["download_allowed"] = download_allowed
|
| 1549 |
report["repair_attempts"] = repair_attempts
|
| 1550 |
report["review_terms_for_user_review"] = review_list
|
| 1551 |
+
# HIGH-risk terms the candidate could confirm to strengthen further
|
| 1552 |
+
# (not auto-claimed). Always surfaced for transparency.
|
| 1553 |
+
report.setdefault("high_risk_terms_for_confirmation",
|
| 1554 |
+
locals().get("high_pool", []))
|
| 1555 |
job["_v2_report"] = report
|
| 1556 |
job["_v2_status"] = status
|
| 1557 |
job["quality_flag"] = quality
|