Spaces:
Sleeping
fix(ats): drive Maximum ATS Mode by EXTERNAL coverage (internal 96 vs Jobalytics 54)
Browse filesLive 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 +12 -0
- HISTORY.md +51 -0
- README.md +18 -3
- api_server.py +11 -1
- config.py +9 -0
- extension/manifest.json +1 -1
- extension/popup/popup.js +25 -6
- scripts/verify_max_ats_coverage.py +109 -0
- src/external_ats.py +163 -0
- src/resume_customizer.py +171 -3
|
@@ -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.** |
|
|
@@ -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
|
|
@@ -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 |
-
|
| 328 |
-
|
| 329 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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", "")},
|
|
@@ -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)
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"manifest_version": 3,
|
| 3 |
"name": "ATS Resume Generator",
|
| 4 |
-
"version": "1.
|
| 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",
|
|
@@ -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 |
-
|
|
|
|
|
|
|
| 302 |
const covEl = document.getElementById('score-cov');
|
| 303 |
const cov = result.coverage_report || {};
|
| 304 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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}).
|
| 374 |
const cls = result.status === 'BELOW_TARGET_REPAIRABLE' ? 'explain-yellow' : 'explain-red';
|
| 375 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 || [];
|
|
@@ -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)
|
|
@@ -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 |
+
}
|
|
@@ -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=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 |
+
|