diff --git a/e2e/documents-ai.spec.ts b/e2e/documents-ai.spec.ts new file mode 100644 index 000000000..e73689ecc --- /dev/null +++ b/e2e/documents-ai.spec.ts @@ -0,0 +1,337 @@ +/** + * Document vault — the P2 AI-assist + content-search acceptance suite. + * + * Pins the review-first contract (a suggestion prefills an editable field and + * is only ever written on an explicit save — never auto-committed), the + * session-only summary panel (transient, carrying the "not saved · not a + * diagnosis" note), whole-word content search reaching a body-only word through + * the real blind-token GIN union, the corpus "index all" backfill CTA, the calm + * provider-unavailable pointer (no provider → no assist button, a settings + * link instead), and the text-mode image-only refusal that fires client-side + * before any OCR/upload work. + * + * The seeded demo user has NO AI provider, so the unavailable state is the + * default; the enabled states are driven by `page.route` mocks of `usage` + + * the OCR capability probe + the AI endpoints. Content search uses NO mock — it + * seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives + * the real list union. + */ +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, + CONTENT_BODY_WORD, +} from "./setup/vault-fixture"; + +/** The vault usage DTO the UI reads before offering upload + AI affordances. */ +function usageBody(overrides: { + assistAvailable: boolean; + contentIndex: { enabled: boolean; indexedCount: number; totalCount: number }; +}) { + return { + data: { + usedBytes: 4096, + quotaBytes: 1_073_741_824, + maxFileBytes: 26_214_400, + acceptedExtensions: [".pdf", ".png", ".jpg", ".jpeg", ".webp"], + linkedEpisodes: [], + assistAvailable: overrides.assistAvailable, + contentIndex: overrides.contentIndex, + }, + error: null, + }; +} + +/** 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), + }); +} + +/** + * Mock the AI-availability surface: `usage` (assist + content-index flags) and + * the OCR capability probe (transport mode). Endpoints (suggest/summary/reindex) + * are mocked per-test on top of this. + */ +async function mockAiEnabled( + page: Page, + opts: { + mode: "vision" | "text"; + pdfSupported?: boolean; + contentIndex?: { + enabled: boolean; + indexedCount: number; + totalCount: number; + }; + }, +): Promise { + const contentIndex = opts.contentIndex ?? { + enabled: true, + indexedCount: 0, + totalCount: 1, + }; + await page.route("**/api/documents/inbound/usage", (route) => + fulfilJson(route, usageBody({ assistAvailable: true, contentIndex })), + ); + await page.route("**/api/labs/ocr/capability", (route) => + fulfilJson(route, { + data: { + available: true, + mode: opts.mode, + reason: null, + pdfSupported: opts.pdfSupported ?? opts.mode === "vision", + }, + error: null, + }), + ); +} + +test.describe("document vault — AI assist + content search", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeAll(async () => { + await ensureVaultFixture(); + await ensureVaultAiFixture(); + }); + + // ── (a) Review-first assist: prefill → edit → save, nothing auto-committed ── + + test("assist prefills an editable draft that only saves on commit", async ({ + page, + }) => { + await mockAiEnabled(page, { mode: "vision" }); + + const suggestedTitle = `Suggested lab report ${Date.now()}`; + await page.route( + `**/api/documents/inbound/${AI_PROBE_DOC_ID}/suggest`, + (route) => + fulfilJson(route, { + data: { + suggestions: { + title: suggestedTitle, + kind: "LAB_RESULT", + documentDate: "2025-03-03", + }, + }, + error: null, + }), + ); + + // Count PATCH writes to this document — a review-first draft must not fire + // one until the user commits. + let patchCount = 0; + page.on("request", (req) => { + if ( + req.method() === "PATCH" && + req.url().includes(`/api/documents/inbound/${AI_PROBE_DOC_ID}`) + ) { + patchCount += 1; + } + }); + + await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + const suggest = sheet.locator('[data-slot="assist-suggest"]'); + await expect(suggest).toBeEnabled(); + await suggest.click(); + + const review = sheet.locator('[data-slot="assist-suggestion-review"]'); + await expect(review).toBeVisible(); + await expect( + review.getByText("AI suggestion — review before saving"), + ).toBeVisible(); + + // Applying the title seeds the EDITABLE field only — no write yet. + await review.getByRole("button", { name: "Use title" }).click(); + const titleInput = sheet.locator("#document-title-input"); + await expect(titleInput).toHaveValue(suggestedTitle); + expect(patchCount).toBe(0); + + // Commit (blur/Enter) → the write fires exactly once, carrying the title. + const saved = page.waitForRequest( + (req) => + req.method() === "PATCH" && + req.url().includes(`/api/documents/inbound/${AI_PROBE_DOC_ID}`), + ); + await titleInput.press("Enter"); + const savedReq = await saved; + expect(JSON.parse(savedReq.postData() ?? "{}").title).toBe(suggestedTitle); + expect(patchCount).toBe(1); + }); + + // ── (b) Session-only summary: transient, note present, closing discards ── + + test("summary panel is transient and labelled not-saved / not-a-diagnosis", async ({ + page, + }) => { + await mockAiEnabled(page, { mode: "vision" }); + const summaryText = "A chest imaging report issued by a radiology clinic."; + let summaryCalls = 0; + await page.route( + `**/api/documents/inbound/${AI_PROBE_DOC_ID}/summary**`, + (route) => { + summaryCalls += 1; + return fulfilJson(route, { + data: { summary: summaryText }, + error: null, + }); + }, + ); + + await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + await sheet.getByRole("button", { name: "Summarise" }).click(); + const panel = sheet.locator('[data-slot="document-summary-panel"]'); + await expect(panel).toBeVisible(); + await expect(panel.getByText(summaryText)).toBeVisible(); + await expect( + panel.getByText( + "Not saved · not a diagnosis. Shown once for this view only.", + ), + ).toBeVisible(); + expect(summaryCalls).toBe(1); + + // Closing discards — the panel is gone and nothing lingers. + await panel.getByRole("button", { name: "Hide" }).click(); + await expect(panel).not.toBeVisible(); + }); + + // ── (c) Content search reaches a BODY-ONLY word through the token union ── + + test("content search finds a word that lives only in the document body", async ({ + page, + }) => { + await page.goto("/documents"); + await expect( + page.getByRole("heading", { name: "Documents" }), + ).toBeVisible(); + + // The word is absent from every title/filename — the only route is the + // real blind-token union over the seeded content index. + await page + .getByRole("searchbox", { name: "Search documents" }) + .fill(CONTENT_BODY_WORD); + + const card = page.getByRole("button", { name: "Open Radiology note" }); + await expect(card).toBeVisible({ timeout: 10_000 }); + await expect(page).toHaveURL(new RegExp(`q=${CONTENT_BODY_WORD}`)); + + // The matched card advertises its searchable body. + const searchable = page + .locator('[data-slot="document-card"]', { hasText: "Radiology note" }) + .locator('[data-slot="document-searchable"]'); + await expect(searchable).toBeVisible(); + + // Sanity: the word really is absent from the short fields (server-side + // confirmation the union — not an ILIKE — did the work). + const viaApi = await page.request.get( + `/api/documents/inbound?q=${CONTENT_BODY_WORD}&limit=10`, + ); + const body = await viaApi.json(); + const hit = body.data.documents.find( + (d: { id: string }) => d.id === CONTENT_DOC_ID, + ); + expect(hit).toBeTruthy(); + expect(`${hit.title} ${hit.filename}`.toLowerCase()).not.toContain( + CONTENT_BODY_WORD, + ); + }); + + // ── (d) Corpus "index all" backfill CTA ────────────────────────────────── + + test("the index-all CTA enqueues the corpus backfill", async ({ page }) => { + await mockAiEnabled(page, { + mode: "vision", + contentIndex: { enabled: true, indexedCount: 1, totalCount: 10 }, + }); + let reindexCalls = 0; + await page.route("**/api/documents/inbound/reindex", (route) => { + reindexCalls += 1; + return fulfilJson(route, { data: { enqueued: 5 }, error: null }); + }); + + await page.goto("/documents"); + const hint = page.locator('[data-slot="content-search-hint"]'); + await expect(hint).toBeVisible(); + const indexAll = page.locator('[data-slot="content-index-all"]'); + await expect(indexAll).toBeVisible(); + await indexAll.click(); + + await expect( + page.getByText("Indexing 5 document(s) in the background."), + ).toBeVisible(); + expect(reindexCalls).toBe(1); + }); + + // ── (e) No-provider degradation: calm pointer, no assist button ────────── + + test("with no provider the detail sheet shows a calm settings pointer", async ({ + page, + }) => { + // No mocks — the seeded demo user has no AI provider configured. + await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + await expect( + sheet.locator('[data-slot="assist-unavailable"]'), + ).toBeVisible(); + await expect( + sheet.getByRole("link", { name: "Open AI settings" }), + ).toBeVisible(); + // The action button is absent — the endpoint would 422, so the UI never + // offers it. + await expect(sheet.locator('[data-slot="assist-suggest"]')).toHaveCount(0); + }); + + // ── (f) Text-mode refuses a non-image before any OCR/upload ────────────── + + test("text-mode assist refuses a PDF client-side before any request", async ({ + page, + }) => { + await mockAiEnabled(page, { mode: "text", pdfSupported: false }); + let suggestCalls = 0; + await page.route( + `**/api/documents/inbound/${AI_PROBE_DOC_ID}/suggest`, + (route) => { + suggestCalls += 1; + return fulfilJson(route, { + data: { + suggestions: { title: null, kind: null, documentDate: null }, + }, + error: null, + }); + }, + ); + + await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + const suggest = sheet.locator('[data-slot="assist-suggest"]'); + await expect(suggest).toBeEnabled(); + await suggest.click(); + + // The PDF is refused locally (tesseract reads images only) — the error + // shows and the server suggest route is never called. + await expect( + sheet.getByText("Local OCR reads images only — not this file."), + ).toBeVisible(); + await page.waitForTimeout(300); + expect(suggestCalls).toBe(0); + }); +}); diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts index 9cd1538ba..31320e09c 100644 --- a/e2e/setup/vault-fixture.ts +++ b/e2e/setup/vault-fixture.ts @@ -23,7 +23,13 @@ * same date and link. `seedNamespaceDocs` adds disposable per-test * namespaces (bulk flows) that never collide with the doctor-flow corpus. */ -import { createCipheriv, createHash, randomBytes } from "node:crypto"; +import { + createCipheriv, + createHash, + createHmac, + hkdfSync, + randomBytes, +} from "node:crypto"; import pg from "pg"; @@ -68,6 +74,68 @@ function encryptBinary2(plaintext: Buffer): Buffer { ]); } +/** + * Encrypt a plaintext string into the `encrypt()`-string-as-UTF-8 Bytes shape + * the content index stores `text_encrypted` in (`.` + * → UTF-8 bytes). Mirrors src/lib/crypto.ts `encrypt` + the coach bytes codec. + */ +function encryptStringToBytes(plaintext: string): Buffer { + const { id, key } = resolveActiveKey(); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + const payload = Buffer.concat([iv, tag, enc]).toString("base64"); + return Buffer.from(`${id}.${payload}`, "utf8"); +} + +// ─── Blind content-index tokens (mirrors src/lib/documents/content-index) ──── +// +// HKDF-derived index subkey off the ACTIVE encryption key + domain label, then +// each normalised token HMAC-SHA256'd and truncated to a 16-char hex tag. The +// server hashes a search query the same way, so a body-only word seeded here is +// findable through the real GIN union without ever storing the word in clear. + +const CONTENT_INDEX_INFO = "healthlog:document-content-index:v1"; + +function indexSubkey(): Buffer { + const { key } = resolveActiveKey(); + return Buffer.from( + hkdfSync("sha256", key, new Uint8Array(0), CONTENT_INDEX_INFO, 32), + ); +} + +/** Tokenise like the server: NFKD de-accent, lowercase, alnum runs, len 3..64. + * The seeded words are deliberately non-stopwords, so the stopword drop the + * server also applies is a no-op here and the tag sets match exactly. */ +function tokenise(text: string): string[] { + const normalised = text + .normalize("NFKD") + .replace(/\p{Diacritic}/gu, "") + .toLowerCase(); + const seen = new Set(); + for (const raw of normalised.split(/[^\p{L}\p{N}]+/u)) { + const token = raw.trim(); + if (token.length < 3 || token.length > 64) continue; + seen.add(token); + } + return [...seen]; +} + +function tokeniseAndHash(text: string): string[] { + const subkey = indexSubkey(); + const hashes = new Set(); + for (const token of tokenise(text)) { + hashes.add( + createHmac("sha256", subkey) + .update(token, "utf8") + .digest("hex") + .slice(0, 16), + ); + } + return [...hashes]; +} + // ─── Deterministic tiny documents ─────────────────────────────────────────── /** Minimal single-page PDF; `marker` lands in a comment so every doc's bytes @@ -96,6 +164,15 @@ function tinyPng(marker: string): Buffer { export const KNIE_EPISODE_ID = "e2evaultknie000000000001"; export const MRT_DOC_ID = "e2evaultmrt0000000000001"; +/** A stored PDF the AI-assist specs drive (mocked provider); title/kind are + * bland so an applied suggestion is visibly different. */ +export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001"; +/** A stored document whose searchable body word lives ONLY in its content + * index — never in the title or filename — so content search is the only + * route to it. */ +export const CONTENT_DOC_ID = "e2evaultcontent00000001"; +/** The whole word that appears only in `CONTENT_DOC_ID`'s indexed body. */ +export const CONTENT_BODY_WORD = "pneumothorax"; /** Filing date of the MRT report — "last autumn" relative to the fixture. */ const MRT_DOC_DATE = "2025-10-14"; @@ -263,6 +340,69 @@ export async function ensureVaultFixture(): Promise { } } +/** + * Seed the P2 AI-assist + content-search fixtures: + * - a bland PDF the assist / summary specs drive against a mocked provider; + * - a document whose only searchable word (`CONTENT_BODY_WORD`) lives in its + * blind content index, not its title/filename, so content search is the + * sole route to it — exercising the real GIN token union end-to-end. + * Idempotent (fixed ids upserted); safe to call from every spec's beforeAll. + */ +export async function ensureVaultAiFixture(): Promise { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("[vault-fixture] DATABASE_URL is not set"); + const pool = new pg.Pool({ connectionString: url }); + try { + const userId = await getUserId(pool); + + await upsertDocs(pool, userId, [ + { + id: AI_PROBE_DOC_ID, + kind: "OTHER", + title: "AI probe report", + filename: "ai-probe-report.pdf", + mimeType: "application/pdf", + documentDate: "2026-04-02", + bytes: tinyPdf(AI_PROBE_DOC_ID), + }, + { + // Title + filename deliberately omit CONTENT_BODY_WORD. + id: CONTENT_DOC_ID, + kind: "DOCTOR_REPORT", + title: "Radiology note", + filename: "radiology-note.pdf", + mimeType: "application/pdf", + documentDate: "2026-03-10", + bytes: tinyPdf(CONTENT_DOC_ID), + }, + ]); + + // Seed CONTENT_DOC_ID's blind content index: the body word is only findable + // via the token union. The plaintext text is stored encrypted (never read + // by the search path) purely to mirror a real index row. + const body = `Chest imaging report. Findings consistent with ${CONTENT_BODY_WORD}. No pleural effusion noted.`; + await pool.query( + `INSERT INTO document_content_index + (id, document_id, user_id, text_encrypted, search_tokens, source, + provider_type, tokenizer_version, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, 'vision', NULL, '1', NOW(), NOW()) + ON CONFLICT (document_id) DO UPDATE SET + text_encrypted = EXCLUDED.text_encrypted, + search_tokens = EXCLUDED.search_tokens, + updated_at = NOW()`, + [ + "e2evaultcidx0000000000001", + CONTENT_DOC_ID, + userId, + encryptStringToBytes(body), + tokeniseAndHash(body), + ], + ); + } finally { + await pool.end(); + } +} + /** * Seed a disposable, namespaced batch of OTHER-kind PDF documents for the * bulk-flow specs (`title`/`filename` carry the prefix so a search filter