Skip to content

fix(heuristics): coursework continuation loop over-consumes acronym schools + trailing prose into prior course #184

Description

@s-annam

Problem

The relevant-coursework continuation loop in extractEducation over-consumes lines. When a bullet course item is followed by non-bullet lines, the loop greedily joins every following line into the current course until it hits a bullet, a degree, an institution hint, or a date-only line — and marks each joined line consumed, removing it from entry detection.

That stop condition is too loose. Two real-résumé inputs slip through and get wrongly swallowed into the previous course item (and lost from the parsed entry):

  1. Acronym-only / hint-less schools. INSTITUTION_HINTS only matches University|College|Institute|School|Academy|Polytechnic (src/lib/heuristics/regex.ts:289). Schools like MIT, UC Berkeley, or Stanford don't match, so in a School / Degree ordering the school line gets joined into the trailing course of the prior entry and consumed — that entry then loses its institution.
  2. Trailing prose. Lines like GPA: 3.8 or Minor in Economics match none of the stop tests, so they get appended onto the last course title and consumed.

Reported by Samhit in the #resumelint Slack as a follow-up to the coursework parse work (PR #165, and #169 which shipped via #173).

Affected code

src/lib/heuristics/extract/education.ts:216-238 — the coursework recovery loop:

for (let i = 0; i < ls.length; i++) {
  if (!isBulletLine(ls[i])) continue;
  let item = stripBullet(ls[i].text);
  const span = [i];
  let j = i + 1;
  while (
    j < ls.length &&
    !isBulletLine(ls[j]) &&
    !DEGREE_RE.test(ls[j].text) &&
    !INSTITUTION_HINTS.test(ls[j].text) &&
    !isDateOnlyLine(ls[j].text)
  ) {
    item += ` ${ls[j].text.trim()}`;   // ← joins ANY non-matching line
    span.push(j);
    j++;
  }
  item = item.trim();
  if (/^[A-Z0-9]/.test(item)) {
    coursework.push(item);
    for (const k of span) consumed.add(k);
  }
  i = j - 1;
}

Relevant helpers: DEGREE_RE / INSTITUTION_HINTS (src/lib/heuristics/regex.ts:286-290), isDateOnlyLine (education.ts:136), isBulletLine / stripBullet (imported from ../shared-side helpers).

Root cause

The continuation logic is deny-list driven (join unless it looks like a degree/institution/date/bullet). The deny-list is incomplete: it can't see hint-less schools or prose notes. The fix is to make the join opt-in — only absorb a line that actually looks like a wrapped continuation of the bullet — and/or cap the run length.

Proposed fix

Tighten the continuation stop condition. Either approach (or both) is acceptable:

  1. Cap at one continuation line. A wrapped grid cell almost never spills past one line. Limit the inner loop to a single absorbed line (span.length <= 2). This alone prevents runaway swallowing of a whole following entry.
  2. Only join lines that look like a wrap. Add a positive looksLikeWrap(line) guard before absorbing: the continuation should read as a sentence fragment (leading lowercase word, or no strong "new field" signal). Reject lines that look like a standalone field:
    • all-caps / Title-Case short tokens that could be an acronym school (e.g. MIT, UC Berkeley),
    • lines matching a GPA[:\s] / ^Minor\b / ^Major\b style label,
    • lines that are themselves Title-Case headers.

Recommended: implement (1) as the hard cap and layer (2)'s reject-patterns so a single trailing prose line (GPA: 3.8) is still not absorbed. Keep the existing /^[A-Z0-9]/ course-title acceptance guard unchanged.

Step-by-step

  1. In education.ts, extract a small helper isCourseworkContinuation(line: string): boolean that returns false for: acronym/Title-Case standalone tokens, GPA/Minor/Major prose labels, and any line already caught by DEGREE_RE/INSTITUTION_HINTS/isDateOnlyLine/isBulletLine.
  2. Replace the inner while condition to require isCourseworkContinuation(ls[j].text) and cap absorption at one line (j - i <= 1 before the increment, i.e. at most one continuation appended).
  3. Leave the consumed bookkeeping and entry-grouping below (education.ts:240+) untouched — once the loop stops over-consuming, the school/prose lines flow back into entry detection automatically.

Acceptance criteria

  • A School / Degree ordered education section where the school is acronym-only (MIT, UC Berkeley) and the prior entry ends in a coursework bullet parses the acronym school as the institution of its own entry — it is not appended to the prior course nor dropped.
  • A coursework block followed by GPA: 3.8 (or Minor in Economics) does not absorb that prose into the last course title; the course list ends at the real last course.
  • Genuine wrapped cells (e.g. ● Global Dimensions of + Business) still merge into one coursework item — the existing multi-column wrap behavior is preserved.
  • New unit test added covering both regressions (acronym-school-after-coursework, and trailing-prose-after-coursework). Place alongside the existing heuristics extract tests (e.g. a new src/lib/heuristics/extract/education.test.ts, mirroring experience.role-comma.test.ts).
  • npm run test green (full corpus snapshot diff reviewed — any *.expected.json change is an intended improvement, not a regression).
  • npm run typecheck and npm run lint clean.

Notes

  • The corpus fixture tests/fixtures/pdfs/google-docs/google-docs-skia-proxy-multiline-bullets-coursework.pdf exercises the wrap path — verify its snapshot does not regress, and consider a new fixture only if a unit test can't reproduce the School/Degree-acronym case cleanly.
  • Scope is the coursework recovery loop only; do not touch the entry-grouping or scoring logic below it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingimprovementEnhancing existing functionality

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions