saitejatirunagari Cursor commited on
Commit
7cad0b6
Β·
1 Parent(s): b3704a6

fix(ats): drive Maximum ATS Mode by EXTERNAL coverage (internal 96 vs Jobalytics 54)

Browse files

Live bug: a PM/AI JD scored internal 96% but Jobalytics 54% (26/46). Root cause:
generate+repair optimised against our narrow internal taxonomy; the broad ~46-term
set external checkers extract (influence, backlog, business development, corporate
travel, expense management, payments, ...) was never extracted or placed, and
_build_skill_pool sorted non-taxonomy terms last and capped them at 44/50.

- external_ats.py (NEW): extract_external_keywords() broad Jobalytics-style
expected set + external_coverage() measured from the re-parsed export.
- resume_customizer._maximize_external_coverage() (NEW): in Max ATS, honesty-gate
each broad/pasted term via candidate_fit, then GUARANTEE includable ones into the
exported DOCX (Skills verbatim + woven bullets), re-render, re-measure; emits a
per-term debug report (keyword/found/section/why-missing).
- caps relaxed in Max ATS (_build_skill_pool 44/50 -> 200; stop dropping domain terms).
- status driven by external coverage: READY_MAX_ATS_95_PLUS / READY_90_PLUS_
EXTERNAL_ALIGNED / BELOW_TARGET_REPAIRABLE (never accept internal-high/external-low).
- config.MAXIMUM_ATS_SAFE_TERMS expanded with cross-functional PM craft terms.
- api_server: /api/generate returns external_coverage + coverage_report +
external_coverage_pct; both endpoints log maximum_ats_mode + status + coverage.
- extension v1.2.0: shows External ATS % + coverage count; failure panel states
"External ATS below target - internal score is not enough" + exact missing terms.

Tests: scripts/verify_max_ats_coverage.py (NEW) locks the failure -> exported DOCX
covers 25/26=96% of includable PM terms, CISSP/fake-seniority excluded, status
BELOW_TARGET_REPAIRABLE. verify_maximum_ats coverage 39% -> 89%. Anti-cheat,
feedback-repair, 90-pipeline, scoring-v2, API parity+health all still green.
Honesty intact: placement gated by candidate_fit; no fabricated creds/seniority.

Co-authored-by: Cursor <cursoragent@cursor.com>

AGENT_CONTEXT.md CHANGED
@@ -107,6 +107,17 @@ block or ask first. Flows via the job dict (`_maximum_ats_mode`,
107
  repair loop iterates while LOW/MEDIUM gaps remain. Confirmed terms persist to the
108
  vault (`confirm_expansion_terms`). The boundaries in Β§2 are NEVER relaxed by it.
109
 
 
 
 
 
 
 
 
 
 
 
 
110
  **Status system** (`src/fit_gate.py`): `READY_90_PLUS` /
111
  `READY_90_PLUS_REVIEW_RECOMMENDED` / `READY_95_EXTERNAL_ALIGNED` (feedback repair
112
  only: internal AND independent β‰₯ 90, pasted gaps mostly resolved, no review flags β€”
@@ -259,6 +270,7 @@ for resume header address/location ATS compliance.
259
  | `scripts/verify_anticheat.py` | **Anti-circular suite β€” keep green.** |
260
  | `scripts/verify_feedback_repair.py` | External feedback repair: parse + honest weave; CISSP blocked, SIEM unresolved-high. |
261
  | `scripts/verify_maximum_ats.py` | Maximum ATS Mode: PM/AIβ†’user-confirmed; certs/seniority/eng still gated; coverage improves; 65%β†’repairable. |
 
262
  | `scripts/verify_90_pipeline.py` | Full fitβ†’repairβ†’status pipeline (stub floor). |
263
  | `scripts/verify_scoring_v2.py` | jd_analyzer + evidence + scoring + report + feedback loop. |
264
  | `scripts/evaluate_model_providers.py` | Run same jobs through each provider (Kimi/NVIDIA) β€” ready rate, avg independent, schema errors. **Needs real keys.** |
 
107
  repair loop iterates while LOW/MEDIUM gaps remain. Confirmed terms persist to the
108
  vault (`confirm_expansion_terms`). The boundaries in Β§2 are NEVER relaxed by it.
109
 
110
+ **⚠ The internal score is NOT a valid external signal.** Internal taxonomy
111
+ coverage can be 96% while Jobalytics is 54% (it extracts a broader 40-46 term
112
+ set). So Maximum ATS Mode is driven by EXTERNAL coverage: `src/external_ats.py`
113
+ builds a broad Jobalytics-style expected set; `resume_customizer.
114
+ _maximize_external_coverage()` honesty-gates each term then **physically
115
+ guarantees** the includable ones into the exported DOCX (Skills verbatim + woven
116
+ bullets) and measures coverage from the **re-parsed export**. Status becomes
117
+ `READY_MAX_ATS_95_PLUS` / `READY_90_PLUS_EXTERNAL_ALIGNED` / `BELOW_TARGET_REPAIRABLE`.
118
+ `_build_skill_pool` no longer caps off includable terms in max mode (cap β†’ 200).
119
+ Regression: `scripts/verify_max_ats_coverage.py` (the internal-96/external-54 bug).
120
+
121
  **Status system** (`src/fit_gate.py`): `READY_90_PLUS` /
122
  `READY_90_PLUS_REVIEW_RECOMMENDED` / `READY_95_EXTERNAL_ALIGNED` (feedback repair
123
  only: internal AND independent β‰₯ 90, pasted gaps mostly resolved, no review flags β€”
 
270
  | `scripts/verify_anticheat.py` | **Anti-circular suite β€” keep green.** |
271
  | `scripts/verify_feedback_repair.py` | External feedback repair: parse + honest weave; CISSP blocked, SIEM unresolved-high. |
272
  | `scripts/verify_maximum_ats.py` | Maximum ATS Mode: PM/AIβ†’user-confirmed; certs/seniority/eng still gated; coverage improves; 65%β†’repairable. |
273
+ | `scripts/verify_max_ats_coverage.py` | Internal-96/external-54 regression: exported DOCX covers β‰₯90% includable PM terms; creds/seniority excluded; status by external coverage. |
274
  | `scripts/verify_90_pipeline.py` | Full fitβ†’repairβ†’status pipeline (stub floor). |
275
  | `scripts/verify_scoring_v2.py` | jd_analyzer + evidence + scoring + report + feedback loop. |
276
  | `scripts/evaluate_model_providers.py` | Run same jobs through each provider (Kimi/NVIDIA) β€” ready rate, avg independent, schema errors. **Needs real keys.** |
HISTORY.md CHANGED
@@ -4,6 +4,57 @@ A running log of everything built, fixed, and changed. Most recent first.
4
 
5
  ---
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## 2026-06-20 β€” Maximum ATS Mode (User-Confirmed Skill Expansion)
8
 
9
  Goal: for the candidate's target role family (Product / Product Manager / AI
 
4
 
5
  ---
6
 
7
+ ## 2026-06-20 (PM) β€” FIX: internal 96% but Jobalytics 54% (external-coverage bug)
8
+
9
+ Live failure: a PM/AI JD generated in Maximum ATS Mode scored internal **96%** /
10
+ independent 86% but **Jobalytics 54% (26/46, Hard Skills 24/43)**. Root cause:
11
+ the entire generate + repair loop optimised against our **narrow internal
12
+ taxonomy** (~25-30 terms β†’ 96%), while Jobalytics extracts a **broad 46-term set**.
13
+ Terms it wants that aren't in our taxonomy (`influence`, `backlog`, `business
14
+ development`, `corporate travel`, `expense management`, `payments`, …) were never
15
+ extracted, never placed β€” and `_build_skill_pool` even **sorted non-taxonomy terms
16
+ last and capped them at 44/50**. So internal was blind to ~20 external terms.
17
+
18
+ Fix β€” external coverage is now the success signal (internal score is NOT):
19
+ - **`src/external_ats.py` (NEW)**: `extract_external_keywords()` (broad,
20
+ Jobalytics-style expected set: taxonomy floor βˆͺ safe-vocab-in-JD βˆͺ filtered
21
+ JD bi/tri-grams βˆͺ pasted terms) + `external_coverage()` (found/expected/pct/
22
+ missing, measured from the **re-parsed exported text**).
23
+ - **`resume_customizer._maximize_external_coverage()` (NEW)**: in Maximum ATS
24
+ Mode, every includable broad/pasted term is honesty-gated through
25
+ `candidate_fit` (HIGH/BLOCKED excluded) then **physically guaranteed** into the
26
+ exported DOCX β€” Skills (verbatim) + woven into Experience bullets β€” then
27
+ re-rendered and re-measured. Produces a per-term debug report
28
+ (keyword / found_in_export / section / why-missing).
29
+ - **Caps relaxed in Max ATS**: `_build_skill_pool` no longer caps off includable
30
+ terms (cap 44/50 β†’ 200) and stops dropping non-taxonomy domain terms.
31
+ - **Status driven by external coverage**: `READY_MAX_ATS_95_PLUS` (cov β‰₯ 95 +
32
+ gates), `READY_90_PLUS_EXTERNAL_ALIGNED` (cov β‰₯ 90), else
33
+ `BELOW_TARGET_REPAIRABLE` β€” never silently accept internal-high/external-low.
34
+ - **`config.MAXIMUM_ATS_SAFE_TERMS`** expanded (influence, program/product
35
+ management, product marketing, business development, global teams, diverse
36
+ partners, data-driven decisions, etc.).
37
+ - **`api_server.py`**: `/api/generate` returns `external_coverage` +
38
+ `coverage_report` + `external_coverage_pct`; both endpoints log
39
+ `maximum_ats_mode` + status + coverage (task: confirm the flag reaches backend).
40
+ - **Extension v1.2.0**: shows External ATS % + estimated coverage count on
41
+ generate; failure panel now states "External ATS below target β€” internal score
42
+ is not enough" with the exact missing terms.
43
+
44
+ ### Verified (deterministic, no keys)
45
+ - `scripts/verify_max_ats_coverage.py` (NEW, locks the live failure): on a 26/46
46
+ Amazon-PM-style case, the **exported DOCX covers 25/26 = 96%** of includable PM
47
+ terms; CISSP + fake "12+ years" stay out; status `BELOW_TARGET_REPAIRABLE` (not
48
+ stuck NEEDS_REPAIR); per-term section report present.
49
+ - `scripts/verify_maximum_ats.py`: pasted-feedback coverage **17%β†’89%** (was 39%).
50
+ - All prior suites green: anti-cheat, feedback-repair, 90-pipeline, scoring-v2,
51
+ API parity + health.
52
+
53
+ > Honesty intact: placement is gated by `candidate_fit`; credentials, fake
54
+ > seniority, employers, and specialized eng/security terms are never forced.
55
+
56
+ ---
57
+
58
  ## 2026-06-20 β€” Maximum ATS Mode (User-Confirmed Skill Expansion)
59
 
60
  Goal: for the candidate's target role family (Product / Product Manager / AI
README.md CHANGED
@@ -324,9 +324,24 @@ confirmation"**, with a one-click **Confirm & regenerate**).
324
  `BELOW_TARGET_REPAIRABLE` (below target, remaining gaps LOW/MEDIUM β†’ keep
325
  repairing) Β· `NEEDS_USER_CONFIRMATION` (only high-risk-but-supportable terms left).
326
 
327
- Verify: `PYTHONPATH=. python scripts/verify_maximum_ats.py` (deterministic;
328
- proves PM/AI terms become user-confirmed while certs/seniority/engineering stay
329
- gated, coverage improves, and a 65%-style case is repaired not accepted).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
  ---
332
 
 
324
  `BELOW_TARGET_REPAIRABLE` (below target, remaining gaps LOW/MEDIUM β†’ keep
325
  repairing) Β· `NEEDS_USER_CONFIRMATION` (only high-risk-but-supportable terms left).
326
 
327
+ **External-coverage is the success signal (not the internal score).** Our internal
328
+ scorer uses a narrow taxonomy (β†’ 90%+ easily); external checkers extract a broad
329
+ 40-46 term set (β†’ can be ~54% on the same resume). So in Maximum ATS Mode the
330
+ system builds a broad Jobalytics-style **expected** set (`src/external_ats.py`),
331
+ **physically guarantees** every *includable* term into the exported DOCX (Skills
332
+ verbatim + woven into Experience bullets; HIGH/BLOCKED stay gated), re-parses the
333
+ file, and **measures coverage from the export**. Status is then `READY_MAX_ATS_95_PLUS`
334
+ (β‰₯95) / `READY_90_PLUS_EXTERNAL_ALIGNED` (β‰₯90) / `BELOW_TARGET_REPAIRABLE` β€” an
335
+ internal-96 / external-54 result is treated as a bug, never "done". Both endpoints
336
+ return `external_coverage` + a per-term `coverage_report` (keyword / found in export
337
+ / section / why-missing) and log `maximum_ats_mode`.
338
+
339
+ Verify (deterministic, no keys):
340
+ - `PYTHONPATH=. python scripts/verify_maximum_ats.py` β€” PM/AI terms become
341
+ user-confirmed; certs/seniority/engineering stay gated; coverage improves.
342
+ - `PYTHONPATH=. python scripts/verify_max_ats_coverage.py` β€” the 26/46 live-failure
343
+ regression: exported DOCX covers β‰₯90% of includable PM terms; credentials/fake
344
+ seniority excluded; status driven by external coverage.
345
 
346
  ---
347
 
api_server.py CHANGED
@@ -240,12 +240,19 @@ async def generate(
240
  },
241
  "quality_flag": v2_report.get("quality_flag", ""),
242
  "review_terms": v2_report.get("review_terms", []),
243
- "high_risk_terms": v2_report.get("high_risk_terms", []),
 
244
  "maximum_ats_mode": max_ats,
 
 
 
245
  "keyword_coverage": v2_report.get("keyword_coverage", {}),
246
  "docx_b64": docx_b64,
247
  "pdf_b64": pdf_b64,
248
  }
 
 
 
249
  return JSONResponse(response_payload)
250
 
251
  except HTTPException:
@@ -368,6 +375,9 @@ async def repair_with_feedback(
368
  None, repair_resume_for_api, pdf_bytes, jd_text, job_title, company,
369
  feedback, mk, ext, max_ats, conf_terms, tgt,
370
  )
 
 
 
371
  if result.get("error"):
372
  return JSONResponse(
373
  {"error": result["error"], "detail": result.get("detail", "")},
 
240
  },
241
  "quality_flag": v2_report.get("quality_flag", ""),
242
  "review_terms": v2_report.get("review_terms", []),
243
+ "high_risk_terms": v2_report.get("high_risk_terms",
244
+ v2_report.get("high_risk_terms_for_confirmation", [])),
245
  "maximum_ats_mode": max_ats,
246
+ "external_coverage": v2_report.get("external_coverage", {}),
247
+ "external_coverage_pct": v2_report.get("external_coverage_pct"),
248
+ "coverage_report": v2_report.get("coverage_report", {}),
249
  "keyword_coverage": v2_report.get("keyword_coverage", {}),
250
  "docx_b64": docx_b64,
251
  "pdf_b64": pdf_b64,
252
  }
253
+ print(f"[api/generate] maximum_ats_mode={max_ats} status={response_payload['status']} "
254
+ f"internal={response_payload['scores'].get('jd_match')} "
255
+ f"external_cov={response_payload.get('external_coverage_pct')}")
256
  return JSONResponse(response_payload)
257
 
258
  except HTTPException:
 
375
  None, repair_resume_for_api, pdf_bytes, jd_text, job_title, company,
376
  feedback, mk, ext, max_ats, conf_terms, tgt,
377
  )
378
+ print(f"[api/repair] maximum_ats_mode={max_ats} status={result.get('status')} "
379
+ f"after_cov={result.get('after_coverage', {}).get('pct')} "
380
+ f"added={len(result.get('added_terms', []))}")
381
  if result.get("error"):
382
  return JSONResponse(
383
  {"error": result["error"], "detail": result.get("detail", "")},
config.py CHANGED
@@ -63,6 +63,15 @@ MAXIMUM_ATS_SAFE_TERMS = {
63
  "startup environment", "startup", "customer value", "adoption",
64
  "retention", "growth", "engagement", "conversion", "monetization",
65
  "revenue", "customer success", "onboarding",
 
 
 
 
 
 
 
 
 
66
  }
67
 
68
  # Primary key β€” GLM 5.1 (most reliable, used for resume parsing + customization)
 
63
  "startup environment", "startup", "customer value", "adoption",
64
  "retention", "growth", "engagement", "conversion", "monetization",
65
  "revenue", "customer success", "onboarding",
66
+ # Cross-functional PM responsibilities & leadership craft (interview-supportable)
67
+ "influence", "influencing", "program management", "product management",
68
+ "product marketing", "business development", "global teams",
69
+ "diverse partners", "customer results", "customer outcomes", "technology",
70
+ "data-driven decisions", "data driven decisions", "decision making",
71
+ "strategic planning", "execution", "delivery", "collaboration",
72
+ "communication", "leadership", "mentoring", "negotiation", "presentation",
73
+ "stakeholders", "roadmaps", "feature definition", "release management",
74
+ "product launch", "market research", "voice of customer",
75
  }
76
 
77
  # Primary key β€” GLM 5.1 (most reliable, used for resume parsing + customization)
extension/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
  {
2
  "manifest_version": 3,
3
  "name": "ATS Resume Generator",
4
- "version": "1.1.0",
5
  "description": "Tailors your resume to any job posting using the ATS pipeline.",
6
  "permissions": [
7
  "storage",
 
1
  {
2
  "manifest_version": 3,
3
  "name": "ATS Resume Generator",
4
+ "version": "1.2.0",
5
  "description": "Tailors your resume to any job posting using the ATS pipeline.",
6
  "permissions": [
7
  "storage",
extension/popup/popup.js CHANGED
@@ -298,10 +298,14 @@ function applyResult(result) {
298
  document.getElementById('score-ats').textContent = (result.scores.ats_readability ?? 'β€”') + '%';
299
  document.getElementById('score-ind').textContent = (result.scores.independent_jd_match ?? 'β€”') + '%';
300
  const extEl = document.getElementById('score-ext');
301
- if (extEl) extEl.textContent = result.external_score != null ? result.external_score + '%' : 'β€”';
 
 
302
  const covEl = document.getElementById('score-cov');
303
  const cov = result.coverage_report || {};
304
- if (covEl) covEl.textContent = cov.coverage_count || 'β€”';
 
 
305
  scoresEl.style.display = 'block';
306
  }
307
 
@@ -334,11 +338,14 @@ function renderCoverage(result) {
334
  const panel = document.getElementById('coverage-panel');
335
  if (!panel) return;
336
  const cov = result.coverage_report;
 
337
  const added = result.added_terms || [];
338
- const missing = result.still_missing_repairable || [];
 
 
339
  const confirm = result.unresolved_high_risk_terms || [];
340
  const blocked = result.blocked_terms || [];
341
- if (!cov && !added.length && !confirm.length && !blocked.length) {
342
  panel.innerHTML = '';
343
  return;
344
  }
@@ -347,6 +354,10 @@ function renderCoverage(result) {
347
  const pct = cov.after.pct ?? 0;
348
  parts.push(`<div class="cov-line"><b>Keyword coverage:</b> ${cov.coverage_count} (${pct}%)</div>`);
349
  parts.push(`<div class="cov-bar"><div style="width:${Math.min(100, pct)}%"></div></div>`);
 
 
 
 
350
  }
351
  if (added.length)
352
  parts.push(`<div class="cov-line tag-added">βœ“ Added (${added.length}): ${added.slice(0,12).join(', ')}</div>`);
@@ -366,13 +377,21 @@ function renderExplanation(result) {
366
  const readyStatuses = ['READY_90_PLUS', 'READY_95_EXTERNAL_ALIGNED',
367
  'READY_MAX_ATS_95_PLUS', 'READY_90_PLUS_EXTERNAL_ALIGNED'];
368
  const isReady = readyStatuses.includes(result.status);
 
 
 
369
  if (isReady) {
370
  panel.innerHTML = '';
371
  } else {
372
  const why = result.below_target_explanation
373
- || `Below target (${result.status}). Improve with pasted ATS feedback to close the gap.`;
374
  const cls = result.status === 'BELOW_TARGET_REPAIRABLE' ? 'explain-yellow' : 'explain-red';
375
- panel.innerHTML = `<div class="explain-panel ${cls}"><b>Why below 90:</b> ${why}</div>`;
 
 
 
 
 
376
  }
377
  // Show "confirm these terms" only when high-risk-but-supportable terms remain.
378
  const confirmTerms = result.unresolved_high_risk_terms || [];
 
298
  document.getElementById('score-ats').textContent = (result.scores.ats_readability ?? 'β€”') + '%';
299
  document.getElementById('score-ind').textContent = (result.scores.independent_jd_match ?? 'β€”') + '%';
300
  const extEl = document.getElementById('score-ext');
301
+ const extPct = result.external_score != null ? result.external_score
302
+ : (result.external_coverage_pct != null ? result.external_coverage_pct : null);
303
+ if (extEl) extEl.textContent = extPct != null ? extPct + '%' : 'β€”';
304
  const covEl = document.getElementById('score-cov');
305
  const cov = result.coverage_report || {};
306
+ const ec = result.external_coverage || {};
307
+ if (covEl) covEl.textContent = cov.coverage_count
308
+ || (ec.found != null ? `${ec.found}/${ec.expected}` : 'β€”');
309
  scoresEl.style.display = 'block';
310
  }
311
 
 
338
  const panel = document.getElementById('coverage-panel');
339
  if (!panel) return;
340
  const cov = result.coverage_report;
341
+ const ec = result.external_coverage || {};
342
  const added = result.added_terms || [];
343
+ // generate-path missing comes from external_coverage.missing
344
+ const missing = (result.still_missing_repairable && result.still_missing_repairable.length)
345
+ ? result.still_missing_repairable : (ec.missing || []);
346
  const confirm = result.unresolved_high_risk_terms || [];
347
  const blocked = result.blocked_terms || [];
348
+ if (!cov && !ec.found && !added.length && !confirm.length && !blocked.length) {
349
  panel.innerHTML = '';
350
  return;
351
  }
 
354
  const pct = cov.after.pct ?? 0;
355
  parts.push(`<div class="cov-line"><b>Keyword coverage:</b> ${cov.coverage_count} (${pct}%)</div>`);
356
  parts.push(`<div class="cov-bar"><div style="width:${Math.min(100, pct)}%"></div></div>`);
357
+ } else if (ec.found != null) {
358
+ const pct = ec.pct ?? 0;
359
+ parts.push(`<div class="cov-line"><b>External coverage (estimated):</b> ${ec.found}/${ec.expected} (${pct}%)</div>`);
360
+ parts.push(`<div class="cov-bar"><div style="width:${Math.min(100, pct)}%"></div></div>`);
361
  }
362
  if (added.length)
363
  parts.push(`<div class="cov-line tag-added">βœ“ Added (${added.length}): ${added.slice(0,12).join(', ')}</div>`);
 
377
  const readyStatuses = ['READY_90_PLUS', 'READY_95_EXTERNAL_ALIGNED',
378
  'READY_MAX_ATS_95_PLUS', 'READY_90_PLUS_EXTERNAL_ALIGNED'];
379
  const isReady = readyStatuses.includes(result.status);
380
+ const ec = result.external_coverage || {};
381
+ const extPct = result.external_score != null ? result.external_score
382
+ : (result.external_coverage_pct != null ? result.external_coverage_pct : ec.pct);
383
  if (isReady) {
384
  panel.innerHTML = '';
385
  } else {
386
  const why = result.below_target_explanation
387
+ || `Below target (${result.status}). Paste your Jobalytics output below and Improve to close the gap.`;
388
  const cls = result.status === 'BELOW_TARGET_REPAIRABLE' ? 'explain-yellow' : 'explain-red';
389
+ const head = (extPct != null && extPct < 90)
390
+ ? `External ATS below target (${extPct}%). Internal score is not enough.`
391
+ : 'External ATS below target. Internal score is not enough.';
392
+ panel.innerHTML =
393
+ `<div class="explain-panel ${cls}"><b>${head}</b><br>` +
394
+ `Paste Jobalytics feedback and Improve.<br><span style="opacity:.85">${why}</span></div>`;
395
  }
396
  // Show "confirm these terms" only when high-risk-but-supportable terms remain.
397
  const confirmTerms = result.unresolved_high_risk_terms || [];
scripts/verify_max_ats_coverage.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hard regression for the LIVE FAILURE: internal 96% but Jobalytics 54% (26/46,
3
+ Hard Skills 24/43) on an Amazon/Product-Manager-type JD.
4
+
5
+ This locks in the fix: in Maximum ATS Mode the EXPORTED DOCX must physically
6
+ contain 90%+ of the INCLUDABLE external keywords (Skills + Experience), driven by
7
+ external coverage β€” NOT by the internal score β€” while credentials / fake
8
+ seniority stay blocked. Deterministic (StubProvider, no API keys).
9
+ """
10
+ import os, sys, io, shutil
11
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
12
+ sys.path.insert(0, os.path.abspath("."))
13
+
14
+ import src.candidate_vault as _cv
15
+ _cv._VAULT_PATH = "data/_test_vault_maxcov.json"
16
+ if os.path.exists(_cv._VAULT_PATH):
17
+ os.remove(_cv._VAULT_PATH)
18
+
19
+ import shutil as _sh
20
+ src_pdf = r"C:\Users\Nxtwave\Desktop\resume\Saiteja_Tirunagari_Resume A 26 - Copy.pdf"
21
+ dst = "data/resume/resume.pdf"
22
+ os.makedirs("data/resume", exist_ok=True)
23
+ if not os.path.exists(dst) and os.path.exists(src_pdf):
24
+ _sh.copyfile(src_pdf, dst)
25
+
26
+ from src.resume_parser_v2 import parse_resume_pdf
27
+ from src.providers import StubProvider
28
+ from src.jobalytics_repair import repair_with_external_feedback
29
+ from src.resume_customizer import _read_docx_text
30
+ from src.ats_scorer import _kw_in_text
31
+ from src.fit_gate import (READY, READY_REVIEW, READY_MAX_ATS_95_PLUS,
32
+ READY_90_PLUS_EXTERNAL_ALIGNED, BELOW_TARGET_REPAIRABLE,
33
+ NEEDS_USER_CONFIRMATION)
34
+
35
+ ok = True
36
+ def check(name, cond, detail=""):
37
+ global ok; ok = ok and cond
38
+ print(f" [{'PASS' if cond else 'FAIL'}] {name} {detail}")
39
+
40
+ # Normal PM/Product terms a real AI/PM JD wants (from the live screenshot) β€” all
41
+ # interview-supportable, none are credentials.
42
+ INCLUDABLE = [
43
+ "product strategy", "ai", "influence", "acceptance criteria", "backlog",
44
+ "program management", "product management", "product marketing",
45
+ "business development", "global teams", "diverse partners",
46
+ "corporate travel", "expense management", "payments", "roadmap",
47
+ "stakeholder management", "requirements", "user stories", "prioritization",
48
+ "data-driven decisions", "metrics", "experimentation", "product discovery",
49
+ "go-to-market", "saas", "b2b",
50
+ ]
51
+ # Must NEVER be fabricated.
52
+ BLOCKED = ["CISSP", "12+ years"]
53
+
54
+ jd = open("tests/fixtures/jds/generic_pm_3_7yrs.txt", encoding="utf-8").read()
55
+ jd += ("\n\nResponsibilities: product strategy, roadmap, backlog, user stories, "
56
+ "acceptance criteria, prioritization, stakeholder management, requirements, "
57
+ "program management, product marketing, business development, global teams, "
58
+ "diverse partners, corporate travel, expense management, payments, metrics, "
59
+ "experimentation, data-driven decisions, go-to-market, product discovery. "
60
+ "Hard skills: AI, SaaS, B2B, influence. 12+ years required. CISSP preferred.")
61
+
62
+ base = parse_resume_pdf(dst)
63
+ job = {"title": "Product Manager", "company": "Amazon", "description": jd, "_raw_assessment": {}}
64
+
65
+ fb = ("Match score 54%. Resume has 26 out of 46 keywords. Hard Skills 24 of 43. "
66
+ "Missing keywords: " + ", ".join(INCLUDABLE + BLOCKED) + ".")
67
+
68
+ res = repair_with_external_feedback(job, feedback_text=fb, provider=StubProvider(),
69
+ base_resume=base, maximum_ats_mode=True,
70
+ output_dir="data/output/resumes/_maxcov_test")
71
+ print("repair status:", res.get("status"), "scores:", res.get("scores"),
72
+ "after_cov:", res.get("after_coverage", {}).get("pct"))
73
+ check("no error", "error" not in res, res.get("error", ""))
74
+
75
+ # THE core assertion: the EXPORTED DOCX physically contains 90%+ of includable terms.
76
+ txt = _read_docx_text(res["resume_path"]).lower()
77
+ found = [t for t in INCLUDABLE if _kw_in_text(t, txt)]
78
+ missing = [t for t in INCLUDABLE if t not in found]
79
+ pct = int(round(100 * len(found) / len(INCLUDABLE)))
80
+ check("exported DOCX covers >=90% of includable PM terms", pct >= 90,
81
+ f"{len(found)}/{len(INCLUDABLE)} = {pct}% missing={missing}")
82
+
83
+ # Honesty: credentials / fake seniority never fabricated.
84
+ check("CISSP NOT in exported DOCX", "cissp" not in txt)
85
+ check("no fabricated '12+ years'", "12+ years" not in txt and "12 years" not in txt)
86
+ check("CISSP surfaced as blocked", "CISSP" in res.get("blocked_terms", []))
87
+
88
+ # Status must NOT be a stuck NEEDS_REPAIR β€” external coverage drives it.
89
+ good_status = {READY, READY_REVIEW, READY_MAX_ATS_95_PLUS,
90
+ READY_90_PLUS_EXTERNAL_ALIGNED, BELOW_TARGET_REPAIRABLE,
91
+ NEEDS_USER_CONFIRMATION}
92
+ check("status driven by external coverage (not stuck NEEDS_REPAIR)",
93
+ res.get("status") in good_status, res.get("status"))
94
+
95
+ # Coverage report shows where each term landed.
96
+ cov = res.get("coverage_report", {})
97
+ kw = cov.get("keywords", [])
98
+ check("coverage_report lists per-term sections", bool(kw),
99
+ f"{len(kw)} terms reported")
100
+ secs = {k.get("section") for k in kw if k.get("placed_in_resume") or k.get("found_in_export")}
101
+ check("terms placed in Skills AND Experience (multi-section)",
102
+ ("Skills" in secs and any("Experience" in s for s in secs)) or len(secs) >= 2,
103
+ f"sections={secs}")
104
+
105
+ print("\n" + ("\u2713 LIVE-FAILURE REGRESSION PASSES" if ok else "\u2717 SOME FAILED"))
106
+ shutil.rmtree("data/output/resumes/_maxcov_test", ignore_errors=True)
107
+ if os.path.exists(_cv._VAULT_PATH):
108
+ os.remove(_cv._VAULT_PATH)
109
+ sys.exit(0 if ok else 1)
src/external_ats.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ External-ATS coverage simulator (Jobalytics/Simplify-style breadth).
3
+
4
+ WHY THIS EXISTS
5
+ ───────────────
6
+ Our internal scorer extracts keywords from a NARROW PM taxonomy and scores the
7
+ resume against that same narrow set β†’ it easily reports 90%+. External checkers
8
+ (Jobalytics/Simplify) extract a MUCH broader set (40-46 terms incl. domain words,
9
+ JD-specific responsibilities, soft skills) β†’ the same resume scores ~54%. The
10
+ internal number is therefore NOT a valid success signal for the external goal.
11
+
12
+ This module computes a broad, Jobalytics-style EXPECTED keyword set from the JD,
13
+ and measures coverage against the *re-parsed exported resume text* (the only
14
+ truth). Used to (a) decide what to physically place in Maximum ATS Mode and
15
+ (b) gate/report readiness on real external-style coverage, not internal score.
16
+
17
+ It is intentionally broader than `extract_jd_keywords` (which stays skills-only
18
+ to keep the honest default pipeline clean). The broad set is only used in
19
+ Maximum ATS Mode / external-feedback repair, where the user has explicitly opted
20
+ into aggressive coverage.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from typing import Dict, List
26
+
27
+ from .ats_scorer import _kw_in_text, extract_jd_keywords
28
+
29
+ # Words that are never standalone keywords (checkers penalise/ignore them).
30
+ _STOP = {
31
+ "the", "and", "for", "with", "you", "your", "our", "are", "will", "have",
32
+ "has", "had", "this", "that", "these", "those", "from", "into", "out", "off",
33
+ "who", "what", "when", "where", "why", "how", "all", "any", "can", "may",
34
+ "should", "would", "could", "must", "able", "etc", "per", "via", "not",
35
+ "but", "they", "them", "their", "his", "her", "its", "she", "him", "was",
36
+ "were", "been", "being", "more", "most", "such", "than", "then", "also",
37
+ "about", "across", "within", "while", "each", "other", "some", "many",
38
+ "well", "very", "much", "like", "just", "only", "even", "both", "over",
39
+ "under", "between", "during", "including", "include", "includes",
40
+ "looking", "join", "join us", "work", "working", "team", "teams", "role",
41
+ "job", "company", "candidate", "candidates", "ideal", "great", "good",
42
+ "strong", "years", "year", "experience", "experiences", "plus", "preferred",
43
+ "required", "requirement", "responsibilities", "qualifications", "skills",
44
+ "ability", "knowledge", "understanding", "familiarity", "proficiency",
45
+ "we", "us", "is", "in", "on", "of", "to", "at", "as", "an", "or", "be",
46
+ "it", "by", "a", "i",
47
+ }
48
+ # Vague filler β€” never a keyword (mirrors jd_analyzer._BUZZWORDS intent).
49
+ _FILLER = {
50
+ "innovation", "innovative", "solution", "solutions", "tools", "tool",
51
+ "ownership", "synergy", "dynamic", "passionate", "motivated", "self-starter",
52
+ "results-driven", "detail-oriented", "team player", "track record",
53
+ "expertise", "best practices", "thought leadership", "fast-paced",
54
+ "cutting-edge", "world-class", "robust", "seamless", "holistic", "leverage",
55
+ "excellence", "proven", "successful", "goals", "productivity", "reinvent",
56
+ "reinvention", "mission", "vision", "culture", "value", "values", "impact",
57
+ "environment", "opportunity", "opportunities", "responsibility",
58
+ }
59
+
60
+
61
+ def _clean(term: str) -> str:
62
+ return re.sub(r"[^\w\s/+.\-]", "", term or "").strip().lower()
63
+
64
+
65
+ def _is_term_like(t: str) -> bool:
66
+ """A token/phrase that reads like a real skill/responsibility/domain term."""
67
+ t = t.strip()
68
+ if not t or t in _STOP or t in _FILLER:
69
+ return False
70
+ words = t.split()
71
+ if len(words) > 4:
72
+ return False
73
+ if len(t) < 3:
74
+ return False
75
+ if t.isdigit():
76
+ return False
77
+ # All words must be non-stop and not lemmatizer artifacts.
78
+ for w in words:
79
+ if w in _STOP:
80
+ return False
81
+ if len(t) >= 5 and t.endswith(("at", "iz", "ic")) and len(words) == 1:
82
+ return False # "integrat", "automat", "operat"
83
+ return True
84
+
85
+
86
+ def extract_external_keywords(jd_text: str, extra: List[str] = None) -> List[str]:
87
+ """Broad, Jobalytics-style expected keyword set for a JD.
88
+
89
+ Union of: the reliable taxonomy floor (`extract_jd_keywords`), curated
90
+ Maximum-ATS safe terms present in the JD, and term-like 1-3 word phrases
91
+ pulled from the JD body (filtered against stopwords/filler). De-duplicated,
92
+ lowercase. `extra` (e.g. pasted external missing/matched terms) is unioned in.
93
+ """
94
+ jd_text = jd_text or ""
95
+ jd_low = jd_text.lower()
96
+ out: List[str] = []
97
+ seen = set()
98
+
99
+ def _add(term: str):
100
+ t = _clean(term)
101
+ if t and t not in seen and _is_term_like(t):
102
+ seen.add(t)
103
+ out.append(t)
104
+
105
+ # 1. Reliable taxonomy floor.
106
+ for k in extract_jd_keywords(jd_text):
107
+ _add(k)
108
+
109
+ # 2. Curated safe vocabulary that actually appears in this JD.
110
+ try:
111
+ from config import MAXIMUM_ATS_SAFE_TERMS as _SAFE
112
+ except Exception:
113
+ _SAFE = set()
114
+ for t in _SAFE:
115
+ if t in jd_low:
116
+ _add(t)
117
+
118
+ # 3. Term-like phrases from the JD body. Pull capitalised/section phrases and
119
+ # notable bigrams/trigrams. Conservative: only multiword phrases whose
120
+ # words are all alphabetic and non-stop, plus known-good single nouns.
121
+ # (Single-noun garbage is filtered by _is_term_like + the dedup below.)
122
+ # Bi/tri-grams of alphabetic words.
123
+ words = re.findall(r"[a-zA-Z][a-zA-Z\-/+.]{1,}", jd_low)
124
+ for n in (3, 2):
125
+ for i in range(len(words) - n + 1):
126
+ gram = " ".join(words[i:i + n])
127
+ if all(w not in _STOP and w not in _FILLER for w in words[i:i + n]):
128
+ # Only keep grams that recur or look like a skill phrase.
129
+ if jd_low.count(gram) >= 1 and len(gram) <= 34:
130
+ # Skip grams that are mostly filler-ish single words joined.
131
+ _add(gram)
132
+
133
+ # 4. Pasted external terms (ground truth from a checker).
134
+ for t in (extra or []):
135
+ _add(t)
136
+
137
+ return out
138
+
139
+
140
+ def external_coverage(expected: List[str], exported_text: str) -> Dict:
141
+ """Measure coverage of `expected` terms against the exported resume text.
142
+
143
+ Returns {expected, found, pct, present, missing}. Matching uses the same
144
+ word-boundary/phrase logic as scoring (`_kw_in_text`).
145
+ """
146
+ text = (exported_text or "").lower()
147
+ exp = []
148
+ seen = set()
149
+ for t in expected:
150
+ tl = _clean(t)
151
+ if tl and tl not in seen:
152
+ seen.add(tl)
153
+ exp.append(tl)
154
+ present = [t for t in exp if _kw_in_text(t, text)]
155
+ missing = [t for t in exp if t not in present]
156
+ total = len(exp)
157
+ return {
158
+ "expected": total,
159
+ "found": len(present),
160
+ "pct": int(round(100 * len(present) / max(1, total))),
161
+ "present": present,
162
+ "missing": missing,
163
+ }
src/resume_customizer.py CHANGED
@@ -948,6 +948,103 @@ class ResumeCustomizer:
948
  pBdr.append(bottom)
949
  pPr.append(pBdr)
950
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
951
  def _weave_keywords_into_bullets(self, resume, missing_keywords: list,
952
  jd_text: str) -> None:
953
  """
@@ -1491,7 +1588,12 @@ class ResumeCustomizer:
1491
  and sl not in [x.lower() for x in include_pool]):
1492
  include_pool.append(sl)
1493
 
1494
- def _build_skill_pool(terms, cap=44):
 
 
 
 
 
1495
  pool = []
1496
  for k in terms:
1497
  kl = k.lower()
@@ -1596,7 +1698,8 @@ class ResumeCustomizer:
1596
  except Exception:
1597
  pass
1598
  tailored.skills = _build_skill_pool(
1599
- list(tailored.skills) + add, cap=50)
 
1600
  parsed, report, valid, missing_parts, val = _render_score()
1601
  else:
1602
  # Loop exhausted. Re-check the FINAL render (the last repair may
@@ -1629,6 +1732,39 @@ class ResumeCustomizer:
1629
  except Exception:
1630
  pass
1631
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1632
  # Postcondition + attach the v2 report/status to the job (for UI/Sheets)
1633
  try:
1634
  self._assert_no_dump_footer(filepath)
@@ -1704,10 +1840,41 @@ class ResumeCustomizer:
1704
  # (not auto-claimed). Always surfaced for transparency.
1705
  report.setdefault("high_risk_terms_for_confirmation",
1706
  locals().get("high_pool", []))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1707
  job["_v2_report"] = report
1708
  job["_v2_status"] = status
1709
  job["quality_flag"] = quality
1710
- job["download_allowed"] = download_allowed
1711
  job["independent_jd_match"] = independent_jm
1712
 
1713
  try:
@@ -2224,3 +2391,4 @@ class ResumeCustomizer:
2224
 
2225
  doc.save(filepath)
2226
  return filepath
 
 
948
  pBdr.append(bottom)
949
  pPr.append(pBdr)
950
 
951
+ def _maximize_external_coverage(self, resume, filepath: str, jd_text: str,
952
+ base_text: str, include_pool=None,
953
+ excluded_kw=None, pasted_terms=None,
954
+ confirmed_terms=None) -> dict:
955
+ """Maximum ATS Mode: GUARANTEE includable external-style keywords appear
956
+ in the exported DOCX, then re-render.
957
+
958
+ The internal score is not a valid external signal, so we drive placement
959
+ off a broad Jobalytics-style EXPECTED set (+ any pasted external terms).
960
+ Every term is honesty-gated through `candidate_fit` (max mode): only
961
+ action=="include" terms are placed; HIGH/BLOCKED (certs, seniority,
962
+ employers, engineering, specialized) are NEVER forced. Includable terms
963
+ are placed in Skills (reliable verbatim vehicle) AND woven into bullets
964
+ for evidence. Returns the expected set + a per-term debug report.
965
+ """
966
+ from .external_ats import extract_external_keywords, external_coverage
967
+ from .candidate_fit import classify_fit
968
+ from .jd_analyzer import Requirement, _categorize
969
+ from .resume_renderer import render_resume_docx
970
+ from .ats_scorer import _kw_in_text
971
+
972
+ include_pool = include_pool or []
973
+ excluded_kw = {e.lower() for e in (excluded_kw or set())}
974
+ pasted_terms = pasted_terms or []
975
+ confirmed = {t.lower().strip() for t in (confirmed_terms or [])}
976
+ jd_low = jd_text.lower()
977
+
978
+ # Broad expected set = Jobalytics-style extraction βˆͺ our LOW/MEDIUM JD
979
+ # terms βˆͺ pasted external terms (ground truth from a checker).
980
+ expected = extract_external_keywords(
981
+ jd_text, extra=list(include_pool) + list(pasted_terms))
982
+ expected = list(dict.fromkeys([e.lower() for e in expected]))
983
+
984
+ cur_text = _read_docx_text(filepath).lower()
985
+ missing = [t for t in expected if not _kw_in_text(t, cur_text)]
986
+
987
+ # Honesty gate every missing term (max mode). Only include the safe ones.
988
+ includable, gated = [], {}
989
+ for t in missing:
990
+ if t in excluded_kw:
991
+ gated[t] = "high-risk/blocked (gated by fit classifier)"
992
+ continue
993
+ if t in self._BUZZWORDS or t in self._KEYWORD_BLOCKLIST:
994
+ gated[t] = "buzzword/blocklisted (checkers penalise)"
995
+ continue
996
+ r = Requirement(term=t, category=_categorize(t))
997
+ v = classify_fit(r, base_text, maximum_ats_mode=True, confirmed=confirmed)
998
+ if v.action == "include":
999
+ includable.append(t)
1000
+ else:
1001
+ gated[t] = f"{v.action}: {v.reason}"
1002
+
1003
+ # Place: weave the JD-relevant ones into bullets (evidence) + GUARANTEE
1004
+ # all includable terms in Skills (verbatim β†’ external checkers count them).
1005
+ weave = [t for t in includable if t in jd_low][:28]
1006
+ if weave:
1007
+ try:
1008
+ self._weave_keywords_into_bullets(resume, weave, jd_text)
1009
+ except Exception:
1010
+ pass
1011
+ cur_skills = {s.lower() for s in resume.skills}
1012
+ add_skills = [t for t in includable if t.lower() not in cur_skills]
1013
+ resume.skills = self._dedup_keywords_by_lemma(list(resume.skills) + add_skills)
1014
+
1015
+ render_resume_docx(resume, filepath)
1016
+
1017
+ # Measure from the re-parsed export (the only truth) + per-term report.
1018
+ final_text = _read_docx_text(filepath)
1019
+ cov = external_coverage(expected, final_text)
1020
+ present_set = {p.lower() for p in cov["present"]}
1021
+ exp_low = "\n".join(b for r in resume.roles for b in r.bullets).lower()
1022
+ skills_low = " ".join(resume.skills).lower()
1023
+ keywords = []
1024
+ for t in expected:
1025
+ found = t in present_set
1026
+ if found:
1027
+ if _kw_in_text(t, exp_low):
1028
+ section = "Experience bullets"
1029
+ elif _kw_in_text(t, skills_low):
1030
+ section = "Skills"
1031
+ else:
1032
+ section = "Summary/other"
1033
+ else:
1034
+ section = "(not placed)"
1035
+ keywords.append({
1036
+ "keyword": t,
1037
+ "found_in_export": found,
1038
+ "section": section,
1039
+ "reason": "" if found else gated.get(t, "could not place cleanly"),
1040
+ })
1041
+ cov["expected_terms"] = expected
1042
+ cov["includable"] = includable
1043
+ cov["gated"] = gated
1044
+ cov["keywords"] = keywords
1045
+ cov["coverage_count"] = f"{cov['found']}/{cov['expected']}"
1046
+ return cov
1047
+
1048
  def _weave_keywords_into_bullets(self, resume, missing_keywords: list,
1049
  jd_text: str) -> None:
1050
  """
 
1588
  and sl not in [x.lower() for x in include_pool]):
1589
  include_pool.append(sl)
1590
 
1591
+ def _build_skill_pool(terms, cap=None):
1592
+ # Maximum ATS Mode: do NOT cap off includable JD/external terms β€”
1593
+ # external checkers count every term, and dropping domain terms
1594
+ # (sorted last) is exactly what caused internal-high/external-low.
1595
+ if cap is None:
1596
+ cap = 200 if _max_ats else 44
1597
  pool = []
1598
  for k in terms:
1599
  kl = k.lower()
 
1698
  except Exception:
1699
  pass
1700
  tailored.skills = _build_skill_pool(
1701
+ list(tailored.skills) + add,
1702
+ cap=(200 if locals().get("_max_ats") else 50))
1703
  parsed, report, valid, missing_parts, val = _render_score()
1704
  else:
1705
  # Loop exhausted. Re-check the FINAL render (the last repair may
 
1732
  except Exception:
1733
  pass
1734
 
1735
+ # ── Maximum ATS Mode: guarantee external-style keyword coverage ──────
1736
+ # The internal score is NOT a valid external signal (internal 96 vs
1737
+ # Jobalytics 54). Force every INCLUDABLE broad/pasted term physically
1738
+ # into the exported DOCX, re-render, and measure coverage from the
1739
+ # re-parsed file. HIGH/BLOCKED stay gated (no fabrication).
1740
+ ext_cov = None
1741
+ if job.get("_maximum_ats_mode") and report:
1742
+ try:
1743
+ ext_cov = self._maximize_external_coverage(
1744
+ tailored, filepath, jd_text,
1745
+ locals().get("base_text", base_resume.to_flat_text()),
1746
+ include_pool=locals().get("include_pool", []),
1747
+ excluded_kw=locals().get("_excluded_kw", set()),
1748
+ pasted_terms=job.get("_jobalytics_keywords") or [],
1749
+ confirmed_terms=job.get("_confirmed_terms") or [],
1750
+ )
1751
+ # Re-score the re-rendered file so the report reflects the export.
1752
+ parsed, report2, valid, missing_parts, val = _render_score()
1753
+ # Preserve fields the status block expects, then merge.
1754
+ for k, v in report2.items():
1755
+ report[k] = v
1756
+ report["external_coverage"] = ext_cov
1757
+ report["coverage_report"] = {
1758
+ "external_keywords_total": ext_cov.get("expected"),
1759
+ "coverage_count": ext_cov.get("coverage_count"),
1760
+ "found": ext_cov.get("found"),
1761
+ "missing": ext_cov.get("missing", [])[:40],
1762
+ "keywords": ext_cov.get("keywords", []),
1763
+ "gated": ext_cov.get("gated", {}),
1764
+ }
1765
+ except Exception as _me:
1766
+ print(f"[max-ats] {_me}")
1767
+
1768
  # Postcondition + attach the v2 report/status to the job (for UI/Sheets)
1769
  try:
1770
  self._assert_no_dump_footer(filepath)
 
1840
  # (not auto-claimed). Always surfaced for transparency.
1841
  report.setdefault("high_risk_terms_for_confirmation",
1842
  locals().get("high_pool", []))
1843
+ # ── Maximum ATS Mode: status is driven by EXTERNAL coverage, not the
1844
+ # internal score. Don't accept internal-96/external-low as done.
1845
+ if job.get("_maximum_ats_mode") and ext_cov is not None:
1846
+ from .fit_gate import (READY_MAX_ATS_95_PLUS,
1847
+ READY_90_PLUS_EXTERNAL_ALIGNED,
1848
+ BELOW_TARGET_REPAIRABLE)
1849
+ try:
1850
+ from config import MAXIMUM_ATS as _MAXCFG
1851
+ _tgt = _MAXCFG.get("target_external_score", 95)
1852
+ _min = _MAXCFG.get("min_external_score", 90)
1853
+ except Exception:
1854
+ _tgt, _min = 95, 90
1855
+ ext_pct = ext_cov.get("pct", 0)
1856
+ gates_ok = (internal_jm >= 90 and independent_jm >= 90
1857
+ and report.get("estimated_scores", {}).get("ats_readability", 0) >= 90)
1858
+ if ext_pct >= _tgt and gates_ok:
1859
+ status = READY_MAX_ATS_95_PLUS
1860
+ elif ext_pct >= _min:
1861
+ status = READY_90_PLUS_EXTERNAL_ALIGNED
1862
+ elif status not in (READY, READY_REVIEW):
1863
+ # Below external target but resume is downloadable for review
1864
+ # and the loop should keep going on the remaining LOW/MEDIUM
1865
+ # gaps β€” never silently accept a low external score.
1866
+ if ext_cov.get("includable") or ext_cov.get("missing"):
1867
+ status = BELOW_TARGET_REPAIRABLE
1868
+ report["status"] = status
1869
+ report["external_coverage_pct"] = ext_pct
1870
+ # Always offer the download in max mode (review), per spec.
1871
+ report["download_allowed"] = download_allowed or status in (
1872
+ READY_MAX_ATS_95_PLUS, READY_90_PLUS_EXTERNAL_ALIGNED)
1873
+
1874
  job["_v2_report"] = report
1875
  job["_v2_status"] = status
1876
  job["quality_flag"] = quality
1877
+ job["download_allowed"] = report.get("download_allowed", download_allowed)
1878
  job["independent_jd_match"] = independent_jm
1879
 
1880
  try:
 
2391
 
2392
  doc.save(filepath)
2393
  return filepath
2394
+