feat(documents): read documents and lab scans in the background

"Read with AI" and the lab scan used to run their model calls inside the
request. At the default response time a read of three calls outlasted the
100 s a proxy in front of the server commonly allows, and the person saw a
cut connection for a read the server was still doing.

Both now run as a background run (`DocumentAiRun`, migration 0366):

- `POST /api/documents/inbound/{id}/index` with `Prefer: respond-async`
  answers 202 with a run id once every quick refusal has passed. Without
  the header it answers exactly as before, which the iPhone app relies on.
  A second request while a read of the same document is queued or running
  attaches to that run.
- `POST /api/labs/ocr/extract` always answers 202. Only the web client
  calls it.
- `GET /api/ai-runs/{id}` serves the run: the same body the synchronous
  route answers with, or the failure code and status it would have given.
  Scoped to the caller; another account's run is a 404.

The worker (`document-ai-run`, a long queue with a one-hour expiry, one
job per run under `lockedPass`) claims the run, asks the capability again
for the record and re-checks the wire for the provider it picks, sets the
run's deadline from the person's AI response time and refuses a read that
cannot fit the job's expiry. Sync route and worker run one body per kind,
so the two paths cannot drift. Budget is reserved at enqueue and settled
once by whoever ends the run.

Input and result are sealed with labelled AES-256-GCM, registered for key
rotation; the input is dropped when the run ends. A five-minute reaper
fails a run no worker took within fifteen minutes (and hands its
reservation back), fails a run past its deadline, and deletes a finished
run after an hour. Runs are outside backups and inside the data wipe.

The web client polls through the query-key factory and says, calmly, that
the read continues in the background, and when no worker has picked it up.
This commit is contained in:
Marc-André Bombeck committed 2026-10-03 18:18:56 +02:00
1 parent 556bd6d6cd
commit 0acf3da575
49 files changed
+4533 -870

No files matched your search

+6
View File
@@ -45,6 +45,12 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"ai.unavailable",
],
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
aiRuns: [
"aiRuns.failed",
"aiRuns.notFound",
"aiRuns.timedOut",
"aiRuns.workerUnavailable",
],
allergy: ["allergy.invalid"],
analytics: ["analytics.invalid_query"],
anamnesis: [
+58
View File
@@ -0,0 +1,58 @@
/**
* The request header and the 202 response every route that queues a
* background document AI run shares (v1.40). See `./ai-runs.ts` for the poll.
*/
import { z } from "zod/v4";
import { dataEnvelope, errorEnvelope } from "./shared";
/** `Prefer: respond-async` on a route that offers the background path. */
export const preferRespondAsyncParameter = {
name: "Prefer",
in: "header" as const,
required: false,
schema: { type: "string" as const, example: "respond-async" },
description:
"Send `respond-async` (RFC 7240) to run the read in the background: the route answers 202 with a run id once every quick refusal has passed, and `GET /api/ai-runs/{id}` serves the result. Without it the route answers synchronously with the result, as it always has; that form is retained for clients that have not adopted runs, and its retirement will be announced one minor release ahead. A preference the route cannot apply is ignored and the response then carries no `Preference-Applied`.",
};
const acceptedBody = z
.object({
runId: z.string(),
status: z.literal("QUEUED"),
pollAfterMs: z
.number()
.int()
.describe("How long to wait before the first poll."),
})
.meta({
id: "AiRunAccepted",
description:
"The read was queued. Poll `GET /api/ai-runs/{runId}`. A second request for the same document while a read of it is still queued or running answers with that run instead of starting another.",
});
const acceptedHeaders = z.object({
"Preference-Applied": z.literal("respond-async").meta({
description: "The route honoured `Prefer: respond-async`.",
}),
Location: z.string().meta({ description: "The run's poll address." }),
});
/** The 202 a queuing route answers with. */
export const aiRunAccepted = {
"202": {
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.",
content: {
"application/json": {
schema: dataEnvelope(acceptedBody, "AiRunAcceptedEnvelope"),
},
},
headers: acceptedHeaders,
},
"503": {
description:
"`aiRuns.workerUnavailable`: the run could not be handed to the background worker. Nothing was read and nothing was charged; try again.",
content: { "application/json": { schema: errorEnvelope } },
},
};
+98
View File
@@ -0,0 +1,98 @@
/**
* OpenAPI route table for background document AI runs (`/api/ai-runs/*`).
* The request header and the 202 the queuing routes share live in
* `./ai-run-accepted.ts`, so this module can read the result schemas of the
* routes it serves without an import cycle.
*
* 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
* 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.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { documentIndexResponse } 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"]),
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])
.nullable()
.describe(
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`.",
),
error: z
.object({
code: z.string(),
message: z.string(),
status: z.number().int(),
})
.nullable()
.describe(
"Present once `status` is `FAILED`: the `meta.errorCode`, message and HTTP status the queuing route would have answered with, or one of the run's own codes: `aiRuns.workerUnavailable` (no worker took the run within fifteen minutes), `aiRuns.timedOut` (the read outlived the calls the AI response time allows), `aiRuns.failed` (anything else).",
),
createdAt: z.string(),
startedAt: z.string().nullable(),
finishedAt: z.string().nullable(),
queuedForMs: z
.number()
.int()
.nullable()
.describe(
"While queued, how long the run has waited for a worker. A long wait usually means the background worker is not running.",
),
retryAfterMs: z
.number()
.int()
.nullable()
.describe(
"While not terminal, when to poll again (also sent as `Retry-After`, in seconds). Null once the run has ended.",
),
})
.meta({
id: "AiRun",
description:
"One background document AI run. Results and failures stay readable for an hour after the run ends; the run is then deleted and answers 404.",
});
export const aiRunPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/ai-runs/{id}": {
get: {
tags: ["Documents"],
summary: "Poll a background document AI run",
description:
"Where one background read stands, and its result or failure once it ends. Scoped to the caller: another account's run, a run that never existed, and a run more than an hour past its end are the same 404 `aiRuns.notFound`. A plain read of the caller's own run; it calls no model and asks no AI capability (those were asked when the run was queued, and again in the worker before anything left the server). Auth via cookie or Bearer.",
parameters: [
{ name: "id", in: "path", required: true, schema: { type: "string" } },
],
responses: {
"200": {
description: "The run.",
content: {
"application/json": {
schema: dataEnvelope(aiRun, "AiRunEnvelope"),
},
},
},
"404": {
description: "`aiRuns.notFound`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
};
+19 -13
View File
@@ -32,6 +32,7 @@ import {
} from "@/lib/validations/inbound-documents";
import { aiExtractionRefusals } from "./ai-extraction-refusals";
import { aiRunAccepted, preferRespondAsyncParameter } from "./ai-run-accepted";
import { aiCapabilityState } from "./profile";
import {
dataEnvelope,
@@ -286,6 +287,20 @@ const documentUploadEnvelope = dataEnvelope(
"DocumentUploadEnvelope",
);
/** The index route's body; also a `DOCUMENT_INDEX` run's result. */
export const documentIndexResponse = z
.object({
documentId: z.string(),
indexed: z.boolean(),
tokenCount: z.number(),
labFactsStaged: z
.number()
.describe(
"How many lab facts the index run auto-staged for review (0 when the document is not a lab report, staging was not eligible, or facts already exist). Staged facts are PENDING — the confirm route remains the only write into Labs.",
),
})
.meta({ id: "DocumentIndexResponse" });
export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/documents/inbound": {
get: {
@@ -1060,9 +1075,10 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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.",
"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" } },
preferRespondAsyncParameter,
],
requestBody: {
required: false,
@@ -1082,23 +1098,13 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
z
.object({
documentId: z.string(),
indexed: z.boolean(),
tokenCount: z.number(),
labFactsStaged: z
.number()
.describe(
"How many lab facts the index run auto-staged for review (0 when the document is not a lab report, staging was not eligible, or facts already exist). Staged facts are PENDING — the confirm route remains the only write into Labs.",
),
})
.meta({ id: "DocumentIndexResponse" }),
documentIndexResponse,
"DocumentIndexEnvelope",
),
},
},
},
...aiRunAccepted,
...aiExtractionRefusals("documentAi"),
...stdResponses,
},
+2
View File
@@ -64,6 +64,7 @@ import { integrationPaths } from "./integrations";
import { insightsSignalPaths } from "./insights-signals";
import { labsPaths } from "./labs";
import { ocrPaths } from "./ocr";
import { aiRunPaths } from "./ai-runs";
import { measurementPaths } from "./measurements";
import { measurementReminderPaths } from "./measurement-reminders";
import { mentalHealthPaths } from "./mental-health";
@@ -115,6 +116,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...measurementReminderPaths,
...labsPaths,
...ocrPaths,
...aiRunPaths,
...biomarkerPaths,
...encounterPaths,
...vaccinationPaths,
+5 -11
View File
@@ -18,6 +18,7 @@ import { z } from "zod/v4";
import { ocrCommitSchema } from "@/lib/validations/labs-ocr";
import { aiExtractionRefusals } from "./ai-extraction-refusals";
import { aiRunAccepted } from "./ai-run-accepted";
import { aiCapabilityState } from "./profile";
import {
dataEnvelope,
@@ -80,7 +81,7 @@ const extractedRow = z
"One proposed reading transcribed from the upload. UNTRUSTED model output annotated server-side: `biomarkerMatch` flags whether the analyte links an existing catalog marker; `duplicateOf` is the id of a live 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.",
});
const extractResponse = z
export const ocrExtractResponse = z
.object({
reportDate: z.string().nullable(),
providerType: z.string(),
@@ -98,7 +99,7 @@ const extractResponse = z
.meta({
id: "OcrExtractResponse",
description:
"The proposed rows for the human review screen. NEVER written to the database — extraction is read-only and the raw upload is held in memory only.",
"The proposed rows for the human review screen, served by `GET /api/ai-runs/{id}` for a `LABS_OCR_EXTRACT` run. Never written to the person's record; the commit route is the only write.",
});
const committedRow = z
@@ -177,7 +178,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Labs"],
summary: "Extract lab readings from a photo, PDF, or OCR'd text",
description:
"Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload lives in memory only and is never persisted or logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are model work and answer under the `labsOcr` capability (the operator's reading-documents switch, the labs module, and — because a lab report is a document — an active `ai_extraction` or `ai_full` consent receipt for any provider that leaves the machine, checked for the provider actually picked: the first vision-capable entry in vision mode, the chain head in text mode). No provider is 422 `labs.ocr.providerUnsupported`; text mode without the local-OCR opt-in is 422 `labs.ocr.localOcrDisabled`. Then both pass a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`) and the per-day token budget, and return proposed rows for the mandatory human review screen — nothing is written. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.",
"Read-only (NOT idempotent) extraction. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF — read natively on Anthropic, else rasterized to page images for any other vision provider; ≤ 12 MiB) is run through the user's vision-capable provider; the upload is never logged. TEXT (`application/json`, opt-in local OCR): the browser OCR's the image (tesseract.js) and POSTs `{ mode: \"text\", text }` — only the extracted text reaches the server, so a text-only provider (e.g. ChatGPT-OAuth) reaches the same review/commit flow. Both modes are model work and answer under the `labsOcr` capability (the operator's reading-documents switch, the labs module, and — because a lab report is a document — an active `ai_extraction` or `ai_full` consent receipt for any provider that leaves the machine, checked for the provider actually picked: the first vision-capable entry in vision mode, the chain head in text mode). No provider is 422 `labs.ocr.providerUnsupported`; text mode without the local-OCR opt-in is 422 `labs.ocr.localOcrDisabled`. Then both pass a per-user hourly rate bucket (default 6, operator-tunable via `LABS_OCR_LIMIT_PER_HOUR`; a slot is only consumed when the scan reaches the provider, and the 429 carries `Retry-After`, the `X-RateLimit-*` triple and `meta.retryAt`) and the per-day token budget, and the read is queued: since v1.40 the route answers 202 with a run id and `GET /api/ai-runs/{id}` serves the proposed rows (`OcrExtractResponse`) for the mandatory human review screen. The upload or text is held encrypted in the run until it is read, then dropped; the run is deleted an hour after it ends. Nothing is written to the record — the commit route is the only write. A file too large is 413 `labs.ocr.fileTooLarge`, an unknown type 415 `labs.ocr.fileType`, both before anything is queued. Extracted content is treated as untrusted (prompt-injection); the review step is the safety boundary.",
requestBody: {
required: true,
content: {
@@ -204,14 +205,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
"200": {
description: "Proposed rows for review.",
content: {
"application/json": {
schema: dataEnvelope(extractResponse, "OcrExtractEnvelope"),
},
},
},
...aiRunAccepted,
...aiExtractionRefusals("labsOcr"),
...stdResponses,
},