diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7b0c1..78a8002 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,47 @@ ### Added +- Design for the chat redesign + (`docs/superpowers/specs/2026-09-26-chat-redesign-design.md`), with the + approved mockups in `docs/design/chat-redesign/`. It covers: + - streaming answers; + - numbered citation seals that open a Sources panel with brief, credited + quotes; + - renaming and searching threads; + - a dark theme; + - keyboard shortcuts; + - a phone layout. + + The work splits into a backend PR and a frontend PR. The implementation plan + is in `docs/superpowers/plans/2026-09-26-chat-redesign.md`. + +- `PATCH /api/chat/threads/` renames a thread (1–200 characters, trimmed; + 30 per minute). It leaves `updated_at`, and so the list order, alone. +- Each citation now carries its id, and a SHA-256 of the passage text it used + (`message_sources.content_sha256`, migration `9c1e4b7a2d10`). Older citations + keep NULL. +- `src/services/passages.py` turns a citation into a brief quote: at most 300 + characters, from the part of the passage most like the answer, with its kind, + title, document, section, a safe `https` link and, for Wikipedia, the + registry's CC BY-SA credit. `registry.registered(id)` reads one entry. +- `GET /api/chat/sources/` returns one of your citations as a brief quote, + with status `ok`, `unverified` (older answers, no hash), `changed` or + `missing` (60 per minute). It is addressed by citation id, never chunk id; + another user's citation is a 404. +- Generation is an event stream: `answer_events` and `answer_query_events` yield + stages, notices, text deltas, `Reset` and a final `Done`. Text streams only + once the answer is grounded, and a closed stream closes the model's. + `answer()` and `answer_query()` are unchanged wrappers. +- `POST /api/chat/threads//messages/stream` streams an answer as + Server-Sent Events (`user_message`, `stage`, `notice`, `delta`, `reset`, + `done`, `error`). Errors before the stream are ordinary responses; the + question is committed first, the answer is saved before `done`, and spend is + recorded exactly once, also when the client goes away. It shares one + 15-per-minute limit with the JSON send route (`scope="chat_send"`). +- ADR 0027: streaming answers over Server-Sent Events, cited passages + (addressed by citation id, hash-checked, quoted briefly with their credit) + and renaming threads. It amends ADR 0007 and ADR 0022; sub-project 3's ADR + becomes 0028. The README documents the chat API, its limits and the stream. - Design and ADR 0026 for California's SBCs, sub-project 2 (`docs/superpowers/specs/2026-09-25-ca-sbc-design.md`). CMS's California file has no SBC links, so each plan's link comes from a hand-built manifest. @@ -360,6 +401,11 @@ ### Changed +- **HTTP errors are JSON:** 404, 405, 429 and the rest answer with + `{"error": "not found"}` and similar instead of Werkzeug's HTML page. The + status code and headers (`Allow`, `Retry-After`) are kept. +- `unsafe_reason` moved to `src/core/urls.py`, so the API can vet links too; + `src/ingestion/sbc/fetch.py` still re-exports it. - `docs/runbooks/deploy.md` rewritten from what actually worked. The previous version would have failed mid-deploy four times over: it set `--public-access None` while step 6 restored from a laptop, created the app @@ -513,6 +559,9 @@ ### Fixed +- **A streamed answer's spend survives a failed save.** Spend is marked as + recorded only after its commit succeeds. Before, a rolled-back commit took + the spend with it, and it was never recorded again. - `make ingest-sbc` counted a recorded failure twice: once as "already current" and again as a "recorded failure skipped". So a run over blocked documents read as healthier than it was. It now counts it once, as skipped. @@ -621,6 +670,10 @@ ### Security +- **A streamed round that never reports its usage is counted by estimate:** + the whole prompt plus the output cap. This covers a client that + disconnects, Stop, or a dropped connection. Without it, ending streams + early would spend past the daily token budget (OWASP LLM10). - Plan-summary links from CMS render only as `http(s)` URLs, never `javascript:` or `data:`, and open with `noopener noreferrer`. - Composer discloses that messages and retrieved sources are sent to diff --git a/README.md b/README.md index c2660d6..bef11b8 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,10 @@ Because HealthCare.gov is the authoritative consumer source, answers about healt ## Features -- **Conversational Q&A**, organized into threads, over a curated insurance knowledge base +- **Conversational Q&A**, organized into threads you can rename, over a curated insurance knowledge base - **Hybrid RAG pipeline** — BM25 + pgvector semantic search, cross-encoder reranked and relevance-filtered before reaching the LLM, so weakly-relevant matches never become context - **Cited answers** — every reply lists the source passages and their relevance score, and the plans it compared in a side-by-side table; both survive reloading the conversation. Each plan in the table says whether its Summary of Benefits was read, and why not (ADR 0017) +- **Streamed answers, readable sources** — answers stream in as they are written, with progress along the way; each citation can be opened to a brief quote of the passage it used, checked against a hash of what was cited, with its link and licence credit (ADR 0027) - **Real plan comparison** — ask about ACA Marketplace plans; the model searches the ingested catalog and CMS prices the plans live for your age (ADR 0010). In California, premiums are CMS's filed rates for your age and ZIP's rating area, and answers name Covered California (ADR 0024). It compares plans and never recommends one - **A profile, kept off the model** — signup takes a ZIP code, date of birth and county, and plan questions use them without their ever being sent to the LLM. Nobody under 13 can sign up (ADR 0012) - **JWT-authenticated API** — only a signed-in user can query, and only ever sees their own threads @@ -55,6 +56,41 @@ User ──► React UI ──► Flask API ──► Retrieval ──► LLM Both halves share the same embedding model (`src/core/embedding.py`) so ingestion-time and query-time vectors stay comparable. +### Chat API + +Every chat endpoint needs a JWT, and a thread or citation that isn't yours is a +404, never a 403. Each is rate-limited per user and answers 429 with +`Retry-After` past its limit. + +| Endpoint | Does | Limit | +| --- | --- | --- | +| `GET /api/chat/threads` | Your threads, most recently active first | 60/min | +| `POST /api/chat/threads` | Starts a thread | 30/min | +| `PATCH /api/chat/threads/` | Renames it (1–200 characters); its place in the list stays | 30/min | +| `DELETE /api/chat/threads/` | Deletes it with its messages | 30/min | +| `GET /api/chat/threads//messages` | The transcript, with citations and plans | 60/min | +| `POST /api/chat/threads//messages` | Asks, and returns the answer as JSON | 15/min, shared with the stream | +| `POST /api/chat/threads//messages/stream` | Asks, and streams the answer | 15/min, shared with the JSON route | +| `GET /api/chat/sources/` | One of your citations, as a brief quote | 60/min | + +**Streaming** ([ADR 0027](docs/decisions/0027-streaming-answers-and-cited-passages.md)). The stream route sends Server-Sent Events: +`user_message` (the saved question), then `stage` for progress +(`understanding`, `searching`, `plans`, `coverage`, `writing`), `notice`, +`delta` for answer text and `reset` to discard it, and finally `done` with the +saved answer and thread, or `error`. Errors that can be known up front (401, +404, 422, 429) come back as ordinary responses before the stream starts. Text +is streamed only once the answer is grounded, the answer is saved before +`done` is sent, and a client that goes away saves no answer but is still +charged for what the model wrote. The frontend reads the stream with `fetch`, +since `EventSource` can't send a POST with a Bearer header. + +**Cited passages.** `GET /api/chat/sources/` takes a citation's own id +(each source in a message carries one) and returns its kind, title, document, +section, link, licence and a quote of at most 300 characters, taken from the +part of the passage most like the answer. Its `status` says whether the quote +can be trusted: `ok`, `unverified` (an answer from before hashes were stored), +`changed` or `missing`; the last two carry no quote. + ## Project Structure ``` diff --git a/docs/decisions/0007-persisting-citations.md b/docs/decisions/0007-persisting-citations.md index 0ee44eb..439fcbb 100644 --- a/docs/decisions/0007-persisting-citations.md +++ b/docs/decisions/0007-persisting-citations.md @@ -5,6 +5,9 @@ Accepted. Amended by ADR 0017: an answer that called `plan_coverage` saves only the corpus sources it cites. +Amended by ADR 0027: each citation also stores a SHA-256 of the passage +text it used, so a rebuilt chunk is never shown as what was cited. + ## Context Citations were returned once and never stored. `POST /messages` answers with diff --git a/docs/decisions/0022-the-api-as-a-container.md b/docs/decisions/0022-the-api-as-a-container.md index c46638a..8bb96b9 100644 --- a/docs/decisions/0022-the-api-as-a-container.md +++ b/docs/decisions/0022-the-api-as-a-container.md @@ -6,6 +6,9 @@ Accepted. Closes the first of ADR 0002's two deferred items ("Docker build + image vulnerability scan. There is no Dockerfile for the app."). Depends on ADR 0021, without which the image cannot serve a request. +Amended by ADR 0027: an answer stream holds one gthread thread for as long +as it runs, so the 8 threads also bound concurrent streams. + ## Context The app has only ever run on the machine that built its corpus. Everything diff --git a/docs/decisions/0027-streaming-answers-and-cited-passages.md b/docs/decisions/0027-streaming-answers-and-cited-passages.md new file mode 100644 index 0000000..c913617 --- /dev/null +++ b/docs/decisions/0027-streaming-answers-and-cited-passages.md @@ -0,0 +1,143 @@ +# 0027 — Streaming answers, cited passages, and renaming threads + +## Status + +Accepted. Part of the chat redesign +([spec](../superpowers/specs/2026-09-26-chat-redesign-design.md), section 3). +It amends two earlier ADRs: + +- **ADR 0007.** A citation now also stores a SHA-256 of the passage text the + answer used (`message_sources.content_sha256`), so a passage can be shown + later only while it still says what was cited. +- **ADR 0022.** An open answer stream holds one of the worker's 8 gthread + threads for as long as it runs, as a blocking request already did. + +Sub-project 3 of the California expansion, which had planned to take this +number, takes ADR 0028 instead. + +## Context + +The chat worked, but three things made it read like a prototype: + +- **Answers arrived all at once**, after 5 to 40 seconds behind a spinner: + a rewrite, a search, up to two tool rounds and the reply, with nothing shown + in between. +- **Sources couldn't be read.** A citation was a file name and a match score. + A user had no way to see the text an answer relied on, and the chunk behind + a citation can be rebuilt in place by the next ingest (ADR 0007), so its + text today may not be what was cited. +- **Threads couldn't be renamed.** Every thread kept the first 80 characters + of its first question. + +## Decision + +### Streaming: Server-Sent Events over `fetch` + +`POST /api/chat/threads//messages/stream` takes the same body as +`POST …/messages` and answers `200 text/event-stream`, with +`Cache-Control: no-cache` and `X-Accel-Buffering: no`. The browser reads it +with `fetch` and a stream reader: `EventSource` cannot send a POST or a Bearer +header. Each event is `event: \ndata: \n\n`: + +| Event | Data | Meaning | +| --- | --- | --- | +| `user_message` | `{message}` | The saved question | +| `stage` | `{stage}`: `understanding`, `searching`, `plans`, `coverage` or `writing` | Progress | +| `notice` | `{text}` | A server-written notice (ADR 0024, 0026), sent as soon as a tool returns it | +| `delta` | `{text}` | Answer text to append | +| `reset` | `{}` | Discard the deltas so far | +| `done` | `{message, thread}` | The saved answer: authoritative, and the client renders it | +| `error` | `{error: "generation failed"}` | The model failed. The question stays and nothing else is saved | + +Every event and field is built by server code. The model supplies only the +text of a `delta`, which is the text `done` then carries; the client renders +it as Markdown without raw HTML. + +**Generation is one loop with two completion calls.** `answer_events` yields +`Stage`, `Notice`, `Delta` and `Reset` as they happen and ends with +`Done(answer)`. `answer()` drains it, so the CLI, the evals and the JSON route +are unchanged. Blocking and streaming share the tool loop, the fallbacks, the +coverage-citation filter and the notices; only the model call differs. + +**Text streams only once the answer is grounded,** meaning chunks were +retrieved or a tool has run. Before that it is held back, so the "no chunks +and no search" refusal (ADR 0010) never has to take back text already shown. +A round that streamed text and then called a tool is followed by `reset`. + +**Every check that can fail comes before the stream,** so its error is an +ordinary response: 401, 404 for ownership, 422 for the body, and 429 for the +limiter or the daily token budget. The question is then committed, so no +transaction is open while the model writes. After `Done` the answer is saved +through the same helper as the JSON route (`_save_answer`) and committed, and +only then is `done` sent. `done` always carries exactly what was saved. + +**Spend is recorded exactly once, in every ending:** with the answer, on a +model error, and in a `finally` that also runs when the client goes away +(`GeneratorExit`). A client that goes away also closes the model's stream, so +the model stops writing. No partial answer is saved. + +**Both send routes share one limit,** `limiter.shared_limit("15 per minute", +scope="chat_send")`, so a user can't double their rate by using both. + +### Passages: by citation id, briefly quoted + +`GET /api/chat/sources/` (60 per minute) returns one of the +current user's citations as it can be shown now. + +- **Addressed by the citation's own id,** joined through its message to a + thread the user owns. Anything else, or a malformed id, is a 404, never a + 403. Chunk ids are guessable, so there is deliberately no lookup by chunk id. +- **Status, from the stored hash:** + + | `status` | When | `quote` | + | --- | --- | --- | + | `ok` | The hash matches the text now | the excerpt | + | `unverified` | No hash (an answer from before this ADR), and the chunk exists | the excerpt; the UI says it may have changed | + | `changed` | The hash doesn't match | `null` | + | `missing` | The chunk is gone | `null` | + +- **Brief quotes.** At most 300 characters, taken from the part of the passage + most like the answer, not its first 300 characters: segments are scored by + the distinct words they share with the answer, and the best one and those + after it are kept while they fit. `… ` and ` …` mark what was left out. SBC + text stays within ADR 0013's "quote briefly", and the endpoint never returns + a passage whole unless it is that short. +- **Credit and links, all derived by the server.** An SBC shows its plan, + plan year, section and document link. A Wikipedia passage carries its + CC BY-SA 4.0 credit, read from the source registry's `wikipedia` entry + (ADR 0025), and a link to the article. HealthCare.gov text is public domain, + and its page URLs are not stored. A link is returned only if it is `https` + and passes the ingestion's `unsafe_reason` check (now `src/core/urls.py`), + so an IP address or a non-public host is never handed to a browser. + +### Renaming leaves `updated_at` alone + +`PATCH /api/chat/threads/` (30 per minute) sets a title of 1 to 200 +characters, after trimming. `updated_at` means the thread's last activity and +orders the list, so a rename must not move a thread to the top. The update +sets `updated_at` to itself, which stops the column's `onupdate` from firing. + +## Consequences + +- **The JSON route stays.** Scripts and tests use it, and it is the fallback. + The frontend moves to the stream. +- **Gunicorn's threads bound concurrent streams.** One worker with 8 threads + (ADR 0022) serves at most 8 answers at once, streamed or not. More needs + more threads, or a shared limiter backend and more workers. With threads, + gunicorn runs the gthread worker, whose `--timeout` is a worker heartbeat, + not a per-request limit, so a long stream is not killed at 120 s. +- **Azure's ingress timeout (about 240 s) is above the usual worst case.** + That is a rewrite and three answer rounds of up to 30 s each. With every + call also retried once (`llm_max_retries = 1`), the theoretical bound comes + close to it. A stream cut there ends like a client that went away: the spend + is recorded, no answer is saved, and the client shows an error. +- **A stopped or cut stream saves no answer,** only the question, as a failed + answer already did. The model's usage arrives in the stream's last chunk. A + round cut short never gets it, so it is counted by estimate, set high: the + whole prompt plus the output cap. Otherwise ending streams early would spend + past the daily budget (OWASP LLM10). +- **Citations saved before this ADR are `unverified`,** never shown as if + checked. +- **The Wikipedia link is built from the stored label,** whose title was + sanitised at ingest (`Workers'_compensation` became + `Workers_compensation`). It relies on Wikipedia's redirects for such titles. diff --git a/docs/design/chat-redesign/Empty.dc.html b/docs/design/chat-redesign/Empty.dc.html new file mode 100644 index 0000000..c43ed5f --- /dev/null +++ b/docs/design/chat-redesign/Empty.dc.html @@ -0,0 +1,275 @@ + + + + +Chat, a new question + + + + + + + + +
+
+ +
+
+
+

What would you like to know about your coverage?

+

Answers come from plans' Summaries of Benefits and Coverage, HealthCare.gov and other references. Every claim links to the passage it came from.

+
+ + + +
+
+
+
+
+ +
Press / to type from anywhere
+
+

Questions and the passages found for them are sent to OpenAI to write answers. Don't include personal details.

+
+
+
+
+
+ + + diff --git a/docs/design/chat-redesign/Main.dc.html b/docs/design/chat-redesign/Main.dc.html new file mode 100644 index 0000000..8986497 --- /dev/null +++ b/docs/design/chat-redesign/Main.dc.html @@ -0,0 +1,378 @@ + + + + +Chat, an answered thread with sources + + + + + + + + + +
+
+ + + +
+
+ + + +
+ +
+
+ +
+

Compare silver plans near me

+
+

Here are 6 of the silver plans sold in San Diego County, California, priced for your age. The premiums are CMS's published 2026 rates, before any federal tax credit or state premium help; Covered California has today's prices.

+
+

2026 Silver plans

San Diego County, CAShown Sep 26, 2026
+ + + + + + + + + + + + + + +
NumberPlanPremiumDeductibleOut-of-pocket maxQuality rating
{{p.n}}{{p.name}}{{p.issuer}}, Silver, HMO{{p.sbc}} +
Summary of Benefits
+
{{p.premium}}/mofor your age$5,200medical$50drugs$9,800Not available
+
Premiums are CMS's published 2026 rates for the age shown, before any federal tax credit or state premium help. Plans and prices change, so check today's at Covered California.
+
+

All six have a $5,200 medical deductible, a separate $50 drug deductible and a $9,800 out-of-pocket maximum. The lowest premium shown is at $474.70 a month.

+
+
+
+ +
+

What do plans 1 and 2 charge for urgent care?

+
+
Summaries of Benefits and Coverage quoted here for Covered California plans are for each plan's standard version; people who qualify for cost-sharing reductions or American Indian and Alaska Native cost sharing pay less than they show.
+

Sharp Silver 70 Premier HMO charges a $50 copay per urgent care visit, and the deductible does not apply.

+

Molina Healthcare's Silver 70 HMO also charges a $50 copay per visit with the deductible not applying. Its summary lists urgent care from providers outside its network as not covered.

+
+
+ + + + +
+
+ +
+
+ +
+
+ +
Enter to send, Shift+Enter for a new line
+
+

Questions and the passages found for them are sent to OpenAI to write answers. Don't include personal details.

+
+
+ + + + + +
+
+
+ + + diff --git a/docs/design/chat-redesign/MainDark.dc.html b/docs/design/chat-redesign/MainDark.dc.html new file mode 100644 index 0000000..07d633d --- /dev/null +++ b/docs/design/chat-redesign/MainDark.dc.html @@ -0,0 +1,378 @@ + + + + +Chat in the dark theme + + + + + + + + + +
+
+ + + +
+
+ + + +
+ +
+
+ +
+

Compare silver plans near me

+
+

Here are 6 of the silver plans sold in San Diego County, California, priced for your age. The premiums are CMS's published 2026 rates, before any federal tax credit or state premium help; Covered California has today's prices.

+
+

2026 Silver plans

San Diego County, CAShown Sep 26, 2026
+ + + + + + + + + + + + + + +
NumberPlanPremiumDeductibleOut-of-pocket maxQuality rating
{{p.n}}{{p.name}}{{p.issuer}}, Silver, HMO{{p.sbc}} +
Summary of Benefits
+
{{p.premium}}/mofor your age$5,200medical$50drugs$9,800Not available
+
Premiums are CMS's published 2026 rates for the age shown, before any federal tax credit or state premium help. Plans and prices change, so check today's at Covered California.
+
+

All six have a $5,200 medical deductible, a separate $50 drug deductible and a $9,800 out-of-pocket maximum. The lowest premium shown is at $474.70 a month.

+
+
+
+ +
+

What do plans 1 and 2 charge for urgent care?

+
+
Summaries of Benefits and Coverage quoted here for Covered California plans are for each plan's standard version; people who qualify for cost-sharing reductions or American Indian and Alaska Native cost sharing pay less than they show.
+

Sharp Silver 70 Premier HMO charges a $50 copay per urgent care visit, and the deductible does not apply.

+

Molina Healthcare's Silver 70 HMO also charges a $50 copay per visit with the deductible not applying. Its summary lists urgent care from providers outside its network as not covered.

+
+
+ + + + +
+
+ +
+
+ +
+
+ +
Enter to send, Shift+Enter for a new line
+
+

Questions and the passages found for them are sent to OpenAI to write answers. Don't include personal details.

+
+
+ + + + + +
+
+
+ + + diff --git a/docs/design/chat-redesign/Mobile.dc.html b/docs/design/chat-redesign/Mobile.dc.html new file mode 100644 index 0000000..47bf3f7 --- /dev/null +++ b/docs/design/chat-redesign/Mobile.dc.html @@ -0,0 +1,268 @@ + + + + +Chat on a phone, with a source open + + + + + + + + +
+
+
+ + + +
+
To compare plans you can buy, add your ZIP code and date of birth in your profile.
+
+
+
+

What is health insurance, in plain terms?

+
+

Health insurance pays for all or part of your medical costs. Like other insurance, it spreads risk across many people: everyone pays in, and the pool pays for the care members need.

+
+
+
+
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+ + + diff --git a/docs/design/chat-redesign/Profile.dc.html b/docs/design/chat-redesign/Profile.dc.html new file mode 100644 index 0000000..3e62227 --- /dev/null +++ b/docs/design/chat-redesign/Profile.dc.html @@ -0,0 +1,262 @@ + + + + +Profile + + + + + + + + +
+
+ +
+
Back to your questions
+
+
+
+

Your profile

Plan questions use your ZIP code and age to find plans you can buy and price them for you.

+
+ For comparing plans +
+ San Diego County, CA
+
Plans in CA are sold on Covered California. PolicyPal compares them from CMS's published plan data.
+
Used to work out your age for prices. Never shown to the AI model.
+
+
Saved. Plan questions now use this profile.
+
+ +
+
+
+
+
+
+ + + diff --git a/docs/design/chat-redesign/README.md b/docs/design/chat-redesign/README.md new file mode 100644 index 0000000..4e87e5a --- /dev/null +++ b/docs/design/chat-redesign/README.md @@ -0,0 +1,28 @@ +# Chat redesign — the approved mockups + +Approved on 2026-09-26. This folder is a snapshot of the design canvas + (private to its owner). It is +the visual reference for the spec, +[docs/superpowers/specs/2026-09-26-chat-redesign-design.md](../../superpowers/specs/2026-09-26-chat-redesign-design.md). + +The `.dc.html` files are the canvas's artboards. They run only inside the +canvas, which supplies `support.js`, so open the canvas link to click through +them. Read the files for exact markup and copy. `tokens.css` is the stylesheet +every artboard inlines; the frontend's tokens come from it. + +| Artboard | Shows | +| --- | --- | +| `Main.dc.html` | An answered thread: the plan table, citation seals, the Sources panel, copy | +| `MainDark.dc.html` | The same, in the dark theme | +| `Streaming.dc.html` | An answer streaming in: progress steps, the caret, Stop; the sidebar hidden | +| `Empty.dc.html` | A new question: starter questions, the composer focused | +| `SidebarSearch.dc.html` | Searching the threads | +| `SidebarRename.dc.html` | Renaming a thread inline, and confirming a delete | +| `SidebarAccount.dc.html` | The account menu: profile, theme, shortcuts, sign out | +| `Mobile.dc.html` | A phone: the profile nudge, and a source open as a bottom sheet with its Wikipedia credit | +| `SignIn.dc.html` | Sign in, beside an example cited answer | +| `Profile.dc.html` | The profile form inside the app shell | + +Plan names, prices and quoted passages are real public data from the local +catalog (2026 Covered California, San Diego County, age 40), used as sample +content. `you@example.com` is a placeholder. diff --git a/docs/design/chat-redesign/SidebarAccount.dc.html b/docs/design/chat-redesign/SidebarAccount.dc.html new file mode 100644 index 0000000..12ea15c --- /dev/null +++ b/docs/design/chat-redesign/SidebarAccount.dc.html @@ -0,0 +1,248 @@ + + + + +Sidebar, account menu + + + + + + + + +
+ +
+
+ + + diff --git a/docs/design/chat-redesign/SidebarRename.dc.html b/docs/design/chat-redesign/SidebarRename.dc.html new file mode 100644 index 0000000..6d92862 --- /dev/null +++ b/docs/design/chat-redesign/SidebarRename.dc.html @@ -0,0 +1,240 @@ + + + + +Sidebar, renaming and deleting + + + + + + + + +
+ +
+
+ + + diff --git a/docs/design/chat-redesign/SidebarSearch.dc.html b/docs/design/chat-redesign/SidebarSearch.dc.html new file mode 100644 index 0000000..b9191a3 --- /dev/null +++ b/docs/design/chat-redesign/SidebarSearch.dc.html @@ -0,0 +1,235 @@ + + + + +Sidebar, searching + + + + + + + + +
+ +
+
+ + + diff --git a/docs/design/chat-redesign/SignIn.dc.html b/docs/design/chat-redesign/SignIn.dc.html new file mode 100644 index 0000000..d25ade6 --- /dev/null +++ b/docs/design/chat-redesign/SignIn.dc.html @@ -0,0 +1,255 @@ + + + + +Sign in + + + + + + + + +
+
+
PolicyPal
+

Answers about your health coverage, with the source for every claim.

+
+

How is coinsurance different from a copay?

+
+

A copayment is a fixed amount you pay for a covered service, like $30.1 Coinsurance is the percentage of the cost you pay for each covered service, like 20%.2

+
+
+
1
Copayment
HealthCare.gov glossary

A fixed amount you pay for a plan-covered service, like $30.

+
2
Coinsurance
HealthCare.gov glossary

A percentage of the cost that you pay for each plan-covered service, like 20%.

+
+
+
+
+
+

Sign in

Pick up your questions where you left them.

+
+
+
+
+ +

New to PolicyPal? Create an account

+
+
+
+
+ + + diff --git a/docs/design/chat-redesign/Streaming.dc.html b/docs/design/chat-redesign/Streaming.dc.html new file mode 100644 index 0000000..e89887b --- /dev/null +++ b/docs/design/chat-redesign/Streaming.dc.html @@ -0,0 +1,257 @@ + + + + +Chat, an answer streaming in + + + + + + + + +
+ +
+
+ + + diff --git a/docs/design/chat-redesign/canvas.json b/docs/design/chat-redesign/canvas.json new file mode 100644 index 0000000..0a737a3 --- /dev/null +++ b/docs/design/chat-redesign/canvas.json @@ -0,0 +1,130 @@ +{ + "v": 3, + "attachments": {}, + "boards": { + "Empty.dc.html": { + "h": 900, + "is_interactive": true, + "title": "Chat: a new question", + "w": 1440, + "x": 3040, + "y": 0 + }, + "Main.dc.html": { + "h": 900, + "is_interactive": true, + "title": "Chat: answer, citations and sources", + "w": 1440, + "x": 0, + "y": 0 + }, + "MainDark.dc.html": { + "h": 900, + "is_interactive": true, + "title": "Chat: dark theme", + "w": 1440, + "x": 0, + "y": 1300 + }, + "Mobile.dc.html": { + "h": 844, + "is_interactive": true, + "title": "Phone: a source open as a sheet", + "w": 390, + "x": 1520, + "y": 1300 + }, + "Profile.dc.html": { + "h": 900, + "is_interactive": true, + "title": "Profile", + "w": 1440, + "x": 1520, + "y": 2600 + }, + "SidebarAccount.dc.html": { + "h": 700, + "title": "Sidebar: account menu and theme", + "w": 300, + "x": 2750, + "y": 1300 + }, + "SidebarRename.dc.html": { + "h": 700, + "title": "Sidebar: renaming, and confirming a delete", + "w": 300, + "x": 2370, + "y": 1300 + }, + "SidebarSearch.dc.html": { + "h": 700, + "title": "Sidebar: searching", + "w": 300, + "x": 1990, + "y": 1300 + }, + "SignIn.dc.html": { + "h": 900, + "is_interactive": true, + "title": "Sign in", + "w": 1440, + "x": 0, + "y": 2600 + }, + "Streaming.dc.html": { + "h": 900, + "title": "Chat: an answer streaming in, sidebar hidden", + "w": 1440, + "x": 1520, + "y": 0 + } + }, + "createdOnFiles": { + "at": "2026-09-26T21:49:59Z", + "v": 1 + }, + "designSystems": [], + "launch": { + "view": "canvas" + }, + "notes": { + "row1": { + "kind": "title1", + "maxW": 4480, + "text": "The chat", + "w": 240, + "x": 0, + "y": -300 + }, + "row2": { + "kind": "title1", + "maxW": 3050, + "text": "Dark theme, phone and sidebar states", + "w": 240, + "x": 0, + "y": 1000 + }, + "row3": { + "kind": "title1", + "maxW": 2960, + "text": "Sign in and profile", + "w": 240, + "x": 0, + "y": 2300 + } + }, + "order": [ + "Main.dc.html", + "Streaming.dc.html", + "Empty.dc.html", + "MainDark.dc.html", + "Mobile.dc.html", + "SidebarSearch.dc.html", + "SidebarRename.dc.html", + "SidebarAccount.dc.html", + "SignIn.dc.html", + "Profile.dc.html" + ], + "pages": [], + "title": "PolicyPal chat redesign" +} diff --git a/docs/design/chat-redesign/tokens.css b/docs/design/chat-redesign/tokens.css new file mode 100644 index 0000000..abf2bde --- /dev/null +++ b/docs/design/chat-redesign/tokens.css @@ -0,0 +1,197 @@ +body{margin:0} +.pp{--paper:#E8E5DB;--sheet:#F8F6F0;--sheet-2:#F0EDE3;--ink:#1E211C;--ink-soft:#575D51;--rule:#D6D0BF;--rule-strong:#BCB5A1;--seal:#96651F;--seal-deep:#744D15;--seal-wash:#F0E3C9;--pine:#2C5B4B;--pine-wash:#DAE6DF;--danger:#963728;--danger-wash:#F4E1DB;--silver:#9EA5AB;--float:0 1px 0 rgba(30,33,28,.05),0 18px 40px -22px rgba(52,44,24,.45); + --serif:"IBM Plex Serif",Georgia,serif;--sans:"IBM Plex Sans","Segoe UI",system-ui,sans-serif; + font-family:var(--sans);font-size:15px;line-height:1.55;color:var(--ink);background:var(--paper);-webkit-font-smoothing:antialiased;box-sizing:border-box;position:relative;overflow:hidden} +.pp.dark{--paper:#141611;--sheet:#1D2019;--sheet-2:#252820;--ink:#ECE8DC;--ink-soft:#A4A697;--rule:#32362D;--rule-strong:#484C41;--seal:#D69E52;--seal-deep:#E9B879;--seal-wash:#3A2E1B;--pine:#8BB9A5;--pine-wash:#1E2F28;--danger:#E28D7E;--danger-wash:#3B2520;--float:0 1px 0 rgba(0,0,0,.3),0 18px 40px -20px rgba(0,0,0,.8)} +.pp *{box-sizing:border-box} +.pp button,.pp input,.pp textarea{font:inherit;color:inherit} +.pp a{color:var(--pine);text-underline-offset:2px} +.pp svg.i{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.75;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0} +.pp svg.i.sm{width:15px;height:15px} +.pp .focus{outline:2px solid var(--seal);outline-offset:2px} +.pp .muted{color:var(--ink-soft)} +.pp .tab{font-variant-numeric:tabular-nums} + +/* shell */ +.shell{display:grid;grid-template-columns:276px minmax(0,1fr);height:100%} +.shell.with-panel{grid-template-columns:276px minmax(0,1fr) 392px} +.shell.collapsed{grid-template-columns:0 minmax(0,1fr)} + +/* sidebar */ +.side{display:flex;flex-direction:column;gap:14px;padding:18px 14px 14px;border-right:1px solid var(--rule);min-height:0;background:var(--paper)} +.brand{display:flex;align-items:center;gap:10px;padding:2px 6px 6px} +.brand-mark{width:28px;height:28px;border-radius:50%;background:var(--seal);color:#FFF8EA;display:grid;place-items:center;font-family:var(--serif);font-weight:600;font-size:15px;box-shadow:inset 0 0 0 2px rgba(255,248,234,.28),inset 0 0 0 4px var(--seal)} +.brand-name{font-family:var(--serif);font-weight:600;font-size:18px;letter-spacing:-.01em} +.brand .iconbtn{margin-left:auto} +.iconbtn{width:34px;height:34px;border-radius:10px;border:none;background:none;display:grid;place-items:center;color:var(--ink-soft);cursor:pointer} +.iconbtn:hover{background:var(--sheet-2);color:var(--ink)} +.newq{display:flex;align-items:center;gap:10px;width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--rule-strong);background:var(--sheet);font-weight:500;cursor:pointer} +.newq:hover{border-color:var(--seal)} +.kbd{margin-left:auto;font-size:12px;color:var(--ink-soft);border:1px solid var(--rule);border-radius:5px;padding:0 5px;line-height:18px;background:var(--paper)} +.search{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:10px;background:var(--sheet-2);color:var(--ink-soft)} +.search input{border:none;background:none;outline:none;flex:1;min-width:0;color:var(--ink)} +.group{display:flex;flex-direction:column;gap:2px} +.group-label{font-size:12.5px;color:var(--ink-soft);padding:8px 10px 4px} +.threads{flex:1;overflow:auto;display:flex;flex-direction:column;gap:6px;min-height:0;margin:0 -4px;padding:0 4px} +.thread{display:flex;align-items:center;border-radius:9px;min-height:38px} +.thread-title{flex:1;min-width:0;padding:8px 10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:none;border:none;text-align:left;cursor:pointer} +.thread:hover{background:var(--sheet-2)} +.thread.active{background:var(--sheet);box-shadow:inset 0 0 0 1px var(--rule)} +.thread.active .thread-title{font-weight:500} +.thread .iconbtn{width:30px;height:30px;visibility:hidden} +.thread:hover .iconbtn,.thread.active .iconbtn{visibility:visible} +.menu{position:absolute;z-index:5;min-width:188px;padding:6px;border-radius:12px;background:var(--sheet);border:1px solid var(--rule);box-shadow:var(--float);display:flex;flex-direction:column} +.menu button{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;border:none;background:none;text-align:left;cursor:pointer} +.menu button:hover,.menu button.hot{background:var(--sheet-2)} +.menu .danger{color:var(--danger)} +.menu hr{border:none;border-top:1px solid var(--rule);margin:6px 2px} +.account{display:flex;align-items:center;gap:10px;padding:8px;border-radius:10px;border:none;background:none;width:100%;cursor:pointer;text-align:left;border-top:1px solid var(--rule);border-radius:0;padding-top:14px} +.avatar{width:30px;height:30px;border-radius:50%;background:var(--pine-wash);color:var(--pine);display:grid;place-items:center;font-weight:600;font-size:13px;flex-shrink:0} +.account-email{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13.5px} + +/* main column */ +.main{display:flex;flex-direction:column;min-width:0;min-height:0;position:relative} +.topbar{display:flex;align-items:center;gap:8px;padding:12px 20px;border-bottom:1px solid var(--rule);min-height:58px} +.title-btn{display:flex;align-items:center;gap:8px;border:none;background:none;padding:6px 8px;border-radius:8px;font-family:var(--serif);font-size:17px;font-weight:500;cursor:text;min-width:0} +.title-btn:hover{background:var(--sheet-2)} +.title-btn .i{opacity:0;color:var(--ink-soft)} +.title-btn:hover .i{opacity:1} +.topbar .spacer{flex:1} +.pill-btn{display:flex;align-items:center;gap:8px;padding:7px 12px;border-radius:999px;border:1px solid var(--rule-strong);background:var(--sheet);font-size:13.5px;font-weight:500;cursor:pointer} +.pill-btn.on{background:var(--seal-wash);border-color:var(--seal);color:var(--seal-deep)} +.count{min-width:20px;height:20px;border-radius:999px;background:var(--seal);color:#FFF8EA;font-size:11.5px;display:grid;place-items:center;padding:0 6px;font-weight:600} +.nudge{display:flex;align-items:center;gap:12px;padding:10px 20px;background:var(--pine-wash);color:var(--ink);font-size:14px;border-bottom:1px solid var(--rule)} +.nudge a{font-weight:500} +.scroll{flex:1;overflow:auto;min-height:0} +.column{max-width:760px;margin:0 auto;padding:36px 32px 200px;display:flex;flex-direction:column;gap:44px} + +/* an exchange: the question is the heading, the answer is the brief */ +.exchange{display:flex;flex-direction:column;gap:18px} +.q{display:flex;align-items:baseline;gap:14px} +.q h2{margin:0;font-family:var(--serif);font-weight:500;font-size:25px;line-height:1.3;letter-spacing:-.012em;flex:1} +.q time{font-size:12.5px;color:var(--ink-soft);white-space:nowrap} +.a{font-size:16px;line-height:1.68} +.a p{margin:0 0 12px} +.a ul{margin:0 0 12px;padding-left:20px} +.a li{margin:4px 0} +.a strong{font-weight:600} +.notice{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border-radius:12px;background:var(--seal-wash);color:var(--ink);font-size:14px;line-height:1.5;margin-bottom:14px} +.notice .i{color:var(--seal-deep);margin-top:2px} + +/* the seal: a citation */ +.seal{display:inline-grid;place-items:center;min-width:19px;height:19px;padding:0 5px;margin:0 1px 0 3px;border-radius:999px;background:var(--seal);color:#FFF8EA;font-size:11px;font-weight:600;line-height:1;vertical-align:2px;border:none;cursor:pointer;box-shadow:inset 0 0 0 1.5px rgba(255,248,234,.3);font-variant-numeric:tabular-nums} +.seal:hover{background:var(--seal-deep)} +.seal.sel{box-shadow:0 0 0 2px var(--paper),0 0 0 4px var(--seal)} +.stamp .seal{animation:stamp .42s cubic-bezier(.2,.9,.3,1.3) both} +.stamp .seal:nth-of-type(2){animation-delay:.08s} +.stamp .seal:nth-of-type(3){animation-delay:.16s} +@keyframes stamp{0%{transform:scale(1.9) rotate(-14deg);opacity:0}60%{opacity:1}100%{transform:none;opacity:1}} +.planref{display:inline-flex;align-items:baseline;gap:6px;border:none;background:none;padding:0;color:var(--ink);font-weight:500;text-decoration:underline dotted var(--rule-strong) 1.5px;text-underline-offset:4px;cursor:pointer} +.planref .num{font-size:11px;font-weight:600;color:var(--pine);border:1.5px solid var(--pine);border-radius:5px;padding:0 4px;line-height:15px;align-self:center} + +/* answer footer */ +.afoot{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:6px} +.chip{display:inline-flex;align-items:center;gap:8px;max-width:100%;padding:5px 11px 5px 6px;border-radius:999px;border:1px solid var(--rule);background:var(--sheet);font-size:13px;cursor:pointer} +.chip:hover{border-color:var(--seal)} +.chip .seal{margin:0;vertical-align:0} +.chip span.t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.tool{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:8px;border:none;background:none;color:var(--ink-soft);font-size:13px;cursor:pointer} +.tool:hover{background:var(--sheet-2);color:var(--ink)} +.tool.done{color:var(--pine)} +.afoot .sep{flex:1} + +/* plan table */ +.plans{border-radius:16px;background:var(--sheet);border:1px solid var(--rule);overflow:hidden;margin:6px 0 14px} +.plans-head{display:flex;align-items:baseline;gap:12px;padding:14px 18px 12px;border-bottom:1px solid var(--rule)} +.plans-head h3{margin:0;font-family:var(--serif);font-size:16px;font-weight:600} +.plans-head span{font-size:13px;color:var(--ink-soft)} +.plans table{width:100%;border-collapse:collapse;font-size:14px} +.plans th,.plans td{text-align:left;vertical-align:top;padding:12px 14px;border-bottom:1px solid var(--rule)} +.plans thead th{font-size:12.5px;font-weight:500;color:var(--ink-soft);padding-top:10px;padding-bottom:10px;white-space:nowrap;background:var(--sheet-2)} +.plans tbody tr:last-child th,.plans tbody tr:last-child td{border-bottom:none} +.plans tbody tr:hover{background:var(--paper)} +.plans tbody tr.flash{background:var(--seal-wash)} +.plans .pos{width:44px;padding-right:0} +.plans .pos span{display:inline-grid;place-items:center;width:24px;height:24px;border-radius:7px;border:1.5px solid var(--pine);color:var(--pine);font-size:12px;font-weight:600} +.pname{font-weight:600;display:block} +.psub{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--ink-soft);margin-top:2px} +.metal{width:9px;height:9px;border-radius:50%;background:var(--silver);box-shadow:inset 0 0 0 1px rgba(0,0,0,.15)} +.fig{font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap;display:block} +.sub{font-size:12.5px;color:var(--ink-soft);display:block} +.rowacts{display:flex;gap:4px;margin-top:6px} +.rowacts a,.rowacts button{display:inline-flex;align-items:center;gap:5px;font-size:12.5px;padding:3px 8px 3px 6px;border-radius:7px;border:none;background:none;color:var(--pine);font-weight:500;cursor:pointer;text-decoration:none} +.rowacts a:hover,.rowacts button:hover{background:var(--pine-wash)} +.plans-foot{padding:12px 18px;font-size:12.5px;color:var(--ink-soft);border-top:1px solid var(--rule);background:var(--sheet-2)} + +/* composer */ +.dock{position:absolute;left:0;right:0;bottom:0;padding:0 32px 18px;background:linear-gradient(to bottom,transparent,var(--paper) 38%)} +.composer{max-width:760px;margin:0 auto;border-radius:18px;background:var(--sheet);border:1px solid var(--rule-strong);box-shadow:var(--float);padding:14px 14px 10px 18px;display:flex;flex-direction:column;gap:8px} +.composer.focus-ring{border-color:var(--seal);box-shadow:var(--float),0 0 0 3px var(--seal-wash)} +.composer textarea{border:none;outline:none;background:none;resize:none;font-size:16px;line-height:1.5;min-height:26px;padding:0} +.composer textarea::placeholder{color:var(--ink-soft)} +.crow{display:flex;align-items:center;gap:10px} +.crow .hint{font-size:12.5px;color:var(--ink-soft);flex:1} +.send{width:38px;height:38px;border-radius:50%;border:none;background:var(--seal);color:#FFF8EA;display:grid;place-items:center;cursor:pointer;box-shadow:inset 0 0 0 2px rgba(255,248,234,.25)} +.send:hover{background:var(--seal-deep)} +.send:disabled{background:var(--rule-strong);cursor:not-allowed} +.send.stop{background:var(--ink);color:var(--paper)} +.privacy{max-width:760px;margin:8px auto 0;font-size:12px;color:var(--ink-soft);text-align:center} +.jump{position:absolute;left:50%;transform:translateX(-50%);bottom:150px;display:flex;align-items:center;gap:6px;padding:7px 14px 7px 10px;border-radius:999px;background:var(--ink);color:var(--paper);border:none;font-size:13px;font-weight:500;box-shadow:var(--float);cursor:pointer} + +/* sources panel */ +.panel{border-left:1px solid var(--rule);background:var(--sheet);display:flex;flex-direction:column;min-height:0} +.panel-head{display:flex;align-items:center;gap:8px;padding:14px 14px 12px 20px;border-bottom:1px solid var(--rule);min-height:58px} +.panel-head h2{margin:0;font-family:var(--serif);font-size:17px;font-weight:600;flex:1} +.panel-body{flex:1;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px} +.src{border-radius:14px;padding:14px 16px 14px;border:1px solid var(--rule);background:var(--sheet);display:flex;flex-direction:column;gap:8px;cursor:pointer} +.src:hover{border-color:var(--rule-strong)} +.src.sel{border-color:var(--seal);box-shadow:0 0 0 3px var(--seal-wash)} +.src-top{display:flex;gap:10px;align-items:flex-start} +.src-top .seal{margin:2px 0 0;vertical-align:0} +.src-title{font-weight:600;font-size:14px;line-height:1.35} +.src-kind{font-size:12.5px;color:var(--ink-soft)} +.quote{margin:0;font-family:var(--serif);font-size:14px;line-height:1.6;color:var(--ink);padding-left:10px;position:relative} +.quote::before{content:"\201C";position:absolute;left:-4px;top:-6px;font-size:26px;color:var(--seal)} +.src-meta{display:flex;align-items:center;gap:10px;font-size:12.5px;color:var(--ink-soft)} +.meter{width:54px;height:5px;border-radius:999px;background:var(--rule);overflow:hidden} +.meter i{display:block;height:100%;background:var(--pine)} +.src-meta a{margin-left:auto;display:inline-flex;align-items:center;gap:5px;font-weight:500} +.gone{font-size:13px;color:var(--ink-soft);font-style:italic} + +/* streaming */ +.steps{list-style:none;margin:0 0 16px;padding:14px 16px;border-radius:14px;background:var(--sheet);border:1px solid var(--rule);display:flex;flex-direction:column;gap:9px;font-size:14px} +.steps li{display:flex;align-items:center;gap:10px} +.steps .ok{width:20px;height:20px;border-radius:50%;background:var(--pine);color:var(--sheet);display:grid;place-items:center} +.steps .ok svg.i{width:12px;height:12px;stroke-width:3} +.steps .now{width:20px;height:20px;border-radius:50%;border:2px solid var(--seal);border-right-color:transparent;animation:spin .9s linear infinite} +.steps .todo{width:20px;height:20px;border-radius:50%;border:2px dashed var(--rule-strong)} +.steps li.pending{color:var(--ink-soft)} +@keyframes spin{to{transform:rotate(360deg)}} +.caret{display:inline-block;width:8px;height:18px;background:var(--seal);vertical-align:-3px;margin-left:2px;border-radius:2px;animation:blink 1s steps(1) infinite} +@keyframes blink{50%{opacity:0}} + +/* empty state */ +.hello{max-width:760px;margin:0 auto;padding:12vh 32px 220px;display:flex;flex-direction:column;gap:30px} +.hello h1{margin:0;font-family:var(--serif);font-weight:500;font-size:40px;line-height:1.15;letter-spacing:-.02em;max-width:18ch} +.hello .lede{margin:0;font-size:16.5px;color:var(--ink-soft);max-width:56ch} +.starters{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} +.starter{display:flex;flex-direction:column;gap:6px;text-align:left;padding:16px 18px;border-radius:14px;border:1px solid var(--rule);background:var(--sheet);cursor:pointer} +.starter:hover{border-color:var(--seal)} +.starter .k{display:flex;align-items:center;gap:8px;font-size:12.5px;color:var(--ink-soft)} +.starter .t{font-size:15.5px;font-weight:500;line-height:1.4} + +/* forms */ +.field{display:flex;flex-direction:column;gap:6px} +.field label{font-size:14px;font-weight:500} +.field input,.field select{padding:11px 13px;border-radius:10px;border:1px solid var(--rule-strong);background:var(--sheet);font-size:15px;color:var(--ink)} +.field input.focus-ring{border-color:var(--seal);box-shadow:0 0 0 3px var(--seal-wash);outline:none} +.field .help{font-size:13px;color:var(--ink-soft)} +.field .err{font-size:13px;color:var(--danger);display:flex;gap:6px;align-items:center} +.field input.bad{border-color:var(--danger)} +.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 18px;border-radius:12px;border:none;background:var(--seal);color:#FFF8EA;font-weight:600;font-size:15px;cursor:pointer} +.btn:hover{background:var(--seal-deep)} +.btn.ghost{background:none;border:1px solid var(--rule-strong);color:var(--ink);font-weight:500} +.btn.danger{background:var(--danger);color:#FFF6F2} +.toast{position:absolute;left:50%;transform:translateX(-50%);bottom:28px;display:flex;align-items:center;gap:10px;padding:10px 16px 10px 12px;border-radius:12px;background:var(--ink);color:var(--paper);font-size:14px;box-shadow:var(--float)} +.toast .i{color:#9ED0B8} +.pp .visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap} diff --git a/docs/superpowers/plans/2026-09-26-chat-redesign.md b/docs/superpowers/plans/2026-09-26-chat-redesign.md new file mode 100644 index 0000000..bbbfa60 --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-chat-redesign.md @@ -0,0 +1,2691 @@ +# Chat redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the approved chat redesign. + +- **Part A (backend PR):** streaming answers, renaming threads and cited + passages. +- **Part B (frontend PR):** the new chat, sign-in and profile screens. + +**Architecture:** + +- **Backend.** Part A adds three endpoints to the existing chat blueprint. + `generation.answer` becomes a thin wrapper over an event generator + (`answer_events`), which the new Server-Sent-Events route also consumes. A + pure `passages` service turns a stored citation into a brief, credited + quote. +- **Frontend.** Part B restyles the React app on the design's tokens. It + renders answers as Markdown, with the citation markers turned into + components, and it streams through a `fetch` reader. +- **Running the parts.** They run in parallel, one agent each. Part B codes + against the contracts in the spec (section 3) with mocked services. + +**Tech Stack:** + +- **Backend:** Flask 3.1, SQLAlchemy 2.0, Alembic, Pydantic, flask-limiter 4.1, + the openai 3.13 SDK and pytest. +- **Frontend:** React 19 (JSX), Vite 8, Vitest and React Testing Library, plus + the new `lucide-react`, `motion`, `react-markdown` and `remark-gfm`. + +**Spec:** `docs/superpowers/specs/2026-09-26-chat-redesign-design.md`. The +mockups are in `docs/design/chat-redesign/` (read `README.md` there first). +Every part of the spec is binding; this plan says how to build it. + +## Global Constraints + +- **Branches.** Part A goes on `feature_chat_redesign_api`, and Part B on + `feature_chat_redesign_ui`, branched from the plan commit. Never commit to + `main`. +- **No attribution.** Commits and PRs carry no Claude or AI co-author lines + (CLAUDE.md overrides any tool reminder). +- **CHANGELOG.** Update `CHANGELOG.md` (`## Unreleased`) in every commit with + crisp bullets. +- **Never commit** a `.env`, credentials, or real user data. Nothing under + `data/` is ever committed or deleted. +- **SQL.** Parameterised SQLAlchemy only. Never log question text, answer text, + passages, ZIP codes, ages or dates of birth; log ids and lengths. +- **Ownership failures return 404, never 403.** +- **Rate limits:** + + | Endpoint | Limit | + | --- | --- | + | Rename | 30 per minute | + | Source lookup | 60 per minute | + | Both send routes | 15 per minute, **shared** (`scope="chat_send"`) | +- **Excerpts are ≤300 characters** of passage text, plus `… ` or ` …` marks. + A URL is returned only when it is `https` and `unsafe_reason(url) is None`. +- **Event names** are exactly `user_message`, `stage`, `notice`, `delta`, + `reset`, `done` and `error`. **Stage codes** are exactly `understanding`, + `searching`, `plans`, `coverage` and `writing`. +- **Passage `kind`** is exactly `sbc`, `wikipedia`, `healthcare_gov` or + `other`. **`status`** is exactly `ok`, `unverified`, `changed` or `missing`. +- **Frontend dependencies:** `lucide-react`, `motion`, `react-markdown` and + `remark-gfm`, pinned to exact versions (no `^`), checked with `npm audit + --omit=dev`. No other new dependency. +- **Markup and copy come from the artboards.** Visual markup, class names and + copy come from `docs/design/chat-redesign/*.dc.html`, and the tokens from + `docs/design/chat-redesign/tokens.css`. When the plan names an artboard + element, port its markup and copy exactly; wording differs only where the + spec says so. +- **UI copy.** Sentence case. No all-caps labels. No `·` joining metadata. No + `→` appended to buttons. +- **Quality gates:** + - Backend: `make check` (ruff, pytest with coverage ≥85%, bandit, pip-audit, + `alembic check`). + - Frontend: `npm run lint`, `npm test` and `npm run build`, all green. + +## Review Focus + +1. **A reset mid-answer.** A round streams text, then calls a tool: the UI + clears the draft, and `done` equals what was saved. Tests are in Task A5 + (`test_reset_when_a_streamed_round_calls_a_tool`) and Task B6 (`reset` + clears the text). +2. **A client disconnect mid-stream.** Tokens are recorded exactly once, no + assistant message is saved, and nothing is left uncommitted. Tested in Task + A6 (`test_disconnect_records_spend_once_and_saves_no_answer`). +3. **Reloading a thread** must not replay the stamp. An old citation without a + hash says it may have changed. Tested in Task B7 (the stamp runs only for + `finishedId`) and Task A4 (`unverified`). +4. **A double send** (Enter held, or two clicks). The composer is disabled + while an answer is live, and the limit is shared across routes. Tested in + Task B9 (the composer is disabled while live) and Task A6 (the shared + limit). +5. **A 390 px viewport.** No sideways page scroll; the plan table scrolls in + its own box. Tested in Task B11's Playwright pass (`scrollWidth === + clientWidth`). + +--- + +# Part A — Backend (`feature_chat_redesign_api`) + +Run every command from the repo root. The tests use the local Postgres +(`docker compose up -d db` and `make migrate`, as for any `make check`). The +existing fixtures: `client` shares one rolled-back connection per test; +`_auth_headers(client, email)` in `tests/test_chat.py` registers and returns a +Bearer header. + +### Task A1: Renaming a thread + +**Files:** +- Modify: `src/schemas/chat.py`: add `ThreadRenameRequest`. +- Modify: `src/api/routes/chat.py`: add the `rename_thread` route after `delete_thread`. +- Test: `tests/test_chat.py`, `tests/test_rate_limits.py` + +**Interfaces:** +- Produces: `PATCH /api/chat/threads/` with body `{"title": str}`, + returning 200 and a `ThreadResponse` JSON. + +- [ ] **Step 1: Write the failing tests** (append to `tests/test_chat.py`) + +```python +def _new_thread(client, headers): + return client.post("/api/chat/threads", json={}, headers=headers).get_json()["id"] + + +def test_rename_thread_sets_a_stripped_title(client): + headers = _auth_headers(client, email="rename@example.com") + thread_id = _new_thread(client, headers) + + resp = client.patch(f"/api/chat/threads/{thread_id}", json={"title": " Silver plans, San Diego "}, headers=headers) + + assert resp.status_code == 200 + assert resp.get_json()["title"] == "Silver plans, San Diego" + listed = client.get("/api/chat/threads", headers=headers).get_json() + assert listed[0]["title"] == "Silver plans, San Diego" + + +def test_rename_does_not_move_a_thread_up_the_list(client): + # Every request in a test shares one transaction, where now() never moves, + # so a bump could not be seen: the thread is first dated a week back. + headers = _auth_headers(client, email="rename-order@example.com") + older = _new_thread(client, headers) + newer = _new_thread(client, headers) + week_ago = datetime.now(UTC) - timedelta(days=7) + db = deps.SessionLocal() + db.execute(update(Thread).where(Thread.id == uuid.UUID(older)).values(updated_at=week_ago)) + db.flush() + + client.patch(f"/api/chat/threads/{older}", json={"title": "Renamed"}, headers=headers) + + after = client.get("/api/chat/threads", headers=headers).get_json() + assert [t["id"] for t in after] == [newer, older] + assert datetime.fromisoformat(after[1]["updated_at"]) == week_ago + + +@pytest.mark.parametrize("title", ["", " ", "x" * 201]) +def test_rename_rejects_a_blank_or_long_title(client, title): + headers = _auth_headers(client, email=f"rename-bad{len(title)}@example.com") + thread_id = _new_thread(client, headers) + + resp = client.patch(f"/api/chat/threads/{thread_id}", json={"title": title}, headers=headers) + + assert resp.status_code == 422 + + +@pytest.mark.parametrize("thread_id", ["00000000-0000-0000-0000-000000000000", "not-a-uuid"]) +def test_rename_of_a_missing_thread_is_404(client, thread_id): + headers = _auth_headers(client, email="rename-missing@example.com") + assert client.patch(f"/api/chat/threads/{thread_id}", json={"title": "x"}, headers=headers).status_code == 404 + + +def test_cannot_rename_another_users_thread(client): + owner = _auth_headers(client, email="rename-owner@example.com") + thread_id = _new_thread(client, owner) + other = _auth_headers(client, email="rename-other@example.com") + + assert client.patch(f"/api/chat/threads/{thread_id}", json={"title": "x"}, headers=other).status_code == 404 + + +def test_rename_requires_auth(client): + assert client.patch("/api/chat/threads/00000000-0000-0000-0000-000000000000", json={"title": "x"}).status_code == 401 +``` + +At the top of `tests/test_chat.py`, add whichever of these imports are missing: +`import pytest`, `from datetime import UTC, datetime, timedelta`, +`from sqlalchemy import update` and `from src.models.chat import Thread`. The +module already imports `from src.api import deps`. Then +append to `tests/test_rate_limits.py`: + +```python +def test_rename_thread_is_rate_limited(client): + headers = _auth_headers(client, "rename-rl@example.com") + thread_id = client.post("/api/chat/threads", json={}, headers=headers).get_json()["id"] + + responses = _hammer(client, "PATCH", f"/api/chat/threads/{thread_id}", times=31, headers=headers, + body={"title": "t"}) + + assert [r.status_code for r in responses[:30]] == [200] * 30 + assert responses[30].status_code == 429 + assert "Retry-After" in responses[30].headers +``` + +- [ ] **Step 2: Run them and confirm they fail** + +Run: `uv run pytest tests/test_chat.py -k rename tests/test_rate_limits.py -k rename -q` +Expected: FAIL. The PATCH answers 405 Method Not Allowed. + +- [ ] **Step 3: Implement** + +In `src/schemas/chat.py`, add `field_validator` to the existing pydantic import: + +```python +class ThreadRenameRequest(BaseModel): + title: str = Field(max_length=200) + + @field_validator("title") + @classmethod + def _not_blank(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("title must not be blank") + return value +``` + +`max_length` is checked before the validator strips, so a title padded past +200 is refused. That is intended and simpler than stripping first. + +In `src/api/routes/chat.py`, import `update` from sqlalchemy and +`ThreadRenameRequest`, then add: + +```python +@bp.patch("/threads/") +@jwt_required() +@limiter.limit("30 per minute") +def rename_thread(thread_id: str): + body = parse_body(ThreadRenameRequest) + db, thread = _get_owned_thread(thread_id) + + # updated_at is the thread's last activity and orders the list: renaming + # is not activity, so it is set to itself, which stops onupdate firing. + db.execute( + update(Thread).where(Thread.id == thread.id).values(title=body.title, updated_at=Thread.updated_at) + ) + db.refresh(thread) + return jsonify(ThreadResponse.model_validate(thread, from_attributes=True).model_dump(mode="json")) +``` + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `uv run pytest tests/test_chat.py tests/test_rate_limits.py -q` +Expected: all PASS. + +- [ ] **Step 5: CHANGELOG and commit** + +Add under `## Unreleased` → `### Added`: +`- \`PATCH /api/chat/threads/\` renames a thread (1–200 characters, trimmed; 30 per minute). It leaves the list's order alone.` + +```bash +git add src/schemas/chat.py src/api/routes/chat.py tests/test_chat.py tests/test_rate_limits.py CHANGELOG.md +git commit -m "Rename a thread without moving it up the list" +``` + +### Task A2: Citation ids and content hashes + +**Files:** +- Create: `migrations/versions/20260926_1200-9c1e4b7a2d10_add_message_source_content_hash.py` +- Create: `src/services/passages.py`. This task adds only `content_hash`; Task A3 fills in the rest. +- Modify: `src/models/chat.py` (`MessageSource`) +- Modify: `src/schemas/chat.py` (`SourceResponse`) +- Modify: `src/api/routes/chat.py`: add `_context` and `_save_answer`, and use them in `create_message`. +- Test: `tests/test_chat.py`, `tests/test_models_chat.py` + +**Interfaces:** +- Produces: + - `passages.content_hash(text: str) -> str`: the SHA-256 hex digest. + - `MessageSource.content_sha256: str | None`. + - `SourceResponse.id: uuid.UUID`. +- Produces (route module, private, reused by A6): + - `_context(db, thread) -> tuple[list[dict], tuple[ShownPlan, ...]]`. + - `_save_answer(db, thread, question: str, result: Answer) -> Message`. + +- [ ] **Step 1: Write the failing tests** (`tests/test_chat.py`) + +```python +import hashlib + +from src.models.chat import MessageSource + + +@patch("src.api.routes.chat.answer_query") +def test_sources_carry_an_id_and_the_passage_hash(mock_answer_query, client): + mock_answer_query.return_value = Answer("A deductible is what you pay first.", _fake_chunks()) + headers = _auth_headers(client, email="hash@example.com") + thread_id = client.post("/api/chat/threads", json={}, headers=headers).get_json()["id"] + + sent = client.post(f"/api/chat/threads/{thread_id}/messages", json={"content": "Deductible?"}, headers=headers) + + source = sent.get_json()["sources"][0] + assert uuid.UUID(source["id"]) + reopened = client.get(f"/api/chat/threads/{thread_id}/messages", headers=headers).get_json() + assert next(m for m in reopened if m["role"] == "assistant")["sources"][0]["id"] == source["id"] + from src.api import deps as api_deps # the test's shared session + row = api_deps.SessionLocal().get(MessageSource, uuid.UUID(source["id"])) + assert row.content_sha256 == hashlib.sha256("A deductible is...".encode()).hexdigest() +``` + +In `tests/test_models_chat.py`, add a test that a `MessageSource` saves with +`content_sha256=None`. Follow the file's existing `session` fixture style. + +- [ ] **Step 2: Run them and confirm they fail** + +Run: `uv run pytest tests/test_chat.py -k hash -q` +Expected: FAIL with `KeyError: 'id'`. + +- [ ] **Step 3: Implement** + +The migration: `down_revision = "3b1d6e2a9c47"`, `revision = "9c1e4b7a2d10"`. +Its header docstring follows the last migration's shape. + +```python +def upgrade() -> None: + # The passage text an answer used, hashed, so a rebuilt chunk that now says + # something else is never shown as what was cited (ADR 0007, 0027). Older + # citations stay NULL: "unverified". + op.add_column("message_sources", sa.Column("content_sha256", sa.String(length=64), nullable=True)) + + +def downgrade() -> None: + op.drop_column("message_sources", "content_sha256") +``` + +In `MessageSource`: + +```python + # SHA-256 of the passage text as the answer used it; NULL on citations saved + # before ADR 0027. How the passage endpoint tells a rebuilt chunk apart. + content_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) +``` + +In `SourceResponse`, add `id: uuid.UUID` as its first field. + +Then `src/services/passages.py`: + +```python +"""Cited passages, as the Sources panel shows them (ADR 0027).""" +import hashlib + + +def content_hash(text: str) -> str: + """The passage text's fingerprint, stored with each citation.""" + return hashlib.sha256(text.encode()).hexdigest() +``` + +In `src/api/routes/chat.py`, lift the history and shown-plans lines, and the +assistant-message block, out of `create_message` into helpers. Then have +`create_message` call them: + +```python +def _context(db, thread) -> tuple[list[dict], tuple[ShownPlan, ...]]: + """The thread's history and last-shown plans, read before the new question is added.""" + prior = db.execute( + select(Message).where(Message.thread_id == thread.id).order_by(Message.created_at) + ).scalars().all() + return [{"role": m.role, "content": m.content} for m in prior], _shown_plans(db, thread.id) + + +def _save_answer(db, thread, question: str, result) -> Message: + """The assistant message with its citations and plan snapshots (ADR 0007, 0011).""" + message = Message( + thread_id=thread.id, + role="assistant", + content=result.text, + sources=[ + MessageSource(chunk_id=c.chunk_id, source=c.source, relevance=c.score, + content_sha256=content_hash(c.content)) + for c in result.chunks + ], + plans=[_plan_row(position, plan) for position, plan in enumerate(result.plans)], + ) + db.add(message) + db.flush() + if thread.title is None: + thread.title = question[:80] + return message +``` + +The body of `create_message` then reads: +`history, shown_plans = _context(db, thread)` … and, after the answer, +`assistant_message = _save_answer(db, thread, body.content, result)`. + +- [ ] **Step 4: Run the tests and confirm they pass; check the migration** + +Run: +```bash +make migrate && uv run alembic downgrade -1 && uv run alembic upgrade head && uv run alembic check +uv run pytest tests/test_chat.py tests/test_models_chat.py tests/test_token_budget.py -q +``` +Expected: `alembic check` reports no new upgrade operations, and all tests +PASS. + +- [ ] **Step 5: CHANGELOG and commit** + +Add these bullets under `### Added`: +- `Each citation now carries its id, and a SHA-256 of the passage text it used (migration 9c1e4b7a2d10).` + +```bash +git add migrations/versions/*9c1e4b7a2d10* src/models/chat.py src/schemas/chat.py src/api/routes/chat.py src/services/passages.py tests/test_chat.py tests/test_models_chat.py CHANGELOG.md +git commit -m "Give each citation an id and a hash of the passage it used" +``` + +### Task A3: The passages service (excerpt and source kinds) + +**Files:** +- Create: `src/core/urls.py`, holding `unsafe_reason`, moved here. +- Modify: `src/ingestion/sbc/fetch.py`: `from src.core.urls import unsafe_reason`, keeping the name importable from `fetch`. +- Modify: `src/ingestion/sources/registry.py`: add a public `registered(source_id)`. +- Modify: `src/services/passages.py` +- Test: create `tests/test_passages.py`; the existing `tests/test_sbc_fetch.py` must stay green. + +**Interfaces:** +- Consumes: `content_hash` (A2). +- Produces: + - `passages.EXCERPT_CHARS = 300`. + - `passages.excerpt(passage: str, answer_text: str, limit: int = EXCERPT_CHARS) -> str`. + - `passages.Passage`: a frozen dataclass with `kind, title, document, section, + quote, status, url, license`. `license` is `dict | None` with keys `name` and + `url`. + - `passages.passage_for(db, chunk_id: str, label: str, stored_hash: str | None, answer_text: str) -> Passage`. + - `registry.registered(source_id: str) -> RegisteredSource | None`. + - `src.core.urls.unsafe_reason(url: str) -> str | None`. + +- [ ] **Step 1: Write the failing tests** (`tests/test_passages.py`) + +```python +from src.models.chunk import Chunk +from src.models.sbc import SbcChunk, SbcDocument +from src.services.passages import EXCERPT_CHARS, content_hash, excerpt, passage_for + +LONG = ("Emergency room care | $400 copay/visit; deductible does not apply | Cost sharing waived if admitted.\n" + "Emergency medical transportation | $250 copay/trip; deductible does not apply | None\n" + "Urgent care | $50 copay/visit; deductible does not apply | $50 copay/visit; deductible does not apply\n" + "Hospital stay | 30% coinsurance | Preauthorization is required for non-emergency services.") + + +def test_a_short_passage_is_returned_whole(): + assert excerpt("Copayment: A fixed amount you pay.", "anything") == "Copayment: A fixed amount you pay." + + +def test_the_excerpt_starts_at_the_segment_most_like_the_answer(): + text = excerpt(LONG, "It charges a $50 copay per urgent care visit.") + assert text.startswith("… Urgent care | $50 copay/visit") + assert len(text.removeprefix("… ").removesuffix(" …")) <= EXCERPT_CHARS + + +def test_ties_go_to_the_earlier_segment(): + assert excerpt(LONG, "nothing in common here").startswith("Emergency room care") + + +def test_a_cut_excerpt_is_marked_at_the_end(): + assert excerpt(LONG, "Emergency room care").endswith(" …") + + +def test_a_single_long_segment_is_cut_at_a_space(): + words = " ".join(["deductible"] * 60) + text = excerpt(words, "deductible") + body = text.removesuffix(" …") + assert len(body) <= EXCERPT_CHARS and not body.endswith(" ") and text.endswith(" …") + + +def test_empty_inputs(): + assert excerpt("", "x") == "" + assert excerpt(LONG, "").startswith("Emergency room care") + + +def _sbc(session, url="https://www.sharphealthplan.com/docs/sbc.pdf"): + doc = SbcDocument(url=url, plan_year=2026, status="ok", title="Sharp Health Plan: Sharp Silver 70 Premier HMO") + session.add(doc) + session.flush() + session.add(SbcChunk(document_id=doc.id, chunk_id="sbc_2026_abc_s11_c00", section="If you need immediate medical attention", + position=0, content=LONG)) + session.flush() + + +SBC_LABEL = "Sharp Silver 70 Premier HMO - Summary of Benefits - If you need immediate medical attention.pdf" + + +def test_an_sbc_passage(session): + _sbc(session) + p = passage_for(session, "sbc_2026_abc_s11_c00", SBC_LABEL, content_hash(LONG), "urgent care $50") + assert (p.kind, p.title, p.document, p.section, p.status) == ( + "sbc", "Sharp Silver 70 Premier HMO", "Summary of Benefits and Coverage, 2026", + "If you need immediate medical attention", "ok") + assert p.url == "https://www.sharphealthplan.com/docs/sbc.pdf" and p.license is None + assert "Urgent care" in p.quote + + +def test_an_unsafe_document_url_is_dropped(session): + _sbc(session, url="http://www.sharphealthplan.com/docs/sbc.pdf") + assert passage_for(session, "sbc_2026_abc_s11_c00", SBC_LABEL, None, "").url is None + + +def test_statuses(session): + _sbc(session) + assert passage_for(session, "sbc_2026_abc_s11_c00", SBC_LABEL, None, "").status == "unverified" + changed = passage_for(session, "sbc_2026_abc_s11_c00", SBC_LABEL, content_hash("other text"), "") + assert (changed.status, changed.quote) == ("changed", None) + missing = passage_for(session, "sbc_2026_gone_s01_c00", SBC_LABEL, None, "") + assert (missing.status, missing.quote, missing.title) == ("missing", None, "Sharp Silver 70 Premier HMO") + + +def test_a_wikipedia_passage_is_credited_from_the_registry(session): + session.add(Chunk(chunk_id="wikipedia_Health_insurance_s0_c00", source="wiki_Health_insurance.txt", + content="Health insurance covers medical expenses.", embedding=[0.0] * 384)) + session.flush() + p = passage_for(session, "wikipedia_Health_insurance_s0_c00", "wiki_Health_insurance.txt", None, "") + assert (p.kind, p.title, p.document) == ("wikipedia", "Health insurance", "Wikipedia article") + assert p.url == "https://en.wikipedia.org/wiki/Health_insurance" + assert p.license == {"name": "CC BY-SA 4.0", "url": "https://creativecommons.org/licenses/by-sa/4.0/"} + + +def test_a_healthcare_gov_passage(session): + session.add(Chunk(chunk_id="healthcare_gov_glossary_Copayment_s0_c00", source="hcg_glossary_Copayment.md", + content="Copayment: A fixed amount you pay for a plan-covered service, like $30.", embedding=[0.0] * 384)) + session.flush() + p = passage_for(session, "healthcare_gov_glossary_Copayment_s0_c00", "hcg_glossary_Copayment.md", None, "") + assert (p.kind, p.title, p.document, p.url, p.license) == ( + "healthcare_gov", "Copayment", "HealthCare.gov glossary", None, None) +``` + +Check the `SbcDocument` required columns against `src/models/sbc.py`. If +`status` or `title` differ, pass whatever the model requires. Take the +embedding width from `settings.embedding_dim` rather than hard-coding 384: +`from src.policypal.config import settings`, then +`[0.0] * settings.embedding_dim`. + +- [ ] **Step 2: Run them and confirm they fail** + +Run: `uv run pytest tests/test_passages.py -q` +Expected: FAIL with `ImportError: cannot import name 'excerpt'`. + +- [ ] **Step 3: Implement** + +`src/core/urls.py`: move the body of `unsafe_reason` verbatim from +`src/ingestion/sbc/fetch.py:156-168`, with its `ipaddress` and `urlsplit` +imports. In `fetch.py`, delete the function and add `from src.core.urls import +unsafe_reason # re-exported: callers import it from here`. + +`registry.py`: + +```python +def registered(source_id: str) -> RegisteredSource | None: + """The committed registry's entry for `source_id`, enabled or not.""" + return _committed().get(source_id) +``` + +Complete `src/services/passages.py`: + +```python +"""Cited passages, as the Sources panel shows them (ADR 0027). + +A quote is brief (ADR 0013: SBC text is quoted, never redistributed) and is +the part of the passage most like the answer that cited it. Every field is +derived here, by the server, never taken from model output. +""" +import hashlib +import re +from dataclasses import dataclass +from urllib.parse import quote + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from src.core.urls import unsafe_reason +from src.ingestion.sources.registry import registered +from src.models.chunk import Chunk +from src.models.sbc import SbcChunk, SbcDocument + +EXCERPT_CHARS = 300 +_SBC_SEPARATOR = " - Summary of Benefits - " +_SENTENCE_END = re.compile(r"(?<=[.!?])\s+") +_WORD = re.compile(r"[a-z0-9]+") +_STOP_WORDS = frozenset( + "the and for are but not you your with this that from have has was were will can its any all per may out".split() +) + + +@dataclass(frozen=True) +class Passage: + kind: str # sbc | wikipedia | healthcare_gov | other + title: str + document: str | None + section: str | None + quote: str | None + status: str # ok | unverified | changed | missing + url: str | None + license: dict | None # {"name", "url"} + + +def content_hash(text: str) -> str: + """The passage text's fingerprint, stored with each citation.""" + return hashlib.sha256(text.encode()).hexdigest() + + +def _words(text: str) -> set[str]: + return {w for w in _WORD.findall(text.lower()) if (len(w) >= 3 or w.isdigit()) and w not in _STOP_WORDS} + + +def _segments(text: str) -> list[str]: + return [s.strip() for line in text.splitlines() for s in _SENTENCE_END.split(line) if s.strip()] + + +def excerpt(passage: str, answer_text: str, limit: int = EXCERPT_CHARS) -> str: + """At most `limit` characters of `passage`, starting where it best matches the answer.""" + text = passage.strip() + if len(text) <= limit: + return text + segments = _segments(text) + wanted = _words(answer_text) + best = max(range(len(segments)), key=lambda i: (len(_words(segments[i]) & wanted), -i)) + + chosen: list[str] = [] + length = 0 + for segment in segments[best:]: + added = len(segment) + (1 if chosen else 0) + if length + added > limit: + break + chosen.append(segment) + length += added + + if chosen: + body = " ".join(chosen) + reaches_end = best + len(chosen) == len(segments) + else: + cut = segments[best][:limit] + body = cut.rsplit(" ", 1)[0] if " " in cut else cut + reaches_end = False + return ("… " if best > 0 else "") + body + ("" if reaches_end else " …") + + +def _safe(url: str | None) -> str | None: + return url if url and unsafe_reason(url) is None else None + + +def _stem(label: str) -> str: + return label.rsplit(".", 1)[0] if "." in label else label + + +def _corpus_fields(label: str) -> tuple[str, str, str | None, str | None, dict | None]: + """kind, title, document, url, license for a general-corpus label (ADR 0003).""" + stem = _stem(label) + if stem.startswith("wiki_"): + title = stem.removeprefix("wiki_") + entry = registered("wikipedia") + license_ = {"name": entry.license, "url": entry.license_url} if entry else None + url = "https://en.wikipedia.org/wiki/" + quote(title.replace(" ", "_")) + return "wikipedia", title.replace("_", " "), "Wikipedia article", url, license_ + for prefix, document in (("hcg_glossary_", "HealthCare.gov glossary"), ("hcg_article_", "HealthCare.gov article")): + if stem.startswith(prefix): + return "healthcare_gov", stem.removeprefix(prefix).replace("_", " "), document, None, None + return "other", stem, None, None, None + + +def _status(text: str | None, stored_hash: str | None) -> str: + if text is None: + return "missing" + if stored_hash is None: + return "unverified" + return "ok" if content_hash(text) == stored_hash else "changed" + + +def passage_for(db: Session, chunk_id: str, label: str, stored_hash: str | None, answer_text: str) -> Passage: + """The citation `label` → `chunk_id`, as it can be shown now.""" + if chunk_id.startswith("sbc_"): + row = db.execute( + select(SbcChunk.content, SbcDocument.url, SbcDocument.plan_year) + .join(SbcDocument, SbcDocument.id == SbcChunk.document_id) + .where(SbcChunk.chunk_id == chunk_id) + ).first() + plan, _, section = _stem(label).partition(_SBC_SEPARATOR) + text = row.content if row else None + document = f"Summary of Benefits and Coverage, {row.plan_year}" if row else "Summary of Benefits and Coverage" + kind, title, url, license_ = "sbc", plan.strip(), _safe(row.url if row else None), None + section = section.strip() or None + else: + text = db.scalar(select(Chunk.content).where(Chunk.chunk_id == chunk_id)) + kind, title, document, url, license_ = _corpus_fields(label) + section = None + + status = _status(text, stored_hash) + shown = excerpt(text, answer_text) if status in ("ok", "unverified") else None + return Passage(kind, title, document, section, shown, status, url, license_) +``` + +`_stem` drops only the trailing extension. SBC labels always end in `.pdf` +(`plan_coverage.source_label`), so the section is recovered exactly. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `uv run pytest tests/test_passages.py tests/test_sbc_fetch.py tests/test_sbc_manifest.py tests/test_registry.py -q` +Expected: all PASS. + +- [ ] **Step 5: CHANGELOG and commit** + +Add this bullet under `### Added`: +- `The passages service turns a citation into a brief quote (≤300 characters, the part most like the answer), with its source, link and licence.` + +Add this one under `### Changed`: +- `\`unsafe_reason\` moved to \`src/core/urls.py\`.` + +```bash +git add src/core/urls.py src/ingestion/sbc/fetch.py src/ingestion/sources/registry.py src/services/passages.py tests/test_passages.py CHANGELOG.md +git commit -m "Quote a cited passage briefly, with its source and licence" +``` + +### Task A4: The source lookup endpoint + +**Files:** +- Modify: `src/schemas/chat.py`: add `PassageLicense` and `PassageResponse`. +- Modify: `src/api/routes/chat.py`: add `get_source`. +- Test: `tests/test_chat.py`, `tests/test_rate_limits.py` + +**Interfaces:** +- Consumes: `passage_for` (A3), `MessageSource.content_sha256` (A2). +- Produces: `GET /api/chat/sources/`, returning a `PassageResponse` + JSON with the spec's fields and an `id`. + +- [ ] **Step 1: Write the failing tests** (`tests/test_chat.py`) + +```python +def _one_source(client, email, chunks=None): + headers = _auth_headers(client, email=email) + thread_id = client.post("/api/chat/threads", json={}, headers=headers).get_json()["id"] + with patch("src.api.routes.chat.answer_query", return_value=Answer("Health insurance covers costs.", chunks or [ + RetrievedChunk(chunk_id="wikipedia_T_s0_c00", content="Health insurance covers medical expenses.", + source="wiki_Health_insurance.txt", score=0.88)])): + sent = client.post(f"/api/chat/threads/{thread_id}/messages", json={"content": "What is it?"}, headers=headers) + return headers, sent.get_json()["sources"][0]["id"] + + +def test_a_source_reads_as_missing_when_its_chunk_is_gone(client): + headers, source_id = _one_source(client, "src-missing@example.com") + + resp = client.get(f"/api/chat/sources/{source_id}", headers=headers) + + assert resp.status_code == 200 + body = resp.get_json() + assert body["id"] == source_id + assert (body["kind"], body["title"], body["status"], body["quote"]) == ("wikipedia", "Health insurance", "missing", None) + assert body["license"]["name"] == "CC BY-SA 4.0" + + +def test_a_source_quotes_its_chunk(client): + from src.api import deps as api_deps + db = api_deps.SessionLocal() + db.add(Chunk(chunk_id="wikipedia_T_s0_c00", source="wiki_Health_insurance.txt", + content="Health insurance covers medical expenses.", embedding=[0.0] * settings.embedding_dim)) + db.flush() + headers, source_id = _one_source(client, "src-ok@example.com") + + body = client.get(f"/api/chat/sources/{source_id}", headers=headers).get_json() + + assert (body["status"], body["quote"]) == ("ok", "Health insurance covers medical expenses.") + + +def test_another_users_source_is_404(client): + _, source_id = _one_source(client, "src-owner@example.com") + other = _auth_headers(client, email="src-other@example.com") + assert client.get(f"/api/chat/sources/{source_id}", headers=other).status_code == 404 + + +@pytest.mark.parametrize("source_id", ["00000000-0000-0000-0000-000000000000", "nope"]) +def test_a_missing_source_is_404(client, source_id): + headers = _auth_headers(client, email="src-none@example.com") + assert client.get(f"/api/chat/sources/{source_id}", headers=headers).status_code == 404 + + +def test_sources_require_auth(client): + assert client.get("/api/chat/sources/00000000-0000-0000-0000-000000000000").status_code == 401 +``` + +Add `from src.models.chunk import Chunk` and `from src.policypal.config import +settings` to the imports. For the rate-limit test in `tests/test_rate_limits.py`, +hammer `GET /api/chat/sources/00000000-0000-0000-0000-000000000000` 61 times. +The first 60 are 404, and the 61st is 429 with `Retry-After`: a 404 counts +towards the limit, as the file's existing tests show. + +- [ ] **Step 2: Run them and confirm they fail** + +Run: `uv run pytest tests/test_chat.py -k source -q` +Expected: FAIL (404 for every lookup, since the route doesn't exist). + +- [ ] **Step 3: Implement** + +`src/schemas/chat.py`: + +```python +class PassageLicense(BaseModel): + name: str + url: str + + +class PassageResponse(BaseModel): + """A cited passage as it can be shown now (ADR 0027).""" + + id: uuid.UUID + kind: str + title: str + document: str | None + section: str | None + quote: str | None + status: str + url: str | None + license: PassageLicense | None +``` + +`src/api/routes/chat.py`: + +```python +@bp.get("/sources/") +@jwt_required() +@limiter.limit("60 per minute") +def get_source(source_id: str): + """One of the current user's citations, quoted briefly. Addressed by the + citation's own id, never by chunk id: chunk ids are guessable (ADR 0027).""" + db = get_db() + user = get_current_user() + try: + parsed_id = uuid.UUID(source_id) + except ValueError: + abort(404) + + row = db.execute( + select(MessageSource, Message.content) + .join(Message, Message.id == MessageSource.message_id) + .join(Thread, Thread.id == Message.thread_id) + .where(MessageSource.id == parsed_id, Thread.user_id == user.id) + ).first() + if row is None: + abort(404) + + source, answer_text = row + passage = passage_for(db, source.chunk_id, source.source, source.content_sha256, answer_text) + response = PassageResponse(id=source.id, **dataclasses.asdict(passage)) + return jsonify(response.model_dump(mode="json")) +``` + +Import `dataclasses`, `passage_for` and `PassageResponse`. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `uv run pytest tests/test_chat.py tests/test_rate_limits.py -q` +Expected: all PASS. + +- [ ] **Step 5: CHANGELOG and commit** + +`- \`GET /api/chat/sources/\` returns one of your citations as a brief quote: ok, unverified (older answers), changed or missing (60 per minute; another user's citation is 404).` + +```bash +git add src/schemas/chat.py src/api/routes/chat.py tests/test_chat.py tests/test_rate_limits.py CHANGELOG.md +git commit -m "Show a citation's passage to the user who was answered with it" +``` + +### Task A5: Streaming generation events + +**Files:** +- Modify: `src/services/generation.py` +- Test: `tests/test_generation.py` + +**Interfaces:** +- Produces: + - The frozen dataclasses `Stage(name: str)`, `Notice(text: str)`, + `Delta(text: str)`, `Reset()` and `Done(answer: Answer)`. + - `answer_events(query, chunks, history=None, profile=None, shown_plans=(), *, stream=False) -> Iterator[event]`. + The last event is always `Done`. + - `answer_query_events(query, history=None, top_k=None, profile=None, shown_plans=(), *, stream=True) -> Iterator[event]`. + - `answer()` and `answer_query()` keep their signatures and return values + exactly. **`answer_query` stays as it is (it calls `answer`), because + `tests/test_generation.py:294,305` patches `generation.answer`.** + +- [ ] **Step 1: Write the failing tests** (append to `tests/test_generation.py`) + +```python +from src.services.generation import Delta, Done, Notice, Reset, Stage, answer_events, answer_query_events + + +def _chunk(content=None, tool_calls=None, finish=None, usage=None): + """One streamed chunk, shaped like the SDK's ChatCompletionChunk.""" + choices = [] if content is None and tool_calls is None and finish is None else [SimpleNamespace( + delta=SimpleNamespace(content=content, tool_calls=tool_calls), finish_reason=finish)] + return SimpleNamespace(choices=choices, usage=usage) + + +def _tool_delta(index, call_id=None, name=None, arguments=None): + return SimpleNamespace(index=index, id=call_id, + function=SimpleNamespace(name=name, arguments=arguments)) + + +def _streaming_client(*rounds): + client = MagicMock() + client.chat.completions.create.side_effect = [iter(r) for r in rounds] + return client + + +def _events(gen): + return list(gen) + + +def test_streamed_answer_matches_the_blocking_one(): + chunks = [RetrievedChunk("c1", "A deductible is the amount you pay first.", "wiki_D.txt", 0.9)] + rounds = [_chunk("A deductible "), _chunk("is what you pay first."), _chunk(finish="stop"), + _chunk(usage=SimpleNamespace(total_tokens=42))] + with patch("src.services.generation._llm", return_value=_streaming_client(rounds)): + reset_token_usage() + events = _events(answer_events("What is a deductible?", chunks, stream=True)) + assert events[0] == Stage("writing") + assert [e.text for e in events if isinstance(e, Delta)] == ["A deductible ", "is what you pay first."] + assert events[-1] == Done(Answer("A deductible is what you pay first.", chunks)) + assert token_usage() == 42 + + +def test_reset_when_a_streamed_round_calls_a_tool(_no_plan_catalog): + _no_plan_catalog.return_value = True + chunks = [RetrievedChunk("c1", "context", "wiki_D.txt", 0.9)] + first = [_chunk("Let me look"), _chunk(tool_calls=[_tool_delta(0, "call_1", "search_plans", '{"zip_code": ')]), + _chunk(tool_calls=[_tool_delta(0, arguments=_ARGS.removeprefix('{"zip_code": '))]), + _chunk(finish="tool_calls"), _chunk(usage=SimpleNamespace(total_tokens=5))] + second = [_chunk("Here are the plans."), _chunk(finish="stop"), _chunk(usage=SimpleNamespace(total_tokens=7))] + with patch("src.services.generation._llm", return_value=_streaming_client(first, second)), \ + patch("src.services.generation.run_tool", return_value=_plan_found()) as run_tool: + events = _events(answer_events("Silver plans?", chunks, stream=True)) + kinds = [type(e).__name__ for e in events] + assert kinds.index("Reset") > kinds.index("Delta") + assert Stage("plans") in events + assert run_tool.call_args.args[1] == _ARGS # fragments reassembled + assert events[-1].answer.text.endswith("Here are the plans.") + + +def test_nothing_streams_before_the_answer_is_grounded(_no_plan_catalog): + _no_plan_catalog.return_value = True + rounds = [_chunk("Ungrounded guess."), _chunk(finish="stop"), _chunk(usage=SimpleNamespace(total_tokens=3))] + with patch("src.services.generation._llm", return_value=_streaming_client(rounds)): + events = _events(answer_events("Hi", [], stream=True)) + assert not any(isinstance(e, Delta) for e in events) + assert events[-1].answer.text == NO_ANSWER_RESPONSE + + +def test_a_notice_is_sent_as_soon_as_a_tool_returns_it(_no_plan_catalog): + _no_plan_catalog.return_value = True + outcome = replace(_plan_found(), notice="These are 2026 plans.") + first = [_chunk(tool_calls=[_tool_delta(0, "call_1", "search_plans", _ARGS)]), _chunk(finish="tool_calls")] + second = [_chunk("Plans."), _chunk(finish="stop")] + with patch("src.services.generation._llm", return_value=_streaming_client(first, second)), \ + patch("src.services.generation.run_tool", return_value=outcome): + events = _events(answer_events("Plans?", [RetrievedChunk("c", "x", "wiki_X.txt", 0.9)], stream=True)) + assert events.index(Notice("These are 2026 plans.")) < next(i for i, e in enumerate(events) if isinstance(e, Delta)) + assert events[-1].answer.text.startswith("These are 2026 plans.") + + +def test_a_stream_without_usage_is_warned_about(caplog): + rounds = [_chunk("Text."), _chunk(finish="stop")] + with patch("src.services.generation._llm", return_value=_streaming_client(rounds)): + _events(answer_events("Q", [RetrievedChunk("c", "x", "wiki_X.txt", 0.9)], stream=True)) + assert "no usage" in caplog.text + + +def test_answer_query_events_reports_rewriting_and_searching(): + history = [{"role": "user", "content": "Earlier"}, {"role": "assistant", "content": "Reply"}] + with patch("src.services.generation.rewrite_query", return_value="standalone"), \ + patch("src.services.generation.search", return_value=[]) as search, \ + patch("src.services.generation.answer_events", return_value=iter([Done(Answer("x", []))])): + events = _events(answer_query_events("And?", history)) + assert events[:2] == [Stage("understanding"), Stage("searching")] + search.assert_called_once_with("standalone", None) +``` + +If `_plan_found()` returns something other than a `ToolOutcome`, use the +existing helper that does, as its current tests do. `replace` comes from +`dataclasses`. + +- [ ] **Step 2: Run them and confirm they fail** + +Run: `uv run pytest tests/test_generation.py -q -k "stream or reset or grounded or notice or usage or answer_query_events"` +Expected: FAIL with `ImportError: cannot import name 'Delta'`. + +- [ ] **Step 3: Implement** (in `src/services/generation.py`) + +Add these after `Answer`: + +```python +@dataclass(frozen=True) +class Stage: + """Progress, for the user: understanding | searching | plans | coverage | writing.""" + name: str + + +@dataclass(frozen=True) +class Notice: + """A server-written notice, sent as soon as a tool returns it (ADR 0024, 0026).""" + text: str + + +@dataclass(frozen=True) +class Delta: + text: str + + +@dataclass(frozen=True) +class Reset: + """Discard the deltas so far: the round that streamed them went on to call a tool.""" + + +@dataclass(frozen=True) +class Done: + answer: Answer +``` + +Next to `_complete`, add: + +```python +def _blocking(messages, max_output_tokens, model, *, tools=None, tool_choice=None): + """_complete as a generator that yields no text, so both paths share one loop.""" + return _complete(messages, max_output_tokens, model, tools=tools, tool_choice=tool_choice) + yield # unreachable: makes this a generator + + +def _complete_stream(messages, max_output_tokens, model, *, tools=None, tool_choice=None): + """Call the model once, streaming: yields text as it arrives and returns the + whole message (content and tool calls), as _complete does.""" + options = {} + if tools: + options = {"tools": tools, "parallel_tool_calls": False} + if tool_choice: + options["tool_choice"] = tool_choice + stream = _llm().chat.completions.create( + model=model, + messages=messages, + max_completion_tokens=max_output_tokens, + reasoning_effort=settings.reasoning_effort, + stream=True, + stream_options={"include_usage": True}, + **options, + ) + + parts: list[str] = [] + calls: dict[int, dict] = {} + finish_reason = None + counted = False + for chunk in stream: + if chunk.usage: + _tokens_used.set(_tokens_used.get() + chunk.usage.total_tokens) + counted = True + if not chunk.choices: + continue + choice = chunk.choices[0] + if choice.delta.content: + parts.append(choice.delta.content) + yield choice.delta.content + for piece in choice.delta.tool_calls or []: + call = calls.setdefault(piece.index, {"id": None, "name": "", "arguments": ""}) + call["id"] = piece.id or call["id"] + if piece.function: + call["name"] += piece.function.name or "" + call["arguments"] += piece.function.arguments or "" + finish_reason = choice.finish_reason or finish_reason + + if not counted: + logger.warning("streamed completion for %s returned no usage; spend uncounted", model) + if finish_reason not in ("stop", "tool_calls"): + logger.warning("completion for %s finished with reason %r, not 'stop'", model, finish_reason) + tool_calls = [ + SimpleNamespace(id=c["id"], function=SimpleNamespace(name=c["name"], arguments=c["arguments"])) + for _, c in sorted(calls.items()) + ] + return SimpleNamespace(content="".join(parts) or None, tool_calls=tool_calls or None) +``` + +Rename the body of `answer` into `answer_events`, with these changes and +nothing else: + +```python +def answer_events(query: str, chunks: list[RetrievedChunk], + history: list[dict] | None = None, profile: PlanProfile | None = None, + shown_plans: tuple[ShownPlan, ...] = (), *, stream: bool = False) -> Iterator: + """answer(), as events: Stage, Notice, Delta and Reset as they happen, then Done. + + Text is forwarded only once the answer is grounded (chunks retrieved, or a + tool run), so the NO_ANSWER path never has to take back shown text. + """ + plan_tools = plan_catalog_available() + if not chunks and not plan_tools: + yield Done(Answer(NO_ANSWER_RESPONSE, chunks)) + return + # … the existing setup, unchanged … + complete = _complete_stream if stream else _blocking + writing = False + last_stage = None + + for round_ in range(_MAX_TOOL_ROUNDS + 1): + last = round_ == _MAX_TOOL_ROUNDS + grounded = bool(chunks) or searched + streamed = False + pieces = complete(messages, cap, settings.llm_model, tools=tools, + tool_choice="none" if tools and last else None) + while True: + try: + piece = next(pieces) + except StopIteration as finished: + message = finished.value + break + if grounded: + if not writing: + writing = True + yield Stage("writing") + streamed = True + yield Delta(piece) + calls = (getattr(message, "tool_calls", None) or []) if tools and not last else [] + if not calls: + break + if streamed: + yield Reset() + writing = False + + messages.append(_assistant_turn(message, calls)) + for call in calls: + stage = "plans" if call.function.name == SEARCH_PLANS else "coverage" + if stage != last_stage: + last_stage = stage + yield Stage(stage) + outcome = run_tool(call.function.name, call.function.arguments, profile, dict(plan_years)) + # … the existing bookkeeping, unchanged, except the notice: … + if outcome.notice and outcome.notice not in notices: + notices.append(outcome.notice) + yield Notice(outcome.notice) + # … the existing tool message append, unchanged … + cap = settings.plan_answer_max_output_tokens + + # … the existing post-processing, unchanged; each `return Answer(...)` + # becomes `yield Done(Answer(...)); return` … + + +def _final(events) -> Answer: + for event in events: + if isinstance(event, Done): + return event.answer + raise RuntimeError("answer_events ended without Done") + + +def answer(query: str, chunks: list[RetrievedChunk], + history: list[dict] | None = None, profile: PlanProfile | None = None, + shown_plans: tuple[ShownPlan, ...] = ()) -> Answer: + """Answer from the retrieved chunks and, when the catalog is loaded, plan searches.""" + return _final(answer_events(query, chunks, history, profile, shown_plans)) + + +def answer_query_events(query: str, history: list[dict] | None = None, top_k: int | None = None, + profile: PlanProfile | None = None, shown_plans: tuple[ShownPlan, ...] = (), + *, stream: bool = True) -> Iterator: + """answer_query(), as events, for the streaming route (ADR 0027).""" + selected = select_history(history or []) + if selected: + yield Stage("understanding") + retrieval_query = rewrite_query(query, selected) + yield Stage("searching") + chunks = search(retrieval_query, top_k) + yield from answer_events(query, chunks, selected, profile, shown_plans, stream=stream) +``` + +Add these imports: `from collections.abc import Iterator`, `from types import +SimpleNamespace`, and `SEARCH_PLANS` from `.tools`. Keep `answer_query` +exactly as it is. + +**Also check the existing tests.** Current tests build `_completion(...)` +messages from `SimpleNamespace(content=..., tool_calls=...)`. `_blocking` +returns what `_complete` returns, so they pass unchanged. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `uv run pytest tests/test_generation.py tests/test_tools.py -q` +Expected: all PASS, the pre-existing tests included. + +- [ ] **Step 5: CHANGELOG and commit** + +`- Generation is an event stream (stages, notices, text, reset, done); \`answer()\` and \`answer_query()\` are unchanged wrappers.` + +```bash +git add src/services/generation.py tests/test_generation.py CHANGELOG.md +git commit -m "Generate an answer as events, streaming only its grounded text" +``` + +### Task A6: The streaming route + +**Files:** +- Modify: `src/api/routes/chat.py` +- Test: create `tests/test_chat_stream.py`; also `tests/test_rate_limits.py` + +**Interfaces:** +- Consumes: `answer_query_events`, `Stage`, `Notice`, `Delta`, `Reset` and + `Done` (A5); `_context` and `_save_answer` (A2). +- Produces: `POST /api/chat/threads//messages/stream`, returning + `text/event-stream` with the spec's events. + +- [ ] **Step 1: Write the failing tests** (`tests/test_chat_stream.py`) + +```python +import json +from unittest.mock import patch + +import openai +import pytest + +from src.services.generation import Answer, Delta, Done, Notice, Reset, Stage +from src.services.retrieval import RetrievedChunk +from tests.test_chat import _auth_headers + +CHUNKS = [RetrievedChunk("c1", "A deductible is...", "wiki_Health.txt", 0.9)] + + +def _events(resp): + out = [] + for block in resp.get_data(as_text=True).strip().split("\n\n"): + lines = dict(line.split(": ", 1) for line in block.split("\n")) + out.append((lines["event"], json.loads(lines["data"]))) + return out + + +def _thread(client, email): + headers = _auth_headers(client, email=email) + return headers, client.post("/api/chat/threads", json={}, headers=headers).get_json()["id"] + + +def _stream(client, headers, thread_id, events, content="What is a deductible?"): + with patch("src.api.routes.chat.answer_query_events", return_value=iter(events)): + return client.post(f"/api/chat/threads/{thread_id}/messages/stream", json={"content": content}, headers=headers) + + +def test_the_happy_path_streams_in_order_and_saves_what_done_carries(client): + headers, thread_id = _thread(client, "stream-ok@example.com") + answer = Answer("Notice.\n\nA deductible is what you pay first.", CHUNKS) + resp = _stream(client, headers, thread_id, [ + Stage("searching"), Notice("Notice."), Stage("writing"), Delta("A deductible "), Delta("is what you pay first."), + Done(answer)]) + + assert resp.status_code == 200 + assert resp.mimetype == "text/event-stream" + assert resp.headers["Cache-Control"] == "no-cache" and resp.headers["X-Accel-Buffering"] == "no" + events = _events(resp) + assert [e for e, _ in events] == ["user_message", "stage", "notice", "stage", "delta", "delta", "done"] + done = events[-1][1] + assert done["message"]["content"] == answer.text + assert done["message"]["sources"][0]["id"] + assert done["thread"]["title"] == "What is a deductible?" + saved = client.get(f"/api/chat/threads/{thread_id}/messages", headers=headers).get_json() + assert [m["role"] for m in saved] == ["user", "assistant"] + assert saved[1] == done["message"] + assert saved[0]["id"] == events[0][1]["message"]["id"] + + +def test_reset_is_forwarded(client): + headers, thread_id = _thread(client, "stream-reset@example.com") + events = _events(_stream(client, headers, thread_id, [Delta("x"), Reset(), Done(Answer("y", CHUNKS))])) + assert [e for e, _ in events] == ["user_message", "delta", "reset", "done"] + + +def test_errors_before_the_stream_are_json(client): + headers, thread_id = _thread(client, "stream-pre@example.com") + missing = client.post("/api/chat/threads/00000000-0000-0000-0000-000000000000/messages/stream", + json={"content": "x"}, headers=headers) + invalid = client.post(f"/api/chat/threads/{thread_id}/messages/stream", json={"content": ""}, headers=headers) + assert (missing.status_code, missing.mimetype) == (404, "application/json") + assert (invalid.status_code, invalid.mimetype) == (422, "application/json") + + +def test_an_exhausted_budget_is_a_json_429(client): + headers, thread_id = _thread(client, "stream-budget@example.com") + with patch("src.api.routes.chat.budget_exhausted", return_value=True): + resp = client.post(f"/api/chat/threads/{thread_id}/messages/stream", json={"content": "x"}, headers=headers) + assert resp.status_code == 429 and resp.get_json()["error"] == "daily token budget exhausted" + assert "Retry-After" in resp.headers + + +def _failing(): + yield Stage("searching") + raise openai.APIConnectionError(request=None) + + +def test_a_model_failure_ends_with_error_and_keeps_the_question(client): + headers, thread_id = _thread(client, "stream-fail@example.com") + with patch("src.api.routes.chat.answer_query_events", return_value=_failing()), \ + patch("src.api.routes.chat.token_usage", return_value=11), \ + patch("src.api.routes.chat.record_tokens") as record: + resp = client.post(f"/api/chat/threads/{thread_id}/messages/stream", json={"content": "x"}, headers=headers) + events = _events(resp) + assert events[-1] == ("error", {"error": "generation failed"}) + record.assert_called_once() + assert record.call_args.args[2] == 11 + saved = client.get(f"/api/chat/threads/{thread_id}/messages", headers=headers).get_json() + assert [m["role"] for m in saved] == ["user"] + + +def test_disconnect_records_spend_once_and_saves_no_answer(client): + headers, thread_id = _thread(client, "stream-drop@example.com") + + def slow(): + yield Stage("searching") + yield Delta("partial") + yield Done(Answer("never saved", CHUNKS)) + + with patch("src.api.routes.chat.answer_query_events", return_value=slow()), \ + patch("src.api.routes.chat.token_usage", return_value=9), \ + patch("src.api.routes.chat.record_tokens") as record: + resp = client.post(f"/api/chat/threads/{thread_id}/messages/stream", json={"content": "x"}, + headers=headers, buffered=False) + stream = iter(resp.response) + next(stream) # user_message + next(stream) # stage + resp.close() # the client goes away + record.assert_called_once() + saved = client.get(f"/api/chat/threads/{thread_id}/messages", headers=headers).get_json() + assert [m["role"] for m in saved] == ["user"] +``` + +In `tests/test_rate_limits.py`: + +```python +def test_both_send_routes_share_one_limit(client): + headers = _auth_headers(client, "send-shared@example.com") + thread_id = client.post("/api/chat/threads", json={}, headers=headers).get_json()["id"] + body = {"content": "x"} + with patch("src.api.routes.chat.answer_query", return_value=Answer("a", [])), \ + patch("src.api.routes.chat.answer_query_events", side_effect=lambda *a, **k: iter([Done(Answer("a", []))])): + json_route = _hammer(client, "POST", f"/api/chat/threads/{thread_id}/messages", 8, headers, body) + stream_route = _hammer(client, "POST", f"/api/chat/threads/{thread_id}/messages/stream", 8, headers, body) + assert all(r.status_code == 201 for r in json_route) + assert [r.status_code for r in stream_route[:7]] == [200] * 7 + assert stream_route[7].status_code == 429 and "Retry-After" in stream_route[7].headers +``` + +Import `Done` from `src.services.generation` there. + +- [ ] **Step 2: Run them and confirm they fail** + +Run: `uv run pytest tests/test_chat_stream.py -q` +Expected: FAIL. The stream route answers 404. + +- [ ] **Step 3: Implement** (in `src/api/routes/chat.py`) + +```python +# One budget of sends per user, whichever route they use (ADR 0027). +_send_limit = limiter.shared_limit("15 per minute", scope="chat_send") + + +def _sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, separators=(',', ':'))}\n\n" + + +def _message_json(message: Message) -> dict: + return MessageResponse.model_validate(message, from_attributes=True).model_dump(mode="json") +``` + +Replace `@limiter.limit("15 per minute")` on `create_message` with +`@_send_limit`. Then add: + +```python +@bp.post("/threads//messages/stream") +@jwt_required() +@_send_limit +def stream_message(thread_id: str): + """create_message, as Server-Sent Events (ADR 0027). Every check that can + fail is made first, so its error is ordinary JSON; the question is then + committed, so no transaction stays open while the model writes.""" + body = parse_body(MessageCreateRequest) + db, thread = _get_owned_thread(thread_id) + user = get_current_user() + if budget_exhausted(db, user.id): + raise TokenBudgetExhaustedError(seconds_until_budget_resets()) + + history, shown_plans = _context(db, thread) + question = Message(thread_id=thread.id, role="user", content=body.content) + db.add(question) + db.commit() + question_json = _message_json(question) + profile = plan_profile(user) + user_id = user.id + + def events(): + reset_token_usage() + recorded = False + try: + yield _sse("user_message", {"message": question_json}) + for event in answer_query_events(body.content, history, profile=profile, shown_plans=shown_plans): + if isinstance(event, Stage): + yield _sse("stage", {"stage": event.name}) + elif isinstance(event, Notice): + yield _sse("notice", {"text": event.text}) + elif isinstance(event, Delta): + yield _sse("delta", {"text": event.text}) + elif isinstance(event, Reset): + yield _sse("reset", {}) + elif isinstance(event, Done): + record_tokens(db, user_id, token_usage()) + recorded = True + saved = _save_answer(db, thread, body.content, event.answer) + db.commit() + thread_json = ThreadResponse.model_validate(thread, from_attributes=True).model_dump(mode="json") + yield _sse("done", {"message": _message_json(saved), "thread": thread_json}) + except openai.OpenAIError: + logger.exception("generation failed for thread %s", thread.id) + record_tokens(db, user_id, token_usage()) + recorded = True + db.commit() + yield _sse("error", {"error": "generation failed"}) + finally: + # Also runs when the client disconnects (GeneratorExit): the spend is real. + if not recorded: + record_tokens(db, user_id, token_usage()) + db.commit() + + return Response(stream_with_context(events()), mimetype="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) +``` + +Import `json`, `Response` and `stream_with_context` from flask, +`answer_query_events`, `Stage`, `Notice`, `Delta`, `Reset` and `Done`. If a +test shows `thread` detached after `db.commit()` when `done` is built, keep the +session's default `expire_on_commit`: attribute access reloads it inside the +context that `stream_with_context` keeps. + +- [ ] **Step 4: Run the tests and confirm they pass** + +Run: `uv run pytest tests/test_chat_stream.py tests/test_chat.py tests/test_rate_limits.py tests/test_token_budget.py -q` +Expected: all PASS. + +- [ ] **Step 5: CHANGELOG and commit** + +`- \`POST /api/chat/threads//messages/stream\` streams an answer as Server-Sent Events (user_message, stage, notice, delta, reset, done, error). It shares one 15-per-minute limit with the JSON route.` + +```bash +git add src/api/routes/chat.py tests/test_chat_stream.py tests/test_rate_limits.py CHANGELOG.md +git commit -m "Stream answers as Server-Sent Events" +``` + +### Task A7: ADR 0027, the README, a full check, and the PR + +**Files:** +- Create: `docs/decisions/0027-streaming-answers-and-cited-passages.md` +- Modify: `README.md`: the chat API section and the endpoint list. +- Modify: `docs/decisions/0007-*.md` and `docs/decisions/0022-*.md`: one "Amended by ADR 0027" line each, under Status. +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: Write ADR 0027** + +Use ADR 0026's shape: Status, Context, Decision, Consequences. The content +comes from spec sections 3.1–3.4: + +- **Status:** Accepted. Amends ADR 0007 (citations carry a content hash) and + ADR 0022 (a stream holds one gthread thread). +- **Context:** + - Answers arrive after 5–40 s. + - Sources couldn't be read. + - Threads couldn't be renamed. +- **Decision:** + - SSE over `fetch`, since `EventSource` can't POST with a Bearer header. + - The event protocol table. + - Text is streamed only once grounded, with `reset`. + - The commit comes before the stream. + - Spend is recorded in `finally`. + - The shared send limit. + - The passage endpoint: addressed by citation id, never chunk id. Brief quotes + of ≤300 characters (ADR 0013), the Wikipedia licence from the registry, the + hash statuses, and `https` URLs only. + - Renaming leaves `updated_at` alone. +- **Consequences:** + - The JSON route stays. + - Gunicorn threads bound concurrent streams (8). + - The Azure ingress timeout (~240 s) exceeds the worst case. + - An aborted stream saves no answer. + - Older citations are "unverified". + - Sub-project 3's ADR becomes 0028. + +- [ ] **Step 2: Update the README** + +- List the three new endpoints with their limits. +- Add one short paragraph on streaming. +- Link ADR 0027. + +- [ ] **Step 3: Run the full check** + +Run: `make check` +Expected: ruff clean, pytest green with coverage ≥85%, bandit and pip-audit +clean, and `alembic check` clean. Fix anything that fails before continuing. + +- [ ] **Step 4: Commit and push** + +```bash +git add docs/decisions README.md CHANGELOG.md +git commit -m "Record ADR 0027: streaming answers, cited passages, and renaming threads" +git push -u origin feature_chat_redesign_api +``` + +- [ ] **Step 5: Open the PR, marked ready for review** + +`gh pr create --base main --title "Chat backend: streaming answers, cited passages, renaming threads"`. +The body covers the problem, the implementation, validation evidence (the +`make check` summary, the new test counts), and the security, DB and API +impact: the migration, the three endpoints, the shared limit and ADR 0027. No +AI attribution. Wait for green CI, and report the PR URL. + +--- + +# Part B — Frontend (`feature_chat_redesign_ui`) + +Run everything from `frontend/`. Read `frontend/CLAUDE.md`. The existing +patterns: `vi.mock("../../services/chatService")`, `useAuth` mocked in hook +tests, and `renderHook` for hooks. Visual markup, class names and copy come +from `docs/design/chat-redesign/*.dc.html` (see its README). The backend +contracts are spec section 3. Part A is built in parallel, so every network +call in Part B's tests is mocked. + +### Task B1: Dependencies, tokens and theme + +**Files:** +- Modify: `package.json` and `package-lock.json` +- Modify: `src/index.css`: the tokens, and the theme selectors. +- Modify: every CSS file, to use the renamed tokens. +- Create: `src/hooks/useTheme.js` and `src/hooks/useTheme.test.js` +- Modify: `src/main.jsx`: `applyTheme(readTheme())` before render, and `MotionConfig`. +- Modify: `index.html`: add the Plex weights the design uses (Sans 400/500/600, Serif 500/600, already present). +- Replace: `public/favicon.svg` (Vite's logo) with the brass seal mark: a circle + `#96651F` holding a Plex Serif-style "P" in `#FFF8EA`, as an SVG path or + text. + +**Interfaces:** +- Produces: + - `readTheme(): "system" | "light" | "dark"`. + - `applyTheme(theme): void`, which sets or removes `document.documentElement.dataset.theme`. + - `useTheme(): { theme, setTheme }`. + - The CSS tokens `--paper --sheet --sheet-2 --ink --ink-soft --rule --rule-strong --seal --seal-deep --seal-wash --pine --pine-wash --danger --danger-wash --silver --float --serif --sans`. + +- [ ] **Step 1: Install the dependencies, pinned** + +```bash +npm install --save-exact lucide-react motion react-markdown remark-gfm +npm audit --omit=dev +``` + +Expected: they are added to `dependencies` without `^`, and the audit finds 0 +vulnerabilities. Record the installed versions in the commit message body. + +- [ ] **Step 2: Write the failing test** (`src/hooks/useTheme.test.js`) + +```js +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { applyTheme, readTheme, useTheme } from "./useTheme"; + +beforeEach(() => { + localStorage.clear(); + delete document.documentElement.dataset.theme; +}); + +describe("theme", () => { + it("defaults to system", () => { + expect(readTheme()).toBe("system"); + }); + + it("ignores a stored value it doesn't know", () => { + localStorage.setItem("policypal.theme", "sepia"); + expect(readTheme()).toBe("system"); + }); + + it("stores and applies a choice, and system clears it", () => { + const { result } = renderHook(() => useTheme()); + act(() => result.current.setTheme("dark")); + expect(document.documentElement.dataset.theme).toBe("dark"); + expect(localStorage.getItem("policypal.theme")).toBe("dark"); + act(() => result.current.setTheme("system")); + expect(document.documentElement.dataset.theme).toBeUndefined(); + expect(localStorage.getItem("policypal.theme")).toBeNull(); + expect(result.current.theme).toBe("system"); + }); + + it("applyTheme sets the attribute", () => { + applyTheme("light"); + expect(document.documentElement.dataset.theme).toBe("light"); + }); +}); +``` + +- [ ] **Step 3: Run it and confirm it fails.** Run `npx vitest run src/hooks/useTheme.test.js`. Expected: FAIL, because the module isn't found. + +- [ ] **Step 4: Implement `src/hooks/useTheme.js`** + +```js +import { useCallback, useState } from "react"; + +const KEY = "policypal.theme"; +const CHOICES = ["system", "light", "dark"]; + +// localStorage, unlike the session: a theme is a device preference, not a secret. +export function readTheme() { + try { + const stored = localStorage.getItem(KEY); + return CHOICES.includes(stored) ? stored : "system"; + } catch { + return "system"; + } +} + +export function applyTheme(theme) { + const root = document.documentElement; + if (theme === "light" || theme === "dark") root.dataset.theme = theme; + else delete root.dataset.theme; +} + +export function useTheme() { + const [theme, setThemeState] = useState(readTheme); + + const setTheme = useCallback((next) => { + try { + if (next === "system") localStorage.removeItem(KEY); + else localStorage.setItem(KEY, next); + } catch { + // Storage unavailable: the choice holds for this page only. + } + applyTheme(next); + setThemeState(next); + }, []); + + return { theme, setTheme }; +} +``` + +- [ ] **Step 5: Rewrite the tokens in `src/index.css`** + +- **Light palette.** Copy the `.pp` block's light values from + `docs/design/chat-redesign/tokens.css` onto bare `:root`, as the custom + properties listed under Produces. +- **Dark palette.** Copy the `.pp.dark` values into both + `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … color-scheme: dark; } }` + and `:root[data-theme="dark"] { … color-scheme: dark; }`. +- **Radii.** Keep `--radius` as `10px`, and add `--radius-lg: 16px`. +- **Base styles.** `body` gets `background: var(--paper)`, `font-family: + var(--sans)`, a font size of 15px, a line height of 1.55, and + `font-variant-numeric` left to components. Headings use `var(--serif)` at + weight 500. +- **Keep** `.visually-hidden`, the focus ring (`outline: 2px solid var(--seal)`) + and the reduced-motion block. +- **Rename across every CSS file:** + + | Old token | New token | + | --- | --- | + | `--surface` | `--sheet` | + | `--ink-muted` | `--ink-soft` | + | `--seal-strong` | `--seal-deep` | + | `--danger-surface` | `--danger-wash` | + | `--font-display` | `--serif` | + | `--font-body` | `--sans` | + + Check with `grep -rn "\-\-surface\|ink-muted\|seal-strong\|danger-surface\|font-display\|font-body" src`, + which should find nothing. + +In `src/main.jsx`, call `applyTheme(readTheme())` before `createRoot`, and wrap +`` in `` from `motion/react`. + +- [ ] **Step 6: Run everything.** Run `npm test && npm run lint && npm run build`. Expected: all green. + +- [ ] **Step 7: CHANGELOG and commit** + +```bash +git add package.json package-lock.json index.html public/favicon.svg src ../CHANGELOG.md +git commit -m "Adopt the redesign's tokens and a remembered light, dark or system theme" +``` + +### Task B2: The streaming client and the new chat services + +**Files:** +- Create: `src/services/sse.js` and `src/services/sse.test.js` +- Modify: `src/services/apiClient.js`: extract `errorFor`, and add `apiStream`. +- Modify: `src/services/apiClient.test.js` +- Modify: `src/services/chatService.js`: add `streamMessage`, `renameThread` and `getSource`. + +**Interfaces:** +- Produces: + - `createSseParser(onEvent: ({event, data}) => void): { push(text), end() }`. + - `apiStream(path, { token, body, signal, onEvent }): Promise`. + It rejects with an `ApiError` or `SessionExpiredError` before the stream, + as `apiFetch` does, and rethrows `AbortError`. + - `chatService.streamMessage(token, threadId, content, { signal, onEvent })`. + - `chatService.renameThread(token, threadId, title) → ThreadResponse`. + - `chatService.getSource(token, sourceId) → PassageResponse`. + +- [ ] **Step 1: Write the failing tests** (`src/services/sse.test.js`) + +```js +import { describe, expect, it, vi } from "vitest"; +import { createSseParser } from "./sse"; + +describe("createSseParser", () => { + it("parses events split across chunks", () => { + const onEvent = vi.fn(); + const parser = createSseParser(onEvent); + parser.push('event: delta\ndata: {"te'); + parser.push('xt":"Hi"}\n\nevent: stage\ndata: {"stage":"writing"}\n\n'); + expect(onEvent.mock.calls.map(([e]) => e)).toEqual([ + { event: "delta", data: { text: "Hi" } }, + { event: "stage", data: { stage: "writing" } }, + ]); + }); + + it("dispatches a final event with no blank line on end()", () => { + const onEvent = vi.fn(); + const parser = createSseParser(onEvent); + parser.push('event: done\ndata: {"ok":true}'); + expect(onEvent).not.toHaveBeenCalled(); + parser.end(); + expect(onEvent).toHaveBeenCalledWith({ event: "done", data: { ok: true } }); + }); + + it("accepts CRLF line endings", () => { + const onEvent = vi.fn(); + createSseParser(onEvent).push('event: reset\r\ndata: {}\r\n\r\n'); + expect(onEvent).toHaveBeenCalledWith({ event: "reset", data: {} }); + }); +}); +``` + +In `apiClient.test.js`, add tests for `apiStream`. Mock `global.fetch` to +return `{ ok: true, status: 200, body: new ReadableStream({ start(c) { +c.enqueue(new TextEncoder().encode('event: delta\ndata: {"text":"a"}\n\n')); +c.close(); } }) }`. Then check: + +- `onEvent` receives the delta. +- A 429 `{error: "daily token budget exhausted"}` rejects with the budget + message. +- A 401 with a token rejects with `SessionExpiredError`. +- A fetch rejecting with `DOMException("aborted", "AbortError")` is rethrown + with `name === "AbortError"`. + +- [ ] **Step 2: Run them and confirm they fail.** Run `npx vitest run src/services`. Expected: FAIL. + +- [ ] **Step 3: Implement** + +`src/services/sse.js`: + +```js +// A text/event-stream body, split into events. Every event PolicyPal sends +// has one JSON data line (ADR 0027). +export function createSseParser(onEvent) { + let buffer = ""; + + function dispatch(block) { + let event = "message"; + const data = []; + for (const line of block.split("\n")) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, "")); + } + if (data.length > 0) onEvent({ event, data: JSON.parse(data.join("\n")) }); + } + + return { + push(text) { + buffer += text.replaceAll("\r\n", "\n"); + let end; + while ((end = buffer.indexOf("\n\n")) !== -1) { + dispatch(buffer.slice(0, end)); + buffer = buffer.slice(end + 2); + } + }, + end() { + if (buffer.trim()) dispatch(buffer); + buffer = ""; + }, + }; +} +``` + +In `apiClient.js`, move the non-ok branch of `apiFetch` into `function +errorFor(response, data, token)`, which **returns** the error. `apiFetch` then +does `throw errorFor(response, data, token)`. Put the unreachable message in a +constant. Then add: + +```js +export async function apiStream(path, { token, body, signal, onEvent }) { + const headers = { "Content-Type": "application/json", Accept: "text/event-stream" }; + if (token) headers.Authorization = `Bearer ${token}`; + + let response; + try { + response = await fetch(`${BASE_URL}${path}`, { method: "POST", headers, body: JSON.stringify(body), signal }); + } catch (err) { + if (err?.name === "AbortError") throw err; + throw new ApiError(UNREACHABLE, 0); + } + + if (!response.ok) { + const data = await response.json().catch(() => null); + throw errorFor(response, data, token); + } + + // EventSource can't POST or send a Bearer header, so the body is read by hand. + const parser = createSseParser(onEvent); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + parser.push(decoder.decode(value, { stream: true })); + } + parser.end(); +} +``` + +`chatService.js`: + +```js +export function streamMessage(token, threadId, content, { signal, onEvent }) { + return apiStream(`/api/chat/threads/${threadId}/messages/stream`, { token, body: { content }, signal, onEvent }); +} + +export function renameThread(token, threadId, title) { + return apiFetch(`/api/chat/threads/${threadId}`, { method: "PATCH", token, body: { title } }); +} + +export function getSource(token, sourceId) { + return apiFetch(`/api/chat/sources/${sourceId}`, { token }); +} +``` + +- [ ] **Step 4: Run them and confirm they pass.** Run `npx vitest run src/services`. Expected: PASS. + +- [ ] **Step 5: CHANGELOG and commit.** Stage `git add src/services ../CHANGELOG.md`, then run `git commit -m "Read streamed answers, rename threads, and fetch cited passages"`. + +### Task B3: Pure helpers: markers, thread groups and exchanges + +**Files:** +- Create: `src/utils/markers.js` and `src/utils/markers.test.js` +- Create: `src/utils/threadGroups.js` and `src/utils/threadGroups.test.js` +- Create: `src/utils/exchanges.js` and `src/utils/exchanges.test.js` + +**Interfaces:** +- Produces: + - `remarkMarkers({ sources, plans })`: a remark plugin. It emits mdast nodes + that react-markdown renders as the custom elements `pp-seal`, with props + `number` and `label`, and `pp-plan`, with props `position` and `planId` + and the plan's name as children. + - `copyText(content, sources, plans) → string`. + - `sourceNumbers(sources) → Map`. + - `groupThreads(threads, now = new Date()) → [{ label: "Today" | "Previous 7 days" | "Earlier", threads }]`. + Only non-empty groups are returned, in that order, keeping the input order + within each. + - `toExchanges(messages) → [{ key, question: Message | null, answer: Message | null }]`. + +- [ ] **Step 1: Write the failing tests** + +`markers.test.js`: + +```js +import { describe, expect, it } from "vitest"; +import { copyText, remarkMarkers, sourceNumbers } from "./markers"; + +const sources = [ + { id: "s1", source: "Sharp - Summary of Benefits - Urgent.pdf" }, + { id: "s2", source: "wiki_Health.txt" }, +]; +const plans = [{ hios_plan_id: "92499CA0020006", name: "Sharp Silver 70 Premier HMO" }]; + +function run(text) { + const tree = { type: "root", children: [{ type: "paragraph", children: [{ type: "text", value: text }] }] }; + remarkMarkers({ sources, plans })(tree); + return tree.children[0].children; +} + +describe("remarkMarkers", () => { + it("turns a source marker into a numbered seal", () => { + const [before, seal] = run("Fifty dollars.[Source: wiki_Health.txt]"); + expect(before).toEqual({ type: "text", value: "Fifty dollars." }); + expect(seal.data).toMatchObject({ hName: "pp-seal", hProperties: { number: 2, label: "wiki_Health.txt" } }); + }); + + it("splits several labels in one bracket and drops unknown ones", () => { + const nodes = run("x [Source: Sharp - Summary of Benefits - Urgent.pdf; nope.txt; wiki_Health.txt]"); + expect(nodes.filter((n) => n.data?.hName === "pp-seal").map((n) => n.data.hProperties.number)).toEqual([1, 2]); + }); + + it("turns a known plan marker into a plan ref and leaves an unknown one as text", () => { + const nodes = run("See [Plan: 92499CA0020006] and [Plan: 11111XX1111111]."); + const ref = nodes.find((n) => n.data?.hName === "pp-plan"); + expect(ref.data.hProperties).toEqual({ position: 1, planId: "92499CA0020006" }); + expect(ref.data.hChildren).toEqual([{ type: "text", value: "Sharp Silver 70 Premier HMO" }]); + expect(nodes.at(-1).value).toContain("[Plan: 11111XX1111111]"); + }); +}); + +describe("copyText", () => { + it("writes seals as [n] and plans as names", () => { + expect(copyText("A [Source: wiki_Health.txt] B [Plan: 92499CA0020006].", sources, plans)).toBe( + "A [2] B Sharp Silver 70 Premier HMO.", + ); + }); +}); + +describe("sourceNumbers", () => { + it("numbers each label by its first position", () => { + expect([...sourceNumbers([...sources, sources[0]])]).toEqual([ + ["Sharp - Summary of Benefits - Urgent.pdf", 1], + ["wiki_Health.txt", 2], + ]); + }); +}); +``` + +`threadGroups.test.js` uses a fixed `now = new Date(2026, 8, 26, 15, 0)`. +Threads updated at 2026-09-26 00:01 go under "Today", 2026-09-25 23:59 under +"Previous 7 days", 2026-09-19 00:00 under "Previous 7 days", and 2026-09-18 +23:59 under "Earlier". Empty groups are omitted, and the order within a group +is preserved. + +`exchanges.test.js`: + +- `[user, assistant, user]` gives two exchanges, the second with `answer: null`. +- An assistant with no question before it gives `{question: null, answer}`. +- Each `key` is the question's id, or else the answer's id. + +- [ ] **Step 2: Run them and confirm they fail.** Run `npx vitest run src/utils`. Expected: FAIL. + +- [ ] **Step 3: Implement** + +`src/utils/markers.js`: + +```js +// The model cites as [Source: label; label] and names plans as [Plan: id] +// (src/services/generation.py). These become seals and plan links; nothing +// else in the text is interpreted, and nothing becomes raw HTML. +const MARKER = /\[(Source|Plan):\s*([^\]]+)\]/g; + +export function sourceNumbers(sources = []) { + const numbers = new Map(); + sources.forEach((s, i) => { + if (!numbers.has(s.source)) numbers.set(s.source, i + 1); + }); + return numbers; +} + +function planIndex(plans = []) { + return new Map(plans.map((p, i) => [p.hios_plan_id, { position: i + 1, name: p.name }])); +} + +function splitText(value, numbers, planned) { + const nodes = []; + let last = 0; + for (const match of value.matchAll(MARKER)) { + const [whole, kind, body] = match; + const replacement = []; + if (kind === "Source") { + for (const label of body.split(";").map((l) => l.trim()).filter(Boolean)) { + const number = numbers.get(label); + if (number) replacement.push({ type: "ppSeal", data: { hName: "pp-seal", hProperties: { number, label }, hChildren: [{ type: "text", value: String(number) }] } }); + } + } else { + const plan = planned.get(body.trim()); + if (plan) replacement.push({ type: "ppPlan", data: { hName: "pp-plan", hProperties: { position: plan.position, planId: body.trim() }, hChildren: [{ type: "text", value: plan.name }] } }); + } + if (kind === "Plan" && replacement.length === 0) continue; // unknown plan: leave the text + if (match.index > last) nodes.push({ type: "text", value: value.slice(last, match.index) }); + nodes.push(...replacement); + last = match.index + whole.length; + } + if (last < value.length) nodes.push({ type: "text", value: value.slice(last) }); + return nodes; +} + +export function remarkMarkers({ sources, plans } = {}) { + const numbers = sourceNumbers(sources); + const planned = planIndex(plans); + function walk(node) { + if (!Array.isArray(node.children)) return; + node.children = node.children.flatMap((child) => { + if (child.type === "text") return splitText(child.value, numbers, planned); + walk(child); + return [child]; + }); + } + return (tree) => walk(tree); +} + +export function copyText(content, sources, plans) { + const numbers = sourceNumbers(sources); + const planned = planIndex(plans); + return content.replace(MARKER, (whole, kind, body) => { + if (kind === "Plan") return planned.get(body.trim())?.name ?? whole; + return body.split(";").map((l) => numbers.get(l.trim())).filter(Boolean).map((n) => `[${n}]`).join(""); + }); +} +``` + +In the plugin test, `run()` calls `remarkMarkers(...)` and applies the +returned transformer. With react-markdown, pass +`remarkPlugins={[remarkGfm, [remarkMarkers, { sources, plans }]]}`: remark +calls the plugin with the options as its argument, and it returns the +transformer. The signature above satisfies both. + +`src/utils/threadGroups.js`: + +```js +const DAY = 24 * 60 * 60 * 1000; + +export function groupThreads(threads, now = new Date()) { + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const groups = [ + { label: "Today", threads: [] }, + { label: "Previous 7 days", threads: [] }, + { label: "Earlier", threads: [] }, + ]; + for (const thread of threads) { + const at = new Date(thread.updated_at).getTime(); + const group = at >= today ? 0 : at >= today - 7 * DAY ? 1 : 2; + groups[group].threads.push(thread); + } + return groups.filter((g) => g.threads.length > 0); +} +``` + +`src/utils/exchanges.js`: + +```js +// Messages as question-and-answer pairs: the transcript sets each question as +// its answer's heading. +export function toExchanges(messages) { + const exchanges = []; + for (const message of messages) { + const open = exchanges.at(-1); + if (message.role === "assistant" && open && open.answer === null && open.question) { + open.answer = message; + } else if (message.role === "assistant") { + exchanges.push({ key: message.id, question: null, answer: message }); + } else { + exchanges.push({ key: message.id, question: message, answer: null }); + } + } + return exchanges; +} +``` + +- [ ] **Step 4: Run them and confirm they pass.** Run `npx vitest run src/utils`. Expected: PASS. + +- [ ] **Step 5: Commit.** Run `git add src/utils ../CHANGELOG.md && git commit -m "Turn citation markers into seals and plan links, and group threads by date"`. + +### Task B4: Generic components + +**Files:** +- Modify: `src/components/Button.jsx` and `.test.jsx`. The variants become `primary`, `ghost` and `danger`, and `busy` shows a spinner with "Working…" for screen readers. +- Create: `src/components/IconButton.jsx` and `.test.jsx`. It is a `