Skip to content

feat: citation content - #318

Open
cpsievert wants to merge 2 commits into
mainfrom
feat/citation-content-model
Open

feat: citation content#318
cpsievert wants to merge 2 commits into
mainfrom
feat/citation-content-model

Conversation

@cpsievert

@cpsievert cpsievert commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Overview

When a model answers using web search or fetch tools, chatlas now surfaces which
sources back the answer
and which words each source grounds, normalized across
OpenAI, Anthropic, and Google. Grounded answers carry their citations through to
turns uniformly — both progressively during streaming and on the final turn.

Motivation

Citation information used to be dropped: OpenAI's citation annotations were discarded,
Anthropic's citation deltas never reached normalized content, and Google's grounding
metadata was ignored entirely. Each provider exposed this differently (or not at all),
so there was no portable way to build a UI that shows sources. This branch gives
chatlas one citation model so a downstream consumer (e.g. shinychat) can render
footnote markers and source lists without touching raw provider payloads.

The model is deliberately shaped to be general-ready: web search is the first
consumer, but the same ContentCitation extends to file / document / RAG citations
(uploaded files, file_search, etc.) by adding new Source subclasses — no redesign
of ContentCitation required when that work lands.

Public API

  • ContentCitation — a first-class content type representing a source that grounds
    part of the assistant's answer:
    • source — the evidence it points to, a typed Source (see below), or None when
      a citation grounds answer text with no resolvable source.
    • grounded_text — the answer-side span the citation grounds (the words a footnote
      marker attaches to / the highlightable span).
    • cited_text — the source-side evidence quote, when the provider supplies one
      (populated for ChatAnthropic() web search today).
    • extra — remaining provider-specific payload.
  • Source — the identity of a piece of evidence. It is a small hierarchy dispatched
    on type; today the one concrete member is WebSource (type="web", url,
    optional title). File / document / RAG variants slot in later as additional
    subclasses.
  • ContentCitation, Source, and WebSource are all exported from chatlas.types.

Behavior

  • ContentCitation items sit in a turn's contents list immediately after the text
    they ground, in stream order — position and grounded_text together place a citation
    precisely.
  • Under streaming (content="all"), citations arrive interleaved with text
    (OpenAI/Anthropic) or at stream end (Google). The final turn carries the same items in
    the same order, so streaming and replay have one shape.
  • grounded_text is normalized to one meaning across providers — the resolved answer
    span, carried as a string (not offsets), sidestepping Google's byte- vs. OpenAI/
    Anthropic's character-offset mismatch.
  • Web fetch results also carry a status field.

Breaking change

  • ContentToolResponseSearch.urls (list[str]) is replaced by .sources
    (list[WebSource], each carrying url/title). Migrate x.urls
    [s.url for s in x.sources]. (This content type is unreleased, so the blast radius is
    effectively nil.)

Notes for reviewers

  • Per-provider wiring lives in each _provider_*.py's stream_content() and
    _as_turn(). All three filter ContentCitation out of turn serialization (it's
    client-side metadata, not sent back to APIs).
  • Source is a pydantic base class rather than a discriminated union (only one variant
    today). A create_source(data) factory + @field_validator(mode="before") hooks +
    SerializeAsAny on the source-typed fields round-trip the concrete subclass through
    model_dump()/model_validate(). This upgrades cleanly to a real discriminated union
    once a second variant exists.
  • The Google path is greenfield — grounding and url-context metadata were previously
    unsurfaced.

Test plan

  • uv run pyright — 0 errors (full package)
  • ruff check + ruff format --check — clean
  • Content model unit tests: Source/WebSource, nested-source ContentCitation,
    cited_text, round-trip rebuilds WebSource (not a bare Source), exports
  • Per-provider web search/fetch tests assert ContentCitation (with source.url)
    in both streaming and final turns; Anthropic additionally asserts cited_text;
    all replay VCR cassettes offline
  • Reviewer: confirm full make check in CI

@cpsievert
cpsievert marked this pull request as draft June 3, 2026 19:02
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from 10560f5 to 9a944ca Compare June 12, 2026 16:28
Comment thread chatlas/_chat.py Outdated
@cpsievert cpsievert changed the title Surface web search & fetch citations across providers Surface web search/fetch citations across all providers Jun 12, 2026
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from e1ef76b to 172147e Compare June 12, 2026 20:51
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from 3a1eb82 to 079dbb5 Compare July 27, 2026 23:16
@cpsievert cpsievert changed the title Surface web search/fetch citations across all providers Structured citation model across all providers Jul 27, 2026
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from 757035a to 8a5c24f Compare July 28, 2026 14:03
@cpsievert cpsievert changed the title Structured citation model across all providers feat: citation content Jul 28, 2026
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch 2 times, most recently from 286c855 to bcfe6c4 Compare July 28, 2026 18:39
@cpsievert cpsievert changed the title feat: citation content Surface web search citations as structured ContentCitation content Jul 28, 2026
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from 1d27ff4 to fc25cce Compare July 28, 2026 19:00
@cpsievert cpsievert changed the title Surface web search citations as structured ContentCitation content feat: citation content Jul 28, 2026
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from fc25cce to 4c0ee3a Compare July 28, 2026 19:16
@cpsievert
cpsievert changed the base branch from main to feat/google-builtin-tool-content July 28, 2026 19:16
@cpsievert
cpsievert force-pushed the feat/google-builtin-tool-content branch from c666439 to 8df0ecc Compare July 28, 2026 19:18
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from 4c0ee3a to 65515bd Compare July 28, 2026 19:19
@cpsievert
cpsievert force-pushed the feat/google-builtin-tool-content branch from 8df0ecc to 79ec639 Compare July 28, 2026 19:23
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch 2 times, most recently from d1f776d to 1fce4b0 Compare July 28, 2026 19:28
@cpsievert
cpsievert changed the base branch from feat/google-builtin-tool-content to main July 28, 2026 19:28
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from 1fce4b0 to c04ff2e Compare July 28, 2026 19:29
Providers that support web search now emit ContentCitation content
linking a span of the assistant's answer back to the source that
grounded it. Citations arrive during streaming as well as on the
completed turn.

- ContentCitation carries a typed Source (currently WebSource: url +
  title), the grounded_text it supports, and the provider's cited_text
- ContentToolResponseSearch.urls becomes .sources (list[WebSource]), so
  results keep their titles rather than bare URLs
- ContentToolResponseFetch gains status
- Google now surfaces web search/fetch requests and results, which it
  previously dropped
@cpsievert
cpsievert force-pushed the feat/citation-content-model branch from c04ff2e to 6872a54 Compare July 28, 2026 19:32
@cpsievert
cpsievert requested a review from Copilot July 28, 2026 21:40
@cpsievert
cpsievert marked this pull request as ready for review July 28, 2026 21:40

This comment was marked as resolved.

A Gemini grounding chunk without a web URI (e.g. `retrieved_context`)
still grounds a span of the answer, but `google_grounding_citations()`
discarded it. Anthropic already emits `source=None` for document
citations, so Google was the odd one out, and `ContentCitation.source`
documents itself as optional for exactly this case.

The old code conflated two reasons a source can be missing. An
out-of-bounds `grounding_chunk_indices` entry is still skipped — that
one is a provider bug, and inventing a citation from it is worse than
dropping it — while a URL-less chunk now yields a citation with no
source.

Also stop the CHANGELOG from implying that a final-turn citation's
position pins it to a span within the accumulated `ContentText`; it
doesn't, and `grounded_text` is what does.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants