saitejatirunagari Claude Opus 4.8 commited on
Commit
47b43eb
·
1 Parent(s): 2a811e0

feat(ats): redesign to industry standard — skills-only extraction + Skills section

Browse files

User hit Jobalytics/Resume Worded 61 with garbage injected ("Goals, Enterprise,
Authority, Productivity..."). Researched how ATS actually works; our approach
was backwards. Rebuilt to the standard.

- Extraction is SKILLS-ONLY: keep a term only if it's a recognised skill/tool/
method/domain in the gazetteer (_is_professional_term). Killed the "any
recurring/noun-suffix word" rule that produced prose-noun garbage. Expanded
vocab with common PM JD terms (use cases, business objectives, market trends).
- Re-introduced a categorized SKILLS section (Resume.skills + renderer
_write_skills): Tools & Analytics / Methodologies / Domains / Core, one clean
line each, after the summary. The #1 ATS keyword vehicle (reverses old task
to remove it).
- v4 populates Skills with the JD's real skills (taxonomy-first, ~26 cap), keeps
contextual bullet weaving, and RETIRES the summary-noun injection (stuffing
that real checkers penalised).
- Buzzword list fixed: keep real soft skills (communication/leadership/
collaboration); drop only filler + prose nouns.
- Postcondition: allow a clean categorized Skills section, still ban 15+-sep
dump lines. Acronym casing (SIEM/SOAR/XDR/SecOps/PLG/ROI/CAC/LTV/NPS).

Verify: worst-case 75-92, production 78-91, zero garbage, ~75% coverage
(best-practice 60-80%). Internal numbers are honest (lower than the prior
stuffed 87-94); real-checker scores should rise. Verify on Resume Worded/
Jobalytics.

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

HISTORY.md CHANGED
@@ -4,6 +4,55 @@ A running log of everything built, fixed, and changed. Most recent first.
4
 
5
  ---
6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  ## 2026-06-19 (2) — Smart fill: keep ALL keywords (distributed), drop buzzwords
8
 
9
  User feedback on a Resume Worded screenshot (scored 74, top fix = "Buzzwords 7"):
 
4
 
5
  ---
6
 
7
+ ## 2026-06-19 (3) — ATS redesign to the INDUSTRY STANDARD (skills section)
8
+
9
+ User hit a real disaster: Jobalytics 61% / Resume Worded 61, with an injected
10
+ garbage line ("Further strengths span Goals, Enterprise, Generation,
11
+ Organisation, Authority, Ai Technology, Repetitive Tasks, Productivity"). I
12
+ researched how ATS checkers actually work ([Interview Guys](https://blog.theinterviewguys.com/what-ats-looks-for-in-resumes/),
13
+ [Jobscan](https://www.jobscan.co/blog/resume-keyword-stuffing/), [Jobalytics](https://jobalytics.app/),
14
+ [uppl.ai](https://www.uppl.ai/ats-resume-keywords)) and found our whole approach
15
+ was backwards. Rebuilt to match the standard.
16
+
17
+ ### Root causes
18
+ - **We extracted words, not skills.** A "keep any recurring/noun-suffix word"
19
+ rule grabbed prose nouns (Goals, Authority, Enterprise, Productivity,
20
+ Generation, Organisation) — none of which any real ATS treats as keywords.
21
+ - **We injected ~100 keywords as filler sentences** — the opposite of best
22
+ practice (15–25 keywords, each 1–3×). Real ATS detect this as stuffing.
23
+ - **We had deleted the Skills section** — yet a dedicated, standard-headed
24
+ Skills section is the #1 ATS keyword vehicle (Jobalytics literally scores
25
+ "Hard Skills"). Deleting it was wrong.
26
+ - **We over-removed real soft skills** (communication, leadership,
27
+ collaboration) as "buzzwords" — but those are keywords checkers reward.
28
+
29
+ ### The redesign (user approved both decisions)
30
+ - **Extraction is skills-only** (`_extract_content_terms`): a discovered term
31
+ is kept only if it's a recognised skill/tool/method/domain/soft-skill in our
32
+ gazetteer. Prose nouns can no longer appear. Expanded the vocab with common
33
+ PM JD terms (use cases, business objectives, market trends, user personas…).
34
+ - **Re-introduced a categorized SKILLS section** (`Resume.skills` + renderer
35
+ `_write_skills`): Tools & Analytics / Methodologies / Domains / Core
36
+ Competencies, one clean line each (≤12 items), placed after the summary.
37
+ - **Quality coverage, not stuffing**: v4 populates the Skills section with the
38
+ JD's real skills (taxonomy-first, ~26 cap), keeps contextual bullet weaving,
39
+ and **retired the summary-noun injection** entirely.
40
+ - **Buzzword list fixed**: keeps real soft skills, drops only true filler
41
+ (innovation, solutions, world-class, robust, leverage…) + prose nouns.
42
+ - **Postcondition updated**: a clean categorized Skills section is allowed; a
43
+ raw 15+-separator dump line is still banned. Acronym casing
44
+ (SIEM/SOAR/XDR/SecOps/PLG/ROI/CAC/LTV/NPS…).
45
+
46
+ ### Outcome (`scripts/verify_honest_scores.py`)
47
+ - Worst-case stub 75–92, production 78–91, **zero garbage**, coverage ~75%
48
+ (best-practice target 60–80%). The Skills section reads like a real resume.
49
+ - These internal numbers are lower than the previous *stuffed* 87–94 — because
50
+ the padding is gone. The REAL Resume Worded/Jobalytics score should *rise*:
51
+ we removed the buzzword penalty + garbage they flagged and added the Skills
52
+ section they reward. Verify externally.
53
+
54
+ ---
55
+
56
  ## 2026-06-19 (2) — Smart fill: keep ALL keywords (distributed), drop buzzwords
57
 
58
  User feedback on a Resume Worded screenshot (scored 74, top fix = "Buzzwords 7"):
README.md CHANGED
@@ -223,14 +223,19 @@ Hybrid scoring: **70% JD Match + 30% Resume Quality**
223
  - **Iterative optimization**: Up to 3 LLM attempts to push score up, gap report fed back each iteration
224
  - **Fallback**: `_inject_missing_keywords()` weaves still-missing terms naturally
225
 
226
- ### Keyword extraction is JD-driven, meaningful, and honest
227
-
228
- - Keywords are extracted **from each JD itself** (not a fixed stored list) and
229
- **uncapped** a JD with 50 meaningful terms yields 50. Extraction keeps real
230
- nouns/skills (known skills, recurring terms, noun-suffix words, genuine skill
231
- phrases) and **drops JD prose** (one-off verbs/adjectives, locations, company
232
- names). This matches what real checkers like Jobalytics actually extract
233
- (~35–55 terms), so our denominator isn't inflated.
 
 
 
 
 
234
  - **No cap, distributed smartly.** Every meaningful missing keyword is kept —
235
  woven into relevant bullets first (the ideal, never-penalised place), then any
236
  remainder spread across several short sentences (each its own paragraph, ≤10
 
223
  - **Iterative optimization**: Up to 3 LLM attempts to push score up, gap report fed back each iteration
224
  - **Fallback**: `_inject_missing_keywords()` weaves still-missing terms naturally
225
 
226
+ ### Keyword extraction is JD-driven, skills-only, and honest (industry standard)
227
+
228
+ - Keywords are extracted **from each JD itself** (not a fixed stored list), but
229
+ **skills-only**: a term is kept only if it's a recognised skill/tool/method/
230
+ domain/soft-skill in our gazetteer. Prose nouns (Goals, Authority, Enterprise,
231
+ Productivity…) can never become "keywords" exactly how real checkers like
232
+ Jobalytics work (they match against a curated skills list, not every word).
233
+ - **Dedicated, categorized SKILLS section** (Tools & Analytics / Methodologies /
234
+ Domains / Core Competencies) — the #1 ATS keyword vehicle, placed after the
235
+ summary, parsed and counted by Jobalytics/Resume Worded.
236
+ - **Quality over quantity**: cover the JD's real skills (~20–26), each 1–3×,
237
+ across the skills section + summary + woven bullets. Best practice is 15–25
238
+ keywords at 60–80% coverage — NOT 100 stuffed terms.
239
  - **No cap, distributed smartly.** Every meaningful missing keyword is kept —
240
  woven into relevant bullets first (the ideal, never-penalised place), then any
241
  remainder spread across several short sentences (each its own paragraph, ≤10
src/ats_scorer.py CHANGED
@@ -279,6 +279,18 @@ GENERIC_PROFESSIONAL_VOCAB = {
279
  "quantitative", "qualitative", "sql", "excel", "spreadsheets",
280
  "tableau", "looker", "powerbi", "amplitude", "mixpanel", "ga4",
281
  "jira", "confluence", "figma", "notion", "asana", "miro",
 
 
 
 
 
 
 
 
 
 
 
 
282
  }
283
 
284
 
@@ -854,32 +866,17 @@ def _extract_content_terms(jd_text: str, max_terms: int = 0) -> List[str]:
854
  bg = f"{w1} {w2}"
855
  bigram_freq[bg] = bigram_freq.get(bg, 0) + 1
856
 
857
- # Keep MEANINGFUL unigrams only — match what real ATS checkers (Jobalytics)
858
- # actually extract: nouns/skills, not JD prose. A discovered word that isn't
859
- # a known skill is kept only if it carries a strong NOUN-forming suffix or
860
- # recurs AND is not an obvious verb/adjective form. Prose verbs/gerunds/
861
- # adjectives (respond, defend, evolving, collaborating, reliable, prioritize)
862
- # are dropped: stuffing them never raises a real-checker score and reads as
863
- # filler. (Real skills like "marketing"/"onboarding"/"testing" survive via
864
- # the professional-term vocab, not via a blanket -ing rule.)
865
- _NOUN_SUFFIX = (
866
- "tion", "sion", "ment", "ity", "ility", "ance", "ence", "ics",
867
- "ism", "ist", "ology", "ware", "ization", "isation", "ship",
868
- "ategy", "ategies", "analytics",
869
- )
870
- # Verb/gerund/adjective endings → prose unless they're known skills.
871
- _PROSE_SUFFIX = ("ing", "ize", "ise", "ate", "ify", "able", "ible", "ous", "ive")
872
-
873
- def _meaningful(t: str, c: int) -> bool:
874
- if _is_professional_term(t):
875
- return True
876
- # Reject obvious verb/adjective prose forms outright.
877
- if t.endswith(_PROSE_SUFFIX):
878
- return False
879
- # Strong noun suffix → keep. Otherwise require recurrence (emphasis).
880
- return t.endswith(_NOUN_SUFFIX) or c >= 2
881
-
882
- meaningful = {t: c for t, c in freq.items() if _meaningful(t, c)}
883
  # Rank unigrams: known skills first, then frequency
884
  uni = sorted(meaningful.items(),
885
  key=lambda x: (_is_professional_term(x[0]), x[1]), reverse=True)
 
279
  "quantitative", "qualitative", "sql", "excel", "spreadsheets",
280
  "tableau", "looker", "powerbi", "amplitude", "mixpanel", "ga4",
281
  "jira", "confluence", "figma", "notion", "asana", "miro",
282
+ # ── Common PM/business JD terms real checkers extract (so coverage stays
283
+ # high now that extraction is skills-only). Multi-word forms also live in
284
+ # PM_SKILL_PHRASES via the taxonomy; these single tokens + phrases fill gaps
285
+ # seen on real PM JDs (Ema/Jobalytics): use cases, business objectives, etc.
286
+ "use cases", "use case", "business objectives", "market trends",
287
+ "user personas", "customer support", "product strategy", "product vision",
288
+ "product development", "product management", "product manager",
289
+ "product features", "product requirements",
290
+ "competitor analysis", "competitive", "roadmapping", "gap analysis",
291
+ "performance tracking", "iteration", "go-to-market strategy",
292
+ "cross-functional collaboration", "senior management",
293
+ "user-friendly", "milestones", "timelines",
294
  }
295
 
296
 
 
866
  bg = f"{w1} {w2}"
867
  bigram_freq[bg] = bigram_freq.get(bg, 0) + 1
868
 
869
+ # SKILLS ONLY — match how real ATS checkers (Jobalytics/Resume Worded)
870
+ # actually work: they compare against a curated gazetteer of hard skills,
871
+ # tools, methods, domains, and real soft skills NOT "any noun in the JD".
872
+ # We therefore keep a discovered unigram ONLY if it is a recognised
873
+ # professional term (in our skill vocab/taxonomy). This is what stops prose
874
+ # nouns like "Goals", "Enterprise", "Authority", "Productivity",
875
+ # "Generation", "Organisation" from ever being treated as keywords. Coverage
876
+ # of genuinely common PM/business terms comes from expanding the vocab, not
877
+ # from blindly grabbing every noun (which produced garbage and lowered the
878
+ # real-checker score).
879
+ meaningful = {t: c for t, c in freq.items() if _is_professional_term(t)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
880
  # Rank unigrams: known skills first, then frequency
881
  uni = sorted(meaningful.items(),
882
  key=lambda x: (_is_professional_term(x[0]), x[1]), reverse=True)
src/resume_customizer.py CHANGED
@@ -474,17 +474,22 @@ class ResumeCustomizer:
474
  # INJECT these — they are abstractions, not the concrete tools/methods/
475
  # domains that count as keywords. (They may still appear in a JD; we simply
476
  # don't stuff them into the resume.) General-purpose, not JD-specific.
 
 
 
 
 
477
  _BUZZWORDS = {
478
  "innovation", "innovative", "solutions", "solution", "tools", "tool",
479
- "lifecycle", "problem-solving", "problem solving", "ownership",
480
- "leadership", "communication", "collaboration", "collaborative",
481
- "teamwork", "synergy", "dynamic", "passionate", "motivated",
482
- "results-driven", "results driven", "detail-oriented", "detail oriented",
483
- "team player", "track record", "expertise", "strengths", "strength",
484
- "best practices", "value-add", "thought leadership", "self-starter",
485
- "go-getter", "fast-paced", "cutting-edge", "world-class", "robust",
486
- "scalable", "seamless", "holistic", "leverage", "leveraging",
487
- "spearheaded", "passion", "excellence", "proven", "successful",
488
  }
489
 
490
  # Allowlist patterns: only inject keywords that look like actual skills
@@ -1296,23 +1301,40 @@ class ResumeCustomizer:
1296
  ]
1297
  if missing:
1298
  self._weave_keywords_into_bullets(tailored, missing, jd_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1299
  except Exception as e:
1300
  print(f"[weave] {e}")
1301
  self._pending_summary_inject = []
1302
 
1303
- # 3. Render to DOCX (after weaving)
1304
  render_resume_docx(tailored, filepath)
1305
 
1306
- # 4. Score; if still below 92, also run summary injection for any
1307
- # keywords that couldn't fit a bullet
 
 
1308
  score = _score_resume(_read_docx_text(filepath), jd_text, extra_kw=assessed_kw)["ats_score"]
1309
 
1310
- if score < 92:
1311
- # Run summary-level injection (closing sentence) for stragglers
1312
- self._inject_missing_keywords(filepath, jd_text, extra_kw=assessed_kw)
1313
- doc_text = _read_docx_text(filepath)
1314
- score = _score_resume(doc_text, jd_text, extra_kw=assessed_kw)["ats_score"]
1315
-
1316
  # 5. Postcondition check
1317
  try:
1318
  self._assert_no_dump_footer(filepath)
@@ -1444,14 +1466,11 @@ class ResumeCustomizer:
1444
  @staticmethod
1445
  def _assert_no_dump_footer(filepath: str) -> None:
1446
  """
1447
- Postcondition: the generated resume must NOT contain a keyword-dump
1448
- section. Raises AssertionError if a suspicious pattern is found.
1449
-
1450
- Caught patterns:
1451
- - Any paragraph starting with "Additional relevant skills" (legacy footer)
1452
- - Any paragraph titled "CORE COMPETENCIES" or "SKILLS" — banned by R6
1453
- - Any paragraph with 10+ bullet-separator characters that isn't a
1454
- legitimate experience bullet (heuristic: no JD-style phrasing)
1455
  """
1456
  from docx import Document as _Doc
1457
  doc = _Doc(filepath)
@@ -1462,14 +1481,16 @@ class ResumeCustomizer:
1462
  if text.lower().startswith("additional relevant skills"):
1463
  raise AssertionError(
1464
  f"Dump footer detected: '{text[:80]}'. "
1465
- f"Keywords must be woven into bullets/summary, not appended."
1466
  )
1467
- upper = text.upper()
1468
- if upper in {"CORE COMPETENCIES", "CORE COMPETENCIES & SKILLS", "SKILLS",
1469
- "TECHNICAL SKILLS", "COMPETENCIES"}:
 
 
1470
  raise AssertionError(
1471
- f"Banned section header found: '{text}'. "
1472
- f"Per project policy R6, the resume has no skills section."
1473
  )
1474
 
1475
  def _extract_experience_sections(self, text: str) -> list[dict]:
 
474
  # INJECT these — they are abstractions, not the concrete tools/methods/
475
  # domains that count as keywords. (They may still appear in a JD; we simply
476
  # don't stuff them into the resume.) General-purpose, not JD-specific.
477
+ # NOTE: real soft skills (communication, leadership, collaboration,
478
+ # stakeholder management, ownership) are NOT buzzwords — real checkers
479
+ # (Jobalytics) count them as keywords, so they are deliberately absent here.
480
+ # This set is ONLY vague filler/marketing fluff that adds no ATS value and
481
+ # gets flagged (Resume Worded "Buzzwords" fix). We never inject these.
482
  _BUZZWORDS = {
483
  "innovation", "innovative", "solutions", "solution", "tools", "tool",
484
+ "synergy", "dynamic", "passionate", "motivated", "results-driven",
485
+ "results driven", "detail-oriented", "detail oriented", "team player",
486
+ "track record", "expertise", "strengths", "strength", "best practices",
487
+ "value-add", "thought leadership", "self-starter", "go-getter",
488
+ "fast-paced", "cutting-edge", "world-class", "robust", "seamless",
489
+ "holistic", "leverage", "leveraging", "spearheaded", "passion",
490
+ "excellence", "proven", "successful", "enterprise", "productivity",
491
+ "authority", "generation", "organisation", "organization", "goals",
492
+ "thing", "things", "tasks", "task",
493
  }
494
 
495
  # Allowlist patterns: only inject keywords that look like actual skills
 
1301
  ]
1302
  if missing:
1303
  self._weave_keywords_into_bullets(tailored, missing, jd_text)
1304
+
1305
+ # ── Populate the categorized SKILLS section (industry standard) ──
1306
+ # The skills section is the #1 ATS keyword vehicle: real checkers
1307
+ # (Jobalytics/Resume Worded) parse it and count hard skills. We list
1308
+ # the JD's REAL skills (taxonomy/vocab terms only — no prose nouns),
1309
+ # prioritised by known-skill then JD frequency, capped to a credible
1310
+ # ~26 (best practice = quality, each keyword 1-3x, not 100).
1311
+ from .ats_scorer import _is_professional_term as _isprof, _is_taxonomy_skill as _istax
1312
+ jd_low = jd_text.lower()
1313
+ skill_pool = [
1314
+ k for k in jd_kw
1315
+ if k.lower() not in self._BUZZWORDS
1316
+ and k.lower() not in self._KEYWORD_BLOCKLIST
1317
+ and len(k) >= 3
1318
+ ]
1319
+ skill_pool = self._dedup_keywords_by_lemma(skill_pool)
1320
+ skill_pool.sort(
1321
+ key=lambda k: (_istax(k.lower()), jd_low.count(k.lower())),
1322
+ reverse=True,
1323
+ )
1324
+ tailored.skills = skill_pool[:26]
1325
  except Exception as e:
1326
  print(f"[weave] {e}")
1327
  self._pending_summary_inject = []
1328
 
1329
+ # 3. Render to DOCX (after weaving + skills) — Skills section included
1330
  render_resume_docx(tailored, filepath)
1331
 
1332
+ # 4. Score. Keyword coverage now comes from the categorized Skills
1333
+ # section + contextual bullet weaving (industry standard) — NOT from
1334
+ # appending vague-noun sentences to the summary, which real checkers
1335
+ # penalise as stuffing. The old summary injection is retired.
1336
  score = _score_resume(_read_docx_text(filepath), jd_text, extra_kw=assessed_kw)["ats_score"]
1337
 
 
 
 
 
 
 
1338
  # 5. Postcondition check
1339
  try:
1340
  self._assert_no_dump_footer(filepath)
 
1466
  @staticmethod
1467
  def _assert_no_dump_footer(filepath: str) -> None:
1468
  """
1469
+ Postcondition: the generated resume must NOT contain a keyword-DUMP.
1470
+ A clean, categorized SKILLS section is now ALLOWED (industry standard —
1471
+ it's the #1 ATS keyword vehicle). What stays banned is a raw dump: the
1472
+ legacy "Additional relevant skills" footer, or any single line with 15+
1473
+ separators (the keyword-stuffing pattern real checkers penalise).
 
 
 
1474
  """
1475
  from docx import Document as _Doc
1476
  doc = _Doc(filepath)
 
1481
  if text.lower().startswith("additional relevant skills"):
1482
  raise AssertionError(
1483
  f"Dump footer detected: '{text[:80]}'. "
1484
+ f"Keywords must be woven into bullets/skills, not appended as a footer."
1485
  )
1486
+ # Raw dump heuristic: one line with 15+ separators (commas/pipes/
1487
+ # bullets). The categorized SKILLS section is safe each line is
1488
+ # 'Category: a, b, c' with ≤12 items (<15 separators).
1489
+ sep = text.count(",") + text.count("|") + text.count("•")
1490
+ if sep >= 15:
1491
  raise AssertionError(
1492
+ f"Keyword dump detected ({sep} separators): '{text[:80]}'. "
1493
+ f"Distribute keywords across categorized lines, not one dump."
1494
  )
1495
 
1496
  def _extract_experience_sections(self, text: str) -> list[dict]:
src/resume_model.py CHANGED
@@ -68,6 +68,7 @@ class Resume:
68
  name: str
69
  contact: Contact = field(default_factory=Contact)
70
  summary: str = "" # 4-6 sentences, opens with recruiter pitch
 
71
  roles: list[Role] = field(default_factory=list)
72
  achievements: list[str] = field(default_factory=list)
73
  education: list[Education] = field(default_factory=list)
@@ -80,6 +81,7 @@ class Resume:
80
  "name": self.name,
81
  "contact": asdict(self.contact),
82
  "summary": self.summary,
 
83
  "roles": [asdict(r) for r in self.roles],
84
  "achievements": list(self.achievements),
85
  "education": [asdict(e) for e in self.education],
@@ -100,6 +102,7 @@ class Resume:
100
  website=c.get("website", ""),
101
  ),
102
  summary=data.get("summary", ""),
 
103
  roles=[
104
  Role(
105
  title=r.get("title", ""),
@@ -133,6 +136,12 @@ class Resume:
133
  if self.summary:
134
  parts.append("PROFESSIONAL SUMMARY")
135
  parts.append(self.summary)
 
 
 
 
 
 
136
  if self.roles:
137
  parts.append("PROFESSIONAL EXPERIENCE")
138
  for r in self.roles:
 
68
  name: str
69
  contact: Contact = field(default_factory=Contact)
70
  summary: str = "" # 4-6 sentences, opens with recruiter pitch
71
+ skills: list[str] = field(default_factory=list) # flat skill list; renderer groups into a categorized SKILLS section
72
  roles: list[Role] = field(default_factory=list)
73
  achievements: list[str] = field(default_factory=list)
74
  education: list[Education] = field(default_factory=list)
 
81
  "name": self.name,
82
  "contact": asdict(self.contact),
83
  "summary": self.summary,
84
+ "skills": list(self.skills),
85
  "roles": [asdict(r) for r in self.roles],
86
  "achievements": list(self.achievements),
87
  "education": [asdict(e) for e in self.education],
 
102
  website=c.get("website", ""),
103
  ),
104
  summary=data.get("summary", ""),
105
+ skills=[str(s) for s in (data.get("skills") or []) if str(s).strip()],
106
  roles=[
107
  Role(
108
  title=r.get("title", ""),
 
136
  if self.summary:
137
  parts.append("PROFESSIONAL SUMMARY")
138
  parts.append(self.summary)
139
+ if self.skills:
140
+ parts.append("SKILLS")
141
+ # Emit in chunks of 8 (mirrors the renderer's categorized lines) so
142
+ # no single line trips the scorer's anti-spam strip (15+ separators).
143
+ for i in range(0, len(self.skills), 8):
144
+ parts.append(", ".join(self.skills[i:i + 8]))
145
  if self.roles:
146
  parts.append("PROFESSIONAL EXPERIENCE")
147
  for r in self.roles:
src/resume_renderer.py CHANGED
@@ -9,8 +9,10 @@ The visual is locked. Every tailored resume looks identical:
9
  - Bullets: 10.5pt, hanging indent, single line spacing
10
  - Achievements: 3-5 quantified bullets
11
  - Education: degree (bold) + institution·dates (italic gray)
 
 
12
 
13
- NO Skills/Competencies section anywhere. NO sub-sections.
14
  """
15
 
16
  from __future__ import annotations
@@ -45,6 +47,7 @@ def render_resume_docx(resume: Resume, filepath: str) -> str:
45
 
46
  _write_header(doc, resume)
47
  _write_summary(doc, resume)
 
48
  _write_experience(doc, resume)
49
  _write_achievements(doc, resume)
50
  _write_education(doc, resume)
@@ -122,6 +125,108 @@ def _write_summary(doc: Document, resume: Resume) -> None:
122
  r.font.color.rgb = _BODY_COLOR
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def _write_experience(doc: Document, resume: Resume) -> None:
126
  if not resume.roles:
127
  return
 
9
  - Bullets: 10.5pt, hanging indent, single line spacing
10
  - Achievements: 3-5 quantified bullets
11
  - Education: degree (bold) + institution·dates (italic gray)
12
+ - Skills: categorized SKILLS section (Tools, Methodologies, Domains, Core)
13
+ placed after the summary — the industry-standard ATS keyword vehicle.
14
 
15
+ NO sub-sections within roles.
16
  """
17
 
18
  from __future__ import annotations
 
47
 
48
  _write_header(doc, resume)
49
  _write_summary(doc, resume)
50
+ _write_skills(doc, resume)
51
  _write_experience(doc, resume)
52
  _write_achievements(doc, resume)
53
  _write_education(doc, resume)
 
125
  r.font.color.rgb = _BODY_COLOR
126
 
127
 
128
+ # Canonical casing for acronyms/brands so the Skills section reads correctly.
129
+ _SKILL_CASE = {
130
+ "prd": "PRD", "prds": "PRDs", "saas": "SaaS", "api": "API", "apis": "APIs",
131
+ "crm": "CRM", "ux": "UX", "ui": "UI", "kpi": "KPI", "kpis": "KPIs",
132
+ "ga4": "GA4", "ai": "AI", "ml": "ML", "nlp": "NLP", "llm": "LLM",
133
+ "llms": "LLMs", "ocr": "OCR", "qa": "QA", "sql": "SQL", "gtm": "GTM",
134
+ "okr": "OKR", "okrs": "OKRs", "mvp": "MVP", "roi": "ROI", "cac": "CAC",
135
+ "ltv": "LTV", "nps": "NPS", "arpu": "ARPU", "b2b": "B2B", "b2c": "B2C",
136
+ "siem": "SIEM", "soar": "SOAR", "xdr": "XDR", "secops": "SecOps",
137
+ "devops": "DevOps", "mlops": "MLOps", "plg": "PLG", "sso": "SSO",
138
+ "fintech": "FinTech", "edtech": "EdTech", "martech": "MarTech",
139
+ "healthtech": "HealthTech", "ecommerce": "eCommerce", "a/b testing": "A/B Testing",
140
+ "power bi": "Power BI", "ga": "Google Analytics", "powerbi": "Power BI",
141
+ }
142
+
143
+ # Skill → category buckets (order = display order). A skill matches a bucket if
144
+ # it appears in that bucket's term set; anything else falls into "Core".
145
+ _TOOLS = {
146
+ "jira", "confluence", "figma", "notion", "asana", "miro", "trello", "linear",
147
+ "mixpanel", "amplitude", "ga4", "google analytics", "ga", "metabase",
148
+ "tableau", "looker", "power bi", "powerbi", "sql", "excel", "spreadsheets",
149
+ "segment", "hotjar", "salesforce", "hubspot", "webengage", "clevertap",
150
+ "braze", "slack", "productboard", "airtable", "python",
151
+ }
152
+ _METHODS = {
153
+ "agile", "scrum", "kanban", "lean", "sprint", "sprint planning", "okrs", "okr",
154
+ "a/b testing", "experimentation", "experiments", "design thinking",
155
+ "discovery", "roadmapping", "prioritization", "gtm", "go-to-market",
156
+ "go-to-market strategy", "hypothesis testing", "user research",
157
+ "agile methodologies", "backlog", "story mapping", "iteration",
158
+ "performance tracking", "gap analysis", "competitive analysis",
159
+ "competitor analysis", "market research", "benchmarking",
160
+ }
161
+ _DOMAINS = {
162
+ "fintech", "edtech", "healthtech", "martech", "ecommerce", "saas", "b2b",
163
+ "b2c", "lending", "credit", "insurance", "fraud", "banking", "payments",
164
+ "logistics", "cybersecurity", "secops", "siem", "soar", "xdr",
165
+ }
166
+
167
+
168
+ def _cap_skill(s: str) -> str:
169
+ k = s.strip().lower()
170
+ if k in _SKILL_CASE:
171
+ return _SKILL_CASE[k]
172
+ if "/" in k:
173
+ return "/".join(p.capitalize() for p in k.split("/"))
174
+ return " ".join(w.capitalize() for w in k.split())
175
+
176
+
177
+ def _write_skills(doc: Document, resume: Resume) -> None:
178
+ """Categorized SKILLS section — the industry-standard ATS keyword vehicle.
179
+
180
+ Groups the flat skill list into Tools, Methodologies, Domains, and Core
181
+ Competencies, each rendered as one 'Category: a, b, c' line. Lines are kept
182
+ short (≤12 items) so they read cleanly and never look like a keyword dump.
183
+ """
184
+ if not resume.skills:
185
+ return
186
+ # Dedup (case-insensitive), preserve order
187
+ seen, flat = set(), []
188
+ for s in resume.skills:
189
+ k = s.strip().lower()
190
+ if k and k not in seen:
191
+ seen.add(k)
192
+ flat.append(s.strip())
193
+
194
+ buckets = {"Tools & Analytics": [], "Methodologies": [], "Domains": [], "Core Competencies": []}
195
+ for s in flat:
196
+ k = s.lower()
197
+ if k in _TOOLS:
198
+ buckets["Tools & Analytics"].append(s)
199
+ elif k in _METHODS:
200
+ buckets["Methodologies"].append(s)
201
+ elif k in _DOMAINS:
202
+ buckets["Domains"].append(s)
203
+ else:
204
+ buckets["Core Competencies"].append(s)
205
+
206
+ # One clean line per non-empty category (cap 12 items so it reads as a real
207
+ # skills line, never a dump, and the category label never repeats).
208
+ lines: list[tuple[str, list[str]]] = []
209
+ for cat, items in buckets.items():
210
+ if items:
211
+ lines.append((cat, items[:12]))
212
+ if not lines:
213
+ return
214
+
215
+ _add_section_header(doc, "SKILLS")
216
+ for cat, items in lines:
217
+ p = doc.add_paragraph()
218
+ p.paragraph_format.space_after = Pt(2)
219
+ label = p.add_run(f"{cat}: ")
220
+ label.bold = True
221
+ label.font.name = "Calibri"
222
+ label.font.size = Pt(10.5)
223
+ label.font.color.rgb = _NAME_COLOR
224
+ body = p.add_run(", ".join(_cap_skill(x) for x in items))
225
+ body.font.name = "Calibri"
226
+ body.font.size = Pt(10.5)
227
+ body.font.color.rgb = _BODY_COLOR
228
+
229
+
230
  def _write_experience(doc: Document, resume: Resume) -> None:
231
  if not resume.roles:
232
  return