saitejatirunagari Claude Opus 4.8 commited on
Commit
fd65907
·
1 Parent(s): fa74339

feat(09-03): structured keyword placement for hardcoded resume [R22]

Browse files

place_keywords_structured() distributes keywords in the exact owner order:
summary 15-20 -> BYJU PSM 25-30 -> BYJU PS 25-30 -> ML Edutech 8-12 ->
Skills Other 15-20 -> projects 8-10 each (FDP..NIAT) -> NxtWave 15-20 ->
Skills Other overflow. Anchors on the literal \resumeItemListEnd within the
correct section. Append-only (tagged \resumeItem + fenced summary + Other row),
idempotent (extended _remove_injected_block). inject_keywords routes hardcoded
resume here; generic LaTeX keeps the original distribution.

tests/test_structured_placement.py: 9/9 pass (counts, anchor, append-only,
idempotency, dedupe).

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

.planning/phases/09-hardcoded-resume-keyword-placement/09-03-SUMMARY.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 09-03 SUMMARY — Structured keyword placement [R22]
2
+
3
+ Status: COMPLETE
4
+
5
+ ## What was built (src/latex_resume.py)
6
+ - `_is_hardcoded_resume(src)` — detects the owner's bundled resume.
7
+ - `place_keywords_structured(latex_src, terms)` — waterfall allocation in the
8
+ exact R22 order with per-slot max bounds:
9
+ 1 Summary 20 → 2 BYJU'S PSM 30 → 3 BYJU'S PS 30 → 4 ML Edutech 12 →
10
+ 5 Skills Other 20 → 6 Projects 10 each (FDP, Launchpad, OCR--OMR, Offline NAT,
11
+ AI Chatbot, NIAT Application Portal) → 7 NxtWave 20 → 8 Skills Other overflow
12
+ (merged into the single Other row). Never holds keywords back.
13
+ - Anchoring uses literal `\resumeItemListEnd` (NOT `\end{itemize}` — the resume
14
+ has no bare itemize end). Experience/project anchors are searched within their
15
+ own section so duplicates (e.g. "NIAT Application Portal" appears in both an
16
+ experience bullet and a project) resolve correctly.
17
+ - Append-only: inserts a tagged `\resumeItem{...} % ats-item` on the line before
18
+ each block's `\resumeItemListEnd`; a fenced summary block; one Skills `Other:`
19
+ row tagged `% ats-skills-other`. Original bullets/roles/dates untouched.
20
+ - `inject_keywords` routes to this for the hardcoded resume; generic LaTeX keeps
21
+ the original recruiter-style distribution.
22
+ - `_remove_injected_block` extended to strip the new `\resumeItem … % ats-item`
23
+ lines and the Skills `Other:` row (with its leading `\\` separator) → idempotent.
24
+
25
+ ## Verification (all passed)
26
+ - `tests/test_structured_placement.py` — 9/9 pass: summary 15–20, PSM 25–30,
27
+ PS 25–30, ML 8–12, Skills Other ≥15, anchor-before-list-end, append-only
28
+ (4 original bullets intact), idempotent (o1==o2), dedupe skips present terms.
29
+ - Generic non-hardcoded LaTeX still uses the original distribution (regression OK).
30
+ - Routing: `_is_hardcoded_resume(DEFAULT_RESUME_LATEX) == True`.
31
+
32
+ ## Notes / deviations
33
+ - Tectonic compile of the placed resume is verified on HF (engine not installed
34
+ locally); local checks cover structure + idempotency.
35
+ - Skills-Other strip regex tolerates the leading `\\` separator + whitespace so
36
+ re-runs reproduce byte-identically (idempotency test proves it).
src/latex_resume.py CHANGED
@@ -278,6 +278,136 @@ _EXPERIENCE_RE = (
278
  )
279
 
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  def inject_keywords(latex_src: str, terms: List[str],
282
  heading: str = "Core Competencies") -> Tuple[str, List[str]]:
283
  """Distribute honest keywords across the LaTeX source like a recruiter expects.
@@ -298,6 +428,12 @@ def inject_keywords(latex_src: str, terms: List[str],
298
  second pass reproduces the same output rather than stacking. Returns
299
  (new_source, injected_terms).
300
  """
 
 
 
 
 
 
301
  src = latex_src or ""
302
  # Remove any previously injected fragments so repeated runs don't stack.
303
  src = _remove_injected_block(src)
@@ -440,6 +576,16 @@ def _remove_injected_block(src: str) -> str:
440
  # Strip APPENDED experience items (R17): "\n\item <term> % ats-item\n".
441
  item_pat = r"\n\\item [^\n]*?%\s*ats-item[^\n]*\n?"
442
  s = re.sub(item_pat, "", s)
 
 
 
 
 
 
 
 
 
 
443
  # Legacy: strip inline woven clauses from older outputs.
444
  weave_pat = r" \(applying [^\n]*?\)" + re.escape(_INJECT_WEAVE)
445
  s = re.sub(weave_pat, "", s)
 
278
  )
279
 
280
 
281
+ # ── Structured placement for the HARDCODED resume (R22) ──────────────────────
282
+ _INJECT_SKILLS_OTHER = "% ats-skills-other"
283
+
284
+
285
+ def _is_hardcoded_resume(src: str) -> bool:
286
+ """True when `src` is the owner's bundled resume (known structure → we can
287
+ place keywords in the exact owner-specified order/locations)."""
288
+ s = src or ""
289
+ return ("Saiteja Tirunagari" in s and "\\resumeSubheading" in s
290
+ and "Asst. Product Success Manager" in s)
291
+
292
+
293
+ def place_keywords_structured(latex_src: str,
294
+ terms: List[str]) -> Tuple[str, List[str]]:
295
+ """Place keywords into the hardcoded resume in the exact R22 waterfall:
296
+ 1 Summary 15-20 -> 2 BYJU'S PSM 25-30 -> 3 BYJU'S PS 25-30 -> 4 ML Edutech
297
+ 8-12 -> 5 Skills Other 15-20 -> 6 Projects 8-10 each (FDP, Launchpad,
298
+ OCR--OMR, Offline NAT, AI Chatbot, NIAT Application Portal) -> 7 NxtWave
299
+ 15-20 -> 8 Skills Other overflow.
300
+ Append-only (never edits original bullets) and idempotent (strips prior
301
+ injected fragments first). Returns (new_source, placed_terms).
302
+ """
303
+ src = _remove_injected_block(latex_src or "")
304
+ src_low = src.lower()
305
+
306
+ # De-dupe + skip terms already present in the candidate's resume.
307
+ seen: set = set()
308
+ pool: List[str] = []
309
+ for t in terms or []:
310
+ tl = (t or "").strip().lower()
311
+ if not tl or tl in seen:
312
+ continue
313
+ seen.add(tl)
314
+ if tl in src_low:
315
+ continue
316
+ pool.append(t.strip())
317
+ if not pool:
318
+ return src, []
319
+
320
+ placed = list(pool)
321
+
322
+ def take(n: int) -> List[str]:
323
+ nonlocal pool
324
+ g = pool[:n]
325
+ pool = pool[n:]
326
+ return g
327
+
328
+ # Section bounds (so anchors resolve in the right section — e.g. "NIAT
329
+ # Application Portal" appears in BOTH an experience bullet and a project).
330
+ exp_start = src.find("\\section{EXPERIENCE}")
331
+ proj_start = src.find("\\section{SELECTED")
332
+ if proj_start == -1:
333
+ proj_start = src.find("SELECTED 0")
334
+ skills_start = src.find("\\section{SKILLS}")
335
+
336
+ # Waterfall allocation (max bounds; never hold keywords back).
337
+ summary_terms = take(20)
338
+ psm_terms = take(30)
339
+ ps_terms = take(30)
340
+ ml_terms = take(12)
341
+ skills_terms = take(20)
342
+ proj_order = ["FDP", "Launchpad", "OCR--OMR", "Offline NAT",
343
+ "AI Chatbot", "NIAT Application Portal"]
344
+ proj_terms = {k: take(10) for k in proj_order}
345
+ nxtwave_terms = take(20)
346
+ skills_terms = skills_terms + pool # slot 8 overflow merged into one row
347
+ pool = []
348
+
349
+ def esc_join(ts: List[str]) -> str:
350
+ return ", ".join(latex_escape(t) for t in ts)
351
+
352
+ insertions: List[Tuple[int, str]] = [] # (position, text) on ORIGINAL src
353
+
354
+ def add_item(anchor: str, ts: List[str], search_start: int) -> None:
355
+ """Queue a tagged \\resumeItem appended as the last item of the block that
356
+ `anchor` belongs to (inserted on the line before its \\resumeItemListEnd)."""
357
+ if not ts or search_start < 0:
358
+ return
359
+ a = src.find(anchor, search_start)
360
+ if a == -1:
361
+ return
362
+ e = src.find("\\resumeItemListEnd", a)
363
+ if e == -1:
364
+ return
365
+ p = src.rfind("\n", 0, e) # newline before the (indented) list-end
366
+ if p == -1:
367
+ return
368
+ block = "\n \\resumeItem{" + esc_join(ts) + "} " + _INJECT_ITEM
369
+ insertions.append((p, block))
370
+
371
+ # Experience slots (search within EXPERIENCE section).
372
+ add_item("Asst. Product Success Manager", psm_terms, exp_start)
373
+ add_item("Product Specialist", ps_terms, exp_start)
374
+ add_item("ML Edutech", ml_terms, exp_start)
375
+ add_item("Internal Product Manager", nxtwave_terms, exp_start)
376
+
377
+ # Project slots (search within PROJECTS section, in the owner's order).
378
+ for k in proj_order:
379
+ add_item(k, proj_terms[k], proj_start)
380
+
381
+ # Summary slot: fenced block at the END of the summary (before next \section).
382
+ if summary_terms:
383
+ sm = re.search(_SUMMARY_RE, src, re.I)
384
+ if sm:
385
+ nxt = src.find("\\section", sm.end())
386
+ pos = nxt if nxt != -1 else sm.end()
387
+ block = (
388
+ "\n" + _INJECT_MARKER + "\n"
389
+ "\\par\\noindent Additional areas: " + esc_join(summary_terms) + ".\n"
390
+ + _INJECT_END + "\n"
391
+ )
392
+ insertions.append((pos, block))
393
+
394
+ # Skills "Other:" row (slots 5 + 8): a single new row before the \item close.
395
+ if skills_terms and skills_start != -1:
396
+ se = src.find("\\end{itemize}", skills_start)
397
+ if se != -1:
398
+ close = src.rfind("}}", skills_start, se)
399
+ if close != -1:
400
+ row = (" \\\\\n \\textbf{Other}{: " + esc_join(skills_terms)
401
+ + "} " + _INJECT_SKILLS_OTHER + "\n ")
402
+ insertions.append((close, row))
403
+
404
+ # Apply right-to-left so earlier offsets stay valid.
405
+ for pos, text in sorted(insertions, key=lambda x: x[0], reverse=True):
406
+ src = src[:pos] + text + src[pos:]
407
+
408
+ return src, placed
409
+
410
+
411
  def inject_keywords(latex_src: str, terms: List[str],
412
  heading: str = "Core Competencies") -> Tuple[str, List[str]]:
413
  """Distribute honest keywords across the LaTeX source like a recruiter expects.
 
428
  second pass reproduces the same output rather than stacking. Returns
429
  (new_source, injected_terms).
430
  """
431
+ # Phase 9 (R22): the owner's bundled resume has a known structure, so place
432
+ # keywords in the exact owner-specified order/locations. Arbitrary LaTeX still
433
+ # uses the generic recruiter-style distribution below.
434
+ if _is_hardcoded_resume(latex_src):
435
+ return place_keywords_structured(latex_src, terms)
436
+
437
  src = latex_src or ""
438
  # Remove any previously injected fragments so repeated runs don't stack.
439
  src = _remove_injected_block(src)
 
576
  # Strip APPENDED experience items (R17): "\n\item <term> % ats-item\n".
577
  item_pat = r"\n\\item [^\n]*?%\s*ats-item[^\n]*\n?"
578
  s = re.sub(item_pat, "", s)
579
+ # Phase 9 (R22): strip appended \resumeItem lines from structured placement.
580
+ # Consume the leading newline+indent + the item; do NOT consume the trailing
581
+ # newline (so the following \resumeItemListEnd keeps its own line → idempotent).
582
+ s = re.sub(r"\n[ \t]*\\resumeItem\{[^\n]*?%\s*ats-item", "", s)
583
+ # Phase 9 (R22): strip the Skills "Other:" injected row WITH its leading "\\"
584
+ # separator and trailing indent so re-injection reproduces byte-identically.
585
+ s = re.sub(
586
+ r" \\\\\n\s*\\textbf\{Other\}\{:.*?%\s*ats-skills-other[^\n]*\n[ \t]*",
587
+ "", s,
588
+ )
589
  # Legacy: strip inline woven clauses from older outputs.
590
  weave_pat = r" \(applying [^\n]*?\)" + re.escape(_INJECT_WEAVE)
591
  s = re.sub(weave_pat, "", s)
tests/test_structured_placement.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 9 (R22): structured keyword placement into the hardcoded resume.
2
+
3
+ Asserts the owner's exact order/word-count waterfall, append-only behaviour, and
4
+ idempotency. No LaTeX compilation required.
5
+ """
6
+ import os
7
+ import re
8
+
9
+ import pytest
10
+
11
+ from src.latex_resume import inject_keywords, place_keywords_structured
12
+
13
+
14
+ def _resume() -> str:
15
+ try:
16
+ from src.default_resume import DEFAULT_RESUME_LATEX
17
+ if DEFAULT_RESUME_LATEX.strip():
18
+ return DEFAULT_RESUME_LATEX
19
+ except Exception:
20
+ pass
21
+ p = os.path.join(
22
+ ".planning", "phases", "09-hardcoded-resume-keyword-placement",
23
+ "resume-source.tex",
24
+ )
25
+ if os.path.exists(p):
26
+ with open(p, encoding="utf-8") as f:
27
+ txt = f.read()
28
+ if txt.strip():
29
+ return txt
30
+ pytest.skip("resume source not available")
31
+
32
+
33
+ KWS = [f"kw{i:03d}" for i in range(120)]
34
+
35
+
36
+ def _items_in(block: str):
37
+ """Return the list of tagged-item term-strings found in `block`."""
38
+ return re.findall(r"\\resumeItem\{([^\n]*?)\}\s*% ats-item", block)
39
+
40
+
41
+ def test_summary_slot_15_to_20():
42
+ out, _ = inject_keywords(_resume(), KWS)
43
+ m = re.search(r"Additional areas: (.+?)\.\s*% ===== end", out, re.S)
44
+ assert m, "summary injection block missing"
45
+ n = len([t for t in m.group(1).split(",") if t.strip()])
46
+ assert 15 <= n <= 20, f"summary has {n} terms (want 15-20)"
47
+
48
+
49
+ def test_byjus_psm_25_to_30():
50
+ out, _ = inject_keywords(_resume(), KWS)
51
+ # The tagged item inside the Asst PSM block.
52
+ seg = out[out.find("Asst. Product Success Manager"):]
53
+ seg = seg[: seg.find("\\resumeItemListEnd") + 20]
54
+ items = _items_in(seg)
55
+ assert items, "no tagged item in PSM block"
56
+ n = len([t for t in items[-1].split(",") if t.strip()])
57
+ assert 25 <= n <= 30, f"PSM has {n} terms (want 25-30)"
58
+
59
+
60
+ def test_byjus_ps_25_to_30():
61
+ out, _ = inject_keywords(_resume(), KWS)
62
+ seg = out[out.find("Product Specialist -- User Experience"):]
63
+ seg = seg[: seg.find("\\resumeItemListEnd") + 20]
64
+ items = _items_in(seg)
65
+ assert items, "no tagged item in Product Specialist block"
66
+ n = len([t for t in items[-1].split(",") if t.strip()])
67
+ assert 25 <= n <= 30, f"PS has {n} terms (want 25-30)"
68
+
69
+
70
+ def test_ml_edutech_8_to_12():
71
+ out, _ = inject_keywords(_resume(), KWS)
72
+ seg = out[out.find("ML Edutech"):]
73
+ seg = seg[: seg.find("\\resumeItemListEnd") + 20]
74
+ items = _items_in(seg)
75
+ assert items, "no tagged item in ML Edutech block"
76
+ n = len([t for t in items[-1].split(",") if t.strip()])
77
+ assert 8 <= n <= 12, f"ML Edutech has {n} terms (want 8-12)"
78
+
79
+
80
+ def test_skills_other_row_present():
81
+ out, _ = inject_keywords(_resume(), KWS)
82
+ m = re.search(r"\\textbf\{Other\}\{: (.+?)\}\s*% ats-skills-other", out)
83
+ assert m, "Skills Other row missing"
84
+ n = len([t for t in m.group(1).split(",") if t.strip()])
85
+ assert n >= 15, f"Skills Other has {n} terms (want >=15 once filled)"
86
+
87
+
88
+ def test_anchor_before_list_end():
89
+ out, _ = inject_keywords(_resume(), KWS)
90
+ assert re.search(r"% ats-item[^\n]*\n\s*\\resumeItemListEnd", out), \
91
+ "tagged item not anchored immediately before a \\resumeItemListEnd"
92
+
93
+
94
+ def test_append_only_original_bullets_intact():
95
+ src = _resume()
96
+ out, _ = inject_keywords(src, KWS)
97
+ for original in [
98
+ "Increased user retention by 8",
99
+ "Managed",
100
+ "Launched an EdTech app portfolio",
101
+ "Led end-to-end revamp of the",
102
+ ]:
103
+ assert original in out, f"original bullet text lost: {original!r}"
104
+
105
+
106
+ def test_idempotent():
107
+ src = _resume()
108
+ o1, _ = inject_keywords(src, KWS)
109
+ o2, _ = inject_keywords(o1, KWS)
110
+ assert o1 == o2, "placement is not idempotent (injected lines stacked)"
111
+
112
+
113
+ def test_dedupe_skips_present_terms():
114
+ # A term already in the resume must not be re-added.
115
+ out, placed = place_keywords_structured(_resume(), ["Roadmap Planning", "kwX1", "kwX2"])
116
+ assert "Roadmap Planning" not in placed
117
+ assert "kwX1" in placed