From bbce4d21b33b558599e506f7eb6c937e996e0610 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sat, 26 Sep 2026 15:10:24 -0700 Subject: [PATCH 01/12] Design the chat redesign: the approved mockups and the spec --- CHANGELOG.md | 13 + docs/design/chat-redesign/Empty.dc.html | 275 ++++++++++ docs/design/chat-redesign/Main.dc.html | 378 ++++++++++++++ docs/design/chat-redesign/MainDark.dc.html | 378 ++++++++++++++ docs/design/chat-redesign/Mobile.dc.html | 268 ++++++++++ docs/design/chat-redesign/Profile.dc.html | 262 ++++++++++ docs/design/chat-redesign/README.md | 28 + .../chat-redesign/SidebarAccount.dc.html | 248 +++++++++ .../chat-redesign/SidebarRename.dc.html | 240 +++++++++ .../chat-redesign/SidebarSearch.dc.html | 235 +++++++++ docs/design/chat-redesign/SignIn.dc.html | 255 +++++++++ docs/design/chat-redesign/Streaming.dc.html | 257 +++++++++ docs/design/chat-redesign/canvas.json | 130 +++++ docs/design/chat-redesign/tokens.css | 197 +++++++ .../specs/2026-09-26-chat-redesign-design.md | 487 ++++++++++++++++++ 15 files changed, 3651 insertions(+) create mode 100644 docs/design/chat-redesign/Empty.dc.html create mode 100644 docs/design/chat-redesign/Main.dc.html create mode 100644 docs/design/chat-redesign/MainDark.dc.html create mode 100644 docs/design/chat-redesign/Mobile.dc.html create mode 100644 docs/design/chat-redesign/Profile.dc.html create mode 100644 docs/design/chat-redesign/README.md create mode 100644 docs/design/chat-redesign/SidebarAccount.dc.html create mode 100644 docs/design/chat-redesign/SidebarRename.dc.html create mode 100644 docs/design/chat-redesign/SidebarSearch.dc.html create mode 100644 docs/design/chat-redesign/SignIn.dc.html create mode 100644 docs/design/chat-redesign/Streaming.dc.html create mode 100644 docs/design/chat-redesign/canvas.json create mode 100644 docs/design/chat-redesign/tokens.css create mode 100644 docs/superpowers/specs/2026-09-26-chat-redesign-design.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bc7b0c1..00234ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ ### 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. + - 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. 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/specs/2026-09-26-chat-redesign-design.md b/docs/superpowers/specs/2026-09-26-chat-redesign-design.md new file mode 100644 index 0000000..0f0cb15 --- /dev/null +++ b/docs/superpowers/specs/2026-09-26-chat-redesign-design.md @@ -0,0 +1,487 @@ +# Chat redesign — design + +**Status:** approved design, spec for review · **Date:** 2026-09-26 · **ADR:** 0027 +**Mockups:** [docs/design/chat-redesign/](../../design/chat-redesign/README.md) (canvas ) +**Branches and PRs:** + +1. `feature_chat_redesign_api`: the backend and the docs. +2. `feature_chat_redesign_ui`: the frontend, built on the contracts in section 3. + +## 1. Problem + +The chat works, but it reads like a prototype. + +- **Answers are raw text.** The model writes Markdown with `[Source: …]` and + `[Plan: …]` markers, and all of it shows literally. +- **Sources are a bare list.** Each is a file name and a match score, with no + way to see the text an answer relied on. +- **Answers arrive all at once** after a long wait, behind three pulsing dots. +- **Threads can't be renamed or searched.** +- **There is no dark-mode choice and no keyboard shortcuts.** On a phone the + sidebar takes 40% of the screen. + +## 2. Decisions (user, 2026-09-26) + +| Topic | Decision | +| --- | --- | +| Visual direction | Evolve the current identity: paper, ink and brass, with IBM Plex | +| Screens | Chat, plus sign-in, register and profile | +| Backend features | Streaming answers, renaming threads, showing source passages, and every frontend-only interaction | +| Libraries | `lucide-react`, `motion`, `react-markdown` and `remark-gfm`, pinned to exact versions | +| Passage length | A brief quote of ≤300 characters plus a link. SBC text stays within ADR 0013's "quote briefly". Wikipedia text carries its CC BY-SA credit. | +| Stop | Cancels the answer. The question stays in the thread, as it does on a failed answer; nothing partial is saved. | +| Quote choice | The part of the passage that best matches the answer, not its first 300 characters | +| Theme | System, Light or Dark, remembered per browser; System is the default | +| Process | This spec, then an implementation plan, both reviewed. Then build, with one agent for the backend and one for the frontend. | +| PRs | Backend first, then frontend | + +## 3. Backend (PR 1) + +### 3.1 Renaming a thread + +`PATCH /api/chat/threads/`, JWT, **30 per minute**. + +- **Body:** `ThreadRenameRequest { title: str }`. The title is stripped, and + must then be 1–200 characters. Blank, or over 200, returns 422. +- **Ownership:** the existing `_get_owned_thread`. A missing thread, someone + else's thread, or a malformed id returns 404. +- **Returns** 200 with the `ThreadResponse`. +- **It does not change `updated_at`.** `updated_at` means the thread's last + activity, and orders the list, so renaming must not move a thread to the + top. Issue an explicit `UPDATE threads SET title = :t, updated_at = + threads.updated_at` rather than setting the attribute, because the column's + `onupdate` would otherwise fire. + +### 3.2 Source passages + +**Schema** (a migration after head `3b1d6e2a9c47`): +`message_sources.content_sha256 String(64) NULL`. It holds the SHA-256 (hex) +of the passage text as the answer used it. It is set on every new answer, and +older rows keep NULL. Chunks are rebuilt in place (ADR 0007): the same +`chunk_id` can later hold different text, and the hash is how the endpoint can +tell. + +**`SourceResponse` gains `id`,** the `MessageSource` UUID. Both message +endpoints return it. + +**`GET /api/chat/sources/`, JWT, 60 per minute.** + +- **Ownership.** Join `MessageSource → Message → Thread` and require + `Thread.user_id = current user`. Anything else, or a malformed id, returns + 404, never 403. Chunk ids are guessable, so there is deliberately no lookup + by chunk id. +- **Lookup.** A `chunk_id` starting `sbc_` is read from `sbc_chunks`, joined to + `sbc_documents`. Anything else is read from `chunks`. +- **Status:** + + | `status` | When | `quote` | + | --- | --- | --- | + | `ok` | The hash is stored and matches the text now | the excerpt | + | `unverified` | The hash is NULL (an older answer) and the chunk exists | the excerpt; the UI says it may have changed | + | `changed` | The hash is stored and does not match | `null` | + | `missing` | No such chunk now | `null` | + +- **Response:** + + ```json + { + "id": "uuid", + "kind": "sbc | wikipedia | healthcare_gov | other", + "title": "Sharp Silver 70 Premier HMO", + "document": "Summary of Benefits and Coverage, 2026", + "section": "If you need immediate medical attention", + "quote": "Urgent care | $50 copay/visit; deductible does not apply | …", + "status": "ok", + "url": "https://…pdf", + "license": null + } + ``` + +- **Per kind.** Every field is derived by server code, never by the model. + + | kind | Recognised by | `title` | `document` | `url` | `license` | + | --- | --- | --- | --- | --- | --- | + | `sbc` | `chunk_id` prefix `sbc_` | The plan name: the label before ` - Summary of Benefits - ` | `Summary of Benefits and Coverage, {plan_year}` | `sbc_documents.url`, if `safe` | `null` | + | `wikipedia` | Label `wiki_{Title}.txt` | The title, with underscores as spaces | `Wikipedia article` | `https://en.wikipedia.org/wiki/{Title}`, URL-quoted | `{name, url}` from the registry entry `wikipedia` (CC BY-SA 4.0) | + | `healthcare_gov` | Labels `hcg_glossary_{T}.md` and `hcg_article_{T}.md` | T | `HealthCare.gov glossary` or `HealthCare.gov article` | `null` (not stored) | `null` (public domain) | + | `other` | Anything else | The label without its extension | `null` | `null` | `null` | + + `section` is `sbc_chunks.section` for an SBC, and `null` otherwise. A URL is + returned only if it is `https://` and passes the existing unsafe-URL check + (`src/ingestion/sbc/fetch.py` `unsafe_reason`). Otherwise it is `null`. +- **Excerpt** (`src/services/passages.py`, pure functions): + - Split the passage into segments: lines, then sentences within a line. + - Score each segment by how many distinct lower-cased words of ≥3 letters it + shares with the assistant message's text, leaving out a short stop-word + list. Ties go to the earlier segment. + - Start at the best segment and add the segments after it while the total + stays within **300 characters**. + - A segment longer than 300 is cut at the last space before 300. + - Prefix `… ` when the excerpt doesn't start at the passage's start, and + suffix ` …` when it doesn't reach the end. + - A passage of ≤300 characters is returned whole. + +### 3.3 Streaming answers + +**`POST /api/chat/threads//messages/stream`**, JWT. It has the same +body as `POST …/messages`. + +- **The rate limit is shared.** Both send routes use + `limiter.shared_limit("15 per minute", scope="chat_send")`, so one user can't + double their rate by using both. +- **Before the stream, as today, returning ordinary JSON errors:** + - 401 for the session; + - 404 for ownership; + - 422 for the body; + - 429 for the limiter, and 429 `daily token budget exhausted` for the budget. + + Then the user's message is added and **committed**, so no transaction is held + open while the model runs. +- **Response:** `200 text/event-stream`, with `Cache-Control: no-cache` and + `X-Accel-Buffering: no`, wrapped in `stream_with_context`. Each event is + `event: \ndata: \n\n`. + + | Event | Data | Meaning | + | --- | --- | --- | + | `user_message` | `{message: MessageResponse}` | The saved question. It replaces the optimistic one. | + | `stage` | `{stage}`: one of `understanding`, `searching`, `plans`, `coverage`, `writing` | Progress. `understanding` is sent only when history triggers a rewrite. `plans` is sent before a `search_plans` call, `coverage` before `plan_coverage`, and `writing` just before the first `delta`. | + | `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 every delta so far: a round that had streamed text went on to call a tool | + | `done` | `{message: MessageResponse, thread: ThreadResponse}` | The authoritative saved answer: notices plus text, sources, plans and title. The client renders this and drops its draft. | + | `error` | `{error: "generation failed"}` | The model failed. The question stays and nothing is saved, as with today's 502. | + +- **Generation refactor** (`src/services/generation.py`): + - **Events.** `answer_events(query, chunks, history, profile, shown_plans, *, + stream: bool)` is a generator. It yields event dataclasses (`Stage`, + `Notice`, `Delta`, `Reset`) and last of all `Done(answer)`. + - **Wrappers.** `answer()` drains `answer_events(stream=False)` and returns + the `Answer`. `answer_query_events()` yields `Stage("understanding")` when + it rewrites and `Stage("searching")` before `search`, then delegates. + `answer_query()` drains that. The CLI, the evals and every existing test + keep calling `answer`/`answer_query` unchanged. + - **The tool loop is shared.** Only the completion call differs. + `stream=False` uses today's `_complete`. `stream=True` uses a new + `_complete_stream`, which: + - calls `create(..., stream=True, stream_options={"include_usage": True})`; + - yields `Delta`s; + - puts tool-call deltas together by index; + - adds the usage from the final chunk to `_tokens_used`, and warns when + usage never arrives; + - returns the assembled message with `content`, `tool_calls` and + `finish_reason`. + - **Deltas are only forwarded once the answer is grounded,** meaning + `chunks or searched`. Before that, text is buffered, so the "no chunks and + no search → NO_ANSWER" path never has to take back text already shown. If + a round that streamed text then calls tools, `Reset` is yielded. + - **The rest is unchanged:** + - the empty-answer fallback; + - coverage-citation filtering; + - notices put before the final text; + - `_distinct`. + + All of it happens before `Done`, so `done` always carries exactly what the + JSON route would have saved. +- **Persisting** is shared by both routes in one helper, `_save_answer(db, + thread, body, result)`: + - the assistant message; + - its sources, with `content_sha256`; + - its plan snapshots; + - the automatic title. + + After `Done`, the stream saves the answer, calls `record_tokens`, commits, + then sends `done`. +- **Failures and cancelling.** + - On `openai.OpenAIError`: record the tokens, commit, send `error`, and end. + - In a `finally`, which also covers a client disconnect (`GeneratorExit`): + record any spend not yet recorded, exactly once, and commit. No assistant + message is saved. + - Errors are logged with the thread id only, never content. +- **Serving.** Gunicorn stays at 1 worker and 8 threads (ADR 0022); an open + stream holds one thread, as a request does today. The ADR records that + Azure's ingress timeout (~240 s) exceeds the worst case (3 rounds, 30 s + model timeout). +- **The JSON route stays.** It is used by scripts and existing tests, and it is + the fallback. The frontend switches to the stream. + +### 3.4 Security (OWASP) + +- **Authorization is checked on every new route.** Ownership failures return + 404, and passages are reachable only through the user's own + `MessageSource` ids. +- **Parameterised SQLAlchemy only.** New request bodies use Pydantic with + length limits. +- **No content in logs.** Nothing logs question or answer text, passages, ZIP + codes or ages; logs carry ids and lengths. +- **The stream sends only what `done` would.** Deltas are model output the + client renders as Markdown without raw HTML (section 4.4). The model can't + add a field to an event, because every event is built by server code. +- **Quotes are brief and links are safe.** Passage text is quoted briefly, + keeping to ADR 0013 and the licences, and every URL is `https://` only and + passes `unsafe_reason`. + +### 3.5 Tests (PR 1) + +- **`test_chat.py`, rename:** + - 200, and the new title is shown in the list; + - the list order is unchanged (`updated_at` not bumped); + - whitespace is stripped; + - 422 for a blank or 201-character title; + - 404 for another user's thread, a missing id or a malformed id; + - 401 with no token. +- **`test_chat.py`, sources:** + - an `id` is in both message endpoints' sources; + - `GET` for `ok` (SBC, Wikipedia, HealthCare.gov), `unverified`, `changed` and + `missing`; + - another user's source returns 404; + - an unsafe or `http` URL becomes `null`; + - the Wikipedia licence comes from the registry; + - the hash is written on new answers. +- **`test_passages.py`:** + - the excerpt picks the segment that best matches the answer; + - the 300-character cap; + - the ellipses; + - a passage of 300 characters or fewer is returned whole; + - a single long line; + - ties go to the earlier segment; + - empty inputs. +- **`test_chat_stream.py`** (patching `answer_query_events`): + - the event order for the happy path; + - `done` matches what was saved; + - the user message is committed before any model event; + - 404, 422 and 429 (budget) come back as JSON before the stream; + - `error` on `OpenAIError`, with the user message kept and tokens recorded; + - a disconnect (closing the generator) records tokens once and saves no + answer; + - the headers. +- **`test_generation.py`** (a fake streaming client yielding chunk objects): + - `answer_events(stream=True)` gives the same `Answer` as `stream=False`; + - deltas arrive in order; + - `Reset` when a streamed round turns into tool calls; + - no deltas before grounding (the NO_ANSWER path); + - stages and notices; + - usage counted, and a warning when it is missing; + - tool-call fragments put together. +- **`test_rate_limits.py`:** + - the rename is limited at 30 per minute; + - the source lookup at 60 per minute; + - the stream at 15 per minute, and **shared** with the JSON send (8 + 8 + requests → the 16th is 429); + - each 429 carries `Retry-After`. +- **`test_models_chat.py` / `alembic check`:** the new column. The migration + round-trips (upgrade, downgrade). + +## 4. Frontend (PR 2) + +### 4.1 Tokens and theme + +`src/index.css` takes its tokens from `docs/design/chat-redesign/tokens.css`, +the `.pp` palette: + +- **Light:** paper `#E8E5DB`, sheet `#F8F6F0`, ink `#1E211C`, ink-soft + `#575D51`, rule `#D6D0BF`, seal `#96651F`, pine `#2C5B4B`. +- **Dark:** as in the `.pp.dark` block. +- **Type:** IBM Plex Serif for questions and headings, IBM Plex Sans for body + text, and tabular figures for money. + +The theme is set on ``: + +- `:root` holds the light palette; +- `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) }` + holds the dark one; +- `:root[data-theme="dark"]` holds it too. + +`hooks/useTheme.js` reads and writes `localStorage["policypal.theme"]` +(`system`, `light` or `dark`). Every storage access is wrapped in try/catch, +because storage can be unavailable. + +### 4.2 Structure + +These files are new or rewritten. Every component gets a colocated +`*.test.jsx`. + +``` +components/ Button (primary|ghost|danger|icon), IconButton, Menu (menu button + items, arrow keys, Esc), + TextField (+ password show/hide), ErrorBanner, Toast +hooks/ useTheme, useShortcuts (Ctrl/⌘K, /, Esc), useMediaQuery +features/chat/ + AppShell.jsx sidebar + main (+ sources panel); collapsible sidebar; on ≤720px the sidebar is a drawer + sidebar/ Sidebar, ThreadSearch, ThreadGroup, ThreadListItem (menu → Rename inline | Delete confirm inline), + AccountMenu (profile, theme radio group, shortcuts, sign out) + transcript/ Transcript (pairs messages into exchanges; stick-to-bottom; JumpToLatest), Exchange (question as + heading + answer), AnswerBody (react-markdown + remark-gfm + remarkMarkers), Seal, PlanRef, + AnswerFooter (source chips, Copy), StreamingAnswer (ProgressSteps + text + caret), EmptyState + sources/ SourcesPanel (desktop column / phone bottom sheet), SourceCard, useSource (fetch + cache) + composer/ Composer (autosize, send/stop, hints), ProfileNudge + useMessages.js load, stream send, stop (AbortController), events → state + useThreads.js + renameThread +features/plans/ PlanComparison restyled: numbered rows, "Ask about this plan", row flash from PlanRef +features/auth/ AuthPage split layout with the example cited answer; AuthForm restyled +features/profile/ ProfilePage inside AppShell; ProfileForm restyled; "Where this goes" aside +services/ chatService: streamMessage, renameThread, getSource; sse.js (a fetch ReadableStream parser) +utils/ markers.js (remark plugin + copy-text), threadGroups.js (Today / Previous 7 days / Earlier), + exchanges.js +``` + +### 4.3 Behaviour + +- **Sending:** + - Enter sends and Shift+Enter adds a line. + - The send button becomes Stop while an answer streams. Esc or Stop aborts + the fetch, the draft answer is removed, and the thread shows "You stopped + this answer." with an "Ask again" button. + - **Errors that come before the stream:** + + | Error | Handled as | + | --- | --- | + | 401 | an expired session, as today | + | 429 | today's two messages | + | 422 or 404 | the error banner | +- **Progress:** `stage` events drive the progress steps. Each code maps to + wording: "Understanding your question", "Searching the references", + "Searching plans near you", "Reading Summaries of Benefits", "Writing the + answer". Finished steps are ticked. +- **Markdown and markers:** + - `react-markdown` with `remark-gfm`, with raw HTML never rendered. Links go + through `utils/safeUrl` and open in a new tab with `noopener noreferrer`. + - `remarkMarkers` turns each `[Source: a; b]` into seals. A seal's number is + the matching source's position in `message.sources`, which the API orders + by relevance. A label with no matching source is left out. + - `[Plan: id]` becomes a `PlanRef` showing the plan's position and name from + `message.plans`. An id not among them is left as its text. +- **Seals:** + - Clicking one opens the Sources panel on that source. + - Hovering one outlines its source card. + - When an answer finishes streaming, its seals stamp in (scale and rotate, + with a stagger of 80 ms). This happens only for a live answer, never when + history loads. It is the one self-running animation. +- **Sources panel:** + - It lists the latest selected answer's sources. Each card's passage is + loaded when the card is first shown, and cached per source id. + - Each card shows the title, document, section, quote, a match meter (the + relevance percentage), and a link: "Carrier's PDF" or "Read on Wikipedia". + - Wikipedia text carries its licence credit. + - `unverified` adds "This passage may have changed since the answer." + - `changed` and `missing` say "This passage has changed since the answer and + can't be shown." + - On ≤720 px the panel is a bottom sheet with a scrim, and Esc closes it. +- **Plans:** + - Rows are numbered 1…n in the order the answer showed them. + - "Ask about this plan" puts `About plan {n}, {name}: ` in the composer and + focuses it. + - A `PlanRef` scrolls to its row and flashes it for 1.4 s. + - Nothing marks a "best" plan (ADR 0010: compare, never recommend). +- **Copy:** copies the answer as plain text, with seals as `[n]` and plan refs + as their names. It shows "Copied" for 1.6 s, or "Couldn't copy". +- **Sidebar:** + - A search box filters thread titles as you type, case-insensitively, with + the matches highlighted, and shows "No questions match". + - Threads are grouped by `updated_at`. + - Each thread's ⋯ menu has Rename, which edits inline (Enter saves, Esc + cancels, a blank title is refused), and Delete, which asks for + confirmation inline ("Delete" / "Keep it"). + - The header title can be renamed too. + - The sidebar can be hidden, and the choice is remembered per browser. +- **Shortcuts:** + - Ctrl/⌘K starts a new question. + - `/` focuses the composer, when you're not already typing in a field. + - Esc stops an answer, or closes the open menu, panel or sheet. + - The account menu lists these. +- **Jump to latest:** appears when you are more than 200 px above the bottom. + While you're at the bottom, a streaming answer keeps the view pinned there. +- **Empty state:** the design's heading and four starter questions. The two + plan starters are labelled "Plans near you". +- **Motion:** + - `motion` inside `MotionConfig reducedMotion="user"` (the CSS already + respects `prefers-reduced-motion`). + - Motion that answers an action: the panel and sheet sliding in, the sidebar + collapsing, menus, and toasts. + - The only motion that runs on its own is the stamp. +- **Accessibility:** + - semantic landmarks, and real buttons and links; + - `aria-live="polite"` on the streaming answer, with `aria-busy` while it + streams; + - focus moves into the panel, sheet and menus and back out when they close; + - visible focus on everything; + - text contrast ≥ 4.5:1 in both themes. +- **Sign-in and register:** the split layout. The left half shows the example + cited answer, using the HealthCare.gov glossary text for Copayment and + Coinsurance. Register keeps the ZIP and date-of-birth fields, as today. +- **Profile:** inside the app shell, with the "Where this goes" aside. + +### 4.4 Frontend security + +- **No raw HTML.** `react-markdown`'s default escaping stays on: there is no + `rehype-raw` and no `dangerouslySetInnerHTML`. +- **Only safe links.** `https` URLs from answers, passages and plans pass + `safeUrl`, and every link that opens a new tab has `rel="noopener + noreferrer"`. +- **Only the theme and the sidebar's open state are stored locally.** No + tokens or personal data. + +### 4.5 Tests (PR 2) + +- **Vitest and React Testing Library, for every component and hook:** + - render; + - interaction; + - loading, empty, error and success states; + - accessibility queries by role. + + Key cases: + - the SSE parser on split chunks, several events per chunk, and a final + event with no trailing blank line; + - `useMessages`: the stream's event order, `reset`, `done` replacing the + draft, `error`, and stop, which aborts and keeps the question; + - the marker plugin: several labels in one bracket, an unknown label, and + plan refs; + - `threadGroups` at date boundaries; + - rename and delete confirmation, driven by the keyboard; + - search highlighting; + - the source card in all four statuses and the Wikipedia credit; + - the theme stored and applied; + - shortcuts ignored while typing in an input; + - the copy text. +- **Playwright (plugin), run by hand at the end against the local stack:** + - sign in; + - ask a plan question with a San Diego ZIP; + - watch it stream; + - open a seal's source; + - rename and search threads; + - switch to the dark theme; + - a phone viewport, with its sheet; + - Stop. + + Screenshots from the run go in the PR. + +## 5. Documentation + +- **ADR 0027:** streaming answers, the passage endpoint (hashes, brief quotes, + licence credit), and rename semantics. It amends ADR 0007 (citations now + carry a content hash) and ADR 0022 (streams hold a gthread thread). + Sub-project 3's planned ADR moves to 0028. +- **README:** the chat features, the new endpoints and the new dependencies. +- **CHANGELOG:** bullets after every commit. + +## 6. Out of scope + +- Regenerating an answer. +- Feedback buttons. +- Editing a sent question. +- Sharing a thread. +- Saving a partial answer after Stop. +- Server-side thread search. +- HealthCare.gov passage URLs, which are not stored today. + +## 7. Review focus (risks) + +1. **A reset mid-answer.** The model starts writing, then calls a tool. The UI + must clear the text cleanly, and `done` must match what was saved. +2. **A disconnect mid-stream.** Spend must still be recorded, and no + half-written answer or open transaction left behind. +3. **The stamp must not replay.** On reloading a thread, seals must not stamp + again, and old answers without a hash must say "may have changed", never + show text silently. +4. **Two sends at once, from two tabs or double clicks.** The shared limit + holds, and the composer is disabled while an answer streams. +5. **Narrow screens.** The plan table scrolls inside its own box, and the page + never scrolls sideways at 390 px. From 7263281a0b3700350d59984394d989e57618fb73 Mon Sep 17 00:00:00 2001 From: BADRI NARAYANAN Date: Sat, 26 Sep 2026 15:19:41 -0700 Subject: [PATCH 02/12] Plan the chat redesign: backend and frontend tasks --- CHANGELOG.md | 3 +- .../plans/2026-09-26-chat-redesign.md | 2691 +++++++++++++++++ 2 files changed, 2693 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-09-26-chat-redesign.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 00234ac..6ec6555 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ - keyboard shortcuts; - a phone layout. - The work splits into a backend PR and a frontend PR. + 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`. - Design and ADR 0026 for California's SBCs, sub-project 2 (`docs/superpowers/specs/2026-09-25-ca-sbc-design.md`). CMS's California 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 `