mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Merge remote-tracking branch 'origin/main' into feat/key-backup-and-transport
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
commit
37793298a8
59 files changed
+4736
-905
No files matched your search
@@ -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: [
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -84,7 +85,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(),
|
||||
@@ -102,7 +103,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
|
||||
@@ -184,7 +185,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: {
|
||||
@@ -211,14 +212,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,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user