saitejatirunagari Codex commited on
Commit
0af1836
·
1 Parent(s): d18afec

feat: expand confirmed ATS skills

Browse files

Co-Authored-By: Codex <noreply@openai.com>

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.0",
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 test_unsupported_terms_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" not in tex
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():