Skip to content

feat(chat): chat_sources + inline citations (RAG answer grounding) - #643

Draft
mplatts wants to merge 2 commits into
mainfrom
feat/chat-sources
Draft

feat(chat): chat_sources + inline citations (RAG answer grounding)#643
mplatts wants to merge 2 commits into
mainfrom
feat/chat-sources

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

Closes #615

Stack position

1 of 4 in the 4.18.0 chat stack. This branch (feat/chat-sources) is cut from origin/main and is the base of the other three:

# Issue Branch Builds on
1 #615 chat_sources + citations feat/chat-sources main
2 #616 composer attachments feat/chat-attachments feat/chat-sources
3 #617 chat questionnaire feat/chat-questionnaire feat/chat-attachments
4 #618 tool_call states feat/chat-tool-call feat/chat-questionnaire

The PR is opened against main because this one genuinely sits on main, so the diff here is exactly this issue's work. Please merge in order 615 → 616 → 617 → 618 — the later PRs in the stack will show a cumulative diff until the ones below them land.

Summary

Answer grounding for the chat family. Prompt the model to cite with [^N] footnote markers, hand the same source maps to markdown/1, and the markers become numbered chips; chat_sources/1 renders the deduped source list underneath.

  • citation/1 — the inline chip. Superscript number, preview card (favicon, title, domain, snippet) on hover or focus, activating it opens the source in a new tab.
  • chat_sources/1 — native <details>/<summary>, matching reasoning/1's no-JS approach. Collapsed it reads "4 sources" with a stacked-favicon cluster. Deduped by URL, expanded opens it on render, max_visible tucks the overflow behind a nested "Show all (N)" reveal, and a nil/empty list renders nothing at all (no empty shell).
  • markdown/1 gains a sources attr; to_html/2 gains a :sources option for the streaming path.

Sources are plain maps — %{id, url, title, snippet, favicon_url}, string or atom keys both accepted. Everything but url is optional: no favicon_url falls back to a letter avatar, no snippet is just a shorter row.

How the marker → chip transform works

Option (b) from the issue: post-process the sanitized HTML. MDEx's footnote extension is not enabled here, so [^N] survives as literal text in the output, and splicing after sanitization means the chip markup can't be eaten by the sanitizer.

The output is walked as a tag/text token stream, so markers are only ever rewritten inside text nodes — never inside an attribute value, and never inside <pre>/<code> (a [^1] in a code sample stays a code sample). The chip markup is minted from the numeric index plus Phoenix.HTML.html_escaped source fields, so nothing model-controlled reaches the page as live markup. Only complete markers match, so a half-streamed [^ is left untouched until its closing bracket arrives.

A marker resolves to the source whose id matches N, falling back to the Nth source, so an id-less list still works positionally.

Deviations from the issue's sketch

  1. The chip is an <a>, not a <button>. The issue's a11y section says "real <button>s ... never bare <sup> text". The intent — a real interactive element carrying an accessible name — is met, but the action is "open this URL in a new tab", which is an anchor's job. As a <button> it would need JS to navigate; as an <a href target="_blank" rel="noopener noreferrer"> it works with zero JS, gets middle-click/copy-link for free, and matches external_links/1 in the same file. Happy to flip it if you'd rather hold the line on <button>.

  2. No popover / hover_card machinery — the preview card is pure CSS. hover_card/1 hasn't landed, and composing PetalPopover would have meant a hook and an id-per-chip for aria-controls, on markup that is spliced into a raw HTML string with no assigns to generate ids from. The card is a sibling <span> shown on :hover / :focus-within of the wrapper. This is the CSS-first house rule, and it keeps the "no new positioning system" non-goal honest. Trade-off: no flip/collision handling — the card is fixed above-left of the chip.

  3. The preview card is aria-hidden="true". It is a visual affordance whose title and domain are already in the chip's accessible name, and whose snippet is reachable in the chat_sources list below. This avoids inventing ids for aria-describedby inside spliced HTML and avoids a screen reader reading the same title twice per chip.

  4. to_html/2 rather than a separate functiondef to_html(content, opts \\ []), so existing to_html/1 call sites are untouched.

Backward compatibility

No existing test was modified or deleted, and no existing attr default changed. Every pre-existing chat test passes unmodified.

  • markdown/1 without sources (the default, nil) is byte-identical to before — there's an explicit test for it ("without sources the marker passes through as plain text (today's behaviour)").
  • to_html/1 still exists and behaves exactly as it did (tested).
  • citation/1 and chat_sources/1 are net-new function names. Chat is not pulled in by use PetalComponents, so neither can collide with a consumer's helpers.
  • CSS is appended in a new @layer components { } block at the tail of assets/default.css — nothing above it reflowed, and being inside a layer means consumer utilities passed via class still win.

Verification

Check Result
mix format --check-formatted clean
mix compile --force --warnings-as-errors clean
mix credo 14 refactoring / 31 readability — identical to origin/main, zero new entries
mix test 940 tests, 0 failures, 1 skipped (baseline 913 / 0 / 1 — +27 new)
npm test 157 passed (baseline 157 — no JS touched)

Playground

Extended the existing AI Chat page (slug chat) — no new nav slug.

  • The seeded thread now carries a mocked RAG answer citing 4 sources with [^N] markers, with the chat_sources row underneath.
  • A "Citations while streaming" section: each tick re-renders the growing buffer through to_html(buffer, sources: sources) and pushes it at a format="markdown" streaming_text, so chips light up as their markers complete.
  • Dials: sources expanded (closed / open) and max_visible (2 / 5 / all).

Verified light + dark, keyboard-only (Tab reaches a chip, the preview card opens on focus, Enter opens the source), and the streaming demo end to end.

Accessibility

  • Chips are real links with aria-label="Source N: {title}" — the number alone is never the accessible name.
  • Source links: target="_blank" + rel="noopener noreferrer".
  • Favicons are decorative (alt="", aria-hidden="true", loading="lazy").
  • The sources row uses native <summary> semantics — no ARIA needed.
  • focus-visible rings in primary on the chip, the summary and each source link. No persistent :focus fills.
  • prefers-reduced-motion collapses the card reveal and chevron rotation transitions.

Adds the answer-grounding pattern to the chat family. Prompt the model to
cite with [^N] footnote markers, hand the same source maps to markdown/1,
and the markers become numbered chips; chat_sources/1 renders the deduped
source list underneath.

- citation/1: an inline chip. A real <a> (opens the source in a new tab
  with no JS) carrying aria-label="Source N: {title}". The preview card is
  pure CSS on :hover / :focus-within and marked aria-hidden, since its
  content is already in the accessible name and the sources list.
- chat_sources/1: native <details>/<summary>, matching reasoning/1's
  no-JS approach. Deduped by URL, `expanded` opens on render,
  `max_visible` tucks the overflow behind a nested "Show all" reveal,
  and a nil/empty list renders nothing at all.
- markdown/1 gains a `sources` attr; to_html/2 gains a `:sources` option
  for the streaming path.

Marker splicing runs on the already-sanitized MDEx output, walking it as a
tag/text token stream so markers are only rewritten in text nodes, never
inside attributes or <pre>/<code>. Chip markup is minted from the numeric
index plus escaped source fields, so model-controlled text can never reach
the page as live markup. Only complete markers match, so a half-streamed
"[^" is left alone until its closing bracket arrives.

No new dependencies, no new JS hook, no changes to any existing attr
default or test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.00000% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.46%. Comparing base (871b2cf) to head (16401dc).

Files with missing lines Patch % Lines
lib/petal_components/chat.ex 95.29% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #643      +/-   ##
==========================================
+ Coverage   92.40%   92.46%   +0.06%     
==========================================
  Files         119      119              
  Lines        5066     5164      +98     
==========================================
+ Hits         4681     4775      +94     
- Misses        385      389       +4     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Citation card and sources expanded

Citation card
Sources expanded, dark

Streaming

Streaming with citations

Light / dark

Chat sources, light
Chat sources, dark

Verified independently

Check Result
mix test 940 tests, 0 failures, 1 skipped (+27)
npm test 157 passing, unchanged
mix format / compile -Werror clean
mix credo zero new entries vs main (the chat.ex nesting finding shifts 357 → 416 as lines are added above it)
new dependencies none
new nav slugs none — correct, extends the existing chat page
existing chat tests zero deletions
CSS inside @layer confirmed

Stack position: base of the chat stack. Merge order is #643#644#646#618.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

…rd, house focus ring

Audit round on #615. (1) Source urls reached `href` unvalidated. Chips are
spliced into the HTML *after* MDEx sanitizes it, so the sanitizer never sees
them and a `javascript:` url from the retrieval layer became a live link in
both the marker-splice path and `chat_sources`. `safe_url/1` is default-deny:
only an explicit http/https scheme (after trimming, so whitespace-smuggled
schemes fall through) reaches an href, and anything else still renders the
chip and the row, just link-less. Tested in both paths.

(2) The preview card was `pointer-events-none`, so it could be seen but never
hovered - WCAG 1.4.13 wants that content hoverable. `visibility: hidden`
already keeps the closed card out of hit-testing, so the reset was pure
breakage; dropping it plus a 0.25rem bridge pseudo-element lets the pointer
travel from chip to card to read a long snippet. The card was also a fixed
`w-56` pinned `left-0`, which spilled a whole card's width past the right
edge of a narrow bubble; it is now `w-max` centred on the chip and clamped to
`min(14rem, 100vw - 2rem)`, so worst case is half a card and never wider than
the viewport. Escape dismissal and flip-on-collision still need real
positioning machinery - left alone deliberately, see the PR thread.

(3) The playground explainer documented Tab/Enter and stayed quiet about
dismissal, which read as if the brief's "Escape closes" had shipped. It now
says plainly how the card dismisses, that it is hoverable, and that non-http
urls render without a link.

(4) Focus rings: these four rules used `outline-2 outline-offset-2
outline-primary-500`, which appears nowhere else in default.css - the library
has ~36 uses of `ring-2 ring-primary-500/50`. Switched to the house idiom
(the rest of the chat stack does the same in its own patches).

(5) Two dead bits of CSS surface: `pc-chat__citation-outer` was emitted with
no rule anywhere (the class attr now carries just the consumer's class), and
`.pc-chat__citation-num { top: 0 }` did nothing on a statically positioned
`<sup>`.

(6) Coverage for two behaviours the brief lists that were already correct but
unpinned: a `[^1]` inside inline backticks stays literal (only the fenced
`<pre><code>` path was tested), and two markers on the same url keep their own
chip numbers while `chat_sources` dedupes to one row.

944 tests, 0 failures, 1 skipped (940 before).
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.

Enhancement: Chat — chat_sources + inline citations (RAG answer grounding)

2 participants