fix(heuristics): Word-template parsing + identity-link, education & experience extraction - #125
Conversation
…29) Microsoft Word résumé templates break three single-pass parser assumptions at once. On the Chanchal Word fixture this drove completeness to 12/30 with name, phone, and skills all reported missing. - Name: Word stacks the given and family name on two separate single-word lines ("Chanchal" / "Sharma"), each rejected by extractName's >=2-word guard, so a two-word tagline ("Office Manager") won the slot. extractName now offers an adjacent single-word pair as one merged candidate, guarded against section headers and doc-title boilerplate. Single-line layouts are scored byte-identically (the candidate list is just the top-N lines). - Phone: the number uses an en-dash separator ("(718) 555-0100", U+2013). mightHavePhone's ASCII-only PHONE_RE pre-gate dropped it before the libphonenumber call (which parses Unicode dashes natively) ever ran. The pre-filter now folds en/em/figure dashes to "-"; PHONE_RE is unchanged. - Skills: a borderless multi-column table arrives as one PdfLine because pdfjs fills each inter-column gap with a wide blank "spacer" item (so the edge-gap column split never fires). extractSkills now splits a skills line at those spacer items into one cell per column, recovering individual skills without a blind \s+ split that would shred multi-word skills. mergeItemText is exported from sections.ts to rejoin each cell. Adds the synthetic, PII-verified chanchal-sharma-sample Word fixture (completeness 12->21/30; only the genuinely-absent LinkedIn / role-dates / summary remain missing). Two existing two-column fixtures and awesome-cv-cv gain skills (column rows that were previously merged into one token now split correctly); their snapshots are re-baked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The anonymous scorer's bullet pool was section-unaware: extractBulletsFromText
scanned the whole rawText for marker lines, so a bulleted skills section
("• Project management, Data analysis") entered the same pool as experience
bullets and was judged by startsWithActionVerb / hasMetric / wellFormedLength —
checks a skill fails by design — then surfaced as failures in PerBulletFeedback.
- Section-aware pool: the cascade now passes the detected skills-section text
(skillsSectionText, plumbed openresume -> CascadeResult) into the scorer,
which drops any bullet whose marker-stripped text matches a skills-section
line. score.ts stays a pure, zero-dependency module — it never re-derives
sections from rawText; the cascade owns section detection.
- Lone-bullet merge: a line that is only a bullet glyph now adopts the next
non-empty line as its text before scoring, recovering Word-table layouts that
split the glyph and its text into separate cells. pdfjs renders the motivating
fixture's bullets inline (so this path isn't hit there), so it's covered by a
unit test rather than a fixture.
Completeness is untouched (it reads parsed.skills, not the bullet pool), per the
issue's explicit boundary. Adds the synthetic, PII-verified
chanchal-sharma-bulleted-skills Word fixture (bulletCount 2 -> 0; the two skills
bullets no longer pollute the pool). Four existing fixtures with bulleted skills
re-bake: their skills bullets leave the pool, so bulletCount drops and the
specificity/structure ratios rise to reflect real experience bullets only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Résumé templates ship dates as redaction stubs ("August 20XX – March 20XX").
The placeholder year is correctly rejected as a date, so the role's date was
scored identically to a role with no date text at all — incomplete, with no
explanation to the user.
- Detect year-position redaction stubs (20XX, XXXX, ####, 20--) in a date
context: 20XX/20-- are matched bare; XXXX/#### only when anchored to a month
("August XXXX") or a range dash, so a stray "####" elsewhere doesn't trip it.
REDACTED_DATE_RE lives in the pure scorer (no heuristics dependency).
- Completeness now gives a failing date check half credit when the text carries
a redaction stub, distinct from the zero credit a wholly-missing date earns.
The role still counts as incomplete (it stays in `missing`); the check shape
gains an optional fractional `credit`.
- Expose `completeness.redactedDates`; AtsScoreReadout appends "Dates appear
redacted — use 4-digit years for best results." to the completeness hint.
The all-20XX chanchal-sharma-sample fixture moves completeness 21 -> 23 (half
credit) while still listing "role dates" as missing. Bumps
ATS_SCORE_ALGO_VERSION 1.0 -> 1.1 (covers the #29/#30/#31 score-affecting
changes); all fixture snapshots carry the version field.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Batch of contact, education, and experience parsing fixes surfaced from real-resume bug reports. All extraction-layer (src/lib), no UI behavior change beyond the contact card adapting its row count. Contact / identity links - LinkedIn & GitHub are recovered document-wide (footer / "Links" blocks), promoted into the contact card, de-duplicated out of the rendered body, and kept out of Skills (isSkillToken rejects profile URLs + bare labels). - LinkedIn matching is now symmetric with GitHub: any linkedin.com/<handle> profile (incl. bare vanity URLs) resolves, excluding /company,/jobs,etc. - GitHub is an optional contact row: absent → no "not detected" gap and no penalty to the detected/total ratio. - DOCX header/footer hyperlinks are now extracted. mammoth only converts the body, so a header-placed "LinkedIn | GitHub" row was lost; we read the word/header*.xml and footer*.xml parts from the DOCX zip (jszip) and resolve their relationship-based targets. Adds jszip as a direct dep. Education - Multi-qualification sections now extract every degree (was: only the first, often corrupted). Group one chunk per qualification by dual boundary (a second degree OR a second institution starts a new entry); handles degree-first and institution-first orders and acronym schools (MIT, UC Berkeley) that carry no "University"/"College" word. Experience - Company vs. designation: a university/college (INSTITUTION_HINTS, incl. plural "Schools") counts as the company; expanded title keywords (Assistant, Intern, Coordinator, ...) so designations are recognized. - Boundary integrity: a wrapped bullet's marker-less tail (indented past the bullet marker) no longer leaks into the next entry's header — reuses the x-based continuation signal from collectAnchors. Snapshots re-baked where the wrapped-bullet and doc-wide-link changes shift counts; no PII in snapshots. 494 tests pass; typecheck + lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…raction-29 # Conflicts: # src/lib/score/score.ts # tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-nonstandard-headers.expected.json # tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json # tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json # tests/fixtures/pdfs/unknown/openresume-react-pdf.expected.json # tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json # tests/fixtures/pdfs/unknown/weasyprint-cairo-nonstandard-headers.expected.json # tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json
| * matching lines are dropped so bulleted skills never enter the pool (#30). | ||
| */ | ||
| function extractBulletsFromText(text: string): string[] { | ||
| function extractBulletsFromText( |
There was a problem hiding this comment.
Deferred — extractBulletsFromText is pre-existing code from main (#121), not introduced by this PR, and fallow runs report-only so it won't fail verify. Leaving this open; the lone-bullet-merge extraction is a sensible follow-up cleanup but out of scope for this review pass.
s-annam
left a comment
There was a problem hiding this comment.
PR Review: Word-template parsing + identity-link, education & experience extraction
Gates (run on checked-out branch): typecheck ✅ · test ✅ 575 passed (40 files) · lint ✅ · fixture PII ✅ (synthetic: Chanchal Sharma, chanchals@example.com, (718) 555-0100; no Info-dict/author leak).
Summary
Strong, well-commented extraction branch. Logic is sound, the cascade→scorer skillsSectionText wiring is fully threaded, contact-row optionality is a clean refactor, snapshots re-baked. One trivial-but-real blocker (a stray NUL byte); otherwise mergeable.
Highlights
skillsSectionLines→skillsSectionTextcorrectly threadedopenresume→cascade(both PDF + markdown paths) →useResumeAnalysis→ scorerbuildSkillsExclusion; the pure scorer never re-derives sections.splitColumnCellsreuses the now-exportedmergeItemTextrather than a blind\s+split — multi-word skills survive.URLISH_RE's path-slash requirement deliberately spares dotted skills (Node.js,ASP.NET).- phone en-dash fold is pre-gate-only;
findPhoneNumbersInTextstill parses Unicode natively — matches the(718) 555–0100fixture end-to-end. - Education multi-degree chunking +
educationFromChunkcleanly replaces the first-degree-only loop.
Key Findings
1. [Blocking] — src/lib/ingest/docx.ts:129 contains a raw NUL byte (U+0000) in source.
The dedup key is `${text}\x00${url}` with a literal NUL, not an escape. Consequence: git classifies the file as binary (the PR shows Bin 2468 → 7846 bytes), so the entire ~5KB of new DOCX header/footer logic is invisible in the PR diff and to code-scanning. NUL bytes in source are also an editor/formatter footgun. It works at runtime and tests pass — this is a reviewability/tooling blocker, not a correctness one. Trivial fix, identical runtime string:
const key = `${text}\u0000${url}`; // keeps the file UTF-8 text + diffableConfirmed three ways: python3 byte dump, file reporting data, and the binary diff. Introduced by this PR (base was plain text).
2. [Suggestion] — extractBulletsFromText cognitive complexity 20 (>15). Already surfaced by the github-advanced-security bot on score.ts:612. CI's fallow step is report-only (non-blocking), so this won't fail verify — but lifting the lone-bullet merge into a small helper would clear it.
3. [Nit] — entry-blocks.ts:140 — local let bulletMarkerX shadows the module-level bulletMarkerX(lines) (l.163) and re-inlines the identical computation; call the helper. (Inline below.)
Verdict
Action: REQUEST_CHANGES
Rationale: Single blocking item (#1) — fix the NUL byte so docx.ts stays diffable in main's history. Trivial change. #2/#3 optional. Everything else is solid.
|
Two follow-ups from the architecture pass — neither blocks this PR:
The heuristic accretion in this PR is disciplined (pure named predicates + "non-trigger byte-identical" + fixtures), so this is about the next refactor, not this diff. One note tied to the blocking NUL-byte item: because the literal NUL character ( |
A U+0000 had been written instead of a space in the header/footer link dedup key, which made git treat docx.ts as binary (diff shown as 'Binary files differ', unreviewable). Behavior-equivalent — NUL was only a key separator — but a NUL in a source file is bad hygiene. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rohithgollapalli
left a comment
There was a problem hiding this comment.
Code Review
Self-review pass by the branch author — still needs a second reviewer's approval to merge.
Overview
Extraction-layer fixes addressing a series of real-résumé bug reports: document-wide LinkedIn/GitHub recovery + DOCX header/footer hyperlink extraction (new jszip dep), multi-degree education extraction, experience company-vs-designation disambiguation, and wrapped-bullet-tail boundary integrity. Bundles the prior #29/#30/#31 commits and a merge of main (#119/#121/#123) with re-baked snapshots + algo bump to 1.2.
Correctness
- 🔴 Found & fixed during review (
6ec3d8f): a stray NUL byte (U+0000) indocx.tswhere a space belonged (a dedup-key separator). Behavior-equivalent so tests passed, but it made git classify the file binary → its diff was invisible in this PR. Now textual and reviewable. - ✅
new RegExpfrom a URL slug inopenresume.tsusesescapeRegex— no injection/ReDoS. - ✅ Scanned all touched source: no other binary/NUL files, no
console.log/TODO/.only/debuggerleftovers. - ✅ Merge resolution sound:
score.tskeeps main's unexportedconst(dead-code gate), merges both changelogs, bumps 1.1→1.2; re-baked snapshots match merged behavior (awesome-cv-cvverified equal to main's #119 output).
Code quality & conventions
- Adheres to repo norms: SPDX headers,
@design-systemimports, domain logic insrc/lib, dynamic imports forjszip/mammoth/turndownto keep the entry chunk lean, hand-rolled minimal interfaces instead of@types/jszip. - Helpers are small, well-named, and commented with the why (
looksLikeCompany,isWrappedContinuation,isLinkedinProfileUrl). - Minor:
anywhereOnDoc = () => trueis a no-opbandpredicate — reads slightly oddly but clear in context.
Test coverage — strong
New unit tests for every behavior (contact rows, skills exclusion + dotted-skill guard, vanity LinkedIn, multi-degree education across both orderings + acronym schools, title/company disambiguation, x-positioned wrapped-bullet boundary, end-to-end DOCX-header zip round-trip). Full suite 575 green, typecheck + lint clean.
Risks / observations (non-blocking)
⚠️ PR size: 51 files bundling ~6 distinct fixes + 4 prior commits + a merge is a lot for one reviewer. Suggest a file-by-file skim.- XML-via-regex:
parseHeaderFooterHyperlinksparses DOCX header XML with regex, not a real parser — pragmatic and tested, fragile against unusual<w:hyperlink>nestings. - Heuristic constants:
splitColumnCellsspacer width andisWrappedContinuationmarkerX + 2— commented, low risk. - Documented follow-ups (not in PR): per-entry experience
location,is_currenton en-dash "Present", and merging wrapped-bullet tails back into responsibility text.
Security
No secrets, no new network calls (jszip operates on in-browser DOCX bytes; everything stays client-side). Fixture PII preflight passed — synthetic personas.
Verdict
Approve-worthy once a second reviewer signs off. One real defect (NUL byte) surfaced and fixed in this pass; the rest is well-tested and convention-clean. Recommend a reviewer skim the now-visible docx.ts and the extract-fields.ts education/disambiguation hunks specifically.
Samhit21
left a comment
There was a problem hiding this comment.
PR Review: fix(heuristics): Word-template parsing + identity-link, education & experience extraction
Summary
Re-read post the NUL-byte fix (6ec3d8f) so docx.ts is diffable. The architecture is clean — promoted-link dedup is well-isolated in openresume.ts, the optional-row pattern in contact.ts carries
its own docstring, and splitColumnCells reusing mergeItemText is the right call. s-annam's review covered the load-bearing items (NUL byte, extractBulletsFromText complexity, bulletMarkerX
shadowing) and split out the right follow-up architecture issues (#126 / #127). Two net-new suggestions below — both real, neither blocking.
Findings
- [Suggestion]
decodeXmlatdocx.ts:88misses numeric XML entity references (&, ). Same gap that #117 closed infetch-jd.ts; cheap fix is two extra.replace()calls before the
named-entity pass. Hyperlink URLs and visible text from some Word save paths round-trip corrupted today. - [Suggestion]
stripPromotedUrls' slug regex atopenresume.ts:144uses lookahead(?![\w/]), which doesn't exclude-or.— a user whose GitHub slug is a prefix of another handle in the
body (e.g.foovsfoo-bar) gets chopped mid-token. Same regex also lacks/g, so a line that mentions the same identity link twice keeps the second copy. Hoist+widen the lookahead and add/g.
Verdict
Action: COMMENT — neither finding is merge-blocking. #1 is the higher-value catch (closer parallel to a recently-fixed bug elsewhere in the lib); #2 is the more interesting reasoning bug but
probability of triggering in the wild is low.
- entry-blocks: drop the local bulletMarkerX that shadowed and re-inlined the module-level bulletMarkerX(lines) helper; call the helper instead. - docx: decodeXml now decodes numeric character references (&,  ) before the named-entity pass, matching the htmlToPlaintext fix (#117). - openresume: stripPromotedUrls slug lookahead now also rejects '.' and '-' so a longer handle sharing a prefix (github.com/jane vs github.com/jane-doe) is not chopped; regexes hoisted out of the per-line loop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Revision pushed:
|
| Thread | Resolution |
|---|---|
NUL byte in docx.ts (s-annam, blocking) |
✅ Fixed earlier in 6ec3d8f — file is UTF-8 text + diffable now (Samhit21 confirmed) |
bulletMarkerX shadow (s-annam, nit) |
✅ Fixed — calls the module-level helper |
decodeXml numeric entities (Samhit21) |
✅ Fixed + test (decimal & hex refs) |
stripPromotedUrls slug lookahead (Samhit21) |
✅ Fixed + test (longer prefix-sharing handle preserved); regex hoisted |
extractBulletsFromText complexity (bot) |
↪️ Deferred — pre-existing from #121, fallow is report-only; left open |
Gates green on the branch: typecheck ✅ · test ✅ 577 passed · lint ✅. Re-requesting review — thanks @s-annam @Samhit21.
…raction-29 # Conflicts: # package-lock.json # package.json
|
@rohithgollapalli — re-reviewed the revision ( Holding approval on one thing — the corpus regression surface. This PR regenerates ~25
One delta is a real field loss I want you to confirm:
Four questions before I approve:
Everything else looks solid — once these are answered I'll approve. |
|
@s-annam — thanks for the careful diff; you found the one real problem. Answers below. 1. Corpus testing beyond re-baking. During development I diffed each changed snapshot against my pre-change 2. 3. The other
All single-column wrapped tails that 4. Per-fix → affected-fixtures map:
Net: every count delta is an intended improvement or a corrected over-count, except the #131 two-column-achievements regression — which I'd like to take as a follow-up since the principled fix is segmentation (#127), not a per-section x-tolerance patch. Happy to switch to the fallback instead if you'd rather not reduce the count in this PR. |
s-annam
left a comment
There was a problem hiding this comment.
APPROVE
Re-reviewed the revision (1ccef64 / merged de04de6). All four held questions answered, and I verified the load-bearing ones against the code:
weasyprint-cairo-two-columnachievements 1→0 — not a real loss.collectAnchors(entry-blocks.ts:146,if (lines[i].x > markerX) continue;) uses the global minbulletMarkerX, which can't represent a two-column flatten with two bullet margins — confirmed.main's output there was mangled empty-title noise ({title:"", year:"2021", description:"Acme Innovation Prize, 2023"}, mis-merging two awards), so 0 ≥ noise. Correctly filed as #131; right fix is column-aware segmentation (#127, now wired asblocked_by). Accepting the deferral — a single-column fallback would just restore the noise.- Other
projectsCount2→1 drops — confirmed corrected over-counts (wrapped-bullet tailsmainmis-promoted to phantom headers). - Per-fix → fixture map — delivered; every count delta is an intended improvement or corrected over-count.
Gates green on the branch: typecheck ✅ · test 616 passed ✅ · lint ✅ · fixture PII ✅ · verify + fallow CI ✅. Browser smoke on a Word fixture parses end-to-end.
One process note, not blocking: the per-fix → fixture blast-radius map should ship in the PR body next time, not after a reviewer digs for it — that's the cost of a 51-file / 6-fix bundle. Ties into the #126 split. Nice work clearing the threads.
…tion boundary Replace two segmentation-imprecision workarounds in contact extraction with the proper signals now available from the typed SectionedResume (#138). - #134: retire the stripPromotedUrls after-the-fact slug-subtraction scrub (isPromotedUrl, PROMOTED_LABEL_RE, urlSlug, per-section strip driver) in favor of a line-level ownership model. extractContact now returns consumedLines: ReadonlySet<PdfLine>; buildHeuristicResult strips owned lines from body pools before extraction, so a promoted identity link is claimed by contact and never re-renders as a phantom project/achievement. #125 identity-link fixtures pass; zero corpus-golden movement. - #135: replace extractContact's geometric y-band header proxy (inHeaderRegion: ann.yTop < 280) with inProfileSection, a real boundary derived from the profile section's line extents. portfolio_url/website_url annotation lookups consult it; the document-wide LinkedIn/GitHub identity match (anywhereOnDoc) is retained by design (spike §1.4). No PDF-points magic number remains; contact-field counts unchanged on all fixtures. Verified: typecheck clean, 616/616 tests pass, 24/24 corpus snapshots unchanged. Refs #109, #127. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GstfZc8CpvugDY85bS541Y
…xperience extraction (#125) Document-wide LinkedIn/GitHub recovery + DOCX header/footer hyperlink extraction (new jszip dep), multi-field Word-template parsing, education/ experience boundary fixes, column-aware skills split, and distinct scoring for redacted role dates. Extraction-layer only (src/lib); ATS_SCORE_ALGO_VERSION bumped 1.1→1.2. Resolves #29 Resolves #30 Resolves #31 Co-Authored-By: Rohith Gollapalli <rohithgollapalli@users.noreply.github.com>
…xperience extraction (#125) Document-wide LinkedIn/GitHub recovery + DOCX header/footer hyperlink extraction (new jszip dep), multi-field Word-template parsing, education/ experience boundary fixes, column-aware skills split, and distinct scoring for redacted role dates. Extraction-layer only (src/lib); ATS_SCORE_ALGO_VERSION bumped 1.1→1.2. Resolves #29 Resolves #30 Resolves #31 Co-Authored-By: Rohith Gollapalli <rohithgollapalli@users.noreply.github.com>
… class (offlinecv#787) (offlinecv#863) Four source files carried a raw 0x00 where the six-character JS escape belongs. Identical at runtime — the byte is only ever reached for as a collision-free join separator, and the reasoning is sound each time — but it makes grep and git grep exit 1 with no output at all over the whole file, and renders the blob as Bin on GitHub whenever it lands in the first ~8000 bytes. JobRepostArchiveDialog.tsx (offset 4149) was on the dark side of that threshold; the other three read as normal text diffs while every search over them lied. Encoding-only, so no test moves. Add scripts/check-no-literal-nul.mjs to stop the class coming back. offlinecv#786 shipped a fifth instance through review and PR offlinecv#125 fixed a sixth in June: the construct is individually correct every time someone reaches for it, so a rule does not hold and a gate has to. It walks git ls-files, skips binary formats by declared extension — by path, deliberately, since content sniffing keys on the very byte being hunted and would skip exactly the files it exists to catch — and reports file:line:column with the byte offset. Wired into verify and into verify:quick. It belongs in the inner loop by that gate's own criterion: it costs ~50ms, and all four occurrences were in .ts under src/, which is precisely what the Stop sentinel fires on. Closes offlinecv#787
Summary
Branch of contact / education / experience parsing fixes surfaced from
real-resume bug reports, on top of the earlier Word-template work (#29) and
scoring fixes (#30, #31). Extraction-layer only (
src/lib); the sole UIchange is the contact card adapting its row count.
Contact / identity links
promoted into the contact card, de-duplicated out of the rendered body, and
kept out of Skills (
isSkillTokenrejects profile URLs + bare labels).linkedin.com/<handle>profile (incl. bare vanity URLs) resolves;
/company,/jobs, etc. excluded.penalty to the detected/total ratio.
so a header-placed "LinkedIn | GitHub" row was lost; we read the
word/header*.xml/footer*.xmlparts from the DOCX zip (jszip, added as adirect dep) and resolve their relationship-based targets.
Education — multi-qualification sections now extract every degree (was:
only the first, often corrupted). One chunk per qualification via a dual
boundary; handles degree-first / institution-first orders and acronym schools
(MIT, UC Berkeley).
Experience
as the company; expanded title keywords (Assistant, Intern, …) so designations
are recognized.
bullet marker) no longer leaks into the next entry's header.
Snapshots re-baked where wrapped-bullet / doc-wide-link changes shift counts.
Closes #29
Closes #30
Closes #31
Test plan
npm run typecheckcleannpm run testgreen (494 tests)npm run lintcleannpm run dev(contact card, projects, skills, DOCX header links)Known follow-ups (not in this PR)
location;is_currenton en-dash "Present"