Spaces:
Running
Running
Commit ·
8c3cb9e
1
Parent(s): 0af1836
feat: natural keyword sentence placement across resume sections
Browse filesReplace 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>
- HISTORY.md +20 -0
- README.md +15 -12
- src/ats_safe.py +12 -17
- 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 |
-
###
|
| 220 |
|
| 221 |
-
When
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
|
|
|
| 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 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
|
|
|
|
|
|
| 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.
|
| 351 |
-
#
|
| 352 |
-
#
|
| 353 |
-
# placement
|
| 354 |
disclosed_gaps = []
|
| 355 |
-
if include_gap_keywords
|
| 356 |
-
from .resume_rewrite import
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
|
|
|
| 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 |
-
#
|
| 372 |
-
#
|
| 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.",
|