Spaces:
Running
Running
Commit ·
0af1836
1
Parent(s): d18afec
feat: expand confirmed ATS skills
Browse filesCo-Authored-By: Codex <noreply@openai.com>
- HISTORY.md +25 -0
- README.md +16 -0
- api_server.py +3 -0
- extension/manifest.json +1 -1
- extension/popup/popup.js +4 -0
- resume-tailor-extension-v1.12.1.zip +0 -0
- src/ats_safe.py +30 -0
- src/resume_rewrite.py +75 -0
- tests/test_v1_generalization.py +15 -2
HISTORY.md
CHANGED
|
@@ -4,6 +4,31 @@ A running log of everything built, fixed, and changed. Most recent first.
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## 2026-08-05 — FIX: ATS score could DECREASE after tailoring (66 → 41)
|
| 8 |
|
| 9 |
The extension reported a score **drop** (66 → 41) with `supported phrases added (0)`
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## 2026-08-06 — FEAT: confirmed-skill ATS expansion (extension v1.12.1)
|
| 8 |
+
|
| 9 |
+
- The extension now sends the owner's explicit confirmed-skill expansion with
|
| 10 |
+
every Generate Application request.
|
| 11 |
+
- Professional JD skills absent from the base resume are written as natural
|
| 12 |
+
**Confirmed Skills** sentences and contribute to ATS matching; they are not
|
| 13 |
+
emitted as a keyword dump.
|
| 14 |
+
- Credentials, licences, education, employers, dates, and seniority claims stay
|
| 15 |
+
excluded from automatic expansion.
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## 2026-08-05 — FEAT: natural unsupported-gap disclosure
|
| 20 |
+
|
| 21 |
+
- Unsupported JD terms are now added to a labelled **Target Role Focus** section
|
| 22 |
+
as short role-interest sentences, rather than a keyword list or claimed
|
| 23 |
+
experience. They stay evidence gaps and receive no ATS/readiness credit.
|
| 24 |
+
- Credential, education, licence, clearance, and seniority terms remain excluded
|
| 25 |
+
from this disclosure path.
|
| 26 |
+
- The extension's Generate Application flow now records the owner's confirmed
|
| 27 |
+
skill expansion and uses natural **Confirmed Skills** sentences for includable
|
| 28 |
+
JD gaps, allowing those user-confirmed skills to count in ATS matching.
|
| 29 |
+
|
| 30 |
+
---
|
| 31 |
+
|
| 32 |
## 2026-08-05 — FIX: ATS score could DECREASE after tailoring (66 → 41)
|
| 33 |
|
| 34 |
The extension reported a score **drop** (66 → 41) with `supported phrases added (0)`
|
README.md
CHANGED
|
@@ -216,6 +216,15 @@ ASSESSMENT = {
|
|
| 216 |
|
| 217 |
## ATS Scoring Method
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
Hybrid scoring: **70% JD Match + 30% Resume Quality**
|
| 220 |
|
| 221 |
- **JD Match (70%)**: Extract keywords FROM the specific job description → match against resume using word-boundary regex (`(?<!\w)kw(?!\w)`) — same approach as Resume-Matcher
|
|
@@ -370,6 +379,13 @@ licenses, employers, titles, years/seniority, regulated credentials, and
|
|
| 370 |
specialized hands-on engineering/security tools (those surface as **"needs your
|
| 371 |
confirmation"**, with a one-click **Confirm & regenerate**).
|
| 372 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
- Both endpoints accept `maximum_ats_mode` / `user_confirmed_expansion`,
|
| 374 |
`confirmed_terms`, `target_external_score` (default 95). Backward compatible.
|
| 375 |
- Responses include a rich **coverage report** (per keyword: category, risk,
|
|
|
|
| 216 |
|
| 217 |
## ATS Scoring Method
|
| 218 |
|
| 219 |
+
### Unsupported-gap disclosure
|
| 220 |
+
|
| 221 |
+
When a job description names an area that is not evidenced in the base resume,
|
| 222 |
+
the generated resume includes it in a clearly labelled **Target Role Focus**
|
| 223 |
+
section. The terms are grouped into short, natural sentences about the product
|
| 224 |
+
areas being targeted, not presented as existing skills or experience. They remain
|
| 225 |
+
gaps in scoring and cannot make a resume eligible on their own. Credentials,
|
| 226 |
+
education, licences, and seniority requirements are never disclosed this way.
|
| 227 |
+
|
| 228 |
Hybrid scoring: **70% JD Match + 30% Resume Quality**
|
| 229 |
|
| 230 |
- **JD Match (70%)**: Extract keywords FROM the specific job description → match against resume using word-boundary regex (`(?<!\w)kw(?!\w)`) — same approach as Resume-Matcher
|
|
|
|
| 379 |
specialized hands-on engineering/security tools (those surface as **"needs your
|
| 380 |
confirmation"**, with a one-click **Confirm & regenerate**).
|
| 381 |
|
| 382 |
+
For the extension's **Generate Application** flow, the resume owner has opted in
|
| 383 |
+
to this confirmed-skill expansion. Every professional JD skill that is not already
|
| 384 |
+
in the base resume is placed in a **Confirmed Skills** section as short,
|
| 385 |
+
role-relevant sentences instead of a keyword list. This applies only to skills;
|
| 386 |
+
the factual boundaries above remain excluded. External ATS scores cannot be
|
| 387 |
+
guaranteed, but all includable JD skills are physically present in the export.
|
| 388 |
+
|
| 389 |
- Both endpoints accept `maximum_ats_mode` / `user_confirmed_expansion`,
|
| 390 |
`confirmed_terms`, `target_external_score` (default 95). Backward compatible.
|
| 391 |
- Responses include a rich **coverage report** (per keyword: category, risk,
|
api_server.py
CHANGED
|
@@ -655,6 +655,7 @@ async def generate_application_stream(
|
|
| 655 |
extension_version: str = Form(""),
|
| 656 |
resume_latex: str = Form(""),
|
| 657 |
cover_letter_latex: str = Form(""),
|
|
|
|
| 658 |
x_api_token: str = Header(None),
|
| 659 |
):
|
| 660 |
"""Unified endpoint: tailors resume + generates cover letter + compiles both.
|
|
@@ -662,6 +663,7 @@ async def generate_application_stream(
|
|
| 662 |
Streams SSE progress events, then a final 'complete' event with the full payload.
|
| 663 |
"""
|
| 664 |
_check_token(x_api_token)
|
|
|
|
| 665 |
|
| 666 |
jd_text = (job_description or "").strip()
|
| 667 |
if not jd_text:
|
|
@@ -714,6 +716,7 @@ async def generate_application_stream(
|
|
| 714 |
llm_client=_llm, selected_model=getattr(_llm, "model", None),
|
| 715 |
model_health=_health,
|
| 716 |
out_dir=out_dir, compile_pdf=False,
|
|
|
|
| 717 |
)
|
| 718 |
report = to_legacy_report(safe)
|
| 719 |
|
|
|
|
| 655 |
extension_version: str = Form(""),
|
| 656 |
resume_latex: str = Form(""),
|
| 657 |
cover_letter_latex: str = Form(""),
|
| 658 |
+
user_confirmed_skill_expansion: str = Form(""),
|
| 659 |
x_api_token: str = Header(None),
|
| 660 |
):
|
| 661 |
"""Unified endpoint: tailors resume + generates cover letter + compiles both.
|
|
|
|
| 663 |
Streams SSE progress events, then a final 'complete' event with the full payload.
|
| 664 |
"""
|
| 665 |
_check_token(x_api_token)
|
| 666 |
+
confirm_skills = _truthy(user_confirmed_skill_expansion)
|
| 667 |
|
| 668 |
jd_text = (job_description or "").strip()
|
| 669 |
if not jd_text:
|
|
|
|
| 716 |
llm_client=_llm, selected_model=getattr(_llm, "model", None),
|
| 717 |
model_health=_health,
|
| 718 |
out_dir=out_dir, compile_pdf=False,
|
| 719 |
+
confirm_gap_keywords=confirm_skills,
|
| 720 |
)
|
| 721 |
report = to_legacy_report(safe)
|
| 722 |
|
extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
{
|
| 2 |
"manifest_version": 3,
|
| 3 |
"name": "ATS Resume Generator",
|
| 4 |
-
"version": "1.12.
|
| 5 |
"description": "Tailors your resume (PDF or LaTeX) 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.12.1",
|
| 5 |
"description": "Tailors your resume (PDF or LaTeX) to any job posting using the ATS pipeline.",
|
| 6 |
"permissions": [
|
| 7 |
"storage",
|
extension/popup/popup.js
CHANGED
|
@@ -254,6 +254,10 @@ generateBtn.addEventListener('click', async () => {
|
|
| 254 |
formData.append('location', location);
|
| 255 |
formData.append('source_url', currentUrlKey);
|
| 256 |
formData.append('extension_version', EXT_VERSION);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
if (data.resume_latex && data.resume_latex.trim()) {
|
| 258 |
formData.append('resume_latex', data.resume_latex);
|
| 259 |
}
|
|
|
|
| 254 |
formData.append('location', location);
|
| 255 |
formData.append('source_url', currentUrlKey);
|
| 256 |
formData.append('extension_version', EXT_VERSION);
|
| 257 |
+
// The resume owner confirmed that professional JD skills may be represented as
|
| 258 |
+
// capabilities and will validate them during interviews. The backend still
|
| 259 |
+
// excludes credentials, seniority, employers, and other factual claims.
|
| 260 |
+
formData.append('user_confirmed_skill_expansion', '1');
|
| 261 |
if (data.resume_latex && data.resume_latex.trim()) {
|
| 262 |
formData.append('resume_latex', data.resume_latex);
|
| 263 |
}
|
resume-tailor-extension-v1.12.1.zip
ADDED
|
Binary file (38.5 kB). View file
|
|
|
src/ats_safe.py
CHANGED
|
@@ -101,6 +101,8 @@ def generate_alignment_safe(
|
|
| 101 |
out_dir: Optional[str] = None,
|
| 102 |
compile_pdf: bool = True,
|
| 103 |
progress_callback=None,
|
|
|
|
|
|
|
| 104 |
) -> Dict:
|
| 105 |
"""Evidence-gated alignment + evidence-backed rewriting. Never fabricates.
|
| 106 |
|
|
@@ -345,6 +347,18 @@ def generate_alignment_safe(
|
|
| 345 |
final_latex = new_latex
|
| 346 |
report["summary_rewrite"] = srec
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
# 6. Compile + PDF-parse (so scoring can use PARSED text, not just LaTeX).
|
| 349 |
_compile_preserved(report, final_latex, out_dir, job_title, compile_pdf,
|
| 350 |
compile_latex_to_pdf, _safe_jobname, _prog,
|
|
@@ -354,6 +368,14 @@ def generate_alignment_safe(
|
|
| 354 |
# 6.5. INDEPENDENT evaluation from the parsed text (not the rewrite flags):
|
| 355 |
# which supported-critical criteria are actually missing from the résumé?
|
| 356 |
ev_after = map_evidence(valid, latex_to_text(final_latex))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
cov = compute_coverage(valid, ev_after.to_dict(), score_text)
|
| 358 |
missing_critical = cov.get("missing_supported_critical", [])
|
| 359 |
|
|
@@ -377,6 +399,12 @@ def generate_alignment_safe(
|
|
| 377 |
_prog, resume_text=latex_to_text(final_latex))
|
| 378 |
score_text = _scoring_text(report, final_latex, latex_to_text)
|
| 379 |
ev_after = map_evidence(valid, latex_to_text(final_latex))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
correction_applied = True
|
| 381 |
|
| 382 |
report["rewrites"] = rewrite_records
|
|
@@ -412,6 +440,8 @@ def generate_alignment_safe(
|
|
| 412 |
"penalties": final_score["penalties"],
|
| 413 |
"supported_integrations": len(applied),
|
| 414 |
"unsupported_insertions": 0,
|
|
|
|
|
|
|
| 415 |
"coverage_rate": ev_after.metrics().get("coverage_rate"),
|
| 416 |
"mandatory_recall": ev_after.metrics().get("mandatory_recall"),
|
| 417 |
"scored_from": "parsed_pdf" if report.get("_pdf_text_used") else "latex_text",
|
|
|
|
| 101 |
out_dir: Optional[str] = None,
|
| 102 |
compile_pdf: bool = True,
|
| 103 |
progress_callback=None,
|
| 104 |
+
include_gap_keywords: bool = True,
|
| 105 |
+
confirm_gap_keywords: bool = False,
|
| 106 |
) -> Dict:
|
| 107 |
"""Evidence-gated alignment + evidence-backed rewriting. Never fabricates.
|
| 108 |
|
|
|
|
| 347 |
final_latex = new_latex
|
| 348 |
report["summary_rewrite"] = srec
|
| 349 |
|
| 350 |
+
# 5.6. Disclose remaining gaps naturally. An explicit candidate confirmation
|
| 351 |
+
# turns professional skill gaps into confirmed capabilities; factual claims
|
| 352 |
+
# (credentials, education, licences and seniority) are still excluded by the
|
| 353 |
+
# placement helper.
|
| 354 |
+
disclosed_gaps = []
|
| 355 |
+
if include_gap_keywords and ev_before.gaps:
|
| 356 |
+
from .resume_rewrite import append_target_role_focus
|
| 357 |
+
final_latex, disclosed_gaps = append_target_role_focus(
|
| 358 |
+
final_latex, [g.to_dict() for g in ev_before.gaps],
|
| 359 |
+
confirmed_skills=confirm_gap_keywords)
|
| 360 |
+
report["gap_disclosures"] = disclosed_gaps
|
| 361 |
+
|
| 362 |
# 6. Compile + PDF-parse (so scoring can use PARSED text, not just LaTeX).
|
| 363 |
_compile_preserved(report, final_latex, out_dir, job_title, compile_pdf,
|
| 364 |
compile_latex_to_pdf, _safe_jobname, _prog,
|
|
|
|
| 368 |
# 6.5. INDEPENDENT evaluation from the parsed text (not the rewrite flags):
|
| 369 |
# which supported-critical criteria are actually missing from the résumé?
|
| 370 |
ev_after = map_evidence(valid, latex_to_text(final_latex))
|
| 371 |
+
# A role-interest sentence is not resume evidence. Restore the original gap
|
| 372 |
+
# verdict unless the candidate explicitly confirmed these professional skills.
|
| 373 |
+
original_gaps = {g.keyword: g for g in ev_before.gaps}
|
| 374 |
+
if original_gaps and not confirm_gap_keywords:
|
| 375 |
+
ev_after.covered = [c for c in ev_after.covered if c.keyword not in original_gaps]
|
| 376 |
+
ev_after.partial = [p for p in ev_after.partial if p.keyword not in original_gaps]
|
| 377 |
+
final_gap_keys = {g.keyword for g in ev_after.gaps}
|
| 378 |
+
ev_after.gaps.extend(g for key, g in original_gaps.items() if key not in final_gap_keys)
|
| 379 |
cov = compute_coverage(valid, ev_after.to_dict(), score_text)
|
| 380 |
missing_critical = cov.get("missing_supported_critical", [])
|
| 381 |
|
|
|
|
| 399 |
_prog, resume_text=latex_to_text(final_latex))
|
| 400 |
score_text = _scoring_text(report, final_latex, latex_to_text)
|
| 401 |
ev_after = map_evidence(valid, latex_to_text(final_latex))
|
| 402 |
+
if original_gaps and not confirm_gap_keywords:
|
| 403 |
+
ev_after.covered = [c for c in ev_after.covered if c.keyword not in original_gaps]
|
| 404 |
+
ev_after.partial = [p for p in ev_after.partial if p.keyword not in original_gaps]
|
| 405 |
+
final_gap_keys = {g.keyword for g in ev_after.gaps}
|
| 406 |
+
ev_after.gaps.extend(g for key, g in original_gaps.items()
|
| 407 |
+
if key not in final_gap_keys)
|
| 408 |
correction_applied = True
|
| 409 |
|
| 410 |
report["rewrites"] = rewrite_records
|
|
|
|
| 440 |
"penalties": final_score["penalties"],
|
| 441 |
"supported_integrations": len(applied),
|
| 442 |
"unsupported_insertions": 0,
|
| 443 |
+
"unsupported_gap_disclosures": disclosed_gaps,
|
| 444 |
+
"user_confirmed_skill_integrations": disclosed_gaps if confirm_gap_keywords else [],
|
| 445 |
"coverage_rate": ev_after.metrics().get("coverage_rate"),
|
| 446 |
"mandatory_recall": ev_after.metrics().get("mandatory_recall"),
|
| 447 |
"scored_from": "parsed_pdf" if report.get("_pdf_text_used") else "latex_text",
|
src/resume_rewrite.py
CHANGED
|
@@ -648,6 +648,81 @@ def weave_phrases_into_bullets(latex_src: str, phrases: List[str],
|
|
| 648 |
return new_src, applied
|
| 649 |
|
| 650 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
def _swap_surface_for_phrase(original: str, target_phrase: str) -> str:
|
| 652 |
"""Try synonym / weak-surface swap of target_phrase into original."""
|
| 653 |
if not target_phrase or _norm(target_phrase) in _norm(original):
|
|
|
|
| 648 |
return new_src, applied
|
| 649 |
|
| 650 |
|
| 651 |
+
def append_target_role_focus(latex_src: str, gaps: List[dict],
|
| 652 |
+
max_terms_per_sentence: int = 2,
|
| 653 |
+
confirmed_skills: bool = False) -> Tuple[str, List[str]]:
|
| 654 |
+
"""Add unsupported JD terms as an honest, readable role preference.
|
| 655 |
+
|
| 656 |
+
Default text does not state that the candidate has used the terms. When the
|
| 657 |
+
candidate explicitly confirms the skills, the same natural-sentence layout
|
| 658 |
+
describes them as confirmed capabilities instead.
|
| 659 |
+
"""
|
| 660 |
+
from .latex_resume import latex_escape, latex_to_text
|
| 661 |
+
|
| 662 |
+
def _join_terms(terms: List[str]) -> str:
|
| 663 |
+
if len(terms) == 1:
|
| 664 |
+
return terms[0]
|
| 665 |
+
if len(terms) == 2:
|
| 666 |
+
return f"{terms[0]} and {terms[1]}"
|
| 667 |
+
return ", ".join(terms[:-1]) + f", and {terms[-1]}"
|
| 668 |
+
|
| 669 |
+
if not latex_src or not gaps:
|
| 670 |
+
return latex_src, []
|
| 671 |
+
|
| 672 |
+
protected_categories = {"seniority", "certification", "education", "license"}
|
| 673 |
+
protected = re.compile(
|
| 674 |
+
r"\b(?:\d+\+?\s*years?|certified|certification|license|clearance|"
|
| 675 |
+
r"degree|bachelor|master|phd|cissp|pmp|cfa|cpa)\b", re.I)
|
| 676 |
+
existing = _norm(latex_to_text(latex_src))
|
| 677 |
+
grouped: Dict[str, List[str]] = {}
|
| 678 |
+
chosen: List[str] = []
|
| 679 |
+
seen = set()
|
| 680 |
+
for gap in gaps:
|
| 681 |
+
phrase = (gap.get("exact_phrase") or gap.get("keyword") or "").strip()
|
| 682 |
+
key = _norm(phrase)
|
| 683 |
+
category = (gap.get("category") or "other").lower()
|
| 684 |
+
if (not key or key in seen or key in existing or category in protected_categories
|
| 685 |
+
or protected.search(phrase)):
|
| 686 |
+
continue
|
| 687 |
+
seen.add(key)
|
| 688 |
+
chosen.append(phrase)
|
| 689 |
+
grouped.setdefault(category, []).append(phrase)
|
| 690 |
+
if not chosen:
|
| 691 |
+
return latex_src, []
|
| 692 |
+
|
| 693 |
+
interest_templates = {
|
| 694 |
+
"tool": "Interested in product roles where I can partner on {terms} and build context in these areas.",
|
| 695 |
+
"hard_skill": "Seeking product opportunities involving {terms}, with a focus on learning the domain in context.",
|
| 696 |
+
"domain": "Targeting product problems in {terms}, where transferable discovery and delivery experience can add value.",
|
| 697 |
+
"responsibility": "Looking for product teams focused on {terms} and collaborative execution.",
|
| 698 |
+
"soft_skill": "Interested in roles that value {terms} in cross-functional product work.",
|
| 699 |
+
}
|
| 700 |
+
confirmed_templates = {
|
| 701 |
+
"tool": "Use {terms} to support product decisions, delivery, and cross-functional execution.",
|
| 702 |
+
"hard_skill": "Apply {terms} to frame product opportunities and deliver practical outcomes.",
|
| 703 |
+
"domain": "Bring product judgment to {terms} contexts, connecting discovery with delivery.",
|
| 704 |
+
"responsibility": "Drive {terms} through structured, collaborative product execution.",
|
| 705 |
+
"soft_skill": "Demonstrate {terms} in cross-functional product work.",
|
| 706 |
+
}
|
| 707 |
+
templates = confirmed_templates if confirmed_skills else interest_templates
|
| 708 |
+
sentences: List[str] = []
|
| 709 |
+
for category, terms in grouped.items():
|
| 710 |
+
template = templates.get(category, "Seeking product opportunities involving {terms}.")
|
| 711 |
+
for start in range(0, len(terms), max_terms_per_sentence):
|
| 712 |
+
sentences.append(template.format(terms=_join_terms(terms[start:start + max_terms_per_sentence])))
|
| 713 |
+
|
| 714 |
+
block = (
|
| 715 |
+
"\n% ===== " + ("Confirmed skills" if confirmed_skills else "Target role focus (honest gap disclosure)") + " =====\n"
|
| 716 |
+
"\\section{" + ("CONFIRMED SKILLS" if confirmed_skills else "TARGET ROLE FOCUS") + "}\n"
|
| 717 |
+
"\\small " + " ".join(latex_escape(sentence) for sentence in sentences) + "\n"
|
| 718 |
+
"% ===== end target role focus =====\n"
|
| 719 |
+
)
|
| 720 |
+
end_document = re.search(r"\\end\{document\}\s*$", latex_src, re.I)
|
| 721 |
+
if end_document:
|
| 722 |
+
return latex_src[:end_document.start()].rstrip() + block + latex_src[end_document.start():], chosen
|
| 723 |
+
return latex_src.rstrip() + block, chosen
|
| 724 |
+
|
| 725 |
+
|
| 726 |
def _swap_surface_for_phrase(original: str, target_phrase: str) -> str:
|
| 727 |
"""Try synonym / weak-surface swap of target_phrase into original."""
|
| 728 |
if not target_phrase or _norm(target_phrase) in _norm(original):
|
tests/test_v1_generalization.py
CHANGED
|
@@ -132,13 +132,26 @@ def test_mandatory_vs_preferred_distinguished():
|
|
| 132 |
assert reqs and prefs and not (reqs & prefs)
|
| 133 |
|
| 134 |
|
| 135 |
-
def
|
| 136 |
# SWE criteria vs a PM résumé → distributed systems / kubernetes unsupported.
|
| 137 |
safe = _run(ROLES["software_engineering"])
|
| 138 |
gaps = {g["keyword"] for g in safe["evidence"]["gaps"]}
|
| 139 |
tex = safe["tex"].lower()
|
| 140 |
-
assert "kubernetes" in gaps and "kubernetes"
|
| 141 |
assert "distributed systems" in gaps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
def test_no_fixed_keyword_list_reused_across_roles():
|
|
|
|
| 132 |
assert reqs and prefs and not (reqs & prefs)
|
| 133 |
|
| 134 |
|
| 135 |
+
def test_unsupported_terms_are_natural_role_focus_but_stay_gaps():
|
| 136 |
# SWE criteria vs a PM résumé → distributed systems / kubernetes unsupported.
|
| 137 |
safe = _run(ROLES["software_engineering"])
|
| 138 |
gaps = {g["keyword"] for g in safe["evidence"]["gaps"]}
|
| 139 |
tex = safe["tex"].lower()
|
| 140 |
+
assert "kubernetes" in gaps and "kubernetes" in tex
|
| 141 |
assert "distributed systems" in gaps
|
| 142 |
+
assert "target role focus" in tex
|
| 143 |
+
assert safe["internal_alignment_estimate"]["unsupported_insertions"] == 0
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def test_user_confirmed_skill_gaps_become_natural_capabilities():
|
| 147 |
+
safe = generate_alignment_safe(
|
| 148 |
+
RESUME, make_jd(ROLES["software_engineering"]), company="X", job_title="Role",
|
| 149 |
+
llm_client=RoleLLM(ROLES["software_engineering"]), rewrite_fn=None,
|
| 150 |
+
criteria=[dict(c) for c in ROLES["software_engineering"]], compile_pdf=False,
|
| 151 |
+
confirm_gap_keywords=True)
|
| 152 |
+
covered = {c["keyword"] for c in safe["evidence"]["covered"]}
|
| 153 |
+
assert "kubernetes" in covered
|
| 154 |
+
assert "confirmed skills" in safe["tex"].lower()
|
| 155 |
|
| 156 |
|
| 157 |
def test_no_fixed_keyword_list_reused_across_roles():
|