diff --git a/docs/integrations/ai-providers.md b/docs/integrations/ai-providers.md index 2cdb85c7b..bda355c87 100644 --- a/docs/integrations/ai-providers.md +++ b/docs/integrations/ai-providers.md @@ -198,6 +198,46 @@ The Coach drawer surfaces a "Local" badge when generation resolves to the local provider, so users who pinned a local endpoint can see at a glance that their context stayed on the host. +The document vault is local-first by default: an uploaded document is +only read by an external AI provider when you tap a per-document AI +action, and every text-layer PDF is indexed for search locally with no +egress at all — **unless you opt into automatic AI reading in AI +settings** (next section). + +## Automatic document reading (opt-in) + +**Off by default.** With the switch off, nothing about the vault +changes: a stored document is read externally only when you explicitly +tap "Read with AI" on it, and that tap still needs an active AI-consent +receipt for any provider that leaves the machine. Text-layer PDFs stay +searchable through local extraction with no egress. + +Turn **"Read documents automatically with AI"** on (AI settings) and +each document you upload is read, described, and indexed by your +configured AI provider with no per-document confirmation — the +"upload and it just gets read" flow. What that means, plainly: + +- **The document's contents leave this machine** to your configured + provider. The read happens on the server, right after upload. +- **A subscription connection** — a signed-in AI account rather than an + API key — applies its own consumer data settings. **Subscription + providers may use the content to improve their models, and no + data-processing agreement covers it.** Turning the toggle on is the + standing consent for that trade; it is recorded in the consent audit + trail. +- **A self-hosted local model never egresses.** Documents read by a + local provider stay on your server whether the toggle is on or off. +- **Scanned / image-only PDFs** are rendered to page images server-side + so a provider that only accepts images (a subscription connection) can + read them too; a native-PDF provider reads the PDF directly. Only the + first pages of a long document are sent, to bound cost. + +Regulated or privacy-sensitive deployments should either leave this off +and read documents per-document on demand, or configure a **BYOK +no-training API key** or a **local model** as the provider — both avoid +the subscription-training trade. The setting is per-user: in a +multi-user deployment each account opts in for its own documents. + ## What the Coach is — and isn't The Coach is an informational assistant that works from your own diff --git a/e2e/documents-auto-read.spec.ts b/e2e/documents-auto-read.spec.ts new file mode 100644 index 000000000..a5f03705b --- /dev/null +++ b/e2e/documents-auto-read.spec.ts @@ -0,0 +1,209 @@ +/** + * Automatic AI document reading — the per-user opt-in acceptance suite. + * + * Two surfaces: + * (1) AI settings — the single switch reveals a once-shown honesty confirm + * BEFORE writing the setting (off → on), and the confirm is what commits + * `documentsAutoAiRead: true`. Turning it off is immediate. + * (2) The vault detail sheet — the per-egress "leaves your machine" notice is + * shown once and the per-document "Read with AI" action is the explicit + * path; reading through it succeeds with no per-document consent prompt. + * + * Everything is `page.route`-mocked so the suite mutates no shared demo state. + * The document-scoped capability endpoint is mocked on every path — an unmocked + * / changed capability endpoint is the shipped regression that breaks the vault + * AI e2e, so it is pinned here too. Assertions target stable data-slots, never + * viewport-dependent text. + */ +import { expect, test, type Page } from "@playwright/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + ensureVaultFixture, + ensureVaultAiFixture, + AI_PROBE_DOC_ID, +} from "./setup/vault-fixture"; + +async function fulfilJson( + route: Parameters[1]>[0], + body: unknown, +) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +/** Mock the document capability probe so the vault AI section renders. */ +async function mockDocumentCapability( + page: Page, + opts: { egress: "local" | "external"; pdfSupported?: boolean }, +): Promise { + await page.route("**/api/documents/inbound/capability", (route) => + fulfilJson(route, { + data: { + available: true, + mode: "vision", + reason: null, + pdfSupported: opts.pdfSupported ?? true, + egress: opts.egress, + }, + error: null, + }), + ); +} + +test.describe("automatic AI reading — settings toggle", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + // The confirm logic is viewport-independent; run the settings flow once on + // desktop to keep the heavy AI-settings page off the mobile matrix. + test.skip( + testInfo.project.name !== "chromium-desktop", + "settings toggle flow runs on desktop only", + ); + test.slow(); + }); + + test("turning it on reveals a once-shown honesty confirm before writing", async ({ + page, + }) => { + // The toggle starts OFF; capture the write so we can prove it only fires on + // confirm (never on the raw switch flip). + let patchBody: unknown = null; + await page.route("**/api/auth/me/documents-auto-ai-read", (route) => { + if (route.request().method() === "PATCH") { + patchBody = JSON.parse(route.request().postData() ?? "{}"); + return fulfilJson(route, { + data: { documentsAutoAiRead: true }, + error: null, + }); + } + return fulfilJson(route, { + data: { documentsAutoAiRead: false }, + error: null, + }); + }); + + await page.goto("/settings/ai"); + const card = page.locator('[data-slot="documents-auto-read-card"]'); + await expect(card).toBeVisible(); + + // Flip the switch ON — the honesty confirm appears and NOTHING is written. + await card.getByTestId("documents-auto-read-enable").click(); + const confirm = card.locator('[data-slot="documents-auto-read-confirm"]'); + await expect(confirm).toBeVisible(); + expect(patchBody).toBeNull(); + + // Acknowledge — now (and only now) the write fires with the true flag. + await confirm + .locator('[data-slot="documents-auto-read-confirm-cta"]') + .click(); + await expect.poll(() => patchBody).toEqual({ documentsAutoAiRead: true }); + await expect(confirm).not.toBeVisible(); + }); + + test("cancelling the confirm writes nothing", async ({ page }) => { + let patchCalls = 0; + await page.route("**/api/auth/me/documents-auto-ai-read", (route) => { + if (route.request().method() === "PATCH") { + patchCalls += 1; + return fulfilJson(route, { + data: { documentsAutoAiRead: true }, + error: null, + }); + } + return fulfilJson(route, { + data: { documentsAutoAiRead: false }, + error: null, + }); + }); + + await page.goto("/settings/ai"); + const card = page.locator('[data-slot="documents-auto-read-card"]'); + await expect(card).toBeVisible(); + await card.getByTestId("documents-auto-read-enable").click(); + const confirm = card.locator('[data-slot="documents-auto-read-confirm"]'); + await expect(confirm).toBeVisible(); + await confirm.getByRole("button", { name: /cancel/i }).click(); + await expect(confirm).not.toBeVisible(); + await page.waitForTimeout(300); + expect(patchCalls).toBe(0); + }); +}); + +test.describe("automatic AI reading — vault per-document contract", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeAll(async () => { + await ensureVaultFixture(); + await ensureVaultAiFixture(); + }); + + test.beforeEach(() => test.slow()); + + test("the vault shows the per-egress notice once and the explicit read action", async ({ + page, + }) => { + // External egress (the codex/OAuth or any non-local provider case). + await mockDocumentCapability(page, { egress: "external" }); + + await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + // The vendor-blind "leaves your machine" notice is shown exactly once. + await expect( + sheet.locator('[data-slot="document-ai-egress-notice"]'), + ).toHaveCount(1); + // The per-document read is the explicit path — the action is present and the + // user must tap it (auto-read never removes the affordance, only the tap). + await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible(); + }); + + test("reading through the action succeeds with no per-document consent prompt", async ({ + page, + }) => { + await mockDocumentCapability(page, { egress: "external" }); + let indexCalls = 0; + await page.route( + `**/api/documents/inbound/${AI_PROBE_DOC_ID}/index`, + (route) => { + indexCalls += 1; + return fulfilJson(route, { + data: { documentId: AI_PROBE_DOC_ID, indexed: true, tokenCount: 9 }, + error: null, + }); + }, + ); + + await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + const read = sheet.locator('[data-slot="document-read-ai"]'); + await expect(read).toBeVisible(); + await read.click(); + + await expect(page.getByText("The AI read your document.")).toBeVisible(); + expect(indexCalls).toBe(1); + }); + + test("a local-egress read shows no external notice", async ({ page }) => { + // A self-hosted local model never leaves the machine — no egress notice. + await mockDocumentCapability(page, { + egress: "local", + pdfSupported: false, + }); + + await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`); + const sheet = page.getByRole("dialog"); + await expect(sheet).toBeVisible(); + + await expect( + sheet.locator('[data-slot="document-ai-egress-notice"]'), + ).toHaveCount(0); + }); +});