Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 64 additions & 17 deletions src/lib/heuristics/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────

Expand All @@ -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 ───────────────────────────────────────
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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<string>([
"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)
);
}
77 changes: 76 additions & 1 deletion src/lib/heuristics/entry-blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
Expand Down
58 changes: 58 additions & 0 deletions src/lib/heuristics/entry-blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading