fix(heuristics): reject resume-doc-title boilerplate as candidate name - #14
Conversation
Closes #10 (mode 1). Adds partial defence against mode 2. The name selector in `extractName()` was scoring "first line in profile + largest font + title-case" up to 1.0, which lets a Microsoft-style "Functional Resume Sample" doc-title header win over the real candidate name on the line below it (reported by @sriyau64 during dogfooding). The data needed for a proper fix was already on `PdfLine` — y-coordinates of each profile line and the contact tokens — the selector just never consumed it. Fix is fully local to `extractName()`: - Hard rejection for resume-doc-title boilerplate. A line where ≥60% of tokens are in {resume, cv, curriculum vitae, sample, template, example, chronological, functional, combination, profile, biography, ...} is skipped before scoring. "Jane Smith Resume" still passes (1/3 boilerplate); "Functional Resume Sample", "Curriculum Vitae", "Resume Sample" all reject. - First-eligible-index reindex. When the literal first line is rejected as boilerplate, the next surviving candidate inherits the first-line +0.4 bonus — it is effectively in the header position. Without this, fixing the wrong-name pick would dial the runner-up's confidence to 0.45, below `ANON_CONTACT_CONFIDENCE_FLOOR = 0.5` in score.ts. The completeness scorer would then mark the (correctly-detected) name as "missing" — mode 2 of issue #10 manifesting inside the fix for mode 1. Reindex puts the surviving candidate over 0.5, so completeness scores the name as present. - Soft contact-cluster proximity bonus (+0.15) when the candidate's y is within ~80pt of the email/phone/linkedin line. Backup signal for ambiguous header layouts; doesn't override the first-line bonus. Note on mode 2 in general: the reporter described it as "separately, on another resume… real name detected only with low confidence — likely because it was set apart from the contact block." Without a specific upstream PDF, the broader mode 2 (Tier 1 fails entirely, Tier 1.5 falls back at 0.5) needs its own repro fixture to investigate properly. Filing as follow-up. Fixture: tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf, a synthetic single-page XeLaTeX export structured as: Functional Resume Sample (Huge, line 0 — the boilerplate) Jane Smith (Large, line 1 — the real name) 123 Example Way, ... (line 2 — contact starts) jane.smith@example.com · ... (line 3) SUMMARY / EXPERIENCE / EDUCATION (3 sections, 5 bullets total) PII-clean: synthetic persona only, no Author / Title / XMP metadata beyond XeTeX producer string. Pre-fix snapshot pinned `full_name = "Functional Resume Sample"` and flagged `name` as missing in completeness; post-fix `full_name = "Jane Smith"` and `name` clears the floor. Overall score moves 75 → 78. Unit tests added to extract-fields.test.ts for the selector: - header-above-name picks the real name (mode 1) - "Curriculum Vitae" rejected - "Resume Sample" rejected - "Jane Smith Resume" still picks (only 1/3 boilerplate) - no regression: top-line name still picked when no boilerplate - runner-up confidence ≥ 0.5 after boilerplate rejection (mode 2 in miniature — guards the threshold-crossing) Verification: - npm run typecheck: clean - npm run test: 174/174 (167 prior baseline + 6 extractName tests + 1 new corpus fixture) - 6 existing corpus snapshots: byte-identical, zero regression - pdftotext + pdfinfo on new fixture: no real-author tokens, no Author/Title metadata Regenerating the fixture: see the .tex source captured in commit body section "fixture source" below. Refs #10 ----- fixture source: header_as_name.tex (xelatex) ----- \documentclass[11pt]{article} \usepackage[margin=1in]{geometry} \usepackage{enumitem} \pagestyle{empty} \setlength{\parindent}{0pt} \begin{document} \begin{center} {\Huge\bfseries Functional Resume Sample}\\[1.5em] {\Large Jane Smith}\\[0.4em] 123 Example Way, Springfield, IL 62701\\ jane.smith@example.com $\cdot$ (555) 010-0123 $\cdot$ linkedin.com/in/janesmith \end{center} \vspace{1em} \textbf{SUMMARY}\\ Experienced software engineer with five years of full-stack development experience building scalable web services. \vspace{0.8em} \textbf{EXPERIENCE}\\ \textbf{Acme Corp} \hfill Jan 2022 -- Present\\ \emph{Senior Software Engineer} \begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt] \item Built scalable web services with Node.js and Go, handling 50M requests/day. \item Led team of three engineers on the payments platform. \item Reduced p99 latency by 40\% through caching and query optimisation. \end{itemize} \textbf{Initech} \hfill 2020 -- 2022\\ \emph{Software Engineer} \begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt] \item Migrated monolith to microservices using Kubernetes. \item Owned authentication and authorisation across all internal tools. \end{itemize} \vspace{0.5em} \textbf{EDUCATION}\\ B.S. Computer Science, Springfield State University, 2020 \end{document}
s-annam
left a comment
There was a problem hiding this comment.
PR Review: reject resume-doc-title boilerplate as candidate name
Summary
Clean, well-scoped, well-tested fix for mode 1 of #10. The hard-rejection approach is sound and the author is admirably transparent about what's not covered (mode 2) and about the verification gaps. 174/174 green, typecheck clean, fixture PII-verified locally (Jane Smith / @example.com / 555 phone, no Author/Title/XMP leakage). No blocking issues.
Spec Alignment (issue #10)
| Requirement | Status | Notes |
|---|---|---|
| Reproduce mode 1 (header-as-name) on a fixture | ✅ | header-as-name-functional-resume.pdf |
| Reproduce mode 2 (separated-name, low confidence) | In-miniature case fixed via reindex; broader case deferred with rationale | |
| Bias toward contact-cluster proximity | ✅ (soft) | +0.15 via findContactClusterY; intentionally too weak to overturn +0.4 first-line — the actual mode-1 fix is the hard rejection, not proximity |
| Snapshot test on fixture | ✅ | mode 1 |
| No regression on standard single-column resumes | ✅ | 6 corpus snapshots byte-identical + new unit test |
Highlights
- Fix is fully local to
extractName()and reuses data already onPdfLine— no new plumbing. - The
firstEligibleIdxreindex is a genuinely sharp catch: it stops the mode-1 fix from regressing into mode-2 (dropping the real name below the 0.5 completeness floor). The unit test guarding that threshold boundary is excellent. - Conservative
≥60%-boilerplate rule, with a test pinning that"Jane Smith Resume"still passes.
Key Findings (all non-blocking)
- [Suggestion]
Closes #10will auto-close the issue on merge, but mode 2's acceptance criteria aren't met. ConsiderRefs #10+ a follow-up issue for mode 2, so the remaining work isn't silently lost. - [Suggestion] Merge ordering — this PR is stacked on #13 (it carries
974761f … (#13), the corpus harness). The new fixture's snapshot only runs viacorpus.test.tsfrom #13. Merge #13 first, or #14 drags the whole harness in with it. - [Suggestion]
firstEligibleIdxblast radius is wider than "boilerplate" — see inline. - [Nit] Dead set entry
"résumé"— see inline. - [Nit] Comment inaccuracy in
findContactClusterY— see inline.
Verdict
APPROVE — 0 blocking items; findings are suggestions/nits that needn't gate merge. Recommend landing #13 first.
Verified locally: full suite (incl. corpus snapshot) 174/174, tsc -b --noEmit clean, fixture binary inspected with pdftotext/pdfinfo.
|
|
||
| let score = 0; | ||
| if (i === 0) score += 0.4; | ||
| if (i === firstEligibleIdx) score += 0.4; |
There was a problem hiding this comment.
[Suggestion]: The reindex is a great catch for the boilerplate case, but its blast radius is broader than the commit title implies. firstEligibleIdx is the first line surviving all the continue filters above (digits, @, length>60, word-count, letterRatio < 0.7), not just boilerplate rejection. So if line 0 is filtered for any of those reasons, the next line now inherits the +0.4 first-line bonus where previously it got nothing. Likely an improvement, but worth either narrowing the comment to reflect that or confirming the intent — the 6 byte-identical snapshots are reassuring but only cover the existing corpus.
| */ | ||
| const NAME_BOILERPLATE_WORDS = new Set([ | ||
| "resume", | ||
| "résumé", |
There was a problem hiding this comment.
[Nit]: This "résumé" entry is dead — looksLikeDocTitleBoilerplate lowers each token with .replace(/[^a-z]/g, ""), so "Résumé" normalizes to "rsum" and never matches this set member. The unaccented "resume" still catches the common case, so impact is small, but consider dropping it or adding the normalized form so the intent isn't misleading.
| PHONE_RE.test(line.text) || | ||
| LINKEDIN_RE.test(line.text) | ||
| ) { | ||
| // Reset lastIndex defensively; the constants are recompiled per call |
There was a problem hiding this comment.
[Nit]: Minor comment inaccuracy — EMAIL_RE/PHONE_RE/LINKEDIN_RE are module-level constants, not "recompiled per call." The defensive lastIndex = 0 reset is correct and harmless (global regexes auto-reset lastIndex on a failed .test(), and the success path resets before returning), so no behavior change needed — just the comment.
|
Correction to my review above — finding #2 ("merge ordering / this PR is stacked on #13") is obsolete: #13 was already merged into The verification still stands — the full suite I ran (174/174, incl. the new corpus snapshot) reflects the real post-merge state. The remaining findings (1, 3, 4, 5) are unchanged. |
#14) Closes #10 (mode 1). Adds partial defence against mode 2. The name selector in `extractName()` was scoring "first line in profile + largest font + title-case" up to 1.0, which lets a Microsoft-style "Functional Resume Sample" doc-title header win over the real candidate name on the line below it (reported by @sriyau64 during dogfooding). The data needed for a proper fix was already on `PdfLine` — y-coordinates of each profile line and the contact tokens — the selector just never consumed it. Fix is fully local to `extractName()`: - Hard rejection for resume-doc-title boilerplate. A line where ≥60% of tokens are in {resume, cv, curriculum vitae, sample, template, example, chronological, functional, combination, profile, biography, ...} is skipped before scoring. "Jane Smith Resume" still passes (1/3 boilerplate); "Functional Resume Sample", "Curriculum Vitae", "Resume Sample" all reject. - First-eligible-index reindex. When the literal first line is rejected as boilerplate, the next surviving candidate inherits the first-line +0.4 bonus — it is effectively in the header position. Without this, fixing the wrong-name pick would dial the runner-up's confidence to 0.45, below `ANON_CONTACT_CONFIDENCE_FLOOR = 0.5` in score.ts. The completeness scorer would then mark the (correctly-detected) name as "missing" — mode 2 of issue #10 manifesting inside the fix for mode 1. Reindex puts the surviving candidate over 0.5, so completeness scores the name as present. - Soft contact-cluster proximity bonus (+0.15) when the candidate's y is within ~80pt of the email/phone/linkedin line. Backup signal for ambiguous header layouts; doesn't override the first-line bonus. Note on mode 2 in general: the reporter described it as "separately, on another resume… real name detected only with low confidence — likely because it was set apart from the contact block." Without a specific upstream PDF, the broader mode 2 (Tier 1 fails entirely, Tier 1.5 falls back at 0.5) needs its own repro fixture to investigate properly. Filing as follow-up. Fixture: tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf, a synthetic single-page XeLaTeX export structured as: Functional Resume Sample (Huge, line 0 — the boilerplate) Jane Smith (Large, line 1 — the real name) 123 Example Way, ... (line 2 — contact starts) jane.smith@example.com · ... (line 3) SUMMARY / EXPERIENCE / EDUCATION (3 sections, 5 bullets total) PII-clean: synthetic persona only, no Author / Title / XMP metadata beyond XeTeX producer string. Pre-fix snapshot pinned `full_name = "Functional Resume Sample"` and flagged `name` as missing in completeness; post-fix `full_name = "Jane Smith"` and `name` clears the floor. Overall score moves 75 → 78. Unit tests added to extract-fields.test.ts for the selector: - header-above-name picks the real name (mode 1) - "Curriculum Vitae" rejected - "Resume Sample" rejected - "Jane Smith Resume" still picks (only 1/3 boilerplate) - no regression: top-line name still picked when no boilerplate - runner-up confidence ≥ 0.5 after boilerplate rejection (mode 2 in miniature — guards the threshold-crossing) Verification: - npm run typecheck: clean - npm run test: 174/174 (167 prior baseline + 6 extractName tests + 1 new corpus fixture) - 6 existing corpus snapshots: byte-identical, zero regression - pdftotext + pdfinfo on new fixture: no real-author tokens, no Author/Title metadata Regenerating the fixture: see the .tex source captured in commit body section "fixture source" below. Refs #10 ----- fixture source: header_as_name.tex (xelatex) ----- \documentclass[11pt]{article} \usepackage[margin=1in]{geometry} \usepackage{enumitem} \pagestyle{empty} \setlength{\parindent}{0pt} \begin{document} \begin{center} {\Huge\bfseries Functional Resume Sample}\\[1.5em] {\Large Jane Smith}\\[0.4em] 123 Example Way, Springfield, IL 62701\\ jane.smith@example.com $\cdot$ (555) 010-0123 $\cdot$ linkedin.com/in/janesmith \end{center} \vspace{1em} \textbf{SUMMARY}\\ Experienced software engineer with five years of full-stack development experience building scalable web services. \vspace{0.8em} \textbf{EXPERIENCE}\\ \textbf{Acme Corp} \hfill Jan 2022 -- Present\\ \emph{Senior Software Engineer} \begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt] \item Built scalable web services with Node.js and Go, handling 50M requests/day. \item Led team of three engineers on the payments platform. \item Reduced p99 latency by 40\% through caching and query optimisation. \end{itemize} \textbf{Initech} \hfill 2020 -- 2022\\ \emph{Software Engineer} \begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt] \item Migrated monolith to microservices using Kubernetes. \item Owned authentication and authorisation across all internal tools. \end{itemize} \vspace{0.5em} \textbf{EDUCATION}\\ B.S. Computer Science, Springfield State University, 2020 \end{document}
) Mode 2 of #10: when the real candidate name is set apart below a larger job-title tagline (e.g. "Product Designer" over "Jane Smith"), extractName picked the tagline — position + size alone won the name slot, and the +0.15 contact-cluster proximity bonus from #14 was too small to overturn it. Re-weight extractName so contact proximity can *change the winner*, not just nudge confidence: - A *later* eligible line within ~80pt of the contact cluster now gets +0.4 (vs +0.15 for the first eligible line). The split gates the strong bonus on `i !== firstEligibleIdx`, which keeps the #14 mode-1 fixture (first-eligible name) byte-identical. - A line that looksLikeTitle() gets -0.6 — a job-title tagline must not win the name slot. Real names never match the title-keyword set, so this only ever penalizes non-name lines. Verified pre/post-fix on the new fixture: pre-fix picks "Product Designer" (conf 1.0), post-fix picks "Jane Smith" (conf 0.70). All 6 existing corpus snapshots + the #14 mode-1 fixture stay byte-identical; standard top-line names unaffected. Scope note: #10/#16 framed mode 2 as "right name detected at low confidence (<=0.5), marked missing." In the current code the actual reproducible failure is "wrong name (tagline) picked confidently" — the +0.4 first-eligible floor structurally prevents a sole correct name from landing <=0.5. Fixture + tests reflect the real defect. Fixture tests/fixtures/pdfs/unknown/name-set-apart-tagline.pdf is synthetic (PII-free), generated with reportlab. Regeneration script: from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import letter W, H = letter c = canvas.Canvas(PATH, pagesize=letter) def line(y, text, size, font="Helvetica", x=72): c.setFont(font, size); c.drawString(x, H - y, text) line(78, "Product Designer", 20, "Helvetica-Bold") # tagline (largest, top) line(100, "Jane Smith", 13, "Helvetica-Bold") # real name line(118, "jane.smith@example.com | (555) 010-0147 | San Francisco, CA", 10) line(150, "SUMMARY", 12, "Helvetica-Bold") line(168, "Product designer with eight years shipping consumer mobile and web", 10) line(182, "experiences, from research through high-fidelity delivery and handoff.", 10) line(214, "EXPERIENCE", 12, "Helvetica-Bold") line(232, "Northwind Labs — Senior Product Designer", 11, "Helvetica-Bold") line(246, "Jan 2021 - Present", 10) line(262, "• Led redesign of the onboarding flow, lifting activation 18% in two quarters.", 10) line(276, "• Built and maintained the cross-platform design system used by 30 engineers.", 10) line(300, "Brightside Studio — Product Designer", 11, "Helvetica-Bold") line(314, "Jun 2017 - Dec 2020", 10) line(330, "• Designed three mobile apps from concept to launch on iOS and Android.", 10) line(344, "• Ran weekly usability sessions and turned findings into shipped changes.", 10) line(376, "EDUCATION", 12, "Helvetica-Bold") line(394, "State University — B.F.A. in Graphic Design", 11, "Helvetica-Bold") line(408, "2013 - 2017", 10) c.showPage(); c.save() Resolves #16 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#14) Closes #10 (mode 1). Adds partial defence against mode 2. The name selector in `extractName()` was scoring "first line in profile + largest font + title-case" up to 1.0, which lets a Microsoft-style "Functional Resume Sample" doc-title header win over the real candidate name on the line below it (reported by @sriyau64 during dogfooding). The data needed for a proper fix was already on `PdfLine` — y-coordinates of each profile line and the contact tokens — the selector just never consumed it. Fix is fully local to `extractName()`: - Hard rejection for resume-doc-title boilerplate. A line where ≥60% of tokens are in {resume, cv, curriculum vitae, sample, template, example, chronological, functional, combination, profile, biography, ...} is skipped before scoring. "Jane Smith Resume" still passes (1/3 boilerplate); "Functional Resume Sample", "Curriculum Vitae", "Resume Sample" all reject. - First-eligible-index reindex. When the literal first line is rejected as boilerplate, the next surviving candidate inherits the first-line +0.4 bonus — it is effectively in the header position. Without this, fixing the wrong-name pick would dial the runner-up's confidence to 0.45, below `ANON_CONTACT_CONFIDENCE_FLOOR = 0.5` in score.ts. The completeness scorer would then mark the (correctly-detected) name as "missing" — mode 2 of issue #10 manifesting inside the fix for mode 1. Reindex puts the surviving candidate over 0.5, so completeness scores the name as present. - Soft contact-cluster proximity bonus (+0.15) when the candidate's y is within ~80pt of the email/phone/linkedin line. Backup signal for ambiguous header layouts; doesn't override the first-line bonus. Note on mode 2 in general: the reporter described it as "separately, on another resume… real name detected only with low confidence — likely because it was set apart from the contact block." Without a specific upstream PDF, the broader mode 2 (Tier 1 fails entirely, Tier 1.5 falls back at 0.5) needs its own repro fixture to investigate properly. Filing as follow-up. Fixture: tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf, a synthetic single-page XeLaTeX export structured as: Functional Resume Sample (Huge, line 0 — the boilerplate) Jane Smith (Large, line 1 — the real name) 123 Example Way, ... (line 2 — contact starts) jane.smith@example.com · ... (line 3) SUMMARY / EXPERIENCE / EDUCATION (3 sections, 5 bullets total) PII-clean: synthetic persona only, no Author / Title / XMP metadata beyond XeTeX producer string. Pre-fix snapshot pinned `full_name = "Functional Resume Sample"` and flagged `name` as missing in completeness; post-fix `full_name = "Jane Smith"` and `name` clears the floor. Overall score moves 75 → 78. Unit tests added to extract-fields.test.ts for the selector: - header-above-name picks the real name (mode 1) - "Curriculum Vitae" rejected - "Resume Sample" rejected - "Jane Smith Resume" still picks (only 1/3 boilerplate) - no regression: top-line name still picked when no boilerplate - runner-up confidence ≥ 0.5 after boilerplate rejection (mode 2 in miniature — guards the threshold-crossing) Verification: - npm run typecheck: clean - npm run test: 174/174 (167 prior baseline + 6 extractName tests + 1 new corpus fixture) - 6 existing corpus snapshots: byte-identical, zero regression - pdftotext + pdfinfo on new fixture: no real-author tokens, no Author/Title metadata Regenerating the fixture: see the .tex source captured in commit body section "fixture source" below. Refs #10 ----- fixture source: header_as_name.tex (xelatex) ----- \documentclass[11pt]{article} \usepackage[margin=1in]{geometry} \usepackage{enumitem} \pagestyle{empty} \setlength{\parindent}{0pt} \begin{document} \begin{center} {\Huge\bfseries Functional Resume Sample}\\[1.5em] {\Large Jane Smith}\\[0.4em] 123 Example Way, Springfield, IL 62701\\ jane.smith@example.com $\cdot$ (555) 010-0123 $\cdot$ linkedin.com/in/janesmith \end{center} \vspace{1em} \textbf{SUMMARY}\\ Experienced software engineer with five years of full-stack development experience building scalable web services. \vspace{0.8em} \textbf{EXPERIENCE}\\ \textbf{Acme Corp} \hfill Jan 2022 -- Present\\ \emph{Senior Software Engineer} \begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt] \item Built scalable web services with Node.js and Go, handling 50M requests/day. \item Led team of three engineers on the payments platform. \item Reduced p99 latency by 40\% through caching and query optimisation. \end{itemize} \textbf{Initech} \hfill 2020 -- 2022\\ \emph{Software Engineer} \begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt] \item Migrated monolith to microservices using Kubernetes. \item Owned authentication and authorisation across all internal tools. \end{itemize} \vspace{0.5em} \textbf{EDUCATION}\\ B.S. Computer Science, Springfield State University, 2020 \end{document}
) Mode 2 of #10: when the real candidate name is set apart below a larger job-title tagline (e.g. "Product Designer" over "Jane Smith"), extractName picked the tagline — position + size alone won the name slot, and the +0.15 contact-cluster proximity bonus from #14 was too small to overturn it. Re-weight extractName so contact proximity can *change the winner*, not just nudge confidence: - A *later* eligible line within ~80pt of the contact cluster now gets +0.4 (vs +0.15 for the first eligible line). The split gates the strong bonus on `i !== firstEligibleIdx`, which keeps the #14 mode-1 fixture (first-eligible name) byte-identical. - A line that looksLikeTitle() gets -0.6 — a job-title tagline must not win the name slot. Real names never match the title-keyword set, so this only ever penalizes non-name lines. Verified pre/post-fix on the new fixture: pre-fix picks "Product Designer" (conf 1.0), post-fix picks "Jane Smith" (conf 0.70). All 6 existing corpus snapshots + the #14 mode-1 fixture stay byte-identical; standard top-line names unaffected. Scope note: #10/#16 framed mode 2 as "right name detected at low confidence (<=0.5), marked missing." In the current code the actual reproducible failure is "wrong name (tagline) picked confidently" — the +0.4 first-eligible floor structurally prevents a sole correct name from landing <=0.5. Fixture + tests reflect the real defect. Fixture tests/fixtures/pdfs/unknown/name-set-apart-tagline.pdf is synthetic (PII-free), generated with reportlab. Regeneration script: from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import letter W, H = letter c = canvas.Canvas(PATH, pagesize=letter) def line(y, text, size, font="Helvetica", x=72): c.setFont(font, size); c.drawString(x, H - y, text) line(78, "Product Designer", 20, "Helvetica-Bold") # tagline (largest, top) line(100, "Jane Smith", 13, "Helvetica-Bold") # real name line(118, "jane.smith@example.com | (555) 010-0147 | San Francisco, CA", 10) line(150, "SUMMARY", 12, "Helvetica-Bold") line(168, "Product designer with eight years shipping consumer mobile and web", 10) line(182, "experiences, from research through high-fidelity delivery and handoff.", 10) line(214, "EXPERIENCE", 12, "Helvetica-Bold") line(232, "Northwind Labs — Senior Product Designer", 11, "Helvetica-Bold") line(246, "Jan 2021 - Present", 10) line(262, "• Led redesign of the onboarding flow, lifting activation 18% in two quarters.", 10) line(276, "• Built and maintained the cross-platform design system used by 30 engineers.", 10) line(300, "Brightside Studio — Product Designer", 11, "Helvetica-Bold") line(314, "Jun 2017 - Dec 2020", 10) line(330, "• Designed three mobile apps from concept to launch on iOS and Android.", 10) line(344, "• Ran weekly usability sessions and turned findings into shipped changes.", 10) line(376, "EDUCATION", 12, "Helvetica-Bold") line(394, "State University — B.F.A. in Graphic Design", 11, "Helvetica-Bold") line(408, "2013 - 2017", 10) c.showPage(); c.save() Resolves #16 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#14) Closes #10 (mode 1). Adds partial defence against mode 2. The name selector in `extractName()` was scoring "first line in profile + largest font + title-case" up to 1.0, which lets a Microsoft-style "Functional Resume Sample" doc-title header win over the real candidate name on the line below it (reported by @sriyau64 during dogfooding). The data needed for a proper fix was already on `PdfLine` — y-coordinates of each profile line and the contact tokens — the selector just never consumed it. Fix is fully local to `extractName()`: - Hard rejection for resume-doc-title boilerplate. A line where ≥60% of tokens are in {resume, cv, curriculum vitae, sample, template, example, chronological, functional, combination, profile, biography, ...} is skipped before scoring. "Jane Smith Resume" still passes (1/3 boilerplate); "Functional Resume Sample", "Curriculum Vitae", "Resume Sample" all reject. - First-eligible-index reindex. When the literal first line is rejected as boilerplate, the next surviving candidate inherits the first-line +0.4 bonus — it is effectively in the header position. Without this, fixing the wrong-name pick would dial the runner-up's confidence to 0.45, below `ANON_CONTACT_CONFIDENCE_FLOOR = 0.5` in score.ts. The completeness scorer would then mark the (correctly-detected) name as "missing" — mode 2 of issue #10 manifesting inside the fix for mode 1. Reindex puts the surviving candidate over 0.5, so completeness scores the name as present. - Soft contact-cluster proximity bonus (+0.15) when the candidate's y is within ~80pt of the email/phone/linkedin line. Backup signal for ambiguous header layouts; doesn't override the first-line bonus. Note on mode 2 in general: the reporter described it as "separately, on another resume… real name detected only with low confidence — likely because it was set apart from the contact block." Without a specific upstream PDF, the broader mode 2 (Tier 1 fails entirely, Tier 1.5 falls back at 0.5) needs its own repro fixture to investigate properly. Filing as follow-up. Fixture: tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf, a synthetic single-page XeLaTeX export structured as: Functional Resume Sample (Huge, line 0 — the boilerplate) Jane Smith (Large, line 1 — the real name) 123 Example Way, ... (line 2 — contact starts) jane.smith@example.com · ... (line 3) SUMMARY / EXPERIENCE / EDUCATION (3 sections, 5 bullets total) PII-clean: synthetic persona only, no Author / Title / XMP metadata beyond XeTeX producer string. Pre-fix snapshot pinned `full_name = "Functional Resume Sample"` and flagged `name` as missing in completeness; post-fix `full_name = "Jane Smith"` and `name` clears the floor. Overall score moves 75 → 78. Unit tests added to extract-fields.test.ts for the selector: - header-above-name picks the real name (mode 1) - "Curriculum Vitae" rejected - "Resume Sample" rejected - "Jane Smith Resume" still picks (only 1/3 boilerplate) - no regression: top-line name still picked when no boilerplate - runner-up confidence ≥ 0.5 after boilerplate rejection (mode 2 in miniature — guards the threshold-crossing) Verification: - npm run typecheck: clean - npm run test: 174/174 (167 prior baseline + 6 extractName tests + 1 new corpus fixture) - 6 existing corpus snapshots: byte-identical, zero regression - pdftotext + pdfinfo on new fixture: no real-author tokens, no Author/Title metadata Regenerating the fixture: see the .tex source captured in commit body section "fixture source" below. Refs #10 ----- fixture source: header_as_name.tex (xelatex) ----- \documentclass[11pt]{article} \usepackage[margin=1in]{geometry} \usepackage{enumitem} \pagestyle{empty} \setlength{\parindent}{0pt} \begin{document} \begin{center} {\Huge\bfseries Functional Resume Sample}\\[1.5em] {\Large Jane Smith}\\[0.4em] 123 Example Way, Springfield, IL 62701\\ jane.smith@example.com $\cdot$ (555) 010-0123 $\cdot$ linkedin.com/in/janesmith \end{center} \vspace{1em} \textbf{SUMMARY}\\ Experienced software engineer with five years of full-stack development experience building scalable web services. \vspace{0.8em} \textbf{EXPERIENCE}\\ \textbf{Acme Corp} \hfill Jan 2022 -- Present\\ \emph{Senior Software Engineer} \begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt] \item Built scalable web services with Node.js and Go, handling 50M requests/day. \item Led team of three engineers on the payments platform. \item Reduced p99 latency by 40\% through caching and query optimisation. \end{itemize} \textbf{Initech} \hfill 2020 -- 2022\\ \emph{Software Engineer} \begin{itemize}[leftmargin=*,topsep=2pt,itemsep=1pt] \item Migrated monolith to microservices using Kubernetes. \item Owned authentication and authorisation across all internal tools. \end{itemize} \vspace{0.5em} \textbf{EDUCATION}\\ B.S. Computer Science, Springfield State University, 2020 \end{document}
) Mode 2 of #10: when the real candidate name is set apart below a larger job-title tagline (e.g. "Product Designer" over "Jane Smith"), extractName picked the tagline — position + size alone won the name slot, and the +0.15 contact-cluster proximity bonus from #14 was too small to overturn it. Re-weight extractName so contact proximity can *change the winner*, not just nudge confidence: - A *later* eligible line within ~80pt of the contact cluster now gets +0.4 (vs +0.15 for the first eligible line). The split gates the strong bonus on `i !== firstEligibleIdx`, which keeps the #14 mode-1 fixture (first-eligible name) byte-identical. - A line that looksLikeTitle() gets -0.6 — a job-title tagline must not win the name slot. Real names never match the title-keyword set, so this only ever penalizes non-name lines. Verified pre/post-fix on the new fixture: pre-fix picks "Product Designer" (conf 1.0), post-fix picks "Jane Smith" (conf 0.70). All 6 existing corpus snapshots + the #14 mode-1 fixture stay byte-identical; standard top-line names unaffected. Scope note: #10/#16 framed mode 2 as "right name detected at low confidence (<=0.5), marked missing." In the current code the actual reproducible failure is "wrong name (tagline) picked confidently" — the +0.4 first-eligible floor structurally prevents a sole correct name from landing <=0.5. Fixture + tests reflect the real defect. Fixture tests/fixtures/pdfs/unknown/name-set-apart-tagline.pdf is synthetic (PII-free), generated with reportlab. Regeneration script: from reportlab.pdfgen import canvas from reportlab.lib.pagesizes import letter W, H = letter c = canvas.Canvas(PATH, pagesize=letter) def line(y, text, size, font="Helvetica", x=72): c.setFont(font, size); c.drawString(x, H - y, text) line(78, "Product Designer", 20, "Helvetica-Bold") # tagline (largest, top) line(100, "Jane Smith", 13, "Helvetica-Bold") # real name line(118, "jane.smith@example.com | (555) 010-0147 | San Francisco, CA", 10) line(150, "SUMMARY", 12, "Helvetica-Bold") line(168, "Product designer with eight years shipping consumer mobile and web", 10) line(182, "experiences, from research through high-fidelity delivery and handoff.", 10) line(214, "EXPERIENCE", 12, "Helvetica-Bold") line(232, "Northwind Labs — Senior Product Designer", 11, "Helvetica-Bold") line(246, "Jan 2021 - Present", 10) line(262, "• Led redesign of the onboarding flow, lifting activation 18% in two quarters.", 10) line(276, "• Built and maintained the cross-platform design system used by 30 engineers.", 10) line(300, "Brightside Studio — Product Designer", 11, "Helvetica-Bold") line(314, "Jun 2017 - Dec 2020", 10) line(330, "• Designed three mobile apps from concept to launch on iOS and Android.", 10) line(344, "• Ran weekly usability sessions and turned findings into shipped changes.", 10) line(376, "EDUCATION", 12, "Helvetica-Bold") line(394, "State University — B.F.A. in Graphic Design", 11, "Helvetica-Bold") line(408, "2013 - 2017", 10) c.showPage(); c.save() Resolves #16 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rap + reuse Reviewer feedback from @Vaishnavi1709 (CHANGES_REQUESTED) and @Samhit21. Blocking: - #1 Added LinkedIn/GitHub link (guided picker → addedProfiles) now back-fills the empty legacy `_url` slot the scorer + contact gap read, so the add moves the score. applyProfileOverrides returns the back-filled keys. - #2 Anonymous scorer now treats a GitHub link as satisfying the "Professional profile" completeness check (parity with the ContactCard rule), so a GitHub-but-no-LinkedIn résumé isn't docked / listed as missing LinkedIn. - #3 applyOverrides returns an edited `fieldConfidence` (user-affirmed contact edits bumped to present, clears dropped to 0), threaded onto both the score input and displayResult so a typed-in / added link stops reading as absent against the frozen base parse. - #4 useDownloadReport.download returns a boolean; the dialog closes only on success, so a generation failure no longer unmounts the error UI. - #5 New shared src/lib/pdf/text-wrap.ts breaks a single overlong word at char boundaries (opt-in); the audit-report identity header uses it so a long URL no longer overflows the page. Secondary: - #6 Audit-report identity sourced via buildContact + basicsFromContact (no full buildAtsResumeModel + toJsonResume walk just to read .basics). - #7 render-audit-report + serialize are now dynamic-imported in useDownloadReport, keeping the ~470 LOC report path out of the entry chunk. - #8 Extracted src/lib/download/blob-download.ts (slugifyName + triggerBlobDownload); useDownloadPdf/useDownloadReport/useReportGap consume it. - #9 wrapWordsToLines shared by both PDF renderers (render-ats-pdf keeps its no-mid-word-break contract; the report opts into breaking). - #12 Achievements now map to JSON Resume `awards[]` (title + optional date; omitted entirely when absent, so achievement-free exports are byte-identical). Nits: - #14 usernameFromUrl drops its unreachable try/catch. - #16 render-audit-report embeds Helvetica + Helvetica-Bold via Promise.all. - #17 LinkedIn non-profile-path carve-out is now a `HostRule.nonProfilePath` field instead of an inline special case in the classify loop. Deferred (with rationale in the PR reply): #10 override-channel consolidation, #11 profiles-mirror memo split, #13 ISO-3166 location country codes, #15 unconditional profiles assign (breaks the empty-override no-op invariant), and @Samhit21's non-profile-LinkedIn label nit (by-design).
…me export, shareable audit report (#400) (#421) * feat(export): download-export standards — profiles[] model, JSON Resume export, shareable audit report (#400) Batch #400. Three sub-issues accumulated onto one branch: - #335 profiles[] model: add ProfileLink {url,network,kind} + profiles[] to ResumeData; contributor-extensible host registry (classifyProfile). Extraction mirrors the 4 legacy link keys into profiles[] — legacy keys stay the scoring/ snapshot source of truth, so every corpus snapshot is byte-unchanged (no re-bake). Variable-length contact-link UI: "+ Add link", per-profile EditableField edit, delete, unknown-host hostname labels, brand-neutral "Professional profile" row. - #334 JSON Resume export: pure toJsonResume(AtsResumeModel) -> JsonResume; basics.profiles from profiles[]; free-form dates degrade to raw strings, never fabricated; layout-contract test asserts every emitted heading re-recognizes via matchSectionHeader(); embed resume.json as a pdf-lib attachment (text layer untouched -> parse->export->re-parse roundtrip byte-identical); pdf-lib stays lazy. - #343 shareable audit report: secondary "Download report" control (Download PDF stays the primary CTA) with PDF/JSON format + include-identity checkbox (default OFF); render-audit-report.ts (lazy pdf-lib) + report/serialize.ts. Privacy gate: identity-off artifacts carry zero PII. Adversarial review: 1 round, 1 blocking finding fixed + covered — the JSON audit report embedded score.bullets verbatim (résumé accomplishment text: employer/ project names) even with identity off; now stripped unconditionally at the builder, with a bullets-bearing fixture added so the gate actually covers it. Deferred to follow-up issues (non-blocking, reviewer-verified safe): the profile-registry <-> extract/contact circular dep (function-scoped, no init hazard) and the render-ats-pdf <-> render-audit-report clone family (renderers have genuinely diverged needs). Closes #335 Closes #334 Closes #343 Refs #400 * refactor(export): clear Fallow findings on PR #421 Address the three Fallow code-scanning comments on PR #421, all logic-preserving and snapshot-safe (44 corpus snapshots byte-unchanged): - Circular dep (profile-registry ↔ extract/contact): extract the shared normalizeUrl / urlSlug / LINKEDIN_NONPROFILE_RE helpers into a new leaf src/lib/contact/url-utils.ts. Both modules now import the leaf; neither imports the other. Closes the cycle #423 was filed to track. - Cognitive complexity in toJsonResume (23 → under threshold): move the per-entry section-kind dispatch out of the double loop into appendEntry(). - Cognitive complexity in renderAuditReportPdf (17 → under threshold): extract the identity-header and layout-flags clusters into drawIdentityHeader() / drawLayoutFlags() helpers. The render-ats-pdf ↔ render-audit-report clone family stays deferred to #424 (the renderers have genuinely diverged font/layout needs). Refs #400, #421 * feat(contact): guided network picker for professional-profile add The empty "Professional profile" links row and the extra-links "+ Add" both handed a naive user a bare `https://…` field with no cue for what a professional profile is or which networks we accept. Replace both with a guided ProfileLinkAdd affordance: an inviting pill that expands to tappable network chips (LinkedIn / GitHub / GitLab / Portfolio) which pre-fill the host prefix and drop the caret at the handle, plus a helper line naming the rest of the recognized hosts. - profile-registry.ts: add a UI-only `quickPick` hint to the LinkedIn / GitHub / GitLab host rules and derive `PROFILE_QUICK_PICKS` (+ a Portfolio catch-all) and `otherRecognizedNetworks()` from PROFILE_HOSTS — one source of truth, so a new host auto-surfaces as a chip / helper example. - ProfileLinkAdd.tsx: new shared picker built from AddPill + the @design-system Button; reused by both add points (a required single slot that collapses after one, and the multi-add extra-links row). - ContactDetails.renderLink: an absent required link (only the brand-neutral "Professional profile" row reaches here) now opens the guided picker instead of a bare warning-pill field; a low-confidence value still edits in place. - ContactExtraLinks: "+ Add link" → guided "+ Add a profile". Reframes the empty state from a warning into an invitation with choices (the gap is still counted by the AttentionStrip). No new parse path — the committed URL is classified by the same classifyProfile sink. * fix(export): address PR #421 review — score-moving edits, error UI, wrap + reuse Reviewer feedback from @Vaishnavi1709 (CHANGES_REQUESTED) and @Samhit21. Blocking: - #1 Added LinkedIn/GitHub link (guided picker → addedProfiles) now back-fills the empty legacy `_url` slot the scorer + contact gap read, so the add moves the score. applyProfileOverrides returns the back-filled keys. - #2 Anonymous scorer now treats a GitHub link as satisfying the "Professional profile" completeness check (parity with the ContactCard rule), so a GitHub-but-no-LinkedIn résumé isn't docked / listed as missing LinkedIn. - #3 applyOverrides returns an edited `fieldConfidence` (user-affirmed contact edits bumped to present, clears dropped to 0), threaded onto both the score input and displayResult so a typed-in / added link stops reading as absent against the frozen base parse. - #4 useDownloadReport.download returns a boolean; the dialog closes only on success, so a generation failure no longer unmounts the error UI. - #5 New shared src/lib/pdf/text-wrap.ts breaks a single overlong word at char boundaries (opt-in); the audit-report identity header uses it so a long URL no longer overflows the page. Secondary: - #6 Audit-report identity sourced via buildContact + basicsFromContact (no full buildAtsResumeModel + toJsonResume walk just to read .basics). - #7 render-audit-report + serialize are now dynamic-imported in useDownloadReport, keeping the ~470 LOC report path out of the entry chunk. - #8 Extracted src/lib/download/blob-download.ts (slugifyName + triggerBlobDownload); useDownloadPdf/useDownloadReport/useReportGap consume it. - #9 wrapWordsToLines shared by both PDF renderers (render-ats-pdf keeps its no-mid-word-break contract; the report opts into breaking). - #12 Achievements now map to JSON Resume `awards[]` (title + optional date; omitted entirely when absent, so achievement-free exports are byte-identical). Nits: - #14 usernameFromUrl drops its unreachable try/catch. - #16 render-audit-report embeds Helvetica + Helvetica-Bold via Promise.all. - #17 LinkedIn non-profile-path carve-out is now a `HostRule.nonProfilePath` field instead of an inline special case in the classify loop. Deferred (with rationale in the PR reply): #10 override-channel consolidation, #11 profiles-mirror memo split, #13 ISO-3166 location country codes, #15 unconditional profiles assign (breaks the empty-override no-op invariant), and @Samhit21's non-profile-LinkedIn label nit (by-design). * refactor(pdf): drop wrapWordsToLines cognitive complexity below fallow threshold Collapse the duplicated break-or-seat branches into a single flush-then-seat loop. The fits / doesn't-fit paths both ended in the same "break the overlong word, else emit whole" logic; hoisting the flush and running that seating once drops cognitive complexity from 18 to under the 15 threshold (PR #421 fallow / Samhit review), with identical output — the 5 text-wrap tests still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFMiiujnuNq6FsXYUXMehM --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Closes #10 (mode 1). Partial coverage for mode 2.
The name selector at
extract-fields.ts:75scored "first profile line + largest font + title-case" up to 1.0, which lets a Microsoft-styleFunctional Resume Sampledoc-title header win over the real candidate name on the next line. Reported by @sriyau64 during dogfooding.The data needed for a proper fix was already on
PdfLine(y-coords of each profile line and the contact tokens) — the selector just never consumed it. Fix is fully local toextractName().What changed
resume / cv / curriculum vitae / sample / template / chronological / functional / example / draft / combination / profile / biography)."Jane Smith Resume"(1/3 boilerplate) still passes."Functional Resume Sample"/"Curriculum Vitae"/"Resume Sample"all reject.+0.4first-line bonus — it's effectively in the header position. Without this, the runner-up's confidence would drop to 0.45 (underANON_CONTACT_CONFIDENCE_FLOOR = 0.5inscore.ts:470), and the completeness scorer would mark the (correctly-detected) name as missing — mode 2 of #10 manifesting inside the mode-1 fix.+0.15when candidate y is within ~80 pt of the email / phone / LinkedIn line, viafindContactClusterY(). Backup signal for ambiguous header layouts; intentionally too small to override+0.4first-line.Verification
Programmatic
npm run typecheck— cleannpm run test— 174 / 174 (167 baseline + 6 newextractNameunit tests + 1 new corpus fixture)pdftotext+pdfinfoon the new fixture: zero real-author tokens, noAuthor/Title/ XMP metadata leakageReal-world PDF (the exact failure pattern in the issue)
Verified locally against a real "Functional Resume Sample" PDF (John W. Smith / Colorado State / public template). Before / after on the same file:
main)full_name"Functional Resume Sample"❌"John W. Smith"✅given_name"Functional""John"family_name"Resume Sample""W. Smith"fieldConfidence.full_name1.000.95completeness.missingname(wrong name still passes the 0.5 floor)name(right name passes)The score numerics don't move here because the bug is "wrong name picked confidently" rather than "no name picked" — both candidates clear the confidence floor, so completeness scores name as present either way. The user-visible win is the displayed name flipping from doc title to the real candidate. (The
extractNameconfidence bump from 0.45 → 0.85 from the first-eligible reindex is what keeps the score from regressing on the fixed branch.)Fixture
tests/fixtures/pdfs/latex/header-as-name-functional-resume.pdf— synthetic single-page XeLaTeX export structured to reproduce mode 1:LaTeX source captured in the commit body so it's regenerable. PII-clean: synthetic persona only.
Acceptance criteria from #10
+0.15viafindContactClusterY)extract-fields.test.ts)Reviewer note — UI verification gap
The Result UI doesn't currently render
parsed.full_nameas a visible field — only the completeness "Missing" list, which is a negative signal. That made the fix invisible end-to-end during local verification. I worked around it with a temporary dev banner underAtsScoreReadout(reverted before commit). Worth a separate UX discussion about whether to surface detected fields for parser-audit verification — not filing yet, deferring to @s-annam's call on whether resumelint's "parser-audit lane" framing wants that surface.Refs #10
🤖 Generated with Claude Code