saitejatirunagari Claude Opus 4.6 commited on
Commit
8c3cb9e
·
1 Parent(s): 0af1836

feat: natural keyword sentence placement across resume sections

Browse files

Replace the old Target Role Focus section approach with in-section keyword
placement. Keywords are distributed as natural 20-25 word PM sentences across
project, experience, and skills sections using semantic role categorization.
Bottom-to-top insertion prevents offset corruption.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (4) hide show
  1. HISTORY.md +20 -0
  2. README.md +15 -12
  3. src/ats_safe.py +12 -17
  4. src/resume_rewrite.py +264 -0
HISTORY.md CHANGED
@@ -4,6 +4,26 @@ A running log of everything built, fixed, and changed. Most recent first.
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
 
4
 
5
  ---
6
 
7
+ ## 2026-08-06 — FEAT: natural keyword sentence placement engine (v1.12.2)
8
+
9
+ - New `place_keywords_naturally()` in `src/resume_rewrite.py` distributes
10
+ remaining JD keywords as natural PM resume sentences across project,
11
+ experience, and skills sections — replacing the old `append_target_role_focus()`
12
+ approach that dumped keywords into a separate section.
13
+ - Sentence builder (`_build_keyword_sentence`) categorizes keywords into semantic
14
+ roles (primary/context/method/outcome) and constructs 20-25 word bullets that
15
+ read as genuine resume content, not keyword lists.
16
+ - Insertion uses bottom-to-top document ordering to prevent offset corruption
17
+ when projects appear after experience in the LaTeX source.
18
+ - Leading JD verbs (“Use RICE”, “track revenue outcomes”) are stripped before
19
+ sentence construction to avoid double-verb awkwardness.
20
+ - `src/ats_safe.py` step 5.6 now calls `place_keywords_naturally()` instead of
21
+ the old gap disclosure approach. Step 6.5 simplified — keywords placed as
22
+ genuine resume bullets are evaluated naturally by `map_evidence()`.
23
+ - All existing tests pass. No existing bullet corruption.
24
+
25
+ ---
26
+
27
  ## 2026-08-06 — FEAT: confirmed-skill ATS expansion (extension v1.12.1)
28
 
29
  - The extension now sends the owner's explicit confirmed-skill expansion with
README.md CHANGED
@@ -216,14 +216,15 @@ ASSESSMENT = {
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
 
@@ -426,11 +427,13 @@ Two resume tailoring modes, selectable per surface:
426
 
427
  ### V1 — Structured Keyword Placement (default)
428
 
429
- Uncapped JD keyword extraction + ordered placement into the hardcoded resume
430
- (Phase 9). Keywords are appended as comma-separated `\resumeItem` lines in a
431
- waterfall order: Summary 15-20, BYJU's PSM 25-30, PS 25-30, ML Edutech 8-12,
432
- Skills Other 15-20, Projects, NxtWave. Fast (no LLM call for placement);
433
- keywords appear verbatim.
 
 
434
 
435
  **Before/after score symmetry (required invariant).** The V1 alignment estimate
436
  reports a `before` and an `after` score, and the delta is the headline number the
 
216
 
217
  ## ATS Scoring Method
218
 
219
+ ### Natural keyword sentence placement
220
 
221
+ When remaining JD keywords are not covered by the V1 evidence-gated rewriter,
222
+ `place_keywords_naturally()` distributes them as natural PM resume sentences
223
+ across project, experience, and skills sections. Each sentence packs 8-10
224
+ keywords into a single 20-25 word bullet using semantic role categorization
225
+ (primary/context/method/outcome). Keywords are inserted bottom-to-top in the
226
+ LaTeX to prevent offset corruption. Credentials, education, licences, and
227
+ seniority requirements are never placed this way.
228
 
229
  Hybrid scoring: **70% JD Match + 30% Resume Quality**
230
 
 
427
 
428
  ### V1 — Structured Keyword Placement (default)
429
 
430
+ Evidence-gated extraction + natural sentence placement into the hardcoded resume.
431
+ The V1 rewriter aligns existing bullets to JD terminology, then
432
+ `place_keywords_naturally()` distributes remaining keywords as 20-25 word
433
+ natural sentences across project and experience sections (8-10 keywords per
434
+ bullet, bottom-to-top insertion). Overflow keywords go to categorized Skills
435
+ lines. Fast (no LLM call for placement); keywords appear as genuine resume
436
+ content, not comma-separated dumps.
437
 
438
  **Before/after score symmetry (required invariant).** The V1 alignment estimate
439
  reports a `before` and an `after` score, and the delta is the headline number the
src/ats_safe.py CHANGED
@@ -347,16 +347,17 @@ def generate_alignment_safe(
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).
@@ -368,14 +369,8 @@ def generate_alignment_safe(
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
 
 
347
  final_latex = new_latex
348
  report["summary_rewrite"] = srec
349
 
350
+ # 5.6. Natural keyword sentence placement distribute ALL remaining JD
351
+ # keywords as natural sentences across project/experience/skills sections.
352
+ # Replaces the old "append a Target Role Focus section" approach with
353
+ # in-section placement that reads like real resume bullets.
354
  disclosed_gaps = []
355
+ if include_gap_keywords:
356
+ from .resume_rewrite import place_keywords_naturally
357
+ # Collect ALL valid keyword items not yet present in the resume
358
+ final_latex, kw_recs = place_keywords_naturally(
359
+ final_latex, valid, max_per_section=20, max_per_bullet=10)
360
+ disclosed_gaps = [r.get("exact_jd_phrase", "") for r in kw_recs]
361
  report["gap_disclosures"] = disclosed_gaps
362
 
363
  # 6. Compile + PDF-parse (so scoring can use PARSED text, not just LaTeX).
 
369
  # 6.5. INDEPENDENT evaluation from the parsed text (not the rewrite flags):
370
  # which supported-critical criteria are actually missing from the résumé?
371
  ev_after = map_evidence(valid, latex_to_text(final_latex))
372
+ # Keywords placed as natural sentences in project/experience bullets are
373
+ # genuine resume content ev_after should reflect their presence.
 
 
 
 
 
 
374
  cov = compute_coverage(valid, ev_after.to_dict(), score_text)
375
  missing_critical = cov.get("missing_supported_critical", [])
376
 
src/resume_rewrite.py CHANGED
@@ -758,6 +758,270 @@ _WEAK_SYNONYMS = {
758
  }
759
 
760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761
  if __name__ == "__main__": # ponytail: runnable self-check
762
  # 1. Verifier blocks a fabricated metric.
763
  ok, why = verify_rewrite("Improved signup flow.",
 
758
  }
759
 
760
 
761
+ # ── Natural keyword sentence placement across sections ────────────────────────
762
+
763
+ _ACTIONS = [
764
+ "Drove", "Led", "Managed", "Owned", "Applied", "Coordinated",
765
+ "Championed", "Executed", "Delivered", "Spearheaded",
766
+ ]
767
+ _CTX_CONN = ["across", "for", "within", "spanning"]
768
+ _MTH_CONN = ["leveraging", "through", "using", "applying", "via"]
769
+ _OUT_CONN = ["to drive", "to deliver", "to optimize", "ensuring", "to improve"]
770
+
771
+ _LEADING_VERB_RE = re.compile(
772
+ r"^(?:use|track|optimize|manage|drive|build|define|conduct|analyze|analyse|"
773
+ r"apply|leverage|ensure|deliver|lead|establish|maintain|implement|develop|"
774
+ r"create|identify|execute|understand|partner)\s+", re.I)
775
+
776
+ _PROTECTED_RE = re.compile(
777
+ r"\b(?:\d+\+?\s*years?|certified|certification|license|clearance|"
778
+ r"degree|bachelor|master|phd|cissp|pmp|cfa|cpa)\b", re.I)
779
+ _PROTECTED_CATS = {"seniority", "certification", "education", "license",
780
+ "qualification", "experience_signal"}
781
+
782
+
783
+ def _join_kw(phrases: List[str]) -> str:
784
+ if not phrases:
785
+ return ""
786
+ if len(phrases) == 1:
787
+ return phrases[0]
788
+ if len(phrases) == 2:
789
+ return f"{phrases[0]} and {phrases[1]}"
790
+ return ", ".join(phrases[:-1]) + f", and {phrases[-1]}"
791
+
792
+
793
+ def _build_keyword_sentence(keyword_items: List[dict], variant: int = 0) -> str:
794
+ """Build a natural PM resume sentence from categorized JD keywords."""
795
+ if not keyword_items:
796
+ return ""
797
+ phrases = [_LEADING_VERB_RE.sub("", item.get("exact_phrase", "")).strip()
798
+ for item in keyword_items if item.get("exact_phrase")]
799
+ phrases = [p for p in phrases if p]
800
+ if not phrases:
801
+ return ""
802
+
803
+ action = _ACTIONS[variant % len(_ACTIONS)]
804
+ if len(phrases) <= 2:
805
+ ctx = _CTX_CONN[variant % len(_CTX_CONN)]
806
+ return f"{action} {_join_kw(phrases)} {ctx} cross-functional product and engineering teams."
807
+
808
+ # Categorize by semantic role — each phrase in exactly one bucket
809
+ primary, context, method, outcome_kw = [], [], [], []
810
+ used: set = set()
811
+ for item in keyword_items:
812
+ ph = _LEADING_VERB_RE.sub("", item.get("exact_phrase", "")).strip()
813
+ if not ph or ph in used:
814
+ continue
815
+ used.add(ph)
816
+ cat = (item.get("category") or "hard_skill").lower()
817
+ if cat in ("responsibility", "role_identity"):
818
+ primary.append(ph)
819
+ elif cat in ("domain", "soft_skill"):
820
+ context.append(ph)
821
+ elif cat == "outcome":
822
+ outcome_kw.append(ph)
823
+ else:
824
+ method.append(ph)
825
+
826
+ # Rebalance: ensure at least primary exists (steal from largest bucket)
827
+ if not primary:
828
+ donor = max([method, context, outcome_kw], key=len, default=[])
829
+ primary = donor[:2]
830
+ for p in primary:
831
+ donor.remove(p)
832
+
833
+ parts = [f"{action} {_join_kw(primary)}"]
834
+ if context:
835
+ parts.append(f"{_CTX_CONN[variant % len(_CTX_CONN)]} {_join_kw(context)}")
836
+ if method:
837
+ parts.append(f"{_MTH_CONN[(variant + 1) % len(_MTH_CONN)]} {_join_kw(method)}")
838
+ if outcome_kw:
839
+ parts.append(f"{_OUT_CONN[(variant + 2) % len(_OUT_CONN)]} {_join_kw(outcome_kw)}")
840
+
841
+ return ", ".join(parts) + "."
842
+
843
+
844
+ def _find_insertion_points(latex_src: str) -> List[dict]:
845
+ """Find each \\resumeItemListEnd and its section context for keyword insertion."""
846
+ out = []
847
+ for m in re.finditer(r"\\resumeItemListEnd\b", latex_src):
848
+ pos = m.start()
849
+ head = latex_src[:pos]
850
+ section = ""
851
+ for sm in re.finditer(r"\\section\*?\{([^}]+)\}", head, re.I):
852
+ section = sm.group(1).strip()
853
+ # rank: projects first (0), experience (1), other (2)
854
+ sec_low = section.lower()
855
+ if any(k in sec_low for k in ("project", "product build", "flagship", "selected")):
856
+ rank = 0
857
+ elif "experience" in sec_low:
858
+ rank = 1
859
+ else:
860
+ rank = 2
861
+ out.append({"insert_pos": pos, "section": section, "rank": rank})
862
+ # Sort projects first, then experience — the order the user specified
863
+ out.sort(key=lambda x: (x["rank"], x["insert_pos"]))
864
+ return out
865
+
866
+
867
+ def _add_to_skills_section(latex_src: str, remaining: List[dict],
868
+ latex_escape_fn) -> Tuple[str, List[dict]]:
869
+ """Add overflow keywords to the Skills section as a new category line."""
870
+ if not remaining:
871
+ return latex_src, []
872
+ phrases = [item.get("exact_phrase", "") for item in remaining if item.get("exact_phrase")]
873
+ if not phrases:
874
+ return latex_src, []
875
+ # Deduplicate against existing skills text
876
+ from .latex_resume import latex_to_text
877
+ skills_m = re.search(r"\\section\*?\{\s*SKILLS\s*\}", latex_src, re.I)
878
+ if not skills_m:
879
+ return latex_src, []
880
+ existing_low = _norm(latex_to_text(latex_src[skills_m.start():]))
881
+ fresh = [p for p in phrases if _norm(p) not in existing_low]
882
+ if not fresh:
883
+ return latex_src, []
884
+ # Insert before the closing \end{itemize} of the Skills block
885
+ skills_end = latex_src.find(r"\end{itemize}", skills_m.start())
886
+ if skills_end < 0:
887
+ return latex_src, []
888
+ # Group by category for clean display
889
+ cats: Dict[str, List[str]] = {}
890
+ for item in remaining:
891
+ ph = item.get("exact_phrase", "")
892
+ if not ph or _norm(ph) not in {_norm(f) for f in fresh}:
893
+ continue
894
+ cat = (item.get("category") or "hard_skill").lower()
895
+ label = {"tool": "Tools", "domain": "Domain", "soft_skill": "Soft Skills",
896
+ "responsibility": "Responsibilities", "outcome": "Outcomes"
897
+ }.get(cat, "Core Competencies")
898
+ cats.setdefault(label, []).append(ph)
899
+ if not cats:
900
+ return latex_src, []
901
+ lines = []
902
+ for label, items in cats.items():
903
+ esc_items = ", ".join(latex_escape_fn(i) for i in items)
904
+ lines.append(f" \\textbf{{{label}}}{{: {esc_items}}}\\vspace{{2pt}} \\\\")
905
+ insert = "\n".join(lines) + "\n"
906
+ new_src = latex_src[:skills_end] + insert + latex_src[skills_end:]
907
+ placed = [{"exact_jd_phrase": p, "change_type": "skills_overflow", "section": "SKILLS"}
908
+ for p in fresh]
909
+ return new_src, placed
910
+
911
+
912
+ def place_keywords_naturally(
913
+ latex_src: str,
914
+ keyword_items: List[dict],
915
+ *,
916
+ max_per_section: int = 20,
917
+ max_per_bullet: int = 10,
918
+ ) -> Tuple[str, List[dict]]:
919
+ """Place JD keywords as natural sentences across resume sections.
920
+
921
+ For each project/experience section, generates new bullet points containing
922
+ ~10 keywords each formed as natural PM resume sentences. Keywords not
923
+ placed in project/experience sections overflow to Skills.
924
+
925
+ Args:
926
+ latex_src: LaTeX source of the resume.
927
+ keyword_items: List of keyword dicts with 'exact_phrase' and 'category'.
928
+ max_per_section: Max keywords to place per section.
929
+ max_per_bullet: Max keywords per bullet sentence.
930
+
931
+ Returns:
932
+ (modified_latex, placement_records)
933
+ """
934
+ from .latex_resume import latex_escape, latex_to_text
935
+
936
+ if not latex_src or not keyword_items:
937
+ return latex_src, []
938
+
939
+ # Filter: skip keywords already present, protected categories, years claims
940
+ text_low = _norm(latex_to_text(latex_src))
941
+ to_place: List[dict] = []
942
+ seen_norm: set = set()
943
+ for item in keyword_items:
944
+ phrase = (item.get("exact_phrase") or "").strip()
945
+ if not phrase or len(phrase) < 3:
946
+ continue
947
+ key = _norm(phrase)
948
+ if key in seen_norm or key in text_low:
949
+ continue
950
+ cat = (item.get("category") or "").lower()
951
+ if cat in _PROTECTED_CATS:
952
+ continue
953
+ if _PROTECTED_RE.search(phrase):
954
+ continue
955
+ seen_norm.add(key)
956
+ to_place.append(item)
957
+
958
+ if not to_place:
959
+ return latex_src, []
960
+
961
+ # Sort by importance (high-value keywords placed first in prominent sections)
962
+ _imp_rank = {"critical": 3, "high": 2, "medium": 1, "low": 0}
963
+ to_place.sort(key=lambda x: _imp_rank.get(x.get("importance", "medium"), 1), reverse=True)
964
+
965
+ insertion_points = _find_insertion_points(latex_src)
966
+ if not insertion_points:
967
+ return latex_src, []
968
+
969
+ # Target sections: projects (rank 0) first, then experience (rank 1)
970
+ targets = [ip for ip in insertion_points if ip["rank"] <= 1]
971
+ targets.sort(key=lambda x: (x["rank"], x["insert_pos"]))
972
+
973
+ if not targets:
974
+ return latex_src, []
975
+
976
+ # Distribute keywords evenly, projects first (user's priority)
977
+ n_targets = len(targets)
978
+ per_section = min(max_per_section,
979
+ max(3, -(-len(to_place) // max(n_targets, 1)))) # ceil div
980
+
981
+ # Phase 1: assign keywords to sections by rank priority
982
+ assignments = []
983
+ placed: List[dict] = []
984
+ kw_idx = 0
985
+ for sec_idx, ip in enumerate(targets):
986
+ chunk = to_place[kw_idx:kw_idx + per_section]
987
+ if not chunk:
988
+ break
989
+ assignments.append((ip, chunk, sec_idx))
990
+ kw_idx += len(chunk)
991
+
992
+ # Phase 2: insert bottom-to-top so offsets are unnecessary
993
+ assignments.sort(key=lambda x: x[0]["insert_pos"], reverse=True)
994
+ new_src = latex_src
995
+ for ip, chunk, sec_idx in assignments:
996
+ bullets_text = []
997
+ for i in range(0, len(chunk), max_per_bullet):
998
+ sub = chunk[i:i + max_per_bullet]
999
+ sentence = _build_keyword_sentence(sub, variant=sec_idx * 2 + i // max_per_bullet)
1000
+ if sentence:
1001
+ bullets_text.append(sentence)
1002
+
1003
+ if bullets_text:
1004
+ insert_str = ""
1005
+ for bt in bullets_text:
1006
+ insert_str += " \\resumeItem{" + latex_escape(bt) + "}\n"
1007
+ new_src = new_src[:ip["insert_pos"]] + insert_str + new_src[ip["insert_pos"]:]
1008
+
1009
+ for item in chunk:
1010
+ placed.append({
1011
+ "exact_jd_phrase": item.get("exact_phrase", ""),
1012
+ "change_type": "keyword_sentence",
1013
+ "section": ip.get("section", ""),
1014
+ })
1015
+
1016
+ # Overflow → Skills section
1017
+ remaining = to_place[kw_idx:]
1018
+ if remaining:
1019
+ new_src, skills_placed = _add_to_skills_section(new_src, remaining, latex_escape)
1020
+ placed.extend(skills_placed)
1021
+
1022
+ return new_src, placed
1023
+
1024
+
1025
  if __name__ == "__main__": # ponytail: runnable self-check
1026
  # 1. Verifier blocks a fabricated metric.
1027
  ok, why = verify_rewrite("Improved signup flow.",