From eda57768864ae2e8e80cec62f99024c010d48f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 3 Oct 2026 20:51:57 +0200 Subject: [PATCH] Run document summaries and filing suggestions in the background The summary and suggest routes accept `Prefer: respond-async`: after the same quick refusals they answer 202 with a run id, and the background document AI worker runs the read under two new run kinds (DOCUMENT_SUMMARY, DOCUMENT_SUGGEST, migration 0370). Without the header both routes answer synchronously as before; both paths run one shared body, so they cannot drift. The worker asks the capability for the record and re-checks the wire for the provider it picks before every read. A summary that is to be stored marks the document PENDING while queued, READY when it lands, and UNAVAILABLE when the run fails or the write does not land. A second request attaches to a live run only when it would produce the same thing. The vault asks for both in the background and shows the same calm waiting line the index uses, worded so a transient read never promises that leaving the page keeps it. The egress guard and the route inventory now hold every run kind to its dispatcher re-check and its capability. --- docs/api/openapi.yaml | 662 +++++++++--------- .../migration.sql | 15 + prisma/schema.prisma | 9 + .../ai-egress-capability-guard.test.ts | 47 +- .../ai-capability-route-inventory.test.ts | 34 + .../documents/inbound/[id]/suggest/route.ts | 268 ++++--- .../documents/inbound/[id]/summary/route.ts | 468 +++++-------- .../__tests__/document-ai-section.test.tsx | 32 + .../__tests__/document-summary-block.test.tsx | 12 + .../documents/document-ai-panels.tsx | 34 + .../documents/document-ai-section.tsx | 26 +- .../documents/document-detail-sheet.tsx | 4 + .../documents/document-summary-block.tsx | 14 + .../documents/use-document-assist.ts | 23 +- src/lib/documents/ai-runs/run-support.ts | 74 ++ src/lib/documents/ai-runs/start.ts | 15 +- src/lib/documents/ai-runs/suggest-run.ts | 112 +++ src/lib/documents/ai-runs/summary-run.ts | 250 +++++++ src/lib/documents/ai-runs/types.ts | 46 +- .../jobs/__tests__/document-ai-run.test.ts | 223 +++++- src/lib/jobs/document-ai-run.ts | 185 ++++- src/lib/openapi/routes/ai-runs.ts | 27 +- src/lib/openapi/routes/documents.ts | 48 +- tests/integration/document-ai-runs.test.ts | 233 +++++- 24 files changed, 2061 insertions(+), 800 deletions(-) create mode 100644 prisma/migrations/0370_document_ai_run_kinds_summary_suggest_extract/migration.sql create mode 100644 src/lib/documents/ai-runs/run-support.ts create mode 100644 src/lib/documents/ai-runs/suggest-run.ts create mode 100644 src/lib/documents/ai-runs/summary-run.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cf0c25eb9..35847ef8a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -14284,7 +14284,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a38 + "202": &a36 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -16722,7 +16722,7 @@ paths: schema: $ref: "#/components/schemas/InboundExtractEnvelope" "401": *a1 - "403": &a36 + "403": &a37 description: "AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and `meta.reason` (one of `AiUnavailableReason`). `assistant.disabled.documentAi` — the operator turned off reading documents, or every AI feature (the master @@ -16746,7 +16746,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a37 + "503": &a38 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -16766,160 +16766,18 @@ paths: only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never - interprets or diagnoses; the title is a neutral filing label." + interprets or diagnoses; the title is a neutral filing label. With `Prefer: respond-async` (v1.40) the read runs + in the background: after the same quick refusals the route answers 202 with a run id, a second request while a + suggestion read of this document over the same transport is queued or running answers with that run, and `GET + /api/ai-runs/{id}` serves this body as the run's `result`." parameters: - name: id in: path required: true schema: type: string - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - mode: - type: string - const: text - text: - type: string - kind: - type: string - enum: - - DOCTOR_REPORT - - DISCHARGE_LETTER - - LAB_RESULT - - IMAGING - - PRESCRIPTION - - REFERRAL - - INSURANCE - - VACCINATION - - OTHER - required: - - mode - - text - responses: - "200": - description: The filing-metadata suggestion (drafts). - content: - application/json: - schema: - $ref: "#/components/schemas/DocumentSuggestEnvelope" - "401": *a1 - "403": *a36 - "422": *a4 - "429": *a5 - "503": *a37 - /api/documents/inbound/{id}/summary: - post: - tags: - - Documents - summary: Summarise or transcribe a document - description: 'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language - summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, - while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw - transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The - summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: - "text", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the - capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` - with no provider.' - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: mode - in: query - required: false - schema: - type: string - enum: - - summary - - text - default: summary - description: "`summary` (plain-language description) or `text` (raw)." - - name: persist - in: query - required: false - schema: - type: boolean - default: false - description: "For summary mode only: persist into an empty stored-summary slot." - - name: replace - in: query - required: false - schema: - type: boolean - default: false - description: With `persist=true`, explicitly replace an existing stored summary. - requestBody: - required: false - content: - application/json: - schema: - type: object - properties: - mode: - type: string - const: text - text: - type: string - kind: - type: string - enum: - - DOCTOR_REPORT - - DISCHARGE_LETTER - - LAB_RESULT - - IMAGING - - PRESCRIPTION - - REFERRAL - - INSURANCE - - VACCINATION - - OTHER - required: - - mode - - text - responses: - "200": - description: The summary and its persistence outcome, or transient extracted text. - content: - application/json: - schema: - $ref: "#/components/schemas/DocumentSummaryEnvelope" - "401": *a1 - "403": *a36 - "422": *a4 - "429": *a5 - "503": *a37 - /api/documents/inbound/{id}/index: - post: - tags: - - Documents - summary: Build / refresh the content-search index - description: "Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION - (empty body) decrypts the stored original and runs one provider transcription (answers under the `documentAi` - capability — 403 with the capability envelope when it is closed, including a missing `ai_extraction` / `ai_full` - receipt for a provider that leaves the machine — then rate / budget gated); TEXT (`application/json` `{ mode: - \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress and stays available with - AI off, so search keeps working. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes - — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index - continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with - no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged - PENDING for review — confirmation stays the only write into Labs. With `Prefer: respond-async` (v1.40) the read - runs in the background: after the same quick refusals the route answers 202 with a run id, a second request - while a read of this document is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves - this body as the run's `result`. Text mode honours the preference too, because the lab staging it continues into - is model work." - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: Prefer + - &a39 + name: Prefer in: header required: false schema: @@ -16957,6 +16815,162 @@ paths: required: - mode - text + responses: + "200": + description: The filing-metadata suggestion (drafts). + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSuggestEnvelope" + "202": *a36 + "401": *a1 + "403": *a37 + "422": *a4 + "429": *a5 + "503": *a38 + /api/documents/inbound/{id}/summary: + post: + tags: + - Documents + summary: Summarise or transcribe a document + description: "On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language + summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, + while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw + transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The + summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: + \"text\", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with + the capability envelope when it is closed), then rate / budget gated. 422 + `documents.inbound.providerUnsupported` with no provider. With `Prefer: respond-async` (v1.40) the read runs in + the background: after the same quick refusals the route answers 202 with a run id, and `GET /api/ai-runs/{id}` + serves this body as the run's `result`. A second request while a read with the same `mode`, `persist` and + `replace` is queued or running answers with that run. With `persist=true` the document's `summaryState` is + PENDING while the run is queued or running (unless a summary is already stored) and becomes UNAVAILABLE when the + run fails. `mode=text` over posted text calls no model and is answered synchronously either way (no + `Preference-Applied`)." + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: mode + in: query + required: false + schema: + type: string + enum: + - summary + - text + default: summary + description: "`summary` (plain-language description) or `text` (raw)." + - name: persist + in: query + required: false + schema: + type: boolean + default: false + description: "For summary mode only: persist into an empty stored-summary slot." + - name: replace + in: query + required: false + schema: + type: boolean + default: false + description: With `persist=true`, explicitly replace an existing stored summary. + - *a39 + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + mode: + type: string + const: text + text: + type: string + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - REFERRAL + - INSURANCE + - VACCINATION + - OTHER + required: + - mode + - text + responses: + "200": + description: The summary and its persistence outcome, or transient extracted text. + content: + application/json: + schema: + $ref: "#/components/schemas/DocumentSummaryEnvelope" + "202": *a36 + "401": *a1 + "403": *a37 + "422": *a4 + "429": *a5 + "503": *a38 + /api/documents/inbound/{id}/index: + post: + tags: + - Documents + summary: Build / refresh the content-search index + description: "Populates or refreshes one document's content-search index so search matches INSIDE its body. VISION + (empty body) decrypts the stored original and runs one provider transcription (answers under the `documentAi` + capability — 403 with the capability envelope when it is closed, including a missing `ai_extraction` / `ai_full` + receipt for a provider that leaves the machine — then rate / budget gated); TEXT (`application/json` `{ mode: + \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress and stays available with + AI off, so search keeps working. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes + — no plaintext body, no plaintext token. Idempotent; re-indexing overwrites in place. A successful index + continues into the same lab auto-staging the background worker performs (still-STORED lab-looking document with + no facts, both modules on, provider + consent eligible); `labFactsStaged` reports how many facts were staged + PENDING for review — confirmation stays the only write into Labs. With `Prefer: respond-async` (v1.40) the read + runs in the background: after the same quick refusals the route answers 202 with a run id, a second request + while a read of this document is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves + this body as the run's `result`. Text mode honours the preference too, because the lab staging it continues into + is model work." + parameters: + - name: id + in: path + required: true + schema: + type: string + - *a39 + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + mode: + type: string + const: text + text: + type: string + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - REFERRAL + - INSURANCE + - VACCINATION + - OTHER + required: + - mode + - text responses: "200": description: The document was indexed. @@ -16964,12 +16978,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a38 + "202": *a36 "401": *a1 - "403": *a36 + "403": *a37 "422": *a4 "429": *a5 - "503": *a37 + "503": *a38 /api/documents/inbound/{id}/chat: post: tags: @@ -17009,10 +17023,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a36 + "403": *a37 "422": *a4 "429": *a5 - "503": *a37 + "503": *a38 get: tags: - Documents @@ -17075,10 +17089,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a36 + "403": *a37 "422": *a4 "429": *a5 - "503": *a37 + "503": *a38 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -17185,7 +17199,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a40 + "409": &a41 description: A custom metric with this name already exists for the caller. content: application/json: @@ -17214,7 +17228,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a11 - "404": &a39 + "404": &a40 description: Custom metric not found (or owned by another user). content: application/json: @@ -17248,8 +17262,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a39 - "409": *a40 + "404": *a40 + "409": *a41 "422": *a4 "429": *a5 delete: @@ -17272,7 +17286,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a39 + "404": *a40 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -17316,7 +17330,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a11 - "404": *a39 + "404": *a40 "422": *a4 "429": *a5 post: @@ -17345,7 +17359,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a39 + "404": *a40 "409": *a7 "422": *a4 "429": *a12 @@ -17381,7 +17395,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a41 + "404": &a42 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -17414,7 +17428,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a41 + "404": *a42 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -17826,7 +17840,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a42 + "403": &a43 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -17881,7 +17895,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a42 + "403": *a43 "422": *a4 "429": *a5 /api/nutrients/water: @@ -17910,7 +17924,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a42 + "403": *a43 "409": *a7 "422": *a4 "429": *a5 @@ -18412,26 +18426,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a43 + "400": &a44 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a44 + "413": &a45 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a45 + "415": &a46 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a46 + "422": &a47 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -18617,11 +18631,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a43 + "400": *a44 "401": *a1 - "413": *a44 - "415": *a45 - "422": *a46 + "413": *a45 + "415": *a46 + "422": *a47 "429": *a5 delete: tags: @@ -18764,11 +18778,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a43 + "400": *a44 "401": *a1 - "413": *a44 - "415": *a45 - "422": *a46 + "413": *a45 + "415": *a46 + "422": *a47 "429": *a5 delete: tags: @@ -18921,11 +18935,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a43 + "400": *a44 "401": *a1 - "413": *a44 - "415": *a45 - "422": *a46 + "413": *a45 + "415": *a46 + "422": *a47 "429": *a5 delete: tags: @@ -19001,21 +19015,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a47 + "422": &a48 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a48 + "429": &a49 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a49 + "502": &a50 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -19060,11 +19074,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a43 + "400": *a44 "401": *a1 - "413": *a44 - "415": *a45 - "422": *a46 + "413": *a45 + "415": *a46 + "422": *a47 "429": *a5 delete: tags: @@ -19140,9 +19154,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a47 - "429": *a48 - "502": *a49 + "422": *a48 + "429": *a49 + "502": *a50 /api/strava/credentials: get: tags: @@ -19178,11 +19192,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a43 + "400": *a44 "401": *a1 - "413": *a44 - "415": *a45 - "422": *a46 + "413": *a45 + "415": *a46 + "422": *a47 "429": *a5 delete: tags: @@ -19260,9 +19274,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a47 - "429": *a48 - "502": *a49 + "422": *a48 + "429": *a49 + "502": *a50 /api/integrations/status: get: tags: @@ -19298,7 +19312,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a48 + "429": *a49 /api/integrations/fitbit/test: post: tags: @@ -19316,9 +19330,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a47 - "429": *a48 - "502": *a49 + "422": *a48 + "429": *a49 + "502": *a50 /api/integrations/google-health/resume: post: tags: @@ -19335,7 +19349,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a48 + "429": *a49 /api/integrations/google-health/test: post: tags: @@ -19368,9 +19382,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a47 - "429": *a48 - "502": *a49 + "422": *a48 + "429": *a49 + "502": *a50 /api/integrations/whoop/resume: post: tags: @@ -19387,7 +19401,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a48 + "429": *a49 /api/integrations/whoop/test: post: tags: @@ -19404,9 +19418,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a47 - "429": *a48 - "502": *a49 + "422": *a48 + "429": *a49 + "502": *a50 /api/integrations/withings/resume: post: tags: @@ -19424,7 +19438,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a48 + "429": *a49 /api/integrations/withings/test: post: tags: @@ -19442,8 +19456,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a47 - "429": *a48 + "422": *a48 + "429": *a49 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -19683,7 +19697,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a50 + "403": &a51 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -19708,7 +19722,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a50 + "403": *a51 "422": *a4 "429": *a5 post: @@ -19810,7 +19824,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a50 + "403": *a51 "404": description: No such invitation for this account. content: @@ -19854,7 +19868,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a50 + "403": *a51 "404": description: No such grant on this account's record. content: @@ -19895,7 +19909,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a50 + "403": *a51 "404": description: No such grant held by this account. content: @@ -20277,7 +20291,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a50 + "403": *a51 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -20607,7 +20621,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a51 + "403": &a52 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -20640,7 +20654,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a51 + "403": *a52 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -20667,7 +20681,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a51 + "403": *a52 "422": *a4 "429": *a5 post: @@ -20700,7 +20714,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a51 + "403": *a52 "413": description: Body exceeds 16 KiB. content: @@ -20743,7 +20757,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a51 + "403": *a52 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -20801,7 +20815,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a52 + - &a53 name: family in: path required: true @@ -20845,7 +20859,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a52 + - *a53 requestBody: required: true content: @@ -21382,13 +21396,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a53 + "403": &a54 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a54 + "404": &a55 description: No stored copy with this id. content: application/json: @@ -21462,8 +21476,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a53 - "404": *a54 + "403": *a54 + "404": *a55 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -21507,7 +21521,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a53 + "403": *a54 /api/admin/backups/restores/{jobId}: get: tags: @@ -21530,7 +21544,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a53 + "403": *a54 "404": description: No restore job with this id. content: @@ -21549,7 +21563,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a55 + security: &a56 - cookieAuth: [] responses: "200": @@ -21559,7 +21573,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a56 + "403": &a57 description: "Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another person's record (a delegate or guardian never uses the owner's archive), or `module.disabled` when the Documents module is off." @@ -21582,9 +21596,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a55 + security: *a56 parameters: - - &a57 + - &a58 name: system in: path required: true @@ -21623,8 +21637,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a56 - "404": &a58 + "403": *a57 + "404": &a59 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -21643,7 +21657,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a59 + "502": &a60 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -21662,9 +21676,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a55 + security: *a56 parameters: - - *a57 + - *a58 responses: "200": description: Removed, or there was nothing to remove. @@ -21673,7 +21687,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a56 + "403": *a57 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -21684,9 +21698,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a55 + security: *a56 parameters: - - *a57 + - *a58 responses: "200": description: The source answered. @@ -21695,9 +21709,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a56 - "404": *a58 - "422": &a60 + "403": *a57 + "404": *a59 + "422": &a61 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -21706,7 +21720,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a59 + "502": *a60 /api/documents/sources/{system}/tags: get: tags: @@ -21714,9 +21728,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a55 + security: *a56 parameters: - - *a57 + - *a58 responses: "200": description: The tags. @@ -21725,11 +21739,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a56 - "404": *a58 - "422": *a60 + "403": *a57 + "404": *a59 + "422": *a61 "429": *a5 - "502": *a59 + "502": *a60 /api/documents/sources/{system}/search: get: tags: @@ -21743,9 +21757,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a55 + security: *a56 parameters: - - *a57 + - *a58 - name: q in: query required: false @@ -21786,11 +21800,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a56 - "404": *a58 - "422": *a60 + "403": *a57 + "404": *a59 + "422": *a61 "429": *a5 - "502": *a59 + "502": *a60 /api/documents/sources/{system}/import: post: tags: @@ -21809,9 +21823,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a55 + security: *a56 parameters: - - *a57 + - *a58 requestBody: required: true content: @@ -21868,7 +21882,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a56 + "403": *a57 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -21899,7 +21913,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a60 + "422": *a61 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -21907,7 +21921,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a59 + "502": *a60 /api/bugreport: post: security: [] @@ -63653,6 +63667,8 @@ components: enum: - DOCUMENT_INDEX - LABS_OCR_EXTRACT + - DOCUMENT_SUMMARY + - DOCUMENT_SUGGEST documentId: anyOf: - type: string @@ -63670,9 +63686,13 @@ components: - anyOf: - $ref: "#/components/schemas/DocumentIndexResponse" - $ref: "#/components/schemas/OcrExtractResponse" + - $ref: "#/components/schemas/DocumentSummaryResponse" + - $ref: "#/components/schemas/DocumentSuggestResponse" - type: "null" description: "Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. - `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`." + `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, + `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`. + Decode by `kind`; treat an unknown `kind` as a run this client did not start." error: anyOf: - type: object @@ -63881,6 +63901,56 @@ components: reading this row likely duplicates (the review row defaults to unchecked when set); `confidence` is the model's per-field self-score (low fields are flagged for the human). Nothing is written until the commit route confirms." + DocumentSummaryResponse: + type: object + properties: + summary: + type: string + text: + type: string + persistence: + type: string + enum: + - stored + - withheld + - failed + additionalProperties: false + DocumentSuggestResponse: + type: object + properties: + suggestions: + type: object + properties: + title: + anyOf: + - type: string + - type: "null" + kind: + anyOf: + - type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - LAB_RESULT + - IMAGING + - PRESCRIPTION + - REFERRAL + - INSURANCE + - VACCINATION + - OTHER + - type: "null" + documentDate: + anyOf: + - type: string + - type: "null" + required: + - title + - kind + - documentDate + additionalProperties: false + required: + - suggestions + additionalProperties: false ListBiomarkersEnvelope: type: object properties: @@ -67515,42 +67585,6 @@ components: - data - error additionalProperties: false - DocumentSuggestResponse: - type: object - properties: - suggestions: - type: object - properties: - title: - anyOf: - - type: string - - type: "null" - kind: - anyOf: - - type: string - enum: - - DOCTOR_REPORT - - DISCHARGE_LETTER - - LAB_RESULT - - IMAGING - - PRESCRIPTION - - REFERRAL - - INSURANCE - - VACCINATION - - OTHER - - type: "null" - documentDate: - anyOf: - - type: string - - type: "null" - required: - - title - - kind - - documentDate - additionalProperties: false - required: - - suggestions - additionalProperties: false DocumentSummaryEnvelope: type: object properties: @@ -67568,20 +67602,6 @@ components: - data - error additionalProperties: false - DocumentSummaryResponse: - type: object - properties: - summary: - type: string - text: - type: string - persistence: - type: string - enum: - - stored - - withheld - - failed - additionalProperties: false DocumentIndexEnvelope: type: object properties: diff --git a/prisma/migrations/0370_document_ai_run_kinds_summary_suggest_extract/migration.sql b/prisma/migrations/0370_document_ai_run_kinds_summary_suggest_extract/migration.sql new file mode 100644 index 000000000..6d1ede60a --- /dev/null +++ b/prisma/migrations/0370_document_ai_run_kinds_summary_suggest_extract/migration.sql @@ -0,0 +1,15 @@ +-- Two more kinds of background document AI run. +-- +-- Summaries and filing suggestions can now run in the same background worker +-- as "Read with AI" and the lab scan (0366): the request queues the read and +-- answers 202, and the result is polled from the run. The synchronous routes +-- are unchanged; the new kinds only name the run. +-- +-- Purely additive. `ADD VALUE IF NOT EXISTS` makes a rerun safe, and no new +-- value is used elsewhere in this migration, which is the one thing Postgres +-- forbids inside the transaction that adds it. No row is rewritten. +-- +-- Reversibility: Postgres cannot drop an enum value, so the values would stay +-- (inert with no rows that carry them). +ALTER TYPE "document_ai_run_kind" ADD VALUE IF NOT EXISTS 'DOCUMENT_SUMMARY'; +ALTER TYPE "document_ai_run_kind" ADD VALUE IF NOT EXISTS 'DOCUMENT_SUGGEST'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36dbabbce..473f7c987 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8031,6 +8031,9 @@ enum InboundDocumentKind { /// every fact, DISCARDED on a soft delete. enum InboundDocumentStatus { STORED + /// @internal: reserved, no writer. An extraction in flight is the state of + /// its background run (`DocumentAiRun`), not of the document, so the vault + /// never shows a status nothing renders. EXTRACTING EXTRACTED FAILED @@ -9033,6 +9036,12 @@ enum DocumentAiRunKind { /// A lab report scan (photo, PDF or browser-read text) into proposed rows /// for the review screen. Nothing is written until the person commits. LABS_OCR_EXTRACT + /// A plain-language summary or the transcribed text of a stored document; + /// stored on the document only when the request asked for that. + DOCUMENT_SUMMARY + /// Filing suggestions (title, kind, date) for a stored document. Drafts + /// only: nothing is written to the document. + DOCUMENT_SUGGEST @@map("document_ai_run_kind") } diff --git a/src/__tests__/ai-egress-capability-guard.test.ts b/src/__tests__/ai-egress-capability-guard.test.ts index 847f41bca..3b06191b0 100644 --- a/src/__tests__/ai-egress-capability-guard.test.ts +++ b/src/__tests__/ai-egress-capability-guard.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; +import { DOCUMENT_AI_RUN_KINDS } from "@/lib/documents/ai-runs/types"; import type { runStatusCompletion } from "@/lib/insights/status-provider"; import { walkSourceFiles } from "./helpers/source-files"; @@ -163,7 +164,7 @@ const EGRESS_SITES: Record = { */ const WORKER_REACHERS: Record = { "lib/jobs/document-ai-run.ts": - "Background document reads and lab scans (v1.40): `transcribeDocument` and `runOcrExtraction` through the shared run bodies.", + "Background document reads and lab scans (v1.40): `transcribeDocument`, `runDocumentSummary`, `runDocumentAssist` and `runOcrExtraction` through the shared run bodies.", }; const JOB_CAPABILITY = /\baiCapabilityForJob\s*\(/; @@ -190,6 +191,50 @@ describe("workers that reach an admitted helper", () => { }); }); +/** The body of one top-level `async function name(` in a source text. */ +function functionBody(text: string, name: string): string | null { + const start = text.search(new RegExp(`async function ${name}\\s*\\(`)); + if (start < 0) return null; + const end = text.indexOf("\n}\n", start); + return end < 0 ? null : text.slice(start, end); +} + +describe("every kind of background run re-checks before its read", () => { + const worker = code("lib/jobs/document-ai-run.ts"); + const table = /const RUN_DISPATCH[^=]*=\s*\{([\s\S]*?)\};/.exec(worker); + const entries = [...(table?.[1] ?? "").matchAll(/(\w+):\s*(\w+),/g)].map( + (m) => ({ kind: m[1], dispatcher: m[2] }), + ); + + it("finds the dispatch table (an empty match is a failure, not a pass)", () => { + expect(entries.length).toBeGreaterThanOrEqual(2); + }); + + it("dispatches every run kind", () => { + expect(entries.map((e) => e.kind).sort()).toEqual( + [...DOCUMENT_AI_RUN_KINDS].sort(), + ); + }); + + it("the shared document pick re-checks the wire on both transports", () => { + const pick = functionBody(worker, "pickForDocumentRead") ?? ""; + expect(pick.match(/\bassertRunMayEgress\s*\(/g)?.length ?? 0).toBe(2); + }); + + it.each(DOCUMENT_AI_RUN_KINDS)( + "%s re-checks the capability and the wire before the body runs", + (kind) => { + const entry = entries.find((e) => e.kind === kind); + const body = entry ? functionBody(worker, entry.dispatcher) : null; + expect(body, `${kind}: dispatcher not found`).not.toBeNull(); + expect( + /\b(?:assertRunMayEgress|pickForDocumentRead)\s*\(/.test(body ?? ""), + `${kind}: its dispatcher sends to a model without assertRunMayEgress`, + ).toBe(true); + }, + ); +}); + describe("AI egress call sites", () => { const found = sourceFiles().filter((rel) => EGRESS_CALL.test(code(rel))); diff --git a/src/app/api/__tests__/ai-capability-route-inventory.test.ts b/src/app/api/__tests__/ai-capability-route-inventory.test.ts index a5b256218..91c3c29c1 100644 --- a/src/app/api/__tests__/ai-capability-route-inventory.test.ts +++ b/src/app/api/__tests__/ai-capability-route-inventory.test.ts @@ -20,6 +20,11 @@ * files (`tests/integration/ai-optional-*.test.ts`) prove behaviour; this * proves no route escapes classification and no data route grows a gate. * + * 5. Every kind of background run (`DOCUMENT_AI_RUN_KINDS`) is queued by a + * route on `AI_ROUTES` that asks the capability the worker asks again for + * that kind (`DOCUMENT_AI_RUN_CAPABILITY`), so a run cannot answer to a + * different switch than the request that started it. + * * Mutation checks (each turned this file red by name): adding * `await requireAiCapability("statusText")` to `insights/ecg/route.ts`; * removing the capability call from `insights/chat/route.ts`; swapping @@ -31,6 +36,11 @@ import { join, relative, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { AI_CAPABILITY_KEYS } from "@/lib/ai/capabilities/types"; +import { + DOCUMENT_AI_RUN_CAPABILITY, + DOCUMENT_AI_RUN_KINDS, + type DocumentAiRunKindValue, +} from "@/lib/documents/ai-runs/types"; import { AI_ROUTES, DATA_ROUTES } from "./ai-route-inventory"; @@ -154,3 +164,27 @@ describe("AI capability route inventory", () => { expect(both).toEqual([]); }); }); + +/** The route that queues each kind of background run. */ +const RUN_QUEUED_BY: Record = { + DOCUMENT_INDEX: "src/app/api/documents/inbound/[id]/index/route.ts", + LABS_OCR_EXTRACT: "src/app/api/labs/ocr/extract/route.ts", + DOCUMENT_SUMMARY: "src/app/api/documents/inbound/[id]/summary/route.ts", + DOCUMENT_SUGGEST: "src/app/api/documents/inbound/[id]/suggest/route.ts", +}; + +describe("background run kinds", () => { + it.each(DOCUMENT_AI_RUN_KINDS)( + "%s is queued by an AI route under the capability the worker re-checks", + (kind) => { + const path = RUN_QUEUED_BY[kind]; + const entry = AI_ROUTES[path]; + expect(entry, `${path} is not on AI_ROUTES`).toBeDefined(); + expect(entry.capabilities).toContain(DOCUMENT_AI_RUN_CAPABILITY[kind]); + expect( + new RegExp(`kind:\\s*"${kind}"`).test(code(path)), + `${path} does not queue ${kind}`, + ).toBe(true); + }, + ); +}); diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index c361d2929..28ac50c64 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -11,13 +11,21 @@ * The document is UNTRUSTED (prompt-injection): the server never acts on an * instruction inside it. With no provider configured this 422s the enhancement; * the stored document and the manual edit form are untouched. + * + * v1.40 — `Prefer: respond-async` (RFC 7240) runs the read in the background + * worker: every refusal that can be answered quickly is still answered here, + * then the route answers 202 with a run id and `GET /api/ai-runs/{id}` serves + * the same body this route answers with synchronously. Without the header the + * route behaves exactly as before; the iPhone app relies on that. Both paths + * run one body (`executeDocumentSuggest`). */ +import { Buffer } from "node:buffer"; + import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, - apiSuccess, apiValidationError, getClientIp, safeJson, @@ -25,33 +33,36 @@ import { } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import type { ProviderChainType } from "@/lib/ai/provider-chain"; import { buildDateKey, - reconcileSpend, reserveBudget, resolveCostOwner, resolveDailyCap, } from "@/lib/ai/coach/budget"; -import { auditLog } from "@/lib/auth/audit"; -import { - DocumentAssistError, - runDocumentAssist, - type DocumentSuggestion, -} from "@/lib/documents/assist"; import { checkDocumentAiRateLimit, documentAiRateLimited, DOCUMENT_AI_TEXT_BODY_MAX_BYTES, loadOwnedDocument, - prepareVisionInput, refundDocumentAiSlot, type LoadedDocument, } from "@/lib/documents/ai-route-support"; +import { + acceptedRunResponse, + outcomeResponse, + prefersRespondAsync, +} from "@/lib/documents/ai-runs/http"; +import { findLiveDocumentRun, startAiRun } from "@/lib/documents/ai-runs/start"; +import { + executeDocumentSuggest, + type DocumentSuggestInput, +} from "@/lib/documents/ai-runs/suggest-run"; +import type { AiRunBudget } from "@/lib/documents/ai-runs/types"; import { requireDocumentTextProvider, requireDocumentVisionProvider, } from "@/lib/documents/provider-order"; -import { annotate } from "@/lib/logging/context"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { prisma } from "@/lib/db"; import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents"; @@ -79,38 +90,75 @@ export const POST = apiHandler( }); } + const background = prefersRespondAsync(request); const contentType = request.headers.get("content-type") ?? ""; if (contentType.includes("application/json")) { - return handleTextSuggest(request, user.id, document); + return handleTextSuggest(request, user.id, document, background); } - return handleVisionSuggest(request, user.id, document); + return handleVisionSuggest(request, user.id, document, background); }, ); -/** Persist the AI-budget ledger side effect + the audit trail. NEVER the row. */ -async function finishSuggest( - request: NextRequest, +/** + * A suggestion read of this document over the same transport, already queued + * or running: attach to it rather than charge a second one. Background only. + */ +async function liveRunResponse( userId: string, documentId: string, mode: "vision" | "text", - suggestion: DocumentSuggestion, -): Promise { - await auditLog("documents.inbound.suggest", { +): Promise { + const live = await findLiveDocumentRun( userId, - ipAddress: getClientIp(request), - details: { documentId, mode }, + documentId, + "DOCUMENT_SUGGEST", + (params) => params.mode === mode, + ); + return live ? acceptedRunResponse(live) : null; +} + +/** Reserve the read's budget; null (slot refunded) when the day is spent. */ +async function reserveFor( + userId: string, + providerType: ProviderChainType, +): Promise { + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + userId, + AI_BUDGETS.documentAssist.maxTokens, + dateKey, + resolveDailyCap([{ providerType }]), + resolveCostOwner([{ providerType }]), + "coach", + ); + if (!reservation.allowed) { + await refundDocumentAiSlot(userId); + return null; + } + return { reserved: reservation.reserved, owner: reservation.owner, dateKey }; +} + +function budgetExceeded(): Response { + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "documents.inbound.budgetExceeded", }); - annotate({ - action: { name: "documents.assist.suggest" }, - meta: { - documentId, - mode, - hasTitle: suggestion.title !== null, - hasKind: suggestion.kind !== null, - hasDate: suggestion.documentDate !== null, - }, - }); - return apiSuccess({ suggestions: suggestion }); +} + +/** The synchronous path: run the shared body inside this request. */ +async function runNow( + request: NextRequest, + userId: string, + document: LoadedDocument, + input: DocumentSuggestInput, +): Promise { + return outcomeResponse( + await executeDocumentSuggest({ + userId, + document, + input, + origin: { ipAddress: getClientIp(request), worker: false }, + }), + ); } /** TEXT mode — suggest from in-browser-OCR'd text (opt-in local OCR). */ @@ -118,6 +166,7 @@ async function handleTextSuggest( request: NextRequest, userId: string, document: LoadedDocument, + background: boolean, ): Promise { const row = await prisma.user.findUnique({ where: { id: userId }, @@ -131,6 +180,11 @@ async function handleTextSuggest( const pick = await requireDocumentTextProvider(userId); + if (background) { + const live = await liveRunResponse(userId, document.id, "text"); + if (live) return live; + } + const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); @@ -154,55 +208,28 @@ async function handleTextSuggest( ); } - const dateKey = buildDateKey(); - const reservation = await reserveBudget( - userId, - AI_BUDGETS.documentAssist.maxTokens, - dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), - resolveCostOwner([{ providerType: pick.entry.providerType }]), - "coach", - ); - if (!reservation.allowed) { - await refundDocumentAiSlot(userId); - return apiError("Your AI usage budget for today is reached.", 429, { - errorCode: "documents.inbound.budgetExceeded", + const budget = await reserveFor(userId, pick.entry.providerType); + if (!budget) return budgetExceeded(); + + if (background) { + // The worker picks the provider again and re-checks the wire for it right + // before the text leaves. + return startAiRun({ + userId, + kind: "DOCUMENT_SUGGEST", + documentId: document.id, + params: { mode: "text", budget }, + input: Buffer.from(parsed.data.text, "utf8"), + refundSlot: () => refundDocumentAiSlot(userId), }); } - try { - const suggestion = await runDocumentAssist({ - provider: pick.entry.instance, - providerType: pick.providerType, - ocrText: parsed.data.text, - }); - await reconcileSpend( - userId, - reservation.reserved, - reservation.reserved, - dateKey, - 0, - { servedBy: pick.entry.providerType, reservedOwner: reservation.owner }, - ); - return finishSuggest(request, userId, document.id, "text", suggestion); - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, { - servedBy: null, - reservedOwner: reservation.owner, - }); - if (err instanceof DocumentAssistError) { - return apiError("Couldn't read the document. Try a clearer copy.", 422, { - errorCode: "documents.inbound.extractFailed", - }); - } - annotate({ - action: { name: "documents.assist.failed" }, - meta: { reason: "provider_error", mode: "text" }, - }); - return apiError("Couldn't read the document. Try a clearer copy.", 502, { - errorCode: "documents.inbound.extractFailed", - }); - } + return runNow(request, userId, document, { + mode: "text", + text: parsed.data.text, + pick, + budget, + }); } /** VISION mode — suggest from the stored original via the vision provider. */ @@ -210,83 +237,32 @@ async function handleVisionSuggest( request: NextRequest, userId: string, document: LoadedDocument, + background: boolean, ): Promise { const pick = await requireDocumentVisionProvider(userId); + if (background) { + const live = await liveRunResponse(userId, document.id, "vision"); + if (live) return live; + } + const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); - const vision = await prepareVisionInput(document, pick.pdfSupported); - if (!vision.ok) { - // Preparation failed before any provider dispatch — the slot goes back. - await refundDocumentAiSlot(userId); - if (vision.reason === "pdfNeedsAnthropic") { - return apiError( - "PDF scanning needs a Claude vision provider; use local OCR instead.", - 422, - { errorCode: "documents.inbound.pdfNeedsAnthropic" }, - ); - } - if (vision.reason === "fileType") { - return apiError( - "This document can't be scanned. Use local OCR (text mode).", - 422, - { errorCode: "documents.inbound.fileType" }, - ); - } - return apiError("Couldn't read the stored document.", 422, { - errorCode: "documents.inbound.extractFailed", - }); - } + const budget = await reserveFor(userId, pick.entry.providerType); + if (!budget) return budgetExceeded(); - const dateKey = buildDateKey(); - const reservation = await reserveBudget( - userId, - AI_BUDGETS.documentAssist.maxTokens, - dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), - resolveCostOwner([{ providerType: pick.entry.providerType }]), - "coach", - ); - if (!reservation.allowed) { - await refundDocumentAiSlot(userId); - return apiError("Your AI usage budget for today is reached.", 429, { - errorCode: "documents.inbound.budgetExceeded", - }); - } - - try { - const suggestion = await runDocumentAssist({ - provider: pick.entry.instance, - providerType: pick.providerType, - images: vision.images, - documents: vision.documents, - }); - await reconcileSpend( + if (background) { + // The worker decrypts the stored original itself, after it has picked + // the provider again and re-checked the wire for it. + return startAiRun({ userId, - reservation.reserved, - reservation.reserved, - dateKey, - 0, - { servedBy: pick.entry.providerType, reservedOwner: reservation.owner }, - ); - return finishSuggest(request, userId, document.id, "vision", suggestion); - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, { - servedBy: null, - reservedOwner: reservation.owner, - }); - if (err instanceof DocumentAssistError) { - return apiError("Couldn't read the document. Try a clearer copy.", 422, { - errorCode: "documents.inbound.extractFailed", - }); - } - annotate({ - action: { name: "documents.assist.failed" }, - meta: { reason: "provider_error", mode: "vision" }, - }); - return apiError("Couldn't read the document. Try a clearer copy.", 502, { - errorCode: "documents.inbound.extractFailed", + kind: "DOCUMENT_SUGGEST", + documentId: document.id, + params: { mode: "vision", budget }, + refundSlot: () => refundDocumentAiSlot(userId), }); } + + return runNow(request, userId, document, { mode: "vision", pick, budget }); } diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index e9e785df7..9c2c7c484 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -18,7 +18,18 @@ * Same VISION/TEXT dispatch and gauntlet as the extract route. The document is * UNTRUSTED (prompt-injection): the server never acts on an instruction inside * it. With no provider configured this 422s; nothing is stored either way. + * + * v1.40 — `Prefer: respond-async` (RFC 7240) runs the read in the background + * worker: every refusal that can be answered quickly is still answered here, + * then the route answers 202 with a run id and `GET /api/ai-runs/{id}` serves + * the same body this route answers with synchronously. A summary that is to + * be stored marks the document PENDING while it is queued. Without the header + * the route behaves exactly as before; the iPhone app relies on that. Both + * paths run one body (`executeDocumentSummary`). A transcription of text the + * browser already read calls no model and is answered here either way. */ +import { Buffer } from "node:buffer"; + import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; @@ -32,9 +43,9 @@ import { } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { requireAiCapability } from "@/lib/ai/capabilities/gate"; +import type { ProviderChainType } from "@/lib/ai/provider-chain"; import { buildDateKey, - reconcileSpend, reserveBudget, resolveCostOwner, resolveDailyCap, @@ -45,19 +56,23 @@ import { documentAiRateLimited, DOCUMENT_AI_TEXT_BODY_MAX_BYTES, loadOwnedDocument, - prepareVisionInput, refundDocumentAiSlot, type LoadedDocument, } from "@/lib/documents/ai-route-support"; import { - DocumentDescribeError, - runDocumentSummary, - documentSummaryBlockedCopy, - transcribeDocument, - type DescribeInput, -} from "@/lib/documents/describe"; -import { encryptDocumentSummary } from "@/lib/documents/store"; -import type { OutboundReason } from "@/lib/ai/safety/outbound-screen"; + acceptedRunResponse, + outcomeResponse, + prefersRespondAsync, +} from "@/lib/documents/ai-runs/http"; +import { findLiveDocumentRun, startAiRun } from "@/lib/documents/ai-runs/start"; +import { + executeDocumentSummary, + markSummaryQueued, + type DocumentSummaryInput, + type SummaryPersistence, +} from "@/lib/documents/ai-runs/summary-run"; +import type { AiRunBudget, AiRunParams } from "@/lib/documents/ai-runs/types"; +import { transcribeDocument } from "@/lib/documents/describe"; import { requireDocumentTextProvider, requireDocumentVisionProvider, @@ -78,11 +93,15 @@ export const dynamic = "force-dynamic"; type RouteParams = { params: Promise<{ id: string }> }; -type SummaryPersistenceTarget = { +/** What one request asks for, read once from the query and the headers. */ +interface SummaryRequest { userId: string; - documentId: string; - replaceExisting: boolean; -}; + document: LoadedDocument; + mode: DocumentSummaryMode; + locale: Locale; + persist: SummaryPersistence | null; + background: boolean; +} function resolveMode(request: NextRequest): DocumentSummaryMode { const raw = new URL(request.url).searchParams.get("mode"); @@ -98,100 +117,6 @@ function resolveReplaceExisting(request: NextRequest): boolean { return new URL(request.url).searchParams.get("replace") === "true"; } -/** - * Run the requested describe call (summary or raw text) over the input. - * - * Ordinary action-row summaries and raw transcription remain session-only. - * The document summary block opts into persistence so an empty document can be - * repaired and an existing stored summary can be explicitly regenerated. - */ -async function describe( - mode: DocumentSummaryMode, - input: DescribeInput, - locale: Locale, - persist: SummaryPersistenceTarget | null, -): Promise< - | { - summary: string; - persistence?: "stored" | "withheld" | "failed"; - } - | { text: string } -> { - if (mode === "text") return transcribeDocument(input); - // A normal summary request fills an empty slot only. Replacement is reserved - // for the document detail's explicit "Generate again" action. - const { summary, blocked } = await runDocumentSummary({ ...input, locale }); - const persistence = persist - ? await persistSummary(persist, summary, blocked) - : undefined; - return { - summary: blocked ? documentSummaryBlockedCopy(locale) : summary, - ...(persistence ? { persistence } : {}), - }; -} - -/** - * Store a screened-clean summary, or record the refusal as a state. - * - * A blocked summary NEVER lands as text — the honest statement the caller shows - * is generated copy for this response, not the model's prose, and persisting it - * would put a refusal where a summary belongs. Only the WITHHELD state is kept, - * and it is not terminal: the user can ask again. - * - * Storing is best-effort. The user is waiting on the summary they can already - * read in the response; a failed write must not turn that into an error. - */ -async function persistSummary( - target: SummaryPersistenceTarget, - summary: string, - blocked: OutboundReason | null, -): Promise<"stored" | "withheld" | "failed"> { - try { - if (blocked) { - await prisma.inboundDocument.updateMany({ - where: { - id: target.documentId, - userId: target.userId, - deletedAt: null, - summaryState: { not: "READY" }, - }, - // An explicit request is the person reading it on purpose, so an - // import's hold on automatic AI reading ends here. - data: { summaryState: "WITHHELD", aiReadDeferred: false }, - }); - return "withheld"; - } - // Preserve a previously stored summary unless the caller explicitly chose - // the replacement action. Failed or screened attempts never reach this - // write, so the previous clean summary remains available in those cases. - const written = await prisma.inboundDocument.updateMany({ - where: { - id: target.documentId, - userId: target.userId, - deletedAt: null, - ...(target.replaceExisting ? {} : { summaryEncrypted: null }), - }, - data: { - summaryEncrypted: encryptDocumentSummary(summary), - summaryGeneratedAt: new Date(), - summaryState: "READY", - aiReadDeferred: false, - }, - }); - annotate({ - action: { name: "documents.summary.persisted" }, - meta: { documentId: target.documentId, stored: written.count > 0 }, - }); - return written.count > 0 ? "stored" : "failed"; - } catch { - annotate({ - action: { name: "documents.summary.persistFailed" }, - meta: { documentId: target.documentId }, - }); - return blocked ? "withheld" : "failed"; - } -} - /** The budget the requested mode charges. */ function budgetFor(mode: DocumentSummaryMode): number { return mode === "text" @@ -219,13 +144,11 @@ export const POST = apiHandler( } const mode = resolveMode(request); - const persist: SummaryPersistenceTarget | null = + // A normal summary request fills an empty slot only. Replacement is + // reserved for the document detail's explicit "Generate again" action. + const persist: SummaryPersistence | null = mode === "summary" && resolvePersistRequested(request) - ? { - userId: user.id, - documentId: document.id, - replaceExisting: resolveReplaceExisting(request), - } + ? { replaceExisting: resolveReplaceExisting(request) } : null; // The outbound screen on the summary needs the reader's locale to pick its // pattern banks; resolve it once here and thread it into both describe legs. @@ -233,57 +156,112 @@ export const POST = apiHandler( request, userLocale: user.locale ?? null, }); - const contentType = request.headers.get("content-type") ?? ""; - if (contentType.includes("application/json")) { - return handleTextSummary( - request, - user.id, - document, - mode, - locale, - persist, - ); - } - return handleVisionSummary( - request, - user.id, + const ask: SummaryRequest = { + userId: user.id, document, mode, locale, persist, - ); + background: prefersRespondAsync(request), + }; + const contentType = request.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + return handleTextSummary(request, ask); + } + return handleVisionSummary(request, ask); }, ); -async function finishSummary( - request: NextRequest, - userId: string, - documentId: string, +/** The run parameters for this request (never health data). */ +function runParams( + ask: SummaryRequest, inputMode: "vision" | "text", - mode: DocumentSummaryMode, - result: { summary: string } | { text: string }, + budget: AiRunBudget, +): AiRunParams { + return { + mode: inputMode, + budget, + summary: { + output: ask.mode, + persist: ask.persist !== null, + replace: ask.persist?.replaceExisting ?? false, + locale: ask.locale, + }, + }; +} + +/** + * A run that will produce the same thing, already queued or running: attach + * to it rather than charge a second one. Background path only. + */ +async function liveRunResponse( + ask: SummaryRequest, + inputMode: "vision" | "text", +): Promise { + const live = await findLiveDocumentRun( + ask.userId, + ask.document.id, + "DOCUMENT_SUMMARY", + (params) => + params.mode === inputMode && + params.summary?.output === ask.mode && + params.summary.persist === (ask.persist !== null) && + params.summary.replace === (ask.persist?.replaceExisting ?? false), + ); + return live ? acceptedRunResponse(live) : null; +} + +/** Reserve the read's budget; null (slot refunded) when the day is spent. */ +async function reserveFor( + ask: SummaryRequest, + providerType: ProviderChainType, +): Promise { + const dateKey = buildDateKey(); + const reservation = await reserveBudget( + ask.userId, + budgetFor(ask.mode), + dateKey, + resolveDailyCap([{ providerType }]), + resolveCostOwner([{ providerType }]), + "coach", + ); + if (!reservation.allowed) { + await refundDocumentAiSlot(ask.userId); + return null; + } + return { reserved: reservation.reserved, owner: reservation.owner, dateKey }; +} + +function budgetExceeded(): Response { + return apiError("Your AI usage budget for today is reached.", 429, { + errorCode: "documents.inbound.budgetExceeded", + }); +} + +/** Queue the read; a summary that is to be stored is marked PENDING first. */ +async function queueSummary( + ask: SummaryRequest, + inputMode: "vision" | "text", + budget: AiRunBudget, + text: string | null, ): Promise { - await auditLog("documents.inbound.summary", { - userId, - ipAddress: getClientIp(request), - details: { documentId, mode, inputMode }, + if (ask.persist) await markSummaryQueued(ask.userId, ask.document.id); + return startAiRun({ + userId: ask.userId, + kind: "DOCUMENT_SUMMARY", + documentId: ask.document.id, + params: runParams(ask, inputMode, budget), + input: text === null ? null : Buffer.from(text, "utf8"), + refundSlot: () => refundDocumentAiSlot(ask.userId), }); - annotate({ - action: { name: "documents.summary.serve" }, - meta: { documentId, mode, inputMode }, - }); - return apiSuccess(result); } /** TEXT mode — summarise / echo in-browser-OCR'd text. */ async function handleTextSummary( request: NextRequest, - userId: string, - document: LoadedDocument, - mode: DocumentSummaryMode, - locale: Locale, - persist: SummaryPersistenceTarget | null, + ask: SummaryRequest, ): Promise { + const { userId, document, mode } = ask; const row = await prisma.user.findUnique({ where: { id: userId }, select: { labsLocalOcrEnabled: true }, @@ -294,6 +272,11 @@ async function handleTextSummary( }); } + if (ask.background && mode !== "text") { + const live = await liveRunResponse(ask, "text"); + if (live) return live; + } + const rl = await checkDocumentAiRateLimit(userId); if (!rl.allowed) return documentAiRateLimited(rl); @@ -319,13 +302,24 @@ async function handleTextSummary( // `mode=text` over posted OCR text is a pure echo — the text IS the // transcription. No provider egress, no consent, no budget: session-only. + // There is nothing to run in the background, so the preference is not + // applied and the answer comes straight back. if (mode === "text") { const result = await transcribeDocument({ provider: {} as never, providerType: "local-ocr", ocrText: parsed.data.text, }); - return finishSummary(request, userId, document.id, "text", mode, result); + await auditLog("documents.inbound.summary", { + userId, + ipAddress: getClientIp(request), + details: { documentId: document.id, mode, inputMode: "text" }, + }); + annotate({ + action: { name: "documents.summary.serve" }, + meta: { documentId: document.id, mode, inputMode: "text" }, + }); + return apiSuccess(result); } let pick: Awaited>; @@ -337,145 +331,65 @@ async function handleTextSummary( throw err; } - const dateKey = buildDateKey(); - const reservation = await reserveBudget( - userId, - budgetFor(mode), - dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), - resolveCostOwner([{ providerType: pick.entry.providerType }]), - "coach", - ); - if (!reservation.allowed) { - await refundDocumentAiSlot(userId); - return apiError("Your AI usage budget for today is reached.", 429, { - errorCode: "documents.inbound.budgetExceeded", - }); + const budget = await reserveFor(ask, pick.entry.providerType); + if (!budget) return budgetExceeded(); + + if (ask.background) { + // The worker picks the provider again and re-checks the wire for it right + // before the text leaves. + return queueSummary(ask, "text", budget, parsed.data.text); } - try { - const result = await describe( - mode, - { - provider: pick.entry.instance, - providerType: pick.providerType, - ocrText: parsed.data.text, - }, - locale, - persist, - ); - await reconcileSpend( - userId, - reservation.reserved, - reservation.reserved, - dateKey, - 0, - { servedBy: pick.entry.providerType, reservedOwner: reservation.owner }, - ); - return finishSummary(request, userId, document.id, "text", mode, result); - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, { - servedBy: null, - reservedOwner: reservation.owner, - }); - return summaryError(err, "text"); - } + return runNow(request, ask, { + mode: "text", + text: parsed.data.text, + pick, + budget, + }); } /** VISION mode — summarise / transcribe the stored original. */ async function handleVisionSummary( request: NextRequest, - userId: string, - document: LoadedDocument, - mode: DocumentSummaryMode, - locale: Locale, - persist: SummaryPersistenceTarget | null, + ask: SummaryRequest, ): Promise { - const pick = await requireDocumentVisionProvider(userId); + const pick = await requireDocumentVisionProvider(ask.userId); - const rl = await checkDocumentAiRateLimit(userId); + if (ask.background) { + const live = await liveRunResponse(ask, "vision"); + if (live) return live; + } + + const rl = await checkDocumentAiRateLimit(ask.userId); if (!rl.allowed) return documentAiRateLimited(rl); - const vision = await prepareVisionInput(document, pick.pdfSupported); - if (!vision.ok) { - // Preparation failed before any provider dispatch — the slot goes back. - await refundDocumentAiSlot(userId); - if (vision.reason === "pdfNeedsAnthropic") { - return apiError( - "PDF scanning needs a Claude vision provider; use local OCR instead.", - 422, - { errorCode: "documents.inbound.pdfNeedsAnthropic" }, - ); - } - if (vision.reason === "fileType") { - return apiError( - "This document can't be scanned. Use local OCR (text mode).", - 422, - { errorCode: "documents.inbound.fileType" }, - ); - } - return apiError("Couldn't read the stored document.", 422, { - errorCode: "documents.inbound.extractFailed", - }); + const budget = await reserveFor(ask, pick.entry.providerType); + if (!budget) return budgetExceeded(); + + if (ask.background) { + // The worker decrypts the stored original itself, after it has picked + // the provider again and re-checked the wire for it. + return queueSummary(ask, "vision", budget, null); } - const dateKey = buildDateKey(); - const reservation = await reserveBudget( - userId, - budgetFor(mode), - dateKey, - resolveDailyCap([{ providerType: pick.entry.providerType }]), - resolveCostOwner([{ providerType: pick.entry.providerType }]), - "coach", + return runNow(request, ask, { mode: "vision", pick, budget }); +} + +/** The synchronous path: run the shared body inside this request. */ +async function runNow( + request: NextRequest, + ask: SummaryRequest, + input: DocumentSummaryInput, +): Promise { + return outcomeResponse( + await executeDocumentSummary({ + userId: ask.userId, + document: ask.document, + input, + output: ask.mode, + locale: ask.locale, + persist: ask.persist, + origin: { ipAddress: getClientIp(request), worker: false }, + }), ); - if (!reservation.allowed) { - await refundDocumentAiSlot(userId); - return apiError("Your AI usage budget for today is reached.", 429, { - errorCode: "documents.inbound.budgetExceeded", - }); - } - - try { - const result = await describe( - mode, - { - provider: pick.entry.instance, - providerType: pick.providerType, - images: vision.images, - documents: vision.documents, - }, - locale, - persist, - ); - await reconcileSpend( - userId, - reservation.reserved, - reservation.reserved, - dateKey, - 0, - { servedBy: pick.entry.providerType, reservedOwner: reservation.owner }, - ); - return finishSummary(request, userId, document.id, "vision", mode, result); - } catch (err) { - await reconcileSpend(userId, reservation.reserved, 0, dateKey, 0, { - servedBy: null, - reservedOwner: reservation.owner, - }); - return summaryError(err, "vision"); - } -} - -function summaryError(err: unknown, mode: "vision" | "text"): Response { - if (err instanceof DocumentDescribeError) { - return apiError("Couldn't read the document. Try a clearer copy.", 422, { - errorCode: "documents.inbound.extractFailed", - }); - } - annotate({ - action: { name: "documents.summary.failed" }, - meta: { reason: "provider_error", mode }, - }); - return apiError("Couldn't read the document. Try a clearer copy.", 502, { - errorCode: "documents.inbound.extractFailed", - }); } diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx index c0af03212..742859335 100644 --- a/src/components/documents/__tests__/document-ai-section.test.tsx +++ b/src/components/documents/__tests__/document-ai-section.test.tsx @@ -254,3 +254,35 @@ describe(" index outcome (#776)", () => { expect(html).not.toContain('data-slot="document-index-outcome"'); }); }); + +describe(" background reads (v1.40)", () => { + it("a running suggestion or summary says it reads in the background without promising it is kept", () => { + const html = render(base({ assistRunPhase: "background" })); + expect(html).toContain('data-slot="document-assist-run-phase"'); + expect(html).toContain("With a slow model this can take a few minutes."); + expect(html).not.toContain("You can leave this page"); + }); + + it("a read whose result lands on the document says the person may leave", () => { + const html = render( + base({ + indexRunPhase: "background", + assistRunPhase: "background", + }), + ); + expect(html).toContain('data-slot="document-index-run-phase"'); + expect(html).toContain("You can leave this page"); + // One line at a time, never two stacked notes. + expect(html).not.toContain('data-slot="document-assist-run-phase"'); + }); + + it("says when no worker has picked the read up yet", () => { + const html = render(base({ assistRunPhase: "waitingForWorker" })); + expect(html).toContain("Waiting for the background worker."); + }); + + it("renders no note while nothing runs", () => { + const html = render(base()); + expect(html).not.toContain("run-phase"); + }); +}); diff --git a/src/components/documents/__tests__/document-summary-block.test.tsx b/src/components/documents/__tests__/document-summary-block.test.tsx index bdf46c1c3..a782849b9 100644 --- a/src/components/documents/__tests__/document-summary-block.test.tsx +++ b/src/components/documents/__tests__/document-summary-block.test.tsx @@ -163,3 +163,15 @@ describe("DocumentSummaryBlock — the action", () => { ); }); }); + +describe("DocumentSummaryBlock — a background read (v1.40)", () => { + it("says the read runs in the background and is stored with the document", () => { + const html = base({ isGenerating: true, runPhase: "background" }); + expect(html).toContain('data-slot="document-detail-summary-run-phase"'); + expect(html).toContain("You can leave this page"); + }); + + it("renders no note while nothing runs", () => { + expect(base()).not.toContain("run-phase"); + }); +}); diff --git a/src/components/documents/document-ai-panels.tsx b/src/components/documents/document-ai-panels.tsx index 919711f4c..daf342284 100644 --- a/src/components/documents/document-ai-panels.tsx +++ b/src/components/documents/document-ai-panels.tsx @@ -12,6 +12,8 @@ * user applies it (and the title lands in the editable field, not on disk). * - `DocumentSummaryPanel` — the transient summary / extracted-text panel with * the persistent "not saved · not a diagnosis" note. + * - `AiRunPhaseNote` — the calm line under an action while its read runs in + * the background (v1.40), shared by every document AI action. */ import { Sparkles } from "lucide-react"; import { Check, FileText, X } from "lucide-react"; @@ -19,6 +21,7 @@ import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import type { AiRunPhase } from "@/hooks/use-ai-run"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { @@ -28,6 +31,37 @@ import { import type { DocumentDescribeResult } from "./use-document-assist"; +/** + * The line under a document AI action while its read runs in the background, + * or nothing when no read is running. + * + * `outcome` keeps the sentence true: a read whose result lands on the document + * (the index, a stored summary, staged facts) tells the person they may leave; + * a read whose answer lives only on this screen (suggestions, a transient + * summary or transcription) does not promise that, because leaving drops it. + */ +export function AiRunPhaseNote({ + phase, + outcome, + slot, +}: { + phase: AiRunPhase; + outcome: "savedWithDocument" | "shownHere"; + slot: string; +}) { + const { t } = useTranslations(); + if (phase === "idle") return null; + return ( +

+ {phase === "waitingForWorker" + ? t("aiRuns.waitingForWorker") + : outcome === "savedWithDocument" + ? t("aiRuns.backgroundDocument") + : t("aiRuns.backgroundScan")} +

+ ); +} + /** * Calm pointer to the AI settings, shown when assist is unavailable. When the * document is already searchable (auto-indexed locally) the copy stays honest — diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx index 4ec4f024d..36beed24d 100644 --- a/src/components/documents/document-ai-section.tsx +++ b/src/components/documents/document-ai-section.tsx @@ -45,6 +45,7 @@ import { } from "@/lib/validations/inbound-documents"; import { + AiRunPhaseNote, AiUnavailableHint, AssistSuggestionReview, DocumentSummaryPanel, @@ -79,6 +80,7 @@ export function DocumentAiSection({ lastIndexOutcome = null, indexPending, indexRunPhase = "idle", + assistRunPhase = "idle", onIndex, }: { aiEnabled: boolean; @@ -124,6 +126,8 @@ export function DocumentAiSection({ indexPending: boolean; /** v1.40 — where a background read stands, for the calm line under it. */ indexRunPhase?: AiRunPhase; + /** v1.40 — the same for a suggestion or transient summary read. */ + assistRunPhase?: AiRunPhase; onIndex: () => void; }) { const { t } = useTranslations(); @@ -253,16 +257,18 @@ export function DocumentAiSection({ ) : null} {indexRunPhase !== "idle" ? ( -

- {indexRunPhase === "waitingForWorker" - ? t("aiRuns.waitingForWorker") - : t("aiRuns.backgroundDocument")} -

- ) : null} + + ) : ( + + )} {indexOutcomeText ? (

@@ -938,6 +939,9 @@ export function DocumentDetailSheet({ lastIndexOutcome={doc.lastIndexOutcome} indexPending={indexDoc.isPending} indexRunPhase={indexDoc.runPhase} + assistRunPhase={ + suggest.isPending ? suggest.runPhase : summary.runPhase + } onIndex={runIndex} /> )} diff --git a/src/components/documents/document-summary-block.tsx b/src/components/documents/document-summary-block.tsx index 7828f5475..7bf383a49 100644 --- a/src/components/documents/document-summary-block.tsx +++ b/src/components/documents/document-summary-block.tsx @@ -30,9 +30,12 @@ import { FileText } from "lucide-react"; import { Button } from "@/components/ui/button"; +import type { AiRunPhase } from "@/hooks/use-ai-run"; import { useTranslations } from "@/lib/i18n/context"; import type { DocumentSummaryStateValue } from "@/lib/validations/inbound-documents"; +import { AiRunPhaseNote } from "./document-ai-panels"; + /** Copy key for a state with no summary to show. */ function absentCopyKey(state: DocumentSummaryStateValue): string { if (state === "WITHHELD") return "documents.detail.summary.withheld"; @@ -46,6 +49,7 @@ export function DocumentSummaryBlock({ generatedAtLabel, aiEnabled, isGenerating, + runPhase = "idle", actionsDisabled, canGenerate = true, onGenerate, @@ -58,6 +62,11 @@ export function DocumentSummaryBlock({ /** Whether an AI provider is configured; false hides the action, not the state. */ aiEnabled: boolean; isGenerating: boolean; + /** + * v1.40 — where the background read stands while one runs. The summary is + * stored on the document, so the line tells the person they may leave. + */ + runPhase?: AiRunPhase; /** Capability still resolving — the transport mode isn't known yet. */ actionsDisabled: boolean; /** @@ -125,6 +134,11 @@ export function DocumentSummaryBlock({ : t("documents.detail.summary.generate")} ) : null} + ); } diff --git a/src/components/documents/use-document-assist.ts b/src/components/documents/use-document-assist.ts index 98f959400..0c26ecdc0 100644 --- a/src/components/documents/use-document-assist.ts +++ b/src/components/documents/use-document-assist.ts @@ -15,9 +15,10 @@ */ import { useCallback } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAiProviderState } from "@/hooks/use-ai-capability"; +import { useAiRunPhase } from "@/hooks/use-ai-run"; import { apiGet, ApiError } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -85,10 +86,15 @@ export function useDocumentsAutoAiRead(enabled: boolean) { * Suggest filing metadata for a stored document. Returns DRAFTS only — the * detail sheet prefills its edit fields and the user saves; this call writes * nothing. + * + * The read runs in the background (v1.40), so no proxy in front of the server + * can cut a slow model; `runPhase` says so while it does. */ export function useSuggestDetails() { + const queryClient = useQueryClient(); const { responseTimeoutMs } = useAiProviderState(); - return useMutation< + const { phase, onQueued, onProgress, reset } = useAiRunPhase(); + const mutation = useMutation< DocumentSuggestionDto, Error, { mode: DocumentAiMode; target: DocumentAiTarget } @@ -100,19 +106,25 @@ export function useSuggestDetails() { target, responseTimeoutMs, modelCalls: 1, + background: { queryClient, onQueued, onProgress }, }); return data.suggestions; }, + onSettled: reset, }); + return { ...mutation, runPhase: mutation.isPending ? phase : "idle" }; } /** * On-demand summary or extracted text. Both stay transient unless the document - * summary block explicitly requests persistence or replacement. + * summary block explicitly requests persistence or replacement. The read runs + * in the background (v1.40); `runPhase` says so while it does. */ export function useDocumentSummary() { + const queryClient = useQueryClient(); const { responseTimeoutMs } = useAiProviderState(); - return useMutation< + const { phase, onQueued, onProgress, reset } = useAiRunPhase(); + const mutation = useMutation< DocumentDescribeResult, Error, { @@ -130,8 +142,11 @@ export function useDocumentSummary() { target, responseTimeoutMs, modelCalls: 1, + background: { queryClient, onQueued, onProgress }, }), + onSettled: reset, }); + return { ...mutation, runPhase: mutation.isPending ? phase : "idle" }; } /** diff --git a/src/lib/documents/ai-runs/run-support.ts b/src/lib/documents/ai-runs/run-support.ts new file mode 100644 index 000000000..340f86b10 --- /dev/null +++ b/src/lib/documents/ai-runs/run-support.ts @@ -0,0 +1,74 @@ +/** + * Pieces the summary, suggest and extract run bodies share: the provider pick + * types, the failure shape, and the answer for a stored original that could + * not be turned into a provider input. + * + * Every failure here carries the status, message and `documents.inbound.*` + * code the synchronous route has always answered with, so the background run + * reports exactly what the request would have. + */ +import type { + requireDocumentTextProvider, + requireDocumentVisionProvider, +} from "@/lib/documents/provider-order"; +import type { VisionInput } from "@/lib/documents/ai-route-support"; + +import type { AiRunOutcome } from "./types"; + +export type DocumentVisionPick = Awaited< + ReturnType +>; +export type DocumentTextPick = Awaited< + ReturnType +>; + +/** Who asked: a request (with its client address) or a background run. */ +export interface RunOrigin { + ipAddress: string | null; + worker: boolean; +} + +/** A failure in the shape the route would answer with. */ +export const runFailure = (f: { + status: number; + message: string; + errorCode: string; +}): AiRunOutcome => ({ ok: false, ...f }); + +/** The provider error every document read answers with (422 parse, 502 wire). */ +export function unreadableDocument(parseError: boolean): AiRunOutcome { + return runFailure({ + status: parseError ? 422 : 502, + message: "Couldn't read the document. Try a clearer copy.", + errorCode: "documents.inbound.extractFailed", + }); +} + +/** + * A stored original that could not become a provider input, as the summary + * and suggest routes have always answered it. + */ +export function visionPreparationFailure( + reason: Extract["reason"], +): AiRunOutcome { + if (reason === "pdfNeedsAnthropic") { + return runFailure({ + status: 422, + message: + "PDF scanning needs a Claude vision provider; use local OCR instead.", + errorCode: "documents.inbound.pdfNeedsAnthropic", + }); + } + if (reason === "fileType") { + return runFailure({ + status: 422, + message: "This document can't be scanned. Use local OCR (text mode).", + errorCode: "documents.inbound.fileType", + }); + } + return runFailure({ + status: 422, + message: "Couldn't read the stored document.", + errorCode: "documents.inbound.extractFailed", + }); +} diff --git a/src/lib/documents/ai-runs/start.ts b/src/lib/documents/ai-runs/start.ts index e53455c18..202c591fd 100644 --- a/src/lib/documents/ai-runs/start.ts +++ b/src/lib/documents/ai-runs/start.ts @@ -23,13 +23,18 @@ import { * The live run of this kind for this document, if one is queued or running: * a second press of "Read with AI" (another tab, a double click) attaches to * it instead of paying for a second read. + * + * `paramsMatch` narrows the attach to a run that will produce the same thing: + * a summary that is to be stored must not attach to a transient transcription + * of the same document. */ export async function findLiveDocumentRun( userId: string, documentId: string, kind: DocumentAiRunKindValue, + paramsMatch?: (params: AiRunParams) => boolean, ): Promise { - const row = await prisma.documentAiRun.findFirst({ + const rows = await prisma.documentAiRun.findMany({ where: { userId, documentId, @@ -37,10 +42,14 @@ export async function findLiveDocumentRun( status: { in: ["QUEUED", "RUNNING"] }, expiresAt: { gt: new Date() }, }, - select: { id: true }, + select: { id: true, paramsJson: true }, orderBy: { createdAt: "desc" }, + take: 10, }); - return row?.id ?? null; + const live = paramsMatch + ? rows.find((row) => paramsMatch(row.paramsJson as unknown as AiRunParams)) + : rows[0]; + return live?.id ?? null; } export async function startAiRun(args: { diff --git a/src/lib/documents/ai-runs/suggest-run.ts b/src/lib/documents/ai-runs/suggest-run.ts new file mode 100644 index 000000000..d6fa7a9db --- /dev/null +++ b/src/lib/documents/ai-runs/suggest-run.ts @@ -0,0 +1,112 @@ +/** + * Filing suggestions for a stored document: the body both the synchronous + * suggest route and the background run execute. + * + * One provider call over the stored original (vision) or text the browser read + * (text) returns a `{ title, kind, documentDate }` draft. Nothing is written to + * the document: the person reviews the draft and saves it on the edit form. + * The reservation the caller took is settled here. + */ +import type { ProviderChainType } from "@/lib/ai/provider-chain"; +import { auditLog } from "@/lib/auth/audit"; +import { + prepareVisionInput, + refundDocumentAiSlot, + type LoadedDocument, +} from "@/lib/documents/ai-route-support"; +import { + DocumentAssistError, + runDocumentAssist, + type DocumentSuggestion, +} from "@/lib/documents/assist"; +import { annotate } from "@/lib/logging/context"; + +import { + unreadableDocument, + visionPreparationFailure, + type DocumentTextPick, + type DocumentVisionPick, + type RunOrigin, +} from "./run-support"; +import { settleAiRunBudget } from "./store"; +import type { + AiRunBudget, + AiRunOutcome, + DocumentSuggestRunResult, +} from "./types"; + +/** One provider call. */ +export const DOCUMENT_SUGGEST_MODEL_CALLS = 1; + +export type DocumentSuggestInput = + | { mode: "text"; text: string; pick: DocumentTextPick; budget: AiRunBudget } + | { mode: "vision"; pick: DocumentVisionPick; budget: AiRunBudget }; + +/** Suggest filing details for one owned document. The caller authorised and charged it. */ +export async function executeDocumentSuggest(args: { + userId: string; + document: LoadedDocument; + input: DocumentSuggestInput; + origin: RunOrigin; +}): Promise> { + const { userId, document, input, origin } = args; + const { pick, budget } = input; + const servedBy = pick.entry.providerType as ProviderChainType; + + let suggestion: DocumentSuggestion; + try { + if (input.mode === "text") { + suggestion = await runDocumentAssist({ + provider: pick.entry.instance, + providerType: pick.providerType, + ocrText: input.text, + }); + } else { + const vision = await prepareVisionInput( + document, + input.pick.pdfSupported, + ); + if (!vision.ok) { + // Preparation failed before any provider dispatch: the reservation + // and the slot go back. + await settleAiRunBudget(userId, budget, 0, null); + await refundDocumentAiSlot(userId); + return visionPreparationFailure(vision.reason); + } + suggestion = await runDocumentAssist({ + provider: pick.entry.instance, + providerType: pick.providerType, + images: vision.images, + documents: vision.documents, + }); + } + } catch (err) { + await settleAiRunBudget(userId, budget, 0, null); + if (!(err instanceof DocumentAssistError)) { + annotate({ + action: { name: "documents.assist.failed" }, + meta: { reason: "provider_error", mode: input.mode }, + }); + } + return unreadableDocument(err instanceof DocumentAssistError); + } + await settleAiRunBudget(userId, budget, budget.reserved, servedBy); + + await auditLog("documents.inbound.suggest", { + userId, + ...(origin.worker ? { actorUserId: userId } : {}), + ipAddress: origin.ipAddress, + details: { documentId: document.id, mode: input.mode }, + }); + annotate({ + action: { name: "documents.assist.suggest" }, + meta: { + documentId: document.id, + mode: input.mode, + hasTitle: suggestion.title !== null, + hasKind: suggestion.kind !== null, + hasDate: suggestion.documentDate !== null, + }, + }); + return { ok: true, data: { suggestions: suggestion } }; +} diff --git a/src/lib/documents/ai-runs/summary-run.ts b/src/lib/documents/ai-runs/summary-run.ts new file mode 100644 index 000000000..898a7e2a3 --- /dev/null +++ b/src/lib/documents/ai-runs/summary-run.ts @@ -0,0 +1,250 @@ +/** + * A document summary or transcription: the body both the synchronous summary + * route and the background run execute, so the two paths cannot drift. + * + * Vision mode decrypts the stored original and makes one provider call; text + * mode sends text the browser read on the device. The call runs against a + * reservation the caller already took, which is settled here. A summary the + * caller asked to keep is stored on the document (or its refusal recorded as + * a state); a transcription and an ordinary summary stay transient. + * + * Every failure comes back as the outcome the route has always answered with. + */ +import type { ProviderChainType } from "@/lib/ai/provider-chain"; +import type { OutboundReason } from "@/lib/ai/safety/outbound-screen"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; +import { + prepareVisionInput, + refundDocumentAiSlot, + type LoadedDocument, +} from "@/lib/documents/ai-route-support"; +import { + DocumentDescribeError, + documentSummaryBlockedCopy, + runDocumentSummary, + transcribeDocument, + type DescribeInput, +} from "@/lib/documents/describe"; +import { encryptDocumentSummary } from "@/lib/documents/store"; +import type { Locale } from "@/lib/i18n/config"; +import { annotate } from "@/lib/logging/context"; + +import { + unreadableDocument, + visionPreparationFailure, + type DocumentTextPick, + type DocumentVisionPick, + type RunOrigin, +} from "./run-support"; +import { settleAiRunBudget } from "./store"; +import type { + AiRunBudget, + AiRunOutcome, + DocumentSummaryRunResult, +} from "./types"; + +/** One provider call: the summary, or the transcription. */ +export const DOCUMENT_SUMMARY_MODEL_CALLS = 1; + +export type DocumentSummaryOutput = "summary" | "text"; + +export type DocumentSummaryInput = + | { mode: "text"; text: string; pick: DocumentTextPick; budget: AiRunBudget } + | { mode: "vision"; pick: DocumentVisionPick; budget: AiRunBudget }; + +export interface SummaryPersistence { + /** Replace a stored summary; without it only an empty slot is filled. */ + replaceExisting: boolean; +} + +/** + * Store a screened-clean summary, or record the refusal as a state. + * + * A blocked summary NEVER lands as text: the honest statement the caller shows + * is generated copy for this response, not the model's prose, and persisting it + * would put a refusal where a summary belongs. Only the WITHHELD state is kept, + * and it is not terminal: the person can ask again. + * + * Storing is best-effort. The summary is already in the answer; a failed write + * must not turn that into an error. + */ +async function persistSummary( + userId: string, + documentId: string, + persist: SummaryPersistence, + summary: string, + blocked: OutboundReason | null, +): Promise<"stored" | "withheld" | "failed"> { + try { + if (blocked) { + await prisma.inboundDocument.updateMany({ + where: { + id: documentId, + userId, + deletedAt: null, + summaryState: { not: "READY" }, + }, + // An explicit request is the person reading it on purpose, so an + // import's hold on automatic AI reading ends here. + data: { summaryState: "WITHHELD", aiReadDeferred: false }, + }); + return "withheld"; + } + // Preserve a previously stored summary unless the caller explicitly chose + // the replacement action. Failed or screened attempts never reach this + // write, so the previous clean summary remains available in those cases. + const written = await prisma.inboundDocument.updateMany({ + where: { + id: documentId, + userId, + deletedAt: null, + ...(persist.replaceExisting ? {} : { summaryEncrypted: null }), + }, + data: { + summaryEncrypted: encryptDocumentSummary(summary), + summaryGeneratedAt: new Date(), + summaryState: "READY", + aiReadDeferred: false, + }, + }); + annotate({ + action: { name: "documents.summary.persisted" }, + meta: { documentId, stored: written.count > 0 }, + }); + return written.count > 0 ? "stored" : "failed"; + } catch { + annotate({ + action: { name: "documents.summary.persistFailed" }, + meta: { documentId }, + }); + return blocked ? "withheld" : "failed"; + } +} + +/** + * Mark a summary the background run was asked to store as not produced. Only + * the PENDING state the queuing route set is moved, so a summary that landed + * meanwhile (another tab, the automatic job) is never overwritten. + */ +export async function markQueuedSummaryUnavailable( + userId: string, + documentId: string, +): Promise { + try { + await prisma.inboundDocument.updateMany({ + where: { id: documentId, userId, summaryState: "PENDING" }, + data: { summaryState: "UNAVAILABLE" }, + }); + } catch { + // The run's failure is already decided; the hourly summary reaper moves a + // PENDING state that outlives its job. + } +} + +/** + * Mark a summary as being generated when a run that will store it is queued, + * so the detail view says so across reloads. Never over a stored summary: a + * replacement keeps showing the old one until the new one lands. + */ +export async function markSummaryQueued( + userId: string, + documentId: string, +): Promise { + await prisma.inboundDocument.updateMany({ + where: { + id: documentId, + userId, + deletedAt: null, + summaryEncrypted: null, + }, + data: { summaryState: "PENDING" }, + }); +} + +/** Summarise or transcribe one owned document. The caller authorised and charged it. */ +export async function executeDocumentSummary(args: { + userId: string; + document: LoadedDocument; + input: DocumentSummaryInput; + output: DocumentSummaryOutput; + locale: Locale; + persist: SummaryPersistence | null; + origin: RunOrigin; +}): Promise> { + const { userId, document, input, output, locale, origin } = args; + const { pick, budget } = input; + const servedBy = pick.entry.providerType as ProviderChainType; + + let describeInput: DescribeInput; + if (input.mode === "text") { + describeInput = { + provider: pick.entry.instance, + providerType: pick.providerType, + ocrText: input.text, + }; + } else { + const vision = await prepareVisionInput(document, input.pick.pdfSupported); + if (!vision.ok) { + // Preparation failed before any provider dispatch: the reservation and + // the slot go back. + await settleAiRunBudget(userId, budget, 0, null); + await refundDocumentAiSlot(userId); + return visionPreparationFailure(vision.reason); + } + describeInput = { + provider: pick.entry.instance, + providerType: pick.providerType, + images: vision.images, + documents: vision.documents, + }; + } + + let result: DocumentSummaryRunResult; + try { + if (output === "text") { + result = await transcribeDocument(describeInput); + } else { + const { summary, blocked } = await runDocumentSummary({ + ...describeInput, + locale, + }); + const persistence = args.persist + ? await persistSummary( + userId, + document.id, + args.persist, + summary, + blocked, + ) + : undefined; + result = { + summary: blocked ? documentSummaryBlockedCopy(locale) : summary, + ...(persistence ? { persistence } : {}), + }; + } + } catch (err) { + await settleAiRunBudget(userId, budget, 0, null); + if (!(err instanceof DocumentDescribeError)) { + annotate({ + action: { name: "documents.summary.failed" }, + meta: { reason: "provider_error", mode: input.mode }, + }); + } + return unreadableDocument(err instanceof DocumentDescribeError); + } + await settleAiRunBudget(userId, budget, budget.reserved, servedBy); + + await auditLog("documents.inbound.summary", { + userId, + // A background run has no request left to attribute the action from. + ...(origin.worker ? { actorUserId: userId } : {}), + ipAddress: origin.ipAddress, + details: { documentId: document.id, mode: output, inputMode: input.mode }, + }); + annotate({ + action: { name: "documents.summary.serve" }, + meta: { documentId: document.id, mode: output, inputMode: input.mode }, + }); + return { ok: true, data: result }; +} diff --git a/src/lib/documents/ai-runs/types.ts b/src/lib/documents/ai-runs/types.ts index a8ce2d599..74986eb77 100644 --- a/src/lib/documents/ai-runs/types.ts +++ b/src/lib/documents/ai-runs/types.ts @@ -8,14 +8,33 @@ * `GET /api/ai-runs/{id}` until the run is terminal. The result lives in the * row for an hour after it finishes and then the row is deleted. */ +import type { AiCapabilityKey } from "@/lib/ai/capabilities/types"; +import type { Locale } from "@/lib/i18n/config"; +import type { InboundDocumentKindValue } from "@/lib/validations/inbound-documents"; import type { OcrExtractResponseDto } from "@/lib/validations/labs-ocr"; export const DOCUMENT_AI_RUN_KINDS = [ "DOCUMENT_INDEX", "LABS_OCR_EXTRACT", + "DOCUMENT_SUMMARY", + "DOCUMENT_SUGGEST", ] as const; export type DocumentAiRunKindValue = (typeof DOCUMENT_AI_RUN_KINDS)[number]; +/** + * The capability each kind of run answers to: the one its queuing route asks, + * asked again by the worker for the record before anything leaves. + */ +export const DOCUMENT_AI_RUN_CAPABILITY: Record< + DocumentAiRunKindValue, + AiCapabilityKey +> = { + DOCUMENT_INDEX: "documentAi", + LABS_OCR_EXTRACT: "labsOcr", + DOCUMENT_SUMMARY: "documentAi", + DOCUMENT_SUGGEST: "documentAi", +}; + export const DOCUMENT_AI_RUN_STATUSES = [ "QUEUED", "RUNNING", @@ -64,6 +83,13 @@ export interface AiRunParams { mime?: "image/jpeg" | "image/png" | "image/webp" | "application/pdf"; /** Present when the enqueue reserved budget for a provider call. */ budget?: AiRunBudget; + /** A summary run: what to produce, whether to store it, and in which language. */ + summary?: { + output: "summary" | "text"; + persist: boolean; + replace: boolean; + locale: Locale; + }; } /** "Read with AI": the same body the synchronous index route answers with. */ @@ -74,7 +100,25 @@ export interface DocumentIndexRunResult { labFactsStaged: number; } -export type AiRunResult = DocumentIndexRunResult | OcrExtractResponseDto; +/** A summary run: the summary route's body. */ +export type DocumentSummaryRunResult = + | { summary: string; persistence?: "stored" | "withheld" | "failed" } + | { text: string }; + +/** A suggest run: the suggest route's body (drafts, nothing written). */ +export interface DocumentSuggestRunResult { + suggestions: { + title: string | null; + kind: InboundDocumentKindValue | null; + documentDate: string | null; + }; +} + +export type AiRunResult = + | DocumentIndexRunResult + | OcrExtractResponseDto + | DocumentSummaryRunResult + | DocumentSuggestRunResult; /** * What one run body produced: the success body, or the failure the diff --git a/src/lib/jobs/__tests__/document-ai-run.test.ts b/src/lib/jobs/__tests__/document-ai-run.test.ts index 56b65972c..752157713 100644 --- a/src/lib/jobs/__tests__/document-ai-run.test.ts +++ b/src/lib/jobs/__tests__/document-ai-run.test.ts @@ -13,7 +13,11 @@ * in `assertRunMayEgress` turns "refuses when consent was withdrawn" red; * deleting the `aiCapabilityForJob` check turns "refuses when the operator * turned reading off" red; dropping the `ends > deadlineAt` comparison turns - * "refuses a read that cannot fit" red. + * "refuses a read that cannot fit" red. For the summary and suggest kinds: + * deleting `afterRunFailed` (or its `markQueuedSummaryUnavailable` call) + * turns "a stored summary that fails is marked UNAVAILABLE" red, and deleting + * the `assertRunMayEgress` call in `pickForDocumentRead`'s text branch turns + * "a text suggestion re-checks the wire" red. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -38,9 +42,12 @@ vi.mock("@/lib/ai/capabilities/egress", () => ({ })); const requireDocumentVisionProvider = vi.fn(); +const requireDocumentTextProvider = vi.fn(); vi.mock("@/lib/documents/provider-order", () => ({ requireDocumentVisionProvider: (...a: unknown[]) => requireDocumentVisionProvider(...a), + requireDocumentTextProvider: (...a: unknown[]) => + requireDocumentTextProvider(...a), })); const requireLabsOcrProvider = vi.fn(); vi.mock("@/lib/labs/ocr-capability", () => ({ @@ -58,6 +65,20 @@ vi.mock("@/lib/labs/ocr-run", () => ({ executeOcrExtraction: (...a: unknown[]) => executeOcrExtraction(...a), })); +const executeDocumentSummary = vi.fn(); +const markQueuedSummaryUnavailable = vi.fn(); +vi.mock("@/lib/documents/ai-runs/summary-run", () => ({ + DOCUMENT_SUMMARY_MODEL_CALLS: 1, + executeDocumentSummary: (...a: unknown[]) => executeDocumentSummary(...a), + markQueuedSummaryUnavailable: (...a: unknown[]) => + markQueuedSummaryUnavailable(...a), +})); +const executeDocumentSuggest = vi.fn(); +vi.mock("@/lib/documents/ai-runs/suggest-run", () => ({ + DOCUMENT_SUGGEST_MODEL_CALLS: 1, + executeDocumentSuggest: (...a: unknown[]) => executeDocumentSuggest(...a), +})); + const loadOwnedDocument = vi.fn(); vi.mock("@/lib/documents/ai-route-support", () => ({ loadOwnedDocument: (...a: unknown[]) => loadOwnedDocument(...a), @@ -333,3 +354,203 @@ describe("handleDocumentAiRunJobs", () => { }); }); }); + +describe("summary and suggestion runs", () => { + const summaryRun = (overrides: Record = {}) => + claimed({ + kind: "DOCUMENT_SUMMARY", + params: { + mode: "vision", + budget, + summary: { + output: "summary", + persist: true, + replace: false, + locale: "de", + }, + }, + ...overrides, + }); + + beforeEach(() => { + executeDocumentSummary.mockResolvedValue({ + ok: true, + data: { summary: "Ein Arztbrief.", persistence: "stored" }, + }); + executeDocumentSuggest.mockResolvedValue({ + ok: true, + data: { + suggestions: { title: "Brief", kind: "OTHER", documentDate: null }, + }, + }); + requireDocumentTextProvider.mockResolvedValue({ + entry: { + providerType: "local", + instance: { responseTimeoutSeconds: 90 }, + }, + providerType: "local", + }); + }); + + it("reads a summary after the re-check, with one call's deadline and its options", async () => { + claimAiRun.mockResolvedValue(summaryRun()); + requireDocumentVisionProvider.mockResolvedValue(visionPick(300)); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe( + "succeeded", + ); + expect(aiCapabilityForJob).toHaveBeenCalledWith("u1", "documentAi"); + expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", [ + "anthropic", + ]); + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "run1", status: "RUNNING" }, + data: { expiresAt: new Date(NOW + 300_000 + 60_000) }, + }); + expect(executeDocumentSummary).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ mode: "vision", budget }), + output: "summary", + locale: "de", + persist: { replaceExisting: false }, + origin: { ipAddress: null, worker: true }, + }), + ); + expect(completeAiRun).toHaveBeenCalledWith( + "run1", + { summary: "Ein Arztbrief.", persistence: "stored" }, + new Date(NOW), + ); + expect(markQueuedSummaryUnavailable).not.toHaveBeenCalled(); + }); + + it("refuses a summary when consent was withdrawn while it waited, and marks it UNAVAILABLE", async () => { + claimAiRun.mockResolvedValue(summaryRun()); + aiEgressRefusal.mockResolvedValue( + new AiUnavailableError("documentAi", "consent_required"), + ); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed"); + expect(executeDocumentSummary).not.toHaveBeenCalled(); + expect(settleAiRunBudget).toHaveBeenCalledWith("u1", budget, 0, null); + expect(markQueuedSummaryUnavailable).toHaveBeenCalledWith("u1", "d1"); + }); + + it("a stored summary that fails is marked UNAVAILABLE", async () => { + claimAiRun.mockResolvedValue(summaryRun()); + executeDocumentSummary.mockResolvedValue({ + ok: false, + status: 502, + message: "Couldn't read the document. Try a clearer copy.", + errorCode: "documents.inbound.extractFailed", + }); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed"); + expect(markQueuedSummaryUnavailable).toHaveBeenCalledWith("u1", "d1"); + }); + + it("a stored summary that could not be written is marked UNAVAILABLE", async () => { + claimAiRun.mockResolvedValue(summaryRun()); + executeDocumentSummary.mockResolvedValue({ + ok: true, + data: { summary: "Ein Arztbrief.", persistence: "failed" }, + }); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe( + "succeeded", + ); + expect(markQueuedSummaryUnavailable).toHaveBeenCalledWith("u1", "d1"); + }); + + it("a transient summary that fails leaves the document's state alone", async () => { + claimAiRun.mockResolvedValue( + summaryRun({ + params: { + mode: "vision", + budget, + summary: { + output: "text", + persist: false, + replace: false, + locale: "en", + }, + }, + }), + ); + executeDocumentSummary.mockResolvedValue({ + ok: false, + status: 502, + message: "Couldn't read the document. Try a clearer copy.", + errorCode: "documents.inbound.extractFailed", + }); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed"); + expect(markQueuedSummaryUnavailable).not.toHaveBeenCalled(); + }); + + it("refuses a summary that cannot fit the job's expiry", async () => { + claimAiRun.mockResolvedValue(summaryRun()); + requireDocumentVisionProvider.mockResolvedValue(visionPick(600)); + // One call at ten minutes plus the margin does not fit in ten minutes. + expect(await runDocumentAiRun("run1", NOW + 10 * 60_000, now)).toBe( + "failed", + ); + expect(executeDocumentSummary).not.toHaveBeenCalled(); + expect(failAiRun).toHaveBeenCalledWith( + "run1", + expect.objectContaining({ errorCode: "aiRuns.timedOut", status: 504 }), + "RUNNING", + new Date(NOW), + ); + }); + + it("fails a summary whose document is gone, settling the reservation once", async () => { + claimAiRun.mockResolvedValue(summaryRun()); + loadOwnedDocument.mockResolvedValue(null); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed"); + expect(settleAiRunBudget).toHaveBeenCalledOnce(); + expect(failAiRun).toHaveBeenCalledWith( + "run1", + expect.objectContaining({ + status: 404, + errorCode: "documents.inbound.notFound", + }), + "RUNNING", + new Date(NOW), + ); + }); + + it("a text suggestion re-checks the wire for the text provider and reads the sealed text", async () => { + claimAiRun.mockResolvedValue( + claimed({ + kind: "DOCUMENT_SUGGEST", + params: { mode: "text", budget }, + input: Buffer.from("Arbeitsunfähigkeitsbescheinigung"), + }), + ); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe( + "succeeded", + ); + expect(aiEgressRefusal).toHaveBeenCalledWith("documentAi", "u1", ["local"]); + expect(updateMany).toHaveBeenCalledWith({ + where: { id: "run1", status: "RUNNING" }, + data: { expiresAt: new Date(NOW + 90_000 + 60_000) }, + }); + expect(executeDocumentSuggest).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + mode: "text", + text: "Arbeitsunfähigkeitsbescheinigung", + }), + }), + ); + }); + + it("refuses a suggestion when the operator turned reading off", async () => { + claimAiRun.mockResolvedValue( + claimed({ kind: "DOCUMENT_SUGGEST", params: { mode: "vision", budget } }), + ); + aiCapabilityForJob.mockResolvedValue({ + available: false, + reason: "operator_disabled", + }); + expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed"); + expect(executeDocumentSuggest).not.toHaveBeenCalled(); + expect(markQueuedSummaryUnavailable).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/jobs/document-ai-run.ts b/src/lib/jobs/document-ai-run.ts index 90ecd379c..c8244de7e 100644 --- a/src/lib/jobs/document-ai-run.ts +++ b/src/lib/jobs/document-ai-run.ts @@ -44,6 +44,15 @@ import { DOCUMENT_INDEX_MODEL_CALLS, executeDocumentIndex, } from "@/lib/documents/ai-runs/index-run"; +import { + DOCUMENT_SUGGEST_MODEL_CALLS, + executeDocumentSuggest, +} from "@/lib/documents/ai-runs/suggest-run"; +import { + DOCUMENT_SUMMARY_MODEL_CALLS, + executeDocumentSummary, + markQueuedSummaryUnavailable, +} from "@/lib/documents/ai-runs/summary-run"; import { claimAiRun, completeAiRun, @@ -55,11 +64,17 @@ import { import { AI_RUN_ERROR_CODES, AI_RUN_MARGIN_MS, + DOCUMENT_AI_RUN_CAPABILITY, + type AiRunBudget, type AiRunOutcome, type AiRunResult, type DocumentAiRunKindValue, } from "@/lib/documents/ai-runs/types"; -import { requireDocumentVisionProvider } from "@/lib/documents/provider-order"; +import { + requireDocumentTextProvider, + requireDocumentVisionProvider, +} from "@/lib/documents/provider-order"; +import { coerceLocale } from "@/lib/i18n/config"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { jobDeadline } from "@/lib/jobs/job-budget"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; @@ -88,10 +103,7 @@ export interface DocumentAiRunPayload { } /** The capability each kind of run answers to. */ -const RUN_CAPABILITY: Record = { - DOCUMENT_INDEX: "documentAi", - LABS_OCR_EXTRACT: "labsOcr", -}; +const RUN_CAPABILITY = DOCUMENT_AI_RUN_CAPABILITY; /** * Hand a created run to the worker. False when no queue is reachable; the @@ -262,6 +274,155 @@ async function runLabsOcr( }); } +/** The owned document a run reads, or the route's own 404 when it is gone. */ +async function loadRunDocument(run: ClaimedAiRun) { + const document = run.documentId + ? await loadOwnedDocument(run.userId, run.documentId) + : null; + if (document) return document; + // The reservation goes back in `runDocumentAiRun`'s catch. + throw new RunRefused({ + status: 404, + message: "Document not found", + errorCode: "documents.inbound.notFound", + }); +} + +/** The reservation a provider read was queued with. */ +function runBudget(run: ClaimedAiRun): AiRunBudget { + if (!run.params.budget) + throw new Error("document read without a reservation"); + return run.params.budget; +} + +/** + * Pick the provider for a document read the way its route does, re-check the + * capability and the wire for exactly that provider, and arm the deadline for + * the calls the read can make. + */ +async function pickForDocumentRead( + run: ClaimedAiRun, + modelCalls: number, + deadlineAt: number | undefined, + now: () => number, +) { + if (run.params.mode === "text") { + const pick = await requireDocumentTextProvider(run.userId); + await assertRunMayEgress("documentAi", run.userId, [pick.providerType]); + await armDeadline( + run.id, + callTimeoutMs({}, pick.entry.instance.responseTimeoutSeconds), + modelCalls, + deadlineAt, + now, + ); + return { mode: "text" as const, pick }; + } + const pick = await requireDocumentVisionProvider(run.userId); + await assertRunMayEgress("documentAi", run.userId, [pick.providerType]); + await armDeadline( + run.id, + callTimeoutMs({}, pick.entry.instance.responseTimeoutSeconds), + modelCalls, + deadlineAt, + now, + ); + return { mode: "vision" as const, pick }; +} + +/** The text the browser read, sealed into the run by the queuing route. */ +function runText(run: ClaimedAiRun): string { + if (!run.input) throw new Error("text read without an input"); + return run.input.toString("utf8"); +} + +async function runDocumentSummaryRead( + run: ClaimedAiRun, + deadlineAt: number | undefined, + now: () => number, +): Promise> { + const options = run.params.summary; + if (!options) throw new Error("summary run without its options"); + const document = await loadRunDocument(run); + const read = await pickForDocumentRead( + run, + DOCUMENT_SUMMARY_MODEL_CALLS, + deadlineAt, + now, + ); + const budget = runBudget(run); + return executeDocumentSummary({ + userId: run.userId, + document, + input: + read.mode === "text" + ? { mode: "text", text: runText(run), pick: read.pick, budget } + : { mode: "vision", pick: read.pick, budget }, + output: options.output, + locale: coerceLocale(options.locale), + persist: options.persist ? { replaceExisting: options.replace } : null, + origin: { ipAddress: null, worker: true }, + }); +} + +async function runDocumentSuggestRead( + run: ClaimedAiRun, + deadlineAt: number | undefined, + now: () => number, +): Promise> { + const document = await loadRunDocument(run); + const read = await pickForDocumentRead( + run, + DOCUMENT_SUGGEST_MODEL_CALLS, + deadlineAt, + now, + ); + const budget = runBudget(run); + return executeDocumentSuggest({ + userId: run.userId, + document, + input: + read.mode === "text" + ? { mode: "text", text: runText(run), pick: read.pick, budget } + : { mode: "vision", pick: read.pick, budget }, + origin: { ipAddress: null, worker: true }, + }); +} + +type RunDispatcher = ( + run: ClaimedAiRun, + deadlineAt: number | undefined, + now: () => number, +) => Promise>; + +/** + * The body each kind of run executes. Every dispatcher that sends anything to + * a model re-checks the capability and the wire for its provider first + * (`assertRunMayEgress`); `ai-egress-capability-guard.test.ts` holds that. + */ +const RUN_DISPATCH: Record = { + DOCUMENT_INDEX: runDocumentIndex, + LABS_OCR_EXTRACT: runLabsOcr, + DOCUMENT_SUMMARY: runDocumentSummaryRead, + DOCUMENT_SUGGEST: runDocumentSuggestRead, +}; + +/** + * What a failed run leaves behind besides itself. A summary that was to be + * stored had its document marked PENDING at enqueue; a failure says it could + * not be produced rather than leaving "being prepared" until the hourly + * reaper notices. + */ +async function afterRunFailed(run: ClaimedAiRun): Promise { + if ( + run.kind === "DOCUMENT_SUMMARY" && + run.params.summary?.persist && + run.documentId + ) { + await markQueuedSummaryUnavailable(run.userId, run.documentId); + } +} + export type DocumentAiRunResult = "succeeded" | "failed" | "skipped"; /** @@ -281,10 +442,7 @@ export async function runDocumentAiRun( let outcome: AiRunOutcome; try { - outcome = - run.kind === "DOCUMENT_INDEX" - ? await runDocumentIndex(run, deadlineAt, now) - : await runLabsOcr(run, deadlineAt, now); + outcome = await RUN_DISPATCH[run.kind](run, deadlineAt, now); } catch (err) { // Everything that throws here threw before the body took over the // reservation, so it goes back here. @@ -298,6 +456,7 @@ export async function runDocumentAiRun( message: "The background read failed.", }; await failAiRun(run.id, failure, "RUNNING", new Date(now())); + await afterRunFailed(run); annotate({ action: { name: "ai_runs.failed" }, meta: { @@ -311,6 +470,13 @@ export async function runDocumentAiRun( if (outcome.ok) { await completeAiRun(run.id, outcome.data, new Date(now())); + if ( + "persistence" in outcome.data && + outcome.data.persistence === "failed" + ) { + // The summary was read but could not be stored on the document. + await afterRunFailed(run); + } annotate({ action: { name: "ai_runs.succeeded" }, meta: { kind: run.kind }, @@ -327,6 +493,7 @@ export async function runDocumentAiRun( "RUNNING", new Date(now()), ); + await afterRunFailed(run); annotate({ action: { name: "ai_runs.failed" }, meta: { kind: run.kind, errorCode: outcome.errorCode }, diff --git a/src/lib/openapi/routes/ai-runs.ts b/src/lib/openapi/routes/ai-runs.ts index dabd0a709..e65d7c1e0 100644 --- a/src/lib/openapi/routes/ai-runs.ts +++ b/src/lib/openapi/routes/ai-runs.ts @@ -6,8 +6,9 @@ * * Part of the OpenAPI route table; aggregated in `./index.ts`. * - * iOS coordination (v1.40): `POST /api/documents/inbound/{id}/index` keeps - * its 200 body when the request carries no `Prefer` header. A client that + * iOS coordination (v1.40): `POST /api/documents/inbound/{id}/index`, + * `…/summary` and `…/suggest` keep their 200 bodies when the request carries + * no `Prefer` header. A client that * sends `Prefer: respond-async` gets 202 with a run id and polls the run here; * the result is the same body the route would have answered with. * `POST /api/labs/ocr/extract` (web only) always answers 202. @@ -15,24 +16,38 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; -import { documentIndexResponse } from "./documents"; +import { + documentIndexResponse, + documentSuggestResponse, + documentSummaryResponse, +} from "./documents"; import { ocrExtractResponse } from "./ocr"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; const aiRun = z .object({ id: z.string(), - kind: z.enum(["DOCUMENT_INDEX", "LABS_OCR_EXTRACT"]), + kind: z.enum([ + "DOCUMENT_INDEX", + "LABS_OCR_EXTRACT", + "DOCUMENT_SUMMARY", + "DOCUMENT_SUGGEST", + ]), documentId: z .string() .nullable() .describe("The document a document run reads; null for a lab scan."), status: z.enum(["QUEUED", "RUNNING", "SUCCEEDED", "FAILED"]), result: z - .union([documentIndexResponse, ocrExtractResponse]) + .union([ + documentIndexResponse, + ocrExtractResponse, + documentSummaryResponse, + documentSuggestResponse, + ]) .nullable() .describe( - "Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`.", + "Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`. Decode by `kind`; treat an unknown `kind` as a run this client did not start.", ), error: z .object({ diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 9d684e770..bc63f1941 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -287,6 +287,26 @@ const documentUploadEnvelope = dataEnvelope( "DocumentUploadEnvelope", ); +/** The suggest route's body; also a `DOCUMENT_SUGGEST` run's result. */ +export const documentSuggestResponse = z + .object({ + suggestions: z.object({ + title: z.string().nullable(), + kind: kindEnum.nullable(), + documentDate: z.string().nullable(), + }), + }) + .meta({ id: "DocumentSuggestResponse" }); + +/** The summary route's body; also a `DOCUMENT_SUMMARY` run's result. */ +export const documentSummaryResponse = z + .object({ + summary: z.string().optional(), + text: z.string().optional(), + persistence: z.enum(["stored", "withheld", "failed"]).optional(), + }) + .meta({ id: "DocumentSummaryResponse" }); + /** The index route's body; also a `DOCUMENT_INDEX` run's result. */ export const documentIndexResponse = z .object({ @@ -954,9 +974,10 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Suggest filing metadata (drafts only)", description: - 'Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR\'d text (TEXT, `application/json` `{ mode: "text", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label.', + "Optional AI assist on an already-stored document. Runs ONE provider call over the stored original (VISION, empty body) or browser-OCR'd text (TEXT, `application/json` `{ mode: \"text\", text }`, opt-in local OCR) and returns a `{ title, kind, documentDate }` DRAFT for the edit form. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated (shares the per-user document-AI bucket with extract/summary/index — default 6/hour, operator-tunable via `DOCUMENT_AI_LIMIT_PER_HOUR`; a slot is only consumed when the request reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-Limit` / `-Remaining` / `-Reset` triple, and `meta.retryAt`). WRITES NOTHING — never stages facts, never flips status; the user reviews and saves. 422 (`documents.inbound.providerUnsupported`) with no provider configured. Never interprets or diagnoses; the title is a neutral filing label. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, a second request while a suggestion read of this document over the same transport is queued or running answers with that run, and `GET /api/ai-runs/{id}` serves this body as the run's `result`.", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" } }, + preferRespondAsyncParameter, ], requestBody: { required: false, @@ -976,20 +997,13 @@ export const inboundDocumentPaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - z - .object({ - suggestions: z.object({ - title: z.string().nullable(), - kind: kindEnum.nullable(), - documentDate: z.string().nullable(), - }), - }) - .meta({ id: "DocumentSuggestResponse" }), + documentSuggestResponse, "DocumentSuggestEnvelope", ), }, }, }, + ...aiRunAccepted, ...aiExtractionRefusals("documentAi"), ...stdResponses, }, @@ -1000,7 +1014,7 @@ export const inboundDocumentPaths: NonNullable = { tags: ["Documents"], summary: "Summarise or transcribe a document", description: - 'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` with no provider.', + "On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) dispatch as extract. Answers under the `documentAi` capability (403 with the capability envelope when it is closed), then rate / budget gated. 422 `documents.inbound.providerUnsupported` with no provider. With `Prefer: respond-async` (v1.40) the read runs in the background: after the same quick refusals the route answers 202 with a run id, and `GET /api/ai-runs/{id}` serves this body as the run's `result`. A second request while a read with the same `mode`, `persist` and `replace` is queued or running answers with that run. With `persist=true` the document's `summaryState` is PENDING while the run is queued or running (unless a summary is already stored) and becomes UNAVAILABLE when the run fails. `mode=text` over posted text calls no model and is answered synchronously either way (no `Preference-Applied`).", parameters: [ { name: "id", in: "path", required: true, schema: { type: "string" } }, { @@ -1031,6 +1045,7 @@ export const inboundDocumentPaths: NonNullable = { description: "With `persist=true`, explicitly replace an existing stored summary.", }, + preferRespondAsyncParameter, ], requestBody: { required: false, @@ -1051,20 +1066,13 @@ export const inboundDocumentPaths: NonNullable = { content: { "application/json": { schema: dataEnvelope( - z - .object({ - summary: z.string().optional(), - text: z.string().optional(), - persistence: z - .enum(["stored", "withheld", "failed"]) - .optional(), - }) - .meta({ id: "DocumentSummaryResponse" }), + documentSummaryResponse, "DocumentSummaryEnvelope", ), }, }, }, + ...aiRunAccepted, ...aiExtractionRefusals("documentAi"), ...stdResponses, }, diff --git a/tests/integration/document-ai-runs.test.ts b/tests/integration/document-ai-runs.test.ts index c21f5de6d..ee54eb1e3 100644 --- a/tests/integration/document-ai-runs.test.ts +++ b/tests/integration/document-ai-runs.test.ts @@ -16,6 +16,10 @@ * anything reaches a model. * 6. The reaper fails a run no worker took (and hands its reservation back) * and deletes a finished run an hour after it ended. + * 7. Summary and suggestion runs (0370) round-trip the routes' own bodies; a + * summary that is to be stored marks the document PENDING while queued, + * READY when it lands and UNAVAILABLE when the run fails; a transient one + * leaves the state alone and never attaches to a stored one. */ import { Buffer } from "node:buffer"; @@ -68,6 +72,18 @@ vi.mock("@/lib/documents/describe", async (importOriginal) => ({ transcribeDocument: vi.fn(async () => ({ text: "Entlassbrief Leukozyten erhoeht", })), + runDocumentSummary: vi.fn(async () => ({ + summary: "A hospital discharge letter.", + blocked: null, + })), +})); +vi.mock("@/lib/documents/assist", async (importOriginal) => ({ + ...(await importOriginal()), + runDocumentAssist: vi.fn(async () => ({ + title: "Discharge letter", + kind: "DISCHARGE_LETTER", + documentDate: "2027-01-10", + })), })); vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({ ...(await importOriginal()), @@ -84,7 +100,11 @@ vi.mock("@/lib/documents/auto-stage-labs", () => ({ })), })); -import { transcribeDocument } from "@/lib/documents/describe"; +import { runDocumentAssist } from "@/lib/documents/assist"; +import { + runDocumentSummary, + transcribeDocument, +} from "@/lib/documents/describe"; import { runOcrExtraction } from "@/lib/labs/ocr-extract"; const PNG = Buffer.from( @@ -186,6 +206,40 @@ async function postLabScanText(text: string) { return { status: res.status, body: (await res.json()) as Envelope }; } +async function postDocumentAi( + documentId: string, + path: string, + prefer: boolean, +) { + const route = path.startsWith("summary") + ? await import("@/app/api/documents/inbound/[id]/summary/route") + : await import("@/app/api/documents/inbound/[id]/suggest/route"); + const { NextRequest } = await import("next/server"); + const res = await route.POST( + new NextRequest( + `http://localhost/api/documents/inbound/${documentId}/${path}`, + { + method: "POST", + headers: prefer ? { prefer: "respond-async" } : {}, + }, + ) as never, + { params: Promise.resolve({ id: documentId }) } as never, + ); + return { + status: res.status, + headers: res.headers, + body: (await res.json()) as Envelope, + }; +} + +async function summaryState(documentId: string) { + const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({ + where: { id: documentId }, + select: { summaryState: true, summaryEncrypted: true }, + }); + return { state: row.summaryState, stored: row.summaryEncrypted !== null }; +} + async function poll(runId: string) { const { GET } = await import("@/app/api/ai-runs/[id]/route"); const res = await GET( @@ -211,6 +265,8 @@ beforeEach(async () => { send.mockClear(); vi.mocked(transcribeDocument).mockClear(); vi.mocked(runOcrExtraction).mockClear(); + vi.mocked(runDocumentSummary).mockClear(); + vi.mocked(runDocumentAssist).mockClear(); }); describe("Read with AI in the background", () => { @@ -449,3 +505,178 @@ describe("the reaper", () => { ).toBeNull(); }); }); + +describe("a summary in the background", () => { + it("marks a stored summary PENDING while queued and READY when it lands", async () => { + const user = await makeUser(); + await grantConsent(user.id); + const document = await storeDocument(user.id); + await signIn(user.id); + + const queued = await postDocumentAi( + document.id, + "summary?mode=summary&persist=true", + true, + ); + expect(queued.status).toBe(202); + expect(queued.headers.get("preference-applied")).toBe("respond-async"); + const { runId } = queued.body.data as { runId: string }; + expect(runDocumentSummary).not.toHaveBeenCalled(); + expect(await summaryState(document.id)).toEqual({ + state: "PENDING", + stored: false, + }); + + // A second press for the same stored summary attaches; a transient + // summary of the same document is its own run. + const again = await postDocumentAi( + document.id, + "summary?mode=summary&persist=true", + true, + ); + expect((again.body.data as { runId: string }).runId).toBe(runId); + const transient = await postDocumentAi( + document.id, + "summary?mode=summary", + true, + ); + expect(transient.status).toBe(202); + expect((transient.body.data as { runId: string }).runId).not.toBe(runId); + + expect(await work(runId)).toBe("succeeded"); + expect(runDocumentSummary).toHaveBeenCalledOnce(); + const done = await poll(runId); + expect(done.body.data).toMatchObject({ + status: "SUCCEEDED", + kind: "DOCUMENT_SUMMARY", + documentId: document.id, + result: { + summary: "A hospital discharge letter.", + persistence: "stored", + }, + }); + expect(await summaryState(document.id)).toEqual({ + state: "READY", + stored: true, + }); + }); + + it("marks a stored summary UNAVAILABLE when its run fails", async () => { + const user = await makeUser(); + await grantConsent(user.id); + const document = await storeDocument(user.id); + await signIn(user.id); + vi.mocked(runDocumentSummary).mockRejectedValueOnce( + new Error("provider down"), + ); + + const queued = await postDocumentAi( + document.id, + "summary?mode=summary&persist=true", + true, + ); + const { runId } = queued.body.data as { runId: string }; + expect(await work(runId)).toBe("failed"); + const done = await poll(runId); + expect(done.body.data).toMatchObject({ + status: "FAILED", + error: { code: "documents.inbound.extractFailed", status: 502 }, + }); + expect(await summaryState(document.id)).toEqual({ + state: "UNAVAILABLE", + stored: false, + }); + }); + + it("leaves the document's summary state alone for a transient summary", async () => { + const user = await makeUser(); + await grantConsent(user.id); + const document = await storeDocument(user.id); + await signIn(user.id); + vi.mocked(runDocumentSummary).mockRejectedValueOnce( + new Error("provider down"), + ); + + const queued = await postDocumentAi( + document.id, + "summary?mode=summary", + true, + ); + const { runId } = queued.body.data as { runId: string }; + expect(await summaryState(document.id)).toEqual({ + state: "NONE", + stored: false, + }); + expect(await work(runId)).toBe("failed"); + expect(await summaryState(document.id)).toEqual({ + state: "NONE", + stored: false, + }); + }); + + it("answers synchronously, exactly as before, without the header", async () => { + const user = await makeUser(); + await grantConsent(user.id); + const document = await storeDocument(user.id); + await signIn(user.id); + + const res = await postDocumentAi(document.id, "summary?mode=text", false); + expect(res.status).toBe(200); + expect(res.headers.get("preference-applied")).toBeNull(); + expect(res.body.data).toEqual({ text: "Entlassbrief Leukozyten erhoeht" }); + expect(send).not.toHaveBeenCalled(); + expect(await getPrismaClient().documentAiRun.count()).toBe(0); + }); +}); + +describe("filing suggestions in the background", () => { + it("queues, reads in the worker, writes nothing to the document", async () => { + const user = await makeUser(); + await grantConsent(user.id); + const document = await storeDocument(user.id); + await signIn(user.id); + + const queued = await postDocumentAi(document.id, "suggest", true); + expect(queued.status).toBe(202); + const { runId } = queued.body.data as { runId: string }; + expect(await work(runId)).toBe("succeeded"); + expect(runDocumentAssist).toHaveBeenCalledOnce(); + + const done = await poll(runId); + expect(done.body.data).toMatchObject({ + status: "SUCCEEDED", + kind: "DOCUMENT_SUGGEST", + result: { + suggestions: { + title: "Discharge letter", + kind: "DISCHARGE_LETTER", + documentDate: "2027-01-10", + }, + }, + }); + const after = await getPrismaClient().inboundDocument.findUniqueOrThrow({ + where: { id: document.id }, + }); + expect(after.kind).toBe("OTHER"); + expect(after.title).toBeNull(); + }); + + it("is a 404 for another account's document and its run", async () => { + const owner = await makeUser(); + await grantConsent(owner.id); + const document = await storeDocument(owner.id); + await signIn(owner.id); + const queued = await postDocumentAi(document.id, "suggest", true); + const { runId } = queued.body.data as { runId: string }; + + const other = await makeUser(); + await grantConsent(other.id); + await signIn(other.id); + const foreignPost = await postDocumentAi(document.id, "suggest", true); + expect(foreignPost.status).toBe(404); + expect(foreignPost.body.meta?.errorCode).toBe("documents.inbound.notFound"); + const foreignPoll = await poll(runId); + expect(foreignPoll.status).toBe(404); + expect(foreignPoll.body.meta?.errorCode).toBe("aiRuns.notFound"); + }); +});