Parser Hardening — Design Spec
Source: plans/step-5.1.2-cv-parser-hardening-design.md · synced 2026-06-12
Date: 2026-05-20
Phase: A — Underlying logic. 9-step coverage: Step 5a hardening.
Status: ✅ Shipped on feat/parser-hardening branch.
Depends on: Existing CV parser in calibre-app/src/lib/cv-parser/
Note (2026-05-25): Reference set is now ~107 CVs across 5 sub-functions. Hardening logic itself is unchanged but a re-batch across the full set is pending (see
context/current-data.md§“Parser Calibration Audit”). “JD-side parsing — Sprint 4” comment in this doc is overtaken — JD work sits in Phase A Sprint 2–3 per SOURCE-OF-TRUTH.md §7.
Context
A 7-CV calibration audit (2026-05-20) found that the parser’s content is accurate but its confidence scores are not honest above ~0.92. Specifically:
- Hallucinated list items at high confidence. Adrian Tan flagged with “LNG” specialisation (0.88) when LNG appears nowhere in his CV. Jonathan Lam flagged with “Crude Oil” (0.95) when source only mentions “crude” as a market reference, not a specialisation.
- Stale CVs treated as current. Jonathan Lam’s CV ended Jan 2019 but the parser called SOCAR his
current_rolewith 1.0 confidence — the CV is 7 years out of date. - Row fabrication. Rachel Seah’s single Concordia tenure split into 3 rows with invented date boundaries at 0.95 confidence.
These errors would compound into the title normaliser (Sprint 1) and Apollo sourcing (Sprint 2). Cleanest fix: deterministic post-checks the model cannot fool.
Architectural Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Where to run checks | Post-processing pass on ParsedCV after Claude returns | No second API call. No prompt complexity. Deterministic, fast, free. |
| Schema impact | None — confidence values get clamped, parse_metadata extended | Backwards compatible. Existing 66 JSONs can be re-processed in place. |
| Source text retention | Pass raw text through parseCVText → grounder | Grounder needs the source. Add a third arg, default to skip if unavailable. |
| Action on ungrounded item | Cap the field’s confidence to 0.5, record the offending items | Field-level cap is the right granularity — losing one item shouldn’t void neighbouring honest ones. |
| Action on stale CV | Re-classify: most-recent role becomes last_role, current_role nullified, stale_cv: true in metadata | Honest representation: this candidate has no current employer. |
| Stale threshold | 12 months since most recent end_date | One year is a defensible recency standard for active candidates. |
| Re-batch | Re-run all 66 CVs after changes land | Lets us verify the hardening catches the known hallucinations. |
Check 1: Source-grounding for list fields
Fields checked
signals.commodity_specialisations.valuesignals.certifications.valuesignals.tools_and_systems.value
Grounding rule
For each item:
- Lowercase the item and the raw source text.
- Tokenise the item: split on whitespace and punctuation, keep tokens of ≥4 characters that are not stop words (
the,and,for,with,from,into). - An item is grounded if all significant tokens appear as substrings in the source text.
- Two-letter and three-letter acronyms (e.g.
LNG,LPG,FRM,VBA,SAP) bypass the 4-char threshold and must appear as whole-word matches (\bLNG\b).
Action on ungrounded items
- Item is kept in the value array (don’t silently delete; keep visible for review).
- The containing field’s
confidenceis clamped tomin(current, 0.5). - An entry is appended to
parse_metadata.ungrounded_items:{ field: "commodity_specialisations", item: "LNG" }.
Why not delete?
Deleting hides the model’s behaviour. Keeping the item with capped confidence makes the failure auditable — useful for the grant audit trail.
Trade-offs
- Risk: too strict. “Crack Spread Products” might fail if source says “Crack Products.” Mitigation: the grounder is informational at first (not a delete), so we can adjust thresholds after seeing what gets flagged.
- Risk: too lenient. “Oil” tokenises to a single 3-char acronym, won’t be checked → matches almost every CV. The 4-char floor + acronym whole-word rule blocks this.
Check 2: Stale-CV detection
Logic
- Look at
current_role.end_date.valueand everyemployment_history[].end_date.value. - If
current_role.end_date.valueisnull→ not stale, return. - Otherwise find the most recent
end_dateacross current_role and history. - Compare against
cutoff = today - 12 months. - If
max_end_date < cutoff→ stale:- Set
parse_metadata.stale_cv = true. - Set
parse_metadata.last_role_end = <max_end_date>for downstream consumers. - Leave
current_rolepopulated (so downstream can still read the role) but document the staleness in metadata.
- Set
Why not null current_role?
The title normaliser needs a role to classify against. Better: keep the role but stamp the metadata flag. Downstream consumers (UI, normaliser, outreach) read stale_cv and decide.
Trade-offs
- Edge case: deliberately omitted end_date. Some candidates leave their current job’s end_date blank. If the parser correctly nulled it, stale detection won’t trigger — good. If the parser mistakenly filled it in, this catches it.
- Edge case: career break. Someone on maternity/sabbatical might have a 2-year gap. False positive risk. Mitigation:
stale_cvis informational; downstream can choose to surface or ignore.
Check 3: Row-fabrication detection (deferred)
Rachel Seah’s split into 3 Concordia rows is harder to catch deterministically — requires understanding the source’s narrative structure. Two options:
- Prompt change (cheap): Add to the parser system prompt: “Do not split a continuous tenure at one employer into multiple rows unless the source explicitly lists separate role-change dates.”
- Detection pass (expensive): Compare consecutive rows with the same employer; if dates abut (no gap) and the source text has only one tenure block for that employer, flag.
Decision: Apply the prompt change in this pass. Detection pass deferred — wait to see if the prompt change alone is sufficient on the next batch run.
File Map
| File | Change |
|---|---|
src/lib/cv-parser/grounding.ts | New module — checkGrounding(cv, sourceText): { clampedCV, ungroundedItems } |
src/lib/cv-parser/staleness.ts | New module — checkStaleness(cv, now?): { staleCV, lastRoleEnd } |
src/lib/cv-parser/parser.ts | Modify parseCVText to call grounding + staleness, update parse_metadata. Tighten system prompt. |
src/lib/cv-parser/types.ts | Extend ParseMetadata with ungrounded_items, stale_cv, last_role_end |
src/lib/cv-parser/index.ts | Pass extraction.text to parseCVText so grounder has source |
tests/lib/cv-parser/grounding.test.ts | Test grounder on synthetic inputs + the 3 known hallucinations |
tests/lib/cv-parser/staleness.test.ts | Test stale detection on Jonathan Lam fixture + a current-employed control |
scripts/batch-parse.ts | No change needed — re-runs as before |
Verification Plan
After changes land, re-run npm run batch-parse and check:
- Adrian Tan:
commodity_specialisationsconfidence is now ≤0.5, “LNG” appears inungrounded_items. - Jonathan Lam:
parse_metadata.stale_cv === true, “Crude Oil” appears inungrounded_items(or is correctly grounded if Marketscan write-up text contains “crude”), confidence ≤0.5 on commodities. - Sim Qi Xuan: no regressions — should still parse honestly at ~0.89.
- Rachel Seah: re-check whether prompt change reduces the 3-row fabrication. If still present, log as known issue and decide on detection pass next session.
- Aggregate:
parse_status="partial"count should rise (from 0) to reflect ungrounded items. That’s a feature, not a bug — partial means “humans should look at this.”
What This Plan Does NOT Cover
- Re-prompt to ask Claude for cleaner output (deterministic > prompt-engineering).
- LinkedIn URL validation — out of scope.
- JD-side parsing — Sprint 4.
- Title normaliser changes — Sprint 1, next plan.
Next Step
If approved: invoke writing-plans to produce the step-by-step implementation plan, then executing-plans to implement.