Skip to content

feat(citations): resolve and validate [n] markers - #114

Merged
mrsibe merged 4 commits into
mainfrom
feat/citation-validation
Sep 25, 2026
Merged

mrsibe merged 4 commits into
mainfrom
feat/citation-validation

Conversation

@mrsibe

@mrsibe mrsibe commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Stacked on #113 (#69). Merge that first; GitHub will retarget this to main.

What does this PR do?

Adds a deterministic, model-free resolveCitations(answer, contexts) service that maps an answer's [n] markers back to the citations produced during retrieval, and splits every marker into resolved / unresolved / misattributed. The chat path now persists only the grounded citations when the answer actually used markers.

Why?

Once the model is asked to emit [n], it can emit a marker with no corresponding source or attribute a quote to the wrong span. A research-grade citation feature has to treat both as failures rather than render a plausible-looking link. This is also the measurement point for citation precision in the eval harness (#75).

Related issue

Fixes #70

What changed?

  • src/shared/utils/citationResolution.ts — pure resolveCitations + normalizeForComparison. Returns matches, resolved, unresolved, misattributed and precision (resolved / total).
  • src/shared/types/citation.ts — CitationContext (citation + the span text it covers), CitationMatch, CitationResolution.
  • src/main/ipc/chatHandlers.ts — builds contexts from the locator's block text, resolves against the final answer, and persists the grounded subset in metadata.citations when markers are present. No markers → keep the full evidence set.
  • test/citationResolution.test.ts — grounded marker, fabricated [9], mis-attributed quote, plus case/whitespace normalisation, non-fuzzy matching, missing-span behaviour, precision and repeated markers.

Design notes

  • Validation is containment, not similarity. Case/whitespace-insensitive, and nothing beyond that: a quote that "roughly" matches is the exact failure being guarded against.
  • Unknown is not an error. If the span text is unavailable, the quote cannot be refuted, so the citation resolves rather than disappearing.
  • unresolved and misattributed are returned, not silently dropped, so precision is computable and the renderer ([Feat] Citation click → open source at page/block with highlight #72) can show the marker as plain text.

How was this tested?

  • npm run typecheck — passes.
  • npm test — 147 tests pass (9 new).
  • npm run build — passes.
  • Lint: no new warnings.

Screenshots / recordings

Not applicable (service + persistence; chips land in #72).

Checklist

  • I have reviewed my own changes.
  • npm run typecheck passes.
  • npm run build passes.
  • I have tested the affected user workflow.
  • I have not included unrelated changes.
  • I have updated documentation when necessary.

Desktop / build changes

  • Not applicable

…nswer

The prompt numbered its sources but threw the provenance away: by the time an
answer existed there was no page, block or span to point back at. `SearchResult`
already carries a `locator` (#74), so assemble it into a shared `Citation`
snapshot — document, page range, block id, char span and the retrieved quote —
and persist it under `chat_messages.metadata.citations`.

The citation is a snapshot, not a reference: re-indexing or deleting the source
later must not erase where the answer was grounded. A defensive reader
(`parseCitations`) drops malformed rows individually so one bad entry cannot hide
the rest.

The renderer's in-memory message never sees the DB row written before streaming,
so the persisted metadata also rides along on the `finish` event; otherwise an
answer would stay un-citable until the session was reloaded.

Refs #69
…rounded one

An answer asked to mark its sources with `[n]` can mark a source that was never
retrieved, or attribute a passage to the wrong span. A plausible link to the
wrong paragraph is worse than no link, so both are treated as first-class failure
modes.

`resolveCitations(answer, contexts)` is pure and deterministic — no model call —
and returns every marker split into `resolved` / `unresolved` / `misattributed`,
so citation precision is derivable for the eval harness (#75) and the UI can
choose to render a bad marker as plain text.

Validation is case/whitespace-insensitive containment of the quote in the cited
span and nothing more: fuzzy matching would let a fabricated quote through. A
missing span cannot refute the quote, so it is not treated as an error.

The chat handler resolves markers against the locator's block text once the
answer is final, and persists only the grounded citations when the answer used
markers at all. With no markers, the full evidence set is kept, because the model
simply did not use the convention.

Refs #70
@github-actions github-actions Bot added the enhancement New feature or request label Sep 25, 2026
@mrsibe
mrsibe changed the base branch from feat/structured-citations to main September 25, 2026 09:17
Resolve the shared type barrel: keep the citation (#69) and source reader (#71)
exports alongside the resolution types (#70).
Take the branch's citation.ts and chatHandlers.ts: origin/main has the #69 squash,
and this branch is #69 plus the #70 resolution work, so those two files are strict
supersets.
@mrsibe
mrsibe merged commit 2e18523 into main Sep 25, 2026
3 checks passed
@mrsibe
mrsibe deleted the feat/citation-validation branch September 25, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Resolve and validate citations; never render an ungrounded [n]

1 participant