Spaces:
Sleeping
Sleeping
Commit ·
fd65907
1
Parent(s): fa74339
feat(09-03): structured keyword placement for hardcoded resume [R22]
Browse filesplace_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
|