Spaces:
Sleeping
feat(ats): redesign to industry standard — skills-only extraction + Skills section
Browse filesUser 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 +49 -0
- README.md +13 -8
- src/ats_scorer.py +23 -26
- src/resume_customizer.py +53 -32
- src/resume_model.py +9 -0
- src/resume_renderer.py +106 -1
|
@@ -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"):
|
|
@@ -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,
|
| 227 |
-
|
| 228 |
-
- Keywords are extracted **from each JD itself** (not a fixed stored list)
|
| 229 |
-
**
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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 |
-
#
|
| 858 |
-
# actually
|
| 859 |
-
#
|
| 860 |
-
#
|
| 861 |
-
#
|
| 862 |
-
#
|
| 863 |
-
#
|
| 864 |
-
#
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 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)
|
|
@@ -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 |
-
"
|
| 480 |
-
"
|
| 481 |
-
"
|
| 482 |
-
"
|
| 483 |
-
"
|
| 484 |
-
"
|
| 485 |
-
"
|
| 486 |
-
"
|
| 487 |
-
"
|
| 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
|
| 1307 |
-
#
|
|
|
|
|
|
|
| 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-
|
| 1448 |
-
|
| 1449 |
-
|
| 1450 |
-
|
| 1451 |
-
|
| 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/
|
| 1466 |
)
|
| 1467 |
-
|
| 1468 |
-
|
| 1469 |
-
|
|
|
|
|
|
|
| 1470 |
raise AssertionError(
|
| 1471 |
-
f"
|
| 1472 |
-
f"
|
| 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]:
|
|
@@ -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:
|
|
@@ -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
|
| 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
|