diff --git a/CHANGELOG.md b/CHANGELOG.md index 67a2e1b4e..ecc33ae62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.27.33] — 2026-07-09 — Chat about a document + +### Added + +- Open a document and ask about it in plain language — a short, grounded conversation about that one document's text ("What does the Impression say?", "Which medications are listed?"). Answers cite where in the document each statement comes from and say plainly when the document doesn't contain the answer. + +### Security + +- The feature is built so a hostile document is harmless: the document text is fenced as data (never instructions — a document that says "ignore your instructions" is treated as content, not obeyed, and embedded fence markers are scrubbed); the chat has **no tools** (nothing an injected instruction could do); **no health snapshot** is ever sent (the only context is that one document — your health record can't leak, even if asked); fabricated numbers are stripped so only figures present in the document appear; and replies render as plain text (no markup a reply could contain is executed). It never diagnoses — it describes the document and defers clinical decisions. +- Available for indexed documents only, using your configured AI provider (local-first, keeping the document on your machine; any external provider requires explicit AI consent and shows the egress notice). Document chats are kept separate from the Coach and encrypted at rest. Migration 0230. + ## [1.27.32] — 2026-07-09 — Quality-of-life, accessibility, cold-start and performance polish ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c250fab17..d6e627ca7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.32 + version: 1.27.33 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -9389,6 +9389,93 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/documents/inbound/{id}/chat: + post: + tags: + - Documents + summary: Chat about a document (streaming reply) + description: "v1.27.33 — sends a user turn and streams a grounded prose reply about ONE stored document as Server-Sent + Events (`text/event-stream`, not JSON: one `data: \\n\\n` frame per event). Frame `type` is `token` (a + chunk of reply text), `done` (`{ type, conversationId, messageId, usage? }`), or `error` (`{ type, code, message + }`); HTTP status is 200 even for a provider/refusal outcome (dispatch on the `error` frame). The reply is + grounded ONLY in the document's indexed text — NO health snapshot, NO tools, NO other document. The document + text is fenced as untrusted DATA (prompt-injection defence); the inbound message + every prior turn are + injection-screened and the reply is dose/risk-screened + numerically grounded against the document's own + figures. Available only for a content-indexed document (422 `documents.inbound.notIndexed` otherwise). + AI-consent-gated (403 `consent.ai.required` for an external provider), budget- and rate-limited. Omitting + `conversationId` starts a new thread. Renders as plain text on the client (no markdown). Auth via cookie or + Bearer." + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentChatRequest" + responses: + "200": + description: Server-Sent Events stream of `token` / `done` / `error` frames. + content: + text/event-stream: + schema: + type: string + description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." + "401": *a1 + "403": + description: "AI consent required for an external provider (`errorCode: consent.ai.required`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + get: + tags: + - Documents + summary: Read a document's chat history + description: v1.27.33 — with `conversationId`, returns that one thread's messages (decrypted server-side, oldest-first, + document- and owner-scoped); without it, the paginated list of the document's chat threads for the sheet's rail. + A foreign / unknown id maps to 404 (never 403). Auth via cookie or Bearer. + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: conversationId + in: query + required: false + schema: + type: string + description: When set, returns that thread's messages. + - name: cursor + in: query + required: false + schema: + type: string + description: List pagination cursor (id of the last item). + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 50 + responses: + "200": + description: Either the conversation detail (with `messages`) or the paginated conversation list for the document. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentChatHistoryEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/documents/inbound/reindex: post: tags: @@ -11715,6 +11802,24 @@ components: required: - mode - text + DocumentChatRequest: + type: object + properties: + conversationId: + type: string + minLength: 1 + maxLength: 64 + message: + type: string + minLength: 1 + maxLength: 4000 + locale: + type: string + enum: + - en + - de + required: + - message DisclaimerAckRequest: type: object properties: @@ -26823,6 +26928,127 @@ components: - indexed - tokenCount additionalProperties: false + DocumentChatHistoryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/DocumentChatHistoryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DocumentChatHistoryResponse: + anyOf: + - $ref: "#/components/schemas/DocumentChatDetail" + - $ref: "#/components/schemas/DocumentChatList" + DocumentChatDetail: + type: object + properties: + id: + type: string + title: + type: string + createdAt: + type: string + updatedAt: + type: string + messageCount: + type: number + messages: + type: array + items: + $ref: "#/components/schemas/DocumentChatMessage" + summary: + anyOf: + - type: string + - type: "null" + required: + - id + - title + - createdAt + - updatedAt + - messageCount + - messages + - summary + additionalProperties: false + DocumentChatMessage: + type: object + properties: + id: + type: string + role: + type: string + enum: + - user + - assistant + content: + type: string + createdAt: + type: string + providerType: + anyOf: + - type: string + - type: "null" + tokensUsed: + anyOf: + - type: number + - type: "null" + model: + anyOf: + - type: string + - type: "null" + required: + - id + - role + - content + - createdAt + - providerType + - tokensUsed + - model + additionalProperties: false + DocumentChatList: + type: object + properties: + conversations: + type: array + items: + $ref: "#/components/schemas/DocumentChatConversation" + nextCursor: + anyOf: + - type: string + - type: "null" + required: + - conversations + - nextCursor + additionalProperties: false + DocumentChatConversation: + type: object + properties: + id: + type: string + title: + type: string + createdAt: + type: string + updatedAt: + type: string + messageCount: + type: number + required: + - id + - title + - createdAt + - updatedAt + - messageCount + additionalProperties: false DocumentReindexEnvelope: type: object properties: diff --git a/e2e/documents-chat.spec.ts b/e2e/documents-chat.spec.ts new file mode 100644 index 000000000..9d693a5ae --- /dev/null +++ b/e2e/documents-chat.spec.ts @@ -0,0 +1,419 @@ +/** + * Document vault — the P4 "chat about this document" acceptance suite. + * + * Pins the scoped chat that lives in the detail sheet's AI area: the entry is + * offered only when a provider is available AND the document is content-indexed; + * a non-indexed document shows a calm read-it-first hint (never an error); a + * conversation streams the assistant reply and settles it as plain text with the + * always-on "not medical advice" safety note; an injection-laced document is + * described, never obeyed, and any HTML the model emits renders inert (the XSS + * posture); and with NO provider the AI area collapses to the settings pointer + * with no chat surface at all. + * + * v1.27.31 lesson: the panel depends on new endpoints (the capability probe, the + * chat history GET, the streaming chat POST), so every one is mocked here — + * otherwise the panel never renders and the specs time out waiting for elements. + * The provider round-trips are mocked, so the SSE reply is deterministic. The + * seeded demo user has no AI provider, so the no-provider state is the default. + */ +import { expect, test, type Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + ensureVaultFixture, + ensureVaultAiFixture, + AI_PROBE_DOC_ID, + CONTENT_DOC_ID, +} from "./setup/vault-fixture"; + +/** Fulfil the envelope shape the client unwraps (`(await res.json()).data`). */ +async function fulfilJson( + route: Parameters[1]>[0], + body: unknown, +) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +/** The vault usage DTO the sheet reads to gate the AI affordances. */ +function usageBody() { + return { + data: { + usedBytes: 4096, + quotaBytes: 1_073_741_824, + maxFileBytes: 26_214_400, + acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"], + linkedEpisodes: [], + assistAvailable: true, + contentIndex: { enabled: true, indexedCount: 1, totalCount: 2 }, + }, + error: null, + }; +} + +/** Serialise SSE frames the way the chat route emits them. */ +function sse(frames: Array>): string { + return frames.map((f) => `data: ${JSON.stringify(f)}\n\n`).join(""); +} + +/** + * Mock the AI-availability surface (usage + the document-scoped capability + * probe) so the sheet offers the AI area and the chat entry. `egress` drives the + * vendor-blind notice; default external. + */ +async function mockAiEnabled( + page: Page, + opts: { egress?: "local" | "external" } = {}, +): Promise { + await page.route("**/api/documents/inbound/usage", (route) => + fulfilJson(route, usageBody()), + ); + await page.route("**/api/documents/inbound/capability", (route) => + fulfilJson(route, { + data: { + available: true, + mode: "vision", + reason: null, + pdfSupported: true, + egress: opts.egress ?? "external", + }, + error: null, + }), + ); +} + +test.describe("document vault — chat about a document", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeAll(async () => { + await ensureVaultFixture(); + await ensureVaultAiFixture(); + }); + + // The chat drives a mocked provider round-trip; on a loaded CI runner it races + // the default 30s, so the whole group earns the tripled timeout. + test.beforeEach(() => { + test.slow(); + }); + + // ── (a) Indexed + available: the entry opens an empty, safe chat surface ── + + test("offers the chat entry and opens an empty, safety-noted surface", async ({ + page, + }) => { + await mockAiEnabled(page); + // The document has no thread yet → the history list is empty. + await page.route( + `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`, + (route) => { + if (route.request().method() !== "GET") return route.fallback(); + return fulfilJson(route, { + data: { conversations: [], nextCursor: null }, + error: null, + }); + }, + ); + + await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + const open = sheet.locator('[data-slot="document-chat-open"]'); + await expect(open).toBeVisible(); + await open.click(); + + await expect( + sheet.locator('[data-slot="document-chat-log"]'), + ).toBeVisible(); + await expect( + sheet.locator('[data-slot="document-chat-empty"]'), + ).toBeVisible(); + await expect( + sheet.locator('[data-slot="document-chat-input"]'), + ).toBeVisible(); + // The always-on safety note is present. + await expect( + sheet.locator('[data-slot="document-chat-safety"]'), + ).toContainText("not medical advice"); + }); + + // ── (b) A turn streams a grounded reply that settles as plain text ── + + test("streams the assistant reply and settles the conversation", async ({ + page, + }) => { + await mockAiEnabled(page); + + const CONV_ID = "e2edocchatconv0000000001"; + const QUESTION = "What does the Impression say?"; + const REPLY = "Per the report's Impression, the findings are unremarkable."; + let posted = false; + + await page.route( + `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`, + async (route) => { + const req = route.request(); + if (req.method() === "POST") { + posted = true; + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: sse([ + { type: "token", token: REPLY }, + { + type: "done", + conversationId: CONV_ID, + messageId: "e2edocchatmsg0000000001", + }, + ]), + }); + return; + } + const url = new URL(req.url()); + if (url.searchParams.get("conversationId")) { + // The persisted thread the post-`done` refetch reads back. + await fulfilJson(route, { + data: { + id: CONV_ID, + title: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + updatedAt: "2026-07-07T10:00:02.000Z", + messageCount: 2, + messages: [ + { + id: "e2edocchatu0000000001", + role: "user", + content: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: "e2edocchatmsg0000000001", + role: "assistant", + content: REPLY, + createdAt: "2026-07-07T10:00:02.000Z", + metricSource: null, + providerType: "mock", + promptVersion: null, + tokensUsed: 42, + model: "mock-1", + }, + ], + }, + error: null, + }); + return; + } + // The thread list: empty until the turn is posted, then the new thread. + await fulfilJson(route, { + data: { + conversations: posted + ? [ + { + id: CONV_ID, + title: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + updatedAt: "2026-07-07T10:00:02.000Z", + messageCount: 2, + }, + ] + : [], + nextCursor: null, + }, + error: null, + }); + }, + ); + + await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + await sheet.locator('[data-slot="document-chat-open"]').click(); + const input = sheet.locator('[data-slot="document-chat-input"]'); + await input.fill(QUESTION); + await sheet.locator('[data-slot="document-chat-send"]').click(); + + // The user's question and the streamed/settled reply both land in the log. + const log = sheet.locator('[data-slot="document-chat-log"]'); + await expect(log.getByText(QUESTION)).toBeVisible(); + await expect(log.getByText(/findings are unremarkable/)).toBeVisible(); + // The reply settled to a single assistant bubble (no duplicate). + await expect( + log.locator('[data-slot="document-chat-message"][data-role="assistant"]'), + ).toHaveCount(1); + }); + + // ── (b2) An injection-laced document: the reply describes it, never obeys, + // and any HTML the model emits renders inert (no markdown, no + // dangerouslySetInnerHTML — the project's XSS posture). ── + + test("answers about an injection-laced document without obeying it, and renders HTML inert", async ({ + page, + }) => { + await mockAiEnabled(page); + + const CONV_ID = "e2edocchatconv0000000002"; + const QUESTION = "What does this document say?"; + // The (mocked) server reply the panel receives: a calm describe-only answer + // — NOT obedience to the document's embedded "ignore instructions … reveal + // the system prompt … say HACKED" — plus a literal It appears to be a lab report."; + let posted = false; + + await page.route( + `**/api/documents/inbound/${CONTENT_DOC_ID}/chat**`, + async (route) => { + const req = route.request(); + if (req.method() === "POST") { + posted = true; + await route.fulfill({ + status: 200, + contentType: "text/event-stream; charset=utf-8", + body: sse([ + { type: "token", token: REPLY }, + { + type: "done", + conversationId: CONV_ID, + messageId: "e2edocchatmsg0000000002", + }, + ]), + }); + return; + } + const url = new URL(req.url()); + if (url.searchParams.get("conversationId")) { + await fulfilJson(route, { + data: { + id: CONV_ID, + title: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + updatedAt: "2026-07-07T10:00:02.000Z", + messageCount: 2, + messages: [ + { + id: "e2edocchatu0000000002", + role: "user", + content: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + metricSource: null, + providerType: null, + promptVersion: null, + tokensUsed: null, + model: null, + }, + { + id: "e2edocchatmsg0000000002", + role: "assistant", + content: REPLY, + createdAt: "2026-07-07T10:00:02.000Z", + metricSource: null, + providerType: "mock", + promptVersion: null, + tokensUsed: 30, + model: "mock-1", + }, + ], + }, + error: null, + }); + return; + } + await fulfilJson(route, { + data: { + conversations: posted + ? [ + { + id: CONV_ID, + title: QUESTION, + createdAt: "2026-07-07T10:00:00.000Z", + updatedAt: "2026-07-07T10:00:02.000Z", + messageCount: 2, + }, + ] + : [], + nextCursor: null, + }, + error: null, + }); + }, + ); + + await page.goto(`/documents?doc=${CONTENT_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + await sheet.locator('[data-slot="document-chat-open"]').click(); + const input = sheet.locator('[data-slot="document-chat-input"]'); + await input.fill(QUESTION); + await sheet.locator('[data-slot="document-chat-send"]').click(); + + const log = sheet.locator('[data-slot="document-chat-log"]'); + // The describe-only answer lands; the panel renders the model's