diff --git a/src/lib/heuristics/cascade.ts b/src/lib/heuristics/cascade.ts index 30fcfb1c..8572c3e3 100644 --- a/src/lib/heuristics/cascade.ts +++ b/src/lib/heuristics/cascade.ts @@ -151,7 +151,7 @@ export async function runCascade( let parsed = heuristic.parsed; let fieldConfidence = heuristic.fieldConfidence; - let extractedCharCount = countExtractedChars(parsed); + let extractedCharCount = countExtractedChars(parsed, heuristic.sections); // ── Tier 1.5: regex fallback for missing contact basics ─────────────────── @@ -173,7 +173,7 @@ export async function runCascade( parsed = fallback.parsed; fieldConfidence = fallback.fieldConfidence; t15Fields.push(...fallback.fieldsFilled); - extractedCharCount = countExtractedChars(parsed); + extractedCharCount = countExtractedChars(parsed, heuristic.sections); } // ── Confidence + escalation routing ─────────────────────────────────────── @@ -363,7 +363,7 @@ export async function runCascadeFromMarkdown( let parsed = heuristic.parsed; let fieldConfidence = heuristic.fieldConfidence; - let extractedCharCount = countExtractedChars(parsed); + let extractedCharCount = countExtractedChars(parsed, heuristic.sections); const t15Fields: string[] = []; let t15Duration = 0; @@ -378,7 +378,7 @@ export async function runCascadeFromMarkdown( parsed = fallback.parsed; fieldConfidence = fallback.fieldConfidence; t15Fields.push(...fallback.fieldsFilled); - extractedCharCount = countExtractedChars(parsed); + extractedCharCount = countExtractedChars(parsed, heuristic.sections); } // Neutral layout probes — DOCX cascade has no x/y positional data, so @@ -493,24 +493,71 @@ function buildScannedResult( }; } -/** Sum visible character counts across the heuristic parse output. */ -function countExtractedChars(parsed: CascadeResult["parsed"]): number { +/** + * Section names whose text is already tallied through a typed `parsed` field + * (profile → contact, summary, skills, experience, education). Everything else + * in `byName` — projects, certifications, achievements, the `other` catch-all — + * lands only in `sections.byName`, so it must be counted separately below. + */ +const TYPED_FIELD_SECTIONS = new Set([ + "profile", + "summary", + "experience", + "education", + "skills", +]); + +/** Length of an optional string field; 0 when absent. */ +const fieldLen = (s: string | null | undefined): number => (s ?? "").length; + +/** Chars from the typed scalar contact + summary fields. */ +function scalarFieldChars(parsed: CascadeResult["parsed"]): number { + return ( + fieldLen(parsed.full_name) + + fieldLen(parsed.email) + + fieldLen(parsed.phone) + + fieldLen(parsed.location) + + fieldLen(parsed.summary) + ); +} + +/** Chars from the typed list fields (skills, experience, education). */ +function listFieldChars(parsed: CascadeResult["parsed"]): number { let n = 0; - n += (parsed.full_name ?? "").length; - n += (parsed.email ?? "").length; - n += (parsed.phone ?? "").length; - n += (parsed.location ?? "").length; - n += (parsed.summary ?? "").length; for (const s of parsed.skills ?? []) n += s.length; for (const e of parsed.experience ?? []) { - n += (e.company ?? "").length; - n += (e.title ?? "").length; - n += (e.team ?? "").length; - n += (e.description ?? "").length; + n += fieldLen(e.company) + fieldLen(e.title) + fieldLen(e.team) + fieldLen(e.description); } for (const e of parsed.education ?? []) { - n += (e.institution ?? "").length; - n += (e.degree ?? "").length; + n += fieldLen(e.institution) + fieldLen(e.degree); } return n; } + +/** + * Chars from sections that no typed field counts — projects, certifications, + * achievements, the `other` catch-all. Without them, correctly terminating an + * unknown section (e.g. a coursework block, #164) drops the extracted/raw ratio + * toward EXTRACTION_RATIO_FLOOR and falsely trips `low_extraction_ratio` → OCR + * escalation (#165 review). + */ +function untypedSectionChars(sections: HeuristicResult["sections"]): number { + let n = 0; + for (const [name, lines] of sections.byName) { + if (TYPED_FIELD_SECTIONS.has(name)) continue; + for (const line of lines) n += line.length; + } + return n; +} + +/** Sum visible character counts across the heuristic parse output. */ +function countExtractedChars( + parsed: CascadeResult["parsed"], + sections: HeuristicResult["sections"], +): number { + return ( + scalarFieldChars(parsed) + + listFieldChars(parsed) + + untypedSectionChars(sections) + ); +} diff --git a/src/lib/heuristics/entry-blocks.test.ts b/src/lib/heuristics/entry-blocks.test.ts index 613e5588..279566da 100644 --- a/src/lib/heuristics/entry-blocks.test.ts +++ b/src/lib/heuristics/entry-blocks.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect } from "vitest"; import { groupIntoLines, splitIntoSections, findSection } from "./sections.ts"; -import { parseEntryBlocks } from "./entry-blocks.ts"; +import { parseEntryBlocks, mergeWrappedContinuations } from "./entry-blocks.ts"; import { mkItems } from "./__test-utils__/mkItem.ts"; import type { PdfSection, PdfLine } from "./sections.ts"; @@ -365,6 +365,81 @@ describe("parseEntryBlocks — first_line anchor (projects / date-optional secti }); }); +describe("mergeWrappedContinuations (#162)", () => { + // x-aware line builder: text + left-x, document-ordered y. Mirrors the real + // PdfLine geometry the merge keys on (the bullet marker margin vs. the wrapped + // bullet-text indent). + function lines(rows: Array<{ text: string; x: number }>): PdfLine[] { + return rows.map((r, i) => ({ + page: 1, + y: 72 + i * 14, + x: r.x, + items: [], + text: r.text, + maxFontSize: 11, + allCaps: false, + })); + } + + it("returns the array unchanged for an empty section", () => { + expect(mergeWrappedContinuations([])).toEqual([]); + }); + + it("folds a marker-less continuation (indented past the marker) into its bullet", () => { + // Marker at x=81; the wrapped tail at x=90 aligns with the bullet TEXT, so + // it folds onto the bullet rather than surviving as a standalone (and thus + // marker-less, droppable) line. + const merged = mergeWrappedContinuations( + lines([ + { text: "Project A", x: 70 }, + { text: "● Collected revenue using 10-K and 10-Q filings", x: 81 }, + { text: "across several reporting periods", x: 90 }, // wrap + { text: "● Used five forecasting methods including MA3", x: 81 }, + { text: "on deseasonalized revenue data", x: 90 }, // wrap + ]), + ); + expect(merged.map((l) => l.text)).toEqual([ + "Project A", + "● Collected revenue using 10-K and 10-Q filings across several reporting periods", + "● Used five forecasting methods including MA3 on deseasonalized revenue data", + ]); + // Items from both physical lines are carried onto the merged line. + expect(merged.map((l) => l.x)).toEqual([70, 81, 81]); // anchor x preserved + }); + + it("does NOT fold header / non-continuation lines at or left of the marker margin", () => { + // Headers (x≤marker) and a fresh bullet are continuations of nothing — they + // must each stay their own line so titles and new bullets are preserved. + const merged = mergeWrappedContinuations( + lines([ + { text: "Revenue Forecasting Project", x: 70 }, + { text: "● First bullet that does not wrap", x: 81 }, + { text: "Global Entry Strategy Project", x: 70 }, // real header, not a wrap + { text: "● Second bullet", x: 81 }, + ]), + ); + expect(merged.map((l) => l.text)).toEqual([ + "Revenue Forecasting Project", + "● First bullet that does not wrap", + "Global Entry Strategy Project", + "● Second bullet", + ]); + }); + + it("is a no-op for a markerless section (markerX = Infinity)", () => { + // No bullet glyph anywhere → no marker margin → nothing folds, even though + // the lines carry distinct x. Profile / education-degree blocks rely on this + // so paragraph-spaced header lines are never collapsed into one another. + const rows = [ + { text: "Jane Smith", x: 253 }, + { text: "San Jose, CA", x: 276 }, + { text: "(312) 555-0123 | jane.smith@example.com", x: 125 }, + ]; + const merged = mergeWrappedContinuations(lines(rows)); + expect(merged.map((l) => l.text)).toEqual(rows.map((r) => r.text)); + }); +}); + describe("parseEntryBlocks — wrapped multi-line role header (#166)", () => { it("reassembles a 3-line wrapped header (org tail + date-year wrap) into one dated block", () => { // The Docent shape from diff --git a/src/lib/heuristics/entry-blocks.ts b/src/lib/heuristics/entry-blocks.ts index 5d45ced2..ece2de1f 100644 --- a/src/lib/heuristics/entry-blocks.ts +++ b/src/lib/heuristics/entry-blocks.ts @@ -336,6 +336,64 @@ function foldHeaderText( .trim(); } +/** + * Fold every wrapped-bullet continuation line in a section into the `PdfLine` + * it continues, returning a new line array where each bullet carries its full + * text on one line. This is the upstream twin of the body-fold logic inside + * {@link buildEntryBlock}, narrowed to the one fold signal that is + * geometrically unambiguous across all section types: an x-indent past the + * bullet *marker* margin (`isWrappedContinuation`), where a long glyph bullet's + * tail wraps onto a marker-less second line that aligns with the bullet TEXT. + * + * Why a separate pass: `SectionedResume.byName` flattens each section's + * `PdfLine`s to trimmed strings (`toSectionedResume`), discarding the x the + * fold needs. Running the fold here — before that flatten — lets the + * string-level bullet pool (`extractBulletsFromLines`, which keeps only + * marker-led lines and would otherwise drop a glyph-less continuation, leaving + * the bullet truncated at the wrap) recover the full bullet text for EVERY + * section, including untyped ones (volunteer, coursework) that never reach + * `experience[]`. By construction the pool then agrees with the merged + * `experience[]/projects[].description` the entry-block parser produces. See + * #162. + * + * The prose-wrap y-gap signal `buildEntryBlock` also uses is deliberately NOT + * applied here: the bullet pool is bullet-marker-gated, so a marker-less prose + * template never contributes pool lines for a prose continuation to extend — + * the signal would add no pool benefit while collaterally collapsing + * paragraph-spaced header/contact/education lines (which sit at or left of the + * margin and are NOT continuations) into one another. The x-indent signal + * touches only lines that wrapped past a real bullet marker, so headers, entry + * titles, and contact blocks are left one-to-one. + * + * A no-op when the section has no bullets (markerX = Infinity) or carries no + * usable x (markdown/DOCX, every x = 0 → nothing indents past the marker): the + * array is returned one line per input, byte-identical to the pre-merge flatten. + */ +export function mergeWrappedContinuations(lines: PdfLine[]): PdfLine[] { + if (lines.length === 0) return lines; + const markerX = bulletMarkerX(lines); + const out: PdfLine[] = []; + for (const line of lines) { + if ( + out.length > 0 && + !isBulletLine(line) && + isWrappedContinuation(line, markerX) + ) { + // Fold this continuation onto the line it wraps from: clone the previous + // emitted line and append the continuation's text + items. + const prev = out[out.length - 1]; + out[out.length - 1] = { + ...prev, + text: `${prev.text.trimEnd()} ${line.text.trim()}`.trim(), + items: [...prev.items, ...line.items], + }; + } else { + out.push(line); + } + } + return out; +} + /** * A description paragraph begins after a vertical gap wider than this multiple * of the section's single line-height. Word/Office templates write the role diff --git a/src/lib/heuristics/sections-column.test.ts b/src/lib/heuristics/sections-column.test.ts index f7ce273a..7ed28ffe 100644 --- a/src/lib/heuristics/sections-column.test.ts +++ b/src/lib/heuristics/sections-column.test.ts @@ -103,18 +103,122 @@ describe("groupIntoLines column awareness", () => { ]); }); - it("interleaves by (y, x) when no boundary is supplied (legacy path)", () => { + it("de-interleaves an embedded multi-column run even without a page boundary (#164)", () => { + // Before #164 this no-boundary path emitted the rows interleaved + // L,R,L,R,L,R — a localized multi-column block (≥2 shared-baseline rows with + // a column-sized gap) that the page-level ink-projection probe never sees, + // so `boundaries` is empty. `reorderEmbeddedColumns` now detects the run at + // the item level and re-emits it column-major: the whole left column, then + // the whole right column — matching the boundary-supplied path above. const lines = groupIntoLines(twoColumnItems); - // Shared baselines split at the wide column gap, so the global (y, x) sort - // emits the rows interleaved L, R, L, R… — the scrambling this fix targets. const texts = lines.map((l) => l.text); expect(texts).toEqual([ "left-1", - "right-1", "left-2", - "right-2", "left-3", + "right-1", + "right-2", "right-3", ]); }); }); + +describe("embedded multi-column reading order (#164)", () => { + // A 3-column "Relevant Coursework" grid embedded in an otherwise single-column + // page (column markers at x≈81 / 244 / 407, with wrapped course-name tails + // indented a few points past their marker). The page-level probe never bands + // this (the single-column body inks straight across its gutters), so the + // reorder must happen at the item level. Geometry mirrors the + // google-docs-skia-proxy-multiline-bullets-coursework fixture. + // Realistic glyph widths (~5.2pt/char, as pdfjs emits for 11pt text) so the + // inter-column gutters (col1→2 ≈ 244-195, col2→3 ≈ 407-356) clear the 50pt + // column-split threshold the same way the real fixture does. + const narrow = (x: number, y: number, str: string): PdfTextItem => ({ + page: 1, + x, + y, + str, + width: str.length * 5.0, + height: 11, + fontSize: 11, + fontName: "font-11", + hasEOL: true, + }); + const courseworkItems: PdfTextItem[] = [ + narrow(81, 695, "● Global Dimensions of"), + narrow(244, 695, "● Financial Accounting"), + narrow(407, 695, "● Microeconomics"), + narrow(90, 709, "Business"), // wrap of col-1 row-1 + narrow(244, 711, "● Fundamentals of"), + narrow(407, 711, "● Macroeconomics"), + narrow(81, 724, "● Fundamentals of HR"), + narrow(253, 724, "Operational Management"), // wrap of col-2 row-2 + narrow(407, 726, "● Legal Environment of"), + narrow(90, 738, "Management"), // wrap of col-1 row-3 + narrow(416, 739, "Business"), // wrap of col-3 row-3 + ]; + + it("emits each column top-to-bottom, columns left-to-right (not row zig-zag)", () => { + const lines = groupIntoLines(courseworkItems); + const texts = lines.map((l) => l.text); + // Column-major: every column-1 line (incl. its wrap), then column-2, then + // column-3. A row-major (y, x) sort would interleave them — the bug. + expect(texts).toEqual([ + "● Global Dimensions of", + "Business", + "● Fundamentals of HR", + "Management", + "● Financial Accounting", + "● Fundamentals of", + "Operational Management", + "● Microeconomics", + "● Macroeconomics", + "● Legal Environment of", + "Business", + ]); + // Guard against regression to interleaving: column-1's wrap ("Business", + // the second line) must precede any column-3 content. + expect(texts.indexOf("● Microeconomics")).toBeGreaterThan( + texts.indexOf("● Global Dimensions of"), + ); + expect(texts.indexOf("● Microeconomics")).toBeGreaterThan( + texts.indexOf("● Financial Accounting"), + ); + }); + + it("leaves a single-column block untouched (no spurious reorder)", () => { + const singleCol: PdfTextItem[] = [ + mkItem(72, 100, "first line of body text"), + mkItem(72, 114, "second line of body text"), + mkItem(72, 128, "third line of body text"), + ]; + const texts = groupIntoLines(singleCol).map((l) => l.text); + expect(texts).toEqual([ + "first line of body text", + "second line of body text", + "third line of body text", + ]); + }); + + it("does not reorder an isolated single multi-column row (date rail)", () => { + // One header line with a right-aligned date — a single multi-column row, + // below the ≥2-row run threshold, so it must NOT be treated as a column + // grid and reordered away from the body that follows it. + const dateRail: PdfTextItem[] = [ + mkItem(72, 100, "Senior Engineer, Acme Corp"), + mkItem(420, 100, "2020 – 2023"), + mkItem(72, 116, "● Built the thing that did the stuff"), + mkItem(72, 132, "● Shipped it on time"), + ]; + const texts = groupIntoLines(dateRail).map((l) => l.text); + // The date stays grouped with its header row (split into its own line at the + // column gap, but in row order), and the bullets follow in order — the body + // is not pulled above the date. + expect(texts[0]).toBe("Senior Engineer, Acme Corp"); + expect(texts[1]).toBe("2020 – 2023"); + expect(texts.slice(2)).toEqual([ + "● Built the thing that did the stuff", + "● Shipped it on time", + ]); + }); +}); diff --git a/src/lib/heuristics/sections.config.json b/src/lib/heuristics/sections.config.json index 65c3f5e8..af83b2c4 100644 --- a/src/lib/heuristics/sections.config.json +++ b/src/lib/heuristics/sections.config.json @@ -45,9 +45,11 @@ "education", "academic background", "academics", - "qualifications" + "qualifications", + "coursework", + "relevant coursework" ], - "anchors": ["education", "academics", "qualifications"], + "anchors": ["education", "academics", "qualifications", "coursework"], "splitLetterNormalizable": true, "anchorFallback": true }, diff --git a/src/lib/heuristics/sections.test.ts b/src/lib/heuristics/sections.test.ts index 367c9c45..01487da0 100644 --- a/src/lib/heuristics/sections.test.ts +++ b/src/lib/heuristics/sections.test.ts @@ -303,6 +303,106 @@ describe("splitIntoSections — visual-primary boundary path (#112)", () => { }); }); +describe("splitIntoSections — coursework header termination (#163)", () => { + // A "Relevant Coursework" header (now an `education` keyword alias, #163 + // sub-problem 1) must OPEN an education section and thereby TERMINATE the + // prior section, so the coursework block stops bleeding into the last + // experience entry's description (and stops leaking into the bullet pool). + it("opens an `education` section at 'Relevant Coursework' and does NOT append it to the prior entry", () => { + const sections = build([ + { text: "Jane Smith", fontSize: 18 }, // name + { text: "jane.smith@example.com | (312) 555-0123", fontSize: 11 }, // contact + { text: "Activities", fontSize: 12 }, // experience alias (font-distinct) + { text: "Discussion Group Facilitator Aug 2025 - Present", fontSize: 11 }, + { text: "• Planned meeting agendas and material for 20+ meetings", fontSize: 11 }, + { text: "• Led and moderated discussions for all participants", fontSize: 11 }, + { text: "Relevant Coursework", fontSize: 12 }, // unrecognized-by-text header → education alias + { text: "• Financial Accounting", fontSize: 11 }, + { text: "• Microeconomics", fontSize: 11 }, + ]); + + // (1) A coursework section opened and is mapped to the `education` type. + // The "Relevant Coursework" header line itself is consumed as the boundary + // (it opens the section, so it isn't stored in any section's `lines`), so we + // assert on the coursework *items* that landed inside the opened section. + const coursework = sectionContaining(sections, "Financial Accounting"); + expect(coursework).toBeDefined(); + expect(coursework!.name).toBe("education"); + + // A second `education` section opened at the coursework header — distinct + // from any degree section above it — confirming the header opened a boundary + // rather than being appended to the prior (experience) section. + expect(names(sections).filter((n) => n === "education").length).toBe(1); + + // (2) The prior experience entry's lines do NOT carry the coursework header + // or items — the section terminated cleanly, no bleed into the description. + const experience = sectionContaining( + sections, + "Discussion Group Facilitator", + ); + expect(experience!.name).toBe("experience"); + expect( + experience!.lines.some((l) => l.text.includes("Relevant Coursework")), + ).toBe(false); + expect( + experience!.lines.some((l) => l.text.includes("Financial Accounting")), + ).toBe(false); + expect( + experience!.lines.some((l) => l.text.includes("Microeconomics")), + ).toBe(false); + + // The boundary opened as `education` via the keyword path — never the + // `other` sink (which would drop coursework out of education completeness). + expect(names(sections)).not.toContain("other"); + }); + + it("font-metadata-independent ALL-CAPS fallback terminates the prior section for an unknown header", () => { + // A renderer that flattens font metadata (every line body-size) still must + // terminate a section at an unrecognized ALL-CAPS header via the text-pattern + // path (#163 sub-problem 2) — generalizing the boundary fix beyond coursework. + const sections = build([ + { text: "Jane Smith", fontSize: 11 }, // name (no font lift — flattened) + { text: "jane.smith@example.com | (312) 555-0123", fontSize: 11 }, + { text: "EXPERIENCE", fontSize: 11 }, + { text: "Engineer, Acme 01/2021 - Present", fontSize: 11 }, + { text: "• Shipped the billing rewrite handling 2M daily events", fontSize: 11 }, + { text: "VOLUNTEER WORK", fontSize: 11 }, // unknown ALL-CAPS header, body-size + { text: "• Mentored five first-generation students weekly", fontSize: 11 }, + ]); + + // The unknown ALL-CAPS header opened a boundary (the `other` sink — not a + // known keyword), so its content did not bleed into the experience entry. + const volunteer = sectionContaining(sections, "Mentored five"); + expect(volunteer).toBeDefined(); + expect(volunteer!.name).toBe("other"); + const experience = sectionContaining(sections, "Engineer, Acme"); + expect( + experience!.lines.some((l) => l.text.includes("Mentored five")), + ).toBe(false); + }); + + it("does NOT promote a body-size Title-Case job title via the text-pattern path", () => { + // The text-pattern fallback is ALL-CAPS only: a body-size Title-Case line + // ("Sr Software Engineer") is a job title / company / institution, never a + // section header — promoting it would strand the role beneath it. + const sections = build([ + { text: "Jane Smith", fontSize: 11 }, + { text: "jane.smith@example.com | (312) 555-0123", fontSize: 11 }, + { text: "EXPERIENCE", fontSize: 11 }, + { text: "Sr Software Engineer", fontSize: 11 }, // Title Case, body size + { text: "Acme Corp 01/2020 - Present", fontSize: 11 }, + { text: "• Built the deploy pipeline cutting release time by 40%", fontSize: 11 }, + ]); + + expect(names(sections).filter((n) => n === "other")).toHaveLength(0); + const exp = sectionContaining(sections, "Sr Software Engineer"); + expect(exp!.name).toBe("experience"); + expect(exp!.lines.some((l) => l.text.includes("Built the deploy"))).toBe( + true, + ); + }); +}); + /** * Section-count regression on a sample of real corpus fixtures (#112 AC). * diff --git a/src/lib/heuristics/sections.ts b/src/lib/heuristics/sections.ts index 53349e17..40b4e5f8 100644 --- a/src/lib/heuristics/sections.ts +++ b/src/lib/heuristics/sections.ts @@ -15,6 +15,7 @@ */ import type { PdfTextItem } from "./types.ts"; +import { mergeWrappedContinuations } from "./entry-blocks.ts"; import { matchSectionHeader, matchSectionAnchorToken, @@ -99,7 +100,15 @@ export function toSectionedResume( // `byName.get("skills")` byte-identical to the retired `skillsSectionLines`. const byName = new Map(); for (const section of sections) { - const lines = section.lines + // Fold wrapped-continuation lines (a long bullet that wrapped onto a + // second, marker-less line indented past the bullet marker) into the line + // they continue BEFORE flattening to strings — the x the fold needs is gone + // once these are trimmed text. This makes the string-level bullet pool + // (`extractBulletsFromLines`, which drops a glyph-less continuation as + // truncation) agree by construction with the merged + // `experience[]/projects[].description` the entry-block parser produces, for + // every section incl. untyped ones (volunteer/coursework). See #162. + const lines = mergeWrappedContinuations(section.lines) .map((l) => l.text.trim()) .filter((t) => t.length > 0); const existing = byName.get(section.name); @@ -183,6 +192,156 @@ export function orderItemsByColumn( return bands; } +// ── Localized multi-column reading-order reconstruction (#164) ─────────────── + +/** + * Minimum number of consecutive multi-column rows for a run to count as a real + * embedded multi-column band. One isolated multi-column row is the common + * single-column case — a header line with a right-aligned date rail, a + * "Title … dates" line — not a column block, so a single row never triggers + * the reorder. A genuine coursework/skills grid runs ≥2 rows deep. + */ +const MULTI_COLUMN_MIN_RUN_ROWS = 2; + +/** A row is "multi-column" when its x-sorted items carry a column-sized + * horizontal gap (the same `COLUMN_GAP_THRESHOLD` the line splitter uses). */ +function rowIsMultiColumn(row: PdfTextItem[]): boolean { + if (row.length < 2) return false; + const sorted = [...row].sort((a, b) => a.x - b.x); + for (let i = 1; i < sorted.length; i++) { + const prev = sorted[i - 1]; + const gap = sorted[i].x - (prev.x + prev.width); + if (gap > COLUMN_GAP_THRESHOLD) return true; + } + return false; +} + +/** + * Cluster a run's items into vertical columns by x-start. Sort the distinct + * x-starts ascending and cut a new column wherever the jump between adjacent + * starts exceeds `COLUMN_GAP_THRESHOLD`. A wrapped continuation (e.g. a course + * name's second line, indented a few points past its bullet marker) lands in + * the same column as its parent because its x sits inside that column's band, + * far from the next column's start. Returns the column-start x boundaries (the + * left edge of each column), ascending. + */ +function columnStartsForRun(run: PdfTextItem[]): number[] { + const xs = [...new Set(run.map((it) => it.x))].sort((a, b) => a - b); + const starts: number[] = []; + for (let i = 0; i < xs.length; i++) { + if (i === 0 || xs[i] - xs[i - 1] > COLUMN_GAP_THRESHOLD) starts.push(xs[i]); + } + return starts; +} + +/** Index of the column an item belongs to: the last column-start at or left of + * the item's x (continuations indented within a column band stay in it). */ +function columnIndexOf(x: number, starts: number[]): number { + let idx = 0; + for (let i = 0; i < starts.length; i++) { + if (x >= starts[i] - 0.5) idx = i; + else break; + } + return idx; +} + +/** + * Reorder the items of a single same-page band so that any *embedded* + * multi-column block (e.g. a 3-column "Relevant Coursework" grid sitting inside + * an otherwise single-column page) reads column-by-column instead of zig-zag + * row-by-row. + * + * Why here and not the page-level column probe: `detectColumnBoundaries` + * (`pdf-layout.ts`) is a *page-wide* vertical ink projection — it only fires + * when a gutter runs the full height of the page, so a localized few-row grid + * inside single-column body text is invisible to it (the body inks straight + * across the grid's gutters). This pass works at the item level over one band, + * detecting contiguous runs of column-split rows and emitting each run's items + * in column-major (left column top-to-bottom, then the next) order. Everything + * outside such a run passes through in its original order, so single-column + * input and already-banded page-level two-column input are untouched — within + * an `orderItemsByColumn` band there is only one column, hence no multi-column + * row and no run. + * + * Operates per page (a band is single-page after `orderItemsByColumn`, but the + * top-level rawText path groups all items at once, so guard on page anyway). + * Runs BEFORE line grouping / sectionizing / `mergeWrappedContinuations`, so + * those later passes see the corrected column order (#162 ordering constraint). + */ +function reorderEmbeddedColumns(items: PdfTextItem[]): PdfTextItem[] { + // Baseline line order (page-major, then y top-to-bottom, then x left-to-right) + // — what `groupLinesSingle` used to compute itself. We now own the ordering so + // a reordered multi-column run survives to line grouping; the single-column / + // already-banded case returns this sorted baseline unchanged. + const sorted = [...items].sort((a, b) => { + if (a.page !== b.page) return a.page - b.page; + if (Math.abs(a.y - b.y) > LINE_Y_EPS) return a.y - b.y; + return a.x - b.x; + }); + if (sorted.length < 2 * MULTI_COLUMN_MIN_RUN_ROWS) return sorted; + + const rows = groupItemsIntoRows(sorted); + const multi = rows.map(rowIsMultiColumn); + let changed = false; + const out: PdfTextItem[] = []; + for (let i = 0; i < rows.length; ) { + if (!multi[i]) { + out.push(...rows[i]); + i++; + continue; + } + // Extend a maximal run of consecutive multi-column rows, then either reorder + // it column-major or pass it through unchanged (run too short / one column). + let j = i; + while (j < rows.length && multi[j]) j++; + const reordered = reorderColumnRun(rows.slice(i, j)); + out.push(...reordered.items); + changed ||= reordered.changed; + i = j; + } + + return changed ? out : sorted; +} + +/** Group y-sorted items into rows: contiguous items sharing a page and baseline + * (within `LINE_Y_EPS`) form one row, so a run is a contiguous slice of rows. */ +function groupItemsIntoRows(sorted: PdfTextItem[]): PdfTextItem[][] { + const rows: PdfTextItem[][] = []; + for (const it of sorted) { + const last = rows[rows.length - 1]; + if ( + last && + last[0].page === it.page && + Math.abs(last[0].y - it.y) <= LINE_Y_EPS + ) { + last.push(it); + } else { + rows.push([it]); + } + } + return rows; +} + +/** Reorder one maximal run of multi-column rows into column-major order. Returns + * the run unchanged (`changed:false`) when it's too short to be a real grid or + * resolves to a single column; otherwise buckets items by column (each column + * top-to-bottom, since `runItems` already ascend by y) and emits column-major. */ +function reorderColumnRun(runRows: PdfTextItem[][]): { + items: PdfTextItem[]; + changed: boolean; +} { + const runItems = runRows.flat(); + const starts = + runRows.length < MULTI_COLUMN_MIN_RUN_ROWS + ? [] + : columnStartsForRun(runItems); + if (starts.length < 2) return { items: runItems, changed: false }; + + const buckets: PdfTextItem[][] = starts.map(() => []); + for (const it of runItems) buckets[columnIndexOf(it.x, starts)].push(it); + return { items: buckets.flat(), changed: true }; +} + // ── Line grouping ─────────────────────────────────────────────────────────── export function groupIntoLines( @@ -194,13 +353,18 @@ export function groupIntoLines( } /** Single-pass line grouping over one band of items (no column awareness). */ -function groupLinesSingle(items: PdfTextItem[]): PdfLine[] { - // Sort by page, then by y (top to bottom), then by x (left to right). - const sorted = [...items].sort((a, b) => { - if (a.page !== b.page) return a.page - b.page; - if (Math.abs(a.y - b.y) > LINE_Y_EPS) return a.y - b.y; - return a.x - b.x; - }); +function groupLinesSingle(bandItems: PdfTextItem[]): PdfLine[] { + // De-interleave any embedded multi-column block (e.g. a coursework grid) so + // its items read column-by-column before we cluster into lines (#164). A + // no-op for single-column input and for already-banded page-level two-column + // input — neither carries a multi-row column-split run within a band. + // `reorderEmbeddedColumns` returns items already in line order (page-major, + // y top-to-bottom, x left-to-right) — with any embedded multi-column run + // rewritten to column-major. We must NOT re-sort here: a global (y, x) sort + // would re-interleave the very columns we just de-zig-zagged. The streaming + // grouper below flushes on any y change, so it clusters this order correctly + // even where a column-major run jumps y backward at a column boundary. + const sorted = reorderEmbeddedColumns(bandItems); const lines: PdfLine[] = []; let current: PdfTextItem[] = []; @@ -294,23 +458,28 @@ export function mergeItemText(items: PdfTextItem[]): string { * `H2_RATIO` (1.25): a job title or company name rendered bold but only * slightly larger than body (≈1.05–1.15×) must NOT promote to a boundary, or it * would split mid-experience and strand every following role into the `other` - * sink. 1.2 clears the slightly-bold-title FP class while still catching the - * genuinely-larger invented-label headers ("Career Journey") this path exists - * to segment. + * sink. 1.15 clears the slightly-bold-title FP class (≈1.1× titles) while still + * catching the genuinely-larger invented-label headers ("Career Journey") this + * path exists to segment. + * + * Lowered 1.2 → 1.15 in #163: the Skia/Chrome renderer (Google Docs → PDF) + * flattens an h2 down to ≈1.09–1.18× body, so a real invented header can sit + * just under 1.2. 1.15 still sits safely above the pinned ≈1.1× bold-title FP + * (`sections.test.ts`), so no role-stranding regression — verified against the + * full corpus snapshot. * - * Font distinction is the SOLE visual signal here. The issue (#112) also listed - * `allCaps` as an alternative, but a full-corpus pass showed bare body-size - * all-caps is dominated by NON-headers a boundary must never open on: acronyms - * and skill tokens ("HTML", "CSS", "C++", "CI/CD"), inline values ("GPA: 3.5"), - * and two-column sidebar labels ("STRENGTHS", "Leadership") whose flattened - * position mid-document would strand every following role into the `other` - * sink — the same hazard that keeps `skills`/`other` out of the L2 anchor - * fallback. Genuine all-caps *section* headers ("OBJECTIVE", "EDUCATION", - * "VOLUNTEER EXPERIENCE") are already caught by the keyword/anchor path before - * the visual path runs, so the all-caps branch added only false positives and - * was dropped. See the L3 corpus regression notes on #112. + * Font distinction is the PRIMARY visual signal here, but not the only one: a + * font-metadata-independent text-pattern fallback (`isTextPatternHeader`, #163) + * runs alongside it for renderers that strip or flatten font size below even + * 1.15. The #112 note that bare body-size all-caps is dominated by NON-headers + * (single-token acronyms/skill tokens "HTML"/"CSS"/"C++", inline values + * "GPA: 3.5") still holds — so that fallback is tightly shaped (multi-word, + * clean, ALL CAPS only; see `isTextPatternHeader`) to exclude exactly those + * classes. Genuine all-caps *section* headers ("OBJECTIVE", "EDUCATION") are + * still caught by the keyword/anchor path first, before either visual branch + * runs. */ -const VISUAL_HEADER_FONT_RATIO = 1.2; +const VISUAL_HEADER_FONT_RATIO = 1.15; /** Max characters for a line to still read as a header (not a prose line). */ const VISUAL_HEADER_MAX_CHARS = 40; @@ -366,15 +535,95 @@ function isHeaderShort(text: string): boolean { } /** - * True when a line is *visually* a header: header-shaped (`isHeaderShort`) and - * meaningfully larger than the body baseline. This is the L3 fallback signal — - * it fires only after `matchSectionHeader` has already declined the line - * (keyword path), so a line passing this test opens a boundary-only `other` - * section (terminates the prior section without rendering). + * Max whitespace-separated words for the font-metadata-independent text-pattern + * header (#163). Slightly looser than the font path's `VISUAL_HEADER_MAX_WORDS` + * (4) because invented multi-word labels ("VOLUNTEER EXPERIENCE & SERVICE") + * run a touch longer than the qualifier+head-noun shape the anchor fallback + * targets; capped at 6 so a short prose fragment can't slip through. + */ +const TEXT_PATTERN_HEADER_MAX_WORDS = 6; + +/** + * Characters that mark a line as content rather than a bare section label: + * digits (dates / metrics / GPA), commas and pipes / mid-dots / dashes / slashes + * (company–location, "ACME CORP | REMOTE", "SEP 2024 - JULY 2025"), and colons + * (inline labels "GPA: 3.5"). A genuine invented header ("VOLUNTEER WORK", + * "ADDITIONAL INFORMATION") carries none of these. + */ +const TEXT_PATTERN_DIRTY_RE = /[0-9,:·|—–/]/; + +/** + * Font-metadata-independent header test (#163). Some renderers (Skia/Chrome via + * Google Docs → PDF) strip or flatten a section header's font-size lift so far + * it doesn't clear even the lowered `VISUAL_HEADER_FONT_RATIO` (1.15). This + * detects a header purely from text *shape* — independent of font size: a short + * (≤ `VISUAL_HEADER_MAX_CHARS` chars, 2–`TEXT_PATTERN_HEADER_MAX_WORDS` words), + * non-bullet, non-terminal-punctuation, ALL-CAPS line carrying none of the + * `TEXT_PATTERN_DIRTY_RE` content markers. + * + * ALL CAPS *only* — deliberately NOT Title Case. The #112 corpus pass showed + * Title-Case shape is dominated on the regex path by NON-header content a + * boundary must never split on: job titles ("Sr Software Engineer", "Staff + * Software Engineer"), company names ("Globex Corporation", "Acme Corp"), and + * institutions ("Springfield State University") — all Title Case, all rendered + * at or barely above body size, so neither a font-ratio floor nor a column gate + * separates them from a real flattened header (the coursework reproducers sit + * mid-band among them). Promoting any of them opens an `other` sink that strands + * the role/degree beneath it. ALL CAPS multi-word lines, by contrast, are + * reliably section labels in this corpus — the only all-caps clean ≥2-word + * non-keyword lines are institution names on the *markdown* path + * ("CORNELL UNIVERSITY"), which never reaches this splitter. So the title-cased + * "Relevant Coursework" reproducer is fixed by its `education` keyword alias + * (#163 sub-problem 1), and this path generalizes the boundary-termination to + * any unknown ALL-CAPS header a metadata-stripping renderer flattens. + * + * The remaining gates kill the FP classes the bare-all-caps #112 experiment + * tripped on: ≥ 2 words excludes single-token skill/acronym tokens ("HTML", + * "CSS", "C++", "PHP"); `TEXT_PATTERN_DIRTY_RE` excludes date / location-comma / + * separator / colon-bearing inline values ("GPA: 3.5"). + * + * Like the font path it only runs after `matchSectionHeader` declines the line, + * and (in `classifyLine`) only past the leading name/contact block — so a real + * header it fires on opens the boundary-only `other` sink, terminating the prior + * section. Verified zero-regression against the full corpus snapshot. + */ +function isTextPatternHeader(text: string): boolean { + const t = text.trim(); + if (t.length === 0 || t.length > VISUAL_HEADER_MAX_CHARS) return false; + if (VISUAL_BULLET_RE.test(t)) return false; + if (TERMINAL_PUNCT_RE.test(t)) return false; + if (TEXT_PATTERN_DIRTY_RE.test(t)) return false; + const words = t.split(/\s+/).filter((w) => w.length > 0); + // ≥ 2 words: a single token is a skill/acronym ("HTML", "GRADUATE"), not a + // section header — bare single-token all-caps is the FP class #112 dropped. + if (words.length < 2 || words.length > TEXT_PATTERN_HEADER_MAX_WORDS) { + return false; + } + return isAllCapsHeader(t); +} + +/** True when every letter-bearing char is uppercase (and at least one exists). */ +function isAllCapsHeader(t: string): boolean { + const letters = t.replace(/[^A-Za-z]/g, ""); + return letters.length > 0 && letters === letters.toUpperCase(); +} + +/** + * True when a line is *visually* a header. Two orthogonal signals, either of + * which qualifies (after `matchSectionHeader` has already declined the line, so + * a pass opens the boundary-only `other` sink that terminates the prior section): + * - font path: header-shaped (`isHeaderShort`) AND meaningfully larger than + * the body baseline (≥ `VISUAL_HEADER_FONT_RATIO`); or + * - text-pattern path (#163): font-metadata-independent — a short clean-shaped + * ALL-CAPS line (`isTextPatternHeader`), for renderers that flatten font + * size below the ratio gate. */ function isVisualHeader(line: PdfLine, bodyBaseline: number): boolean { - if (!isHeaderShort(line.text)) return false; - return line.maxFontSize >= bodyBaseline * VISUAL_HEADER_FONT_RATIO; + if (isHeaderShort(line.text) && + line.maxFontSize >= bodyBaseline * VISUAL_HEADER_FONT_RATIO) { + return true; + } + return isTextPatternHeader(line.text); } // Non-global clones of the contact REs for stateless boolean checks. The diff --git a/src/lib/score/group-bullets.test.ts b/src/lib/score/group-bullets.test.ts index f9d3a207..d58fe3df 100644 --- a/src/lib/score/group-bullets.test.ts +++ b/src/lib/score/group-bullets.test.ts @@ -1,6 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // Copyright 2026 The resumelint Authors +import { promises as fsp } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { describe, it, expect } from "vitest"; import { groupBulletsByExperience, @@ -9,6 +12,11 @@ import { type BulletExperience, } from "./group-bullets.ts"; import type { BulletObservation } from "./score.ts"; +import { runCascade } from "../heuristics/cascade.ts"; +import { computeAnonymousAtsScore } from "./score.ts"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURE_ROOT = join(HERE, "../../..", "tests/fixtures/pdfs"); // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -216,3 +224,79 @@ describe("formatExperienceHeader", () => { expect(formatExperienceHeader({})).toBe(""); }); }); + +// ── Wrapped-bullet pool + attribution (#162) ────────────────────────────────── + +describe("multi-line bullet pool is fully merged and correctly attributed (#162)", () => { + // A long bullet that wraps onto a marker-less second line used to be TRUNCATED + // in the per-bullet pool (`extractBulletsFromLines` drops the glyph-less + // continuation) and the truncated text then no longer matched the merged + // `projects[]/experience[].description`, so the bullet fell into "Other". + // Merging wrapped continuations upstream (`mergeWrappedContinuations` in + // `toSectionedResume`) makes the pool carry each bullet's full text, so it + // matches its role by construction. + it("recovers full wrapped-bullet text and attributes it to its role, not Other", async () => { + const bytes = await fsp.readFile( + join( + FIXTURE_ROOT, + "google-docs/google-docs-skia-proxy-multiline-bullets-coursework.pdf", + ), + ); + const cascade = await runCascade(new Uint8Array(bytes)); + const score = computeAnonymousAtsScore({ + parsed: cascade.parsed, + fieldConfidence: cascade.fieldConfidence, + triggers: cascade.triggers, + rawText: cascade.rawText, + sections: cascade.sections, + }); + const pool = score.bullets ?? []; + + // (1) The pool carries each previously-truncated bullet's FULL merged text — + // the wrap tail (after the marker-less second line) is present, not cut. + const poolText = pool.map((b) => b.text); + const fullText = [ + "Collected company revenue from past four years using data from 10-K and 10-Q filings across several reporting periods", + "Used five different forecasting methods including MA3, Weighted MA, Exponential Smoothing, Linear Trend, and TAF on deseasonalized and reseasonalized revenue data", + "Identified the most suitable method through a comparison of average forecasting error among all methods evaluated", + "Conducted tours for museum guests from a variety of backgrounds, explaining exhibits and informing them of available resources", + ]; + for (const t of fullText) { + expect(poolText).toContain(t); + } + + // (2) The merged project bullets attribute to their project entry — NOT the + // null "Other" group — through the same combined experience+projects + // array the reconstructed-resume UI feeds `groupBulletsByExperience`. + const toBE = ( + entries: ReadonlyArray<{ + title?: string; + name?: string; + description?: string; + start_date?: string; + end_date?: string; + is_current?: boolean; + }>, + ): BulletExperience[] => + entries.map((e) => ({ + title: e.title ?? e.name, + description: e.description, + start_date: e.start_date, + end_date: e.end_date, + is_current: e.is_current, + })); + const combined = [ + ...toBE(cascade.parsed.experience ?? []), + ...toBE(cascade.parsed.projects ?? []), + ]; + const groups = groupBulletsByExperience(pool, combined); + + const other = groups.find((g) => g.experienceIndex === null); + const otherText = new Set((other?.bullets ?? []).map((b) => b.text)); + // The Revenue-Forecasting project's three (now-merged) bullets land on a + // real project entry, not Other — the symptom the issue cited for [13]/[14]. + for (const t of fullText.slice(0, 3)) { + expect(otherText.has(t)).toBe(false); + } + }); +}); diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-additional-skills.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-additional-skills.expected.json index 1310e809..e9eb09f7 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-additional-skills.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-additional-skills.expected.json @@ -1,13 +1,13 @@ { "schemaVersion": 3, "cascade": { - "confidence": 0, + "confidence": 0.74, "triggers": [], "tiers": [ "t0_layout", "t1_openresume" ], - "suggestedEscalation": "ocr", + "suggestedEscalation": "llm", "fieldsPopulated": [ "current_company", "current_title", diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json index 64c49163..f055cae5 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.expected.json @@ -37,21 +37,21 @@ "sectionSource": "regex" }, "score": { - "overall": 58, - "preLayoutOverall": 58, + "overall": 72, + "preLayoutOverall": 72, "specificity": { - "score": 18, + "score": 26, "max": 40, "gradable": true, "metricBullets": 5, - "totalBullets": 18 + "totalBullets": 13 }, "structure": { - "score": 13, + "score": 19, "max": 30, "gradable": true, "goodBullets": 8, - "totalBullets": 18 + "totalBullets": 13 }, "completeness": { "score": 27, @@ -66,7 +66,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 18, + "bulletCount": 13, "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json index d67e0636..4d0a2549 100644 --- a/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json +++ b/tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-two-column.expected.json @@ -47,7 +47,7 @@ "score": 40, "max": 40, "gradable": true, - "metricBullets": 8, + "metricBullets": 9, "totalBullets": 13 }, "structure": { diff --git a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json index ef8725bc..b2867e97 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-cv.expected.json @@ -38,20 +38,20 @@ "sectionSource": "markdown" }, "score": { - "overall": 60, - "preLayoutOverall": 60, + "overall": 62, + "preLayoutOverall": 62, "specificity": { - "score": 13, + "score": 17, "max": 40, "gradable": true, - "metricBullets": 10, + "metricBullets": 13, "totalBullets": 52 }, "structure": { - "score": 20, + "score": 18, "max": 30, "gradable": true, - "goodBullets": 35, + "goodBullets": 32, "totalBullets": 52 }, "completeness": { diff --git a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json index b5bfa520..f8454573 100644 --- a/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json +++ b/tests/fixtures/pdfs/latex/awesome-cv-resume.expected.json @@ -38,20 +38,20 @@ "sectionSource": "markdown" }, "score": { - "overall": 68, - "preLayoutOverall": 68, + "overall": 71, + "preLayoutOverall": 71, "specificity": { - "score": 18, + "score": 24, "max": 40, "gradable": true, - "metricBullets": 8, + "metricBullets": 11, "totalBullets": 30 }, "structure": { - "score": 23, + "score": 20, "max": 30, "gradable": true, - "goodBullets": 23, + "goodBullets": 20, "totalBullets": 30 }, "completeness": { diff --git a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json index 13d31b6a..19f05f1a 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-macfonts.expected.json @@ -1,7 +1,7 @@ { "schemaVersion": 3, "cascade": { - "confidence": 0, + "confidence": 0.64, "triggers": [ "two_column" ], @@ -10,7 +10,7 @@ "t1_openresume", "t1_5_regex" ], - "suggestedEscalation": "ocr", + "suggestedEscalation": "ner", "fieldsPopulated": [ "current_company", "current_title", diff --git a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json index 13d31b6a..19f05f1a 100644 --- a/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json +++ b/tests/fixtures/pdfs/latex/deedy-resume-openfonts.expected.json @@ -1,7 +1,7 @@ { "schemaVersion": 3, "cascade": { - "confidence": 0, + "confidence": 0.64, "triggers": [ "two_column" ], @@ -10,7 +10,7 @@ "t1_openresume", "t1_5_regex" ], - "suggestedEscalation": "ocr", + "suggestedEscalation": "ner", "fieldsPopulated": [ "current_company", "current_title", diff --git a/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json b/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json index 9779ca8e..1e808ee4 100644 --- a/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json +++ b/tests/fixtures/pdfs/unknown/student-projects-activities-singlecol.expected.json @@ -37,21 +37,21 @@ "sectionSource": "regex" }, "score": { - "overall": 58, - "preLayoutOverall": 58, + "overall": 72, + "preLayoutOverall": 72, "specificity": { - "score": 18, + "score": 26, "max": 40, "gradable": true, "metricBullets": 5, - "totalBullets": 19 + "totalBullets": 13 }, "structure": { - "score": 13, + "score": 19, "max": 30, "gradable": true, "goodBullets": 8, - "totalBullets": 19 + "totalBullets": 13 }, "completeness": { "score": 27, @@ -66,7 +66,7 @@ "multiplier": 1, "scanned": false }, - "bulletCount": 19, + "bulletCount": 13, "algoVersion": "1.4" } } diff --git a/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json b/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json index 845b7552..29858591 100644 --- a/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json +++ b/tests/fixtures/pdfs/unknown/two-column-achievements-sidebar.expected.json @@ -38,13 +38,13 @@ "sectionSource": "regex" }, "score": { - "overall": 60, - "preLayoutOverall": 70, + "overall": 63, + "preLayoutOverall": 74, "specificity": { - "score": 25, + "score": 29, "max": 40, "gradable": true, - "metricBullets": 6, + "metricBullets": 7, "totalBullets": 16 }, "structure": { diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json index f9f917ee..5dc39b3a 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-classic.expected.json @@ -43,7 +43,7 @@ "score": 40, "max": 40, "gradable": true, - "metricBullets": 6, + "metricBullets": 8, "totalBullets": 8 }, "structure": { diff --git a/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json b/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json index 4cef7605..69c5c39e 100644 --- a/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json +++ b/tests/fixtures/pdfs/unknown/weasyprint-cairo-two-column.expected.json @@ -41,20 +41,20 @@ "sectionSource": "regex" }, "score": { - "overall": 60, - "preLayoutOverall": 70, + "overall": 74, + "preLayoutOverall": 87, "specificity": { - "score": 26, + "score": 40, "max": 40, "gradable": true, - "metricBullets": 5, + "metricBullets": 9, "totalBullets": 13 }, "structure": { - "score": 17, + "score": 20, "max": 30, "gradable": true, - "goodBullets": 8, + "goodBullets": 9, "totalBullets": 13 }, "completeness": {