saitejatirunagari Claude Opus 4.8 commited on
Commit
7b41b0f
·
1 Parent(s): 46113f1

feat(ats): AUTO_AGGRESSIVE mode + risk severity levels

Browse files

Automate 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 CHANGED
@@ -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
config.py CHANGED
@@ -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
 
scripts/verify_90_pipeline.py CHANGED
@@ -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 (jd_m >= 90 and status == "READY_90_PLUS_REVIEW_RECOMMENDED")
 
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
 
scripts/verify_anticheat.py CHANGED
@@ -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 -> review flagged:")
72
  jd, job, r = gen("sumo_logic_pm", "Sec")
73
- check("security terms flagged for review",
74
- any(t.lower() in ("siem", "soar", "xdr", "secops", "threat intelligence",
75
- "threat detection", "security operations")
76
- for t in r.get("review_terms_for_user_review", [])),
77
- str(r.get("review_terms_for_user_review", []))[:80])
 
 
 
 
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")
src/candidate_fit.py CHANGED
@@ -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"]
src/resume_customizer.py CHANGED
@@ -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 = [k for k in jd_kw if not _kw_check(k, flat)]
 
 
 
 
 
 
 
 
1299
  if missing:
1300
- # Smart fill (user directive): weave EVERY meaningful JD keyword
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
- req_struct = _analyze_jd(jd_text)
1334
- fit_verdicts = classify_all_fit(req_struct, base_text)
1335
- # Seniority signals ("7 years", "Director") are NOT skills never
1336
- # list them (injecting a tenure claim would game the seniority check).
1337
- include_pool = [v.keyword for v in _includable(fit_verdicts)
1338
- if v.category != "seniority"]
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
- missing = list(report.get("missing_terms", []))
1433
- skills_only = list(report.get("skills_only_terms", []))
 
 
 
 
1434
  cur = {s.lower() for s in tailored.skills}
1435
- add = [t for t in (missing + review_pool + include_pool)
1436
- if t.lower() not in cur]
1437
- if any(t in review_pool for t in add):
 
 
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
- status = NEEDS_REPAIR
 
 
 
 
 
 
 
 
 
 
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