docs(api): register Lab-OCR routes in the OpenAPI contract

Add the OcrCapability, OcrExtract, and OcrCommit operations under
/api/labs/ocr/* and regenerate the YAML. Document the multipart upload, the
untrusted-extraction contract, and the source OCR commit value. Mark the three
routes exempt in the module-route gate inventory under the labs data-layer
reasoning.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 12:38:06 +02:00
1 parent ebfebe1fe3
commit 238aac85df
4 files changed
+577

No files matched your search

+375
View File
@@ -6264,6 +6264,123 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/labs/ocr/capability:
get:
tags:
- Labs
summary: Probe Lab-OCR availability
description: Cheap probe (no provider call) the Labs UI uses to decide whether to show the scan affordance. Reports
whether the caller's configured AI provider can read images, why not, and whether PDFs are accepted.
responses:
"200":
description: Capability flags.
content:
application/json:
schema:
$ref: "#/components/schemas/OcrCapabilityEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/labs/ocr/extract:
post:
tags:
- Labs
summary: Extract lab readings from a photo or PDF
description: Read-only (NOT idempotent) vision extraction. Accepts a multipart `file` (JPEG/PNG/WebP, or PDF on an
Anthropic vision provider; ≤ 12 MiB). Gated by AI consent, a 6/hour rate bucket, and the per-day token budget.
The raw upload lives in memory only and is never persisted or logged. Returns proposed rows for the mandatory
human review screen — nothing is written. Extracted text is treated as untrusted (prompt-injection); the review
step is the safety boundary.
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
description: The lab-report image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.
required:
- file
responses:
"200":
description: Proposed rows for review.
content:
application/json:
schema:
$ref: "#/components/schemas/OcrExtractEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/labs/ocr/commit:
post:
tags:
- Labs
summary: Commit confirmed Lab-OCR rows
description: 'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped
biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped.
Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body
field.'
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
rows:
minItems: 1
maxItems: 100
type: array
items:
type: object
properties:
analyte:
type: string
minLength: 1
maxLength: 120
panel:
anyOf:
- type: string
minLength: 1
maxLength: 120
- type: string
const: ""
value:
type: number
valueText:
type: string
minLength: 1
maxLength: 120
unit:
type: string
minLength: 1
maxLength: 40
referenceLow:
type: number
referenceHigh:
type: number
takenAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z))$
required:
- analyte
- takenAt
required:
- rows
responses:
"200":
description: Inserted + skipped rows.
content:
application/json:
schema:
$ref: "#/components/schemas/OcrCommitEnvelope"
"401": *a1
"422": *a3
"429": *a4
/api/biomarkers:
get:
tags:
@@ -19095,6 +19212,264 @@ components:
- data
- error
additionalProperties: false
OcrCapabilityEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/OcrCapabilityResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
OcrCapabilityResponse:
type: object
properties:
available:
type: boolean
reason:
anyOf:
- type: string
enum:
- no-provider
- text-only-model
- type: "null"
pdfSupported:
type: boolean
required:
- available
- reason
- pdfSupported
additionalProperties: false
description: Whether the caller's configured AI provider can read images (drives the UI's scan affordance). `reason`
explains an unavailable state; `pdfSupported` is true only for an Anthropic vision provider (native PDF). No
provider call is made.
OcrExtractEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/OcrExtractResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
OcrExtractResponse:
type: object
properties:
reportDate:
anyOf:
- type: string
- type: "null"
providerType:
type: string
rows:
type: array
items:
$ref: "#/components/schemas/OcrExtractedRow"
required:
- reportDate
- providerType
- rows
additionalProperties: false
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.
OcrExtractedRow:
type: object
properties:
analyte:
type: string
value:
anyOf:
- type: number
- type: "null"
valueText:
anyOf:
- type: string
- type: "null"
unit:
anyOf:
- type: string
- type: "null"
referenceLow:
anyOf:
- type: number
- type: "null"
referenceHigh:
anyOf:
- type: number
- type: "null"
takenAt:
anyOf:
- type: string
- type: "null"
confidence:
type: object
properties:
analyte:
type: number
value:
type: number
unit:
type: number
range:
type: number
required:
- analyte
- value
- unit
- range
additionalProperties: false
biomarkerMatch:
type: string
enum:
- new
- existing
duplicateOf:
anyOf:
- type: string
- type: "null"
required:
- analyte
- value
- valueText
- unit
- referenceLow
- referenceHigh
- takenAt
- confidence
- biomarkerMatch
- duplicateOf
additionalProperties: false
description: "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."
OcrCommitEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/OcrCommitResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
OcrCommitResponse:
type: object
properties:
inserted:
type: array
items:
$ref: "#/components/schemas/OcrCommittedLabResult"
skipped:
type: array
items:
type: object
properties:
analyte:
type: string
reason:
type: string
const: duplicate
required:
- analyte
- reason
additionalProperties: false
required:
- inserted
- skipped
additionalProperties: false
description: 'The rows written (`source: "OCR"`) and those skipped as commit-time duplicates.'
OcrCommittedLabResult:
type: object
properties:
id:
type: string
biomarkerId:
anyOf:
- type: string
- type: "null"
panel:
anyOf:
- type: string
- type: "null"
analyte:
type: string
value:
anyOf:
- type: number
- type: "null"
valueText:
anyOf:
- type: string
- type: "null"
unit:
type: string
referenceLow:
anyOf:
- type: number
- type: "null"
referenceHigh:
anyOf:
- type: number
- type: "null"
takenAt:
type: string
source:
type: string
hasNote:
type: boolean
rangeStatus:
type: string
enum:
- in-range
- below
- above
- unknown
createdAt:
type: string
updatedAt:
type: string
required:
- id
- biomarkerId
- panel
- analyte
- value
- valueText
- unit
- referenceLow
- referenceHigh
- takenAt
- source
- hasNote
- rangeStatus
- createdAt
- updatedAt
additionalProperties: false
ListBiomarkersEnvelope:
type: object
properties:
@@ -158,6 +158,16 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/labs/restore/route.ts",
"src/app/api/biomarkers/route.ts",
"src/app/api/biomarkers/[id]/route.ts",
// v1.18.9 — the Lab-OCR ingestion routes share the LabResult data-layer
// reasoning: the labs module gates the SURFACE (the Labs page + scan
// affordance), not the row store. The extract route is read-only vision
// assistance, the commit route writes the user's own confirmed lab rows,
// and the capability probe is an infra availability check carrying no
// module data. All three are owner-scoped and AI-gated (consent / budget /
// rate); the module toggle hiding the surface does not need to wedge them.
"src/app/api/labs/ocr/capability/route.ts",
"src/app/api/labs/ocr/extract/route.ts",
"src/app/api/labs/ocr/commit/route.ts",
// ── DATA LAYER (medications) ──────────────────────────────────────
// v1.18.1 (D3) — medications graduated from CORE to a toggleable module,
// but it is SURFACE-gated (nav / dashboard widget / settings entry), not
+2
View File
@@ -37,6 +37,7 @@ import { illnessPaths } from "./illness";
import { importPaths } from "./import";
import { insightsPaths } from "./insights";
import { labsPaths } from "./labs";
import { ocrPaths } from "./ocr";
import { measurementPaths } from "./measurements";
import { measurementReminderPaths } from "./measurement-reminders";
import { medicationPaths, medicationResource } from "./medications";
@@ -71,6 +72,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...importPaths,
...measurementReminderPaths,
...labsPaths,
...ocrPaths,
...biomarkerPaths,
...illnessPaths,
...onboardingPaths,
+190
View File
@@ -0,0 +1,190 @@
/**
* OpenAPI route table for Lab-OCR ingestion (`/api/labs/ocr/*`).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The commit
* request reuses the runtime `ocrCommitSchema` from
* `@/lib/validations/labs-ocr` so the wire contract stays single-source. The
* extract upload is a `multipart/form-data` binary body (documented inline);
* the response shapes are declared here.
*
* iOS coordination: server-authoritative — the iOS client consumes the
* committed `LabResult` rows and never re-OCRs. A future native capture would
* POST this same extract/commit contract. `source` carries the new value
* `"OCR"` on rows written through the commit route.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { ocrCommitSchema } from "@/lib/validations/labs-ocr";
import { dataEnvelope, stdResponses } from "./shared";
ocrCommitSchema.meta({
id: "OcrCommitRequest",
description:
"The rows a human confirmed on the Lab-OCR review screen. Each row is EITHER numeric (`value` + `unit`, optional reference bounds) OR qualitative (`valueText`) — exactly one. `analyte` drives a resolve-or-mint of the user-scoped biomarker; `takenAt` is a backdatable ISO instant. No `userId` field — it is narrowed from the session. 1..100 rows. The route skips a row that duplicates a live reading (same analyte + day + value).",
});
const capabilityResponse = z
.object({
available: z.boolean(),
reason: z.enum(["no-provider", "text-only-model"]).nullable(),
pdfSupported: z.boolean(),
})
.meta({
id: "OcrCapabilityResponse",
description:
"Whether the caller's configured AI provider can read images (drives the UI's scan affordance). `reason` explains an unavailable state; `pdfSupported` is true only for an Anthropic vision provider (native PDF). No provider call is made.",
});
const extractConfidence = z.object({
analyte: z.number(),
value: z.number(),
unit: z.number(),
range: z.number(),
});
const extractedRow = z
.object({
analyte: z.string(),
value: z.number().nullable(),
valueText: z.string().nullable(),
unit: z.string().nullable(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
takenAt: z.string().nullable(),
confidence: extractConfidence,
biomarkerMatch: z.enum(["new", "existing"]),
duplicateOf: z.string().nullable(),
})
.meta({
id: "OcrExtractedRow",
description:
"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
.object({
reportDate: z.string().nullable(),
providerType: z.string(),
rows: z.array(extractedRow),
})
.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.",
});
const committedRow = z
.object({
id: z.string(),
biomarkerId: z.string().nullable(),
panel: z.string().nullable(),
analyte: z.string(),
value: z.number().nullable(),
valueText: z.string().nullable(),
unit: z.string(),
referenceLow: z.number().nullable(),
referenceHigh: z.number().nullable(),
takenAt: z.string(),
source: z.string(),
hasNote: z.boolean(),
rangeStatus: z.enum(["in-range", "below", "above", "unknown"]),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({ id: "OcrCommittedLabResult" });
const commitResponse = z
.object({
inserted: z.array(committedRow),
skipped: z.array(
z.object({
analyte: z.string(),
reason: z.literal("duplicate"),
}),
),
})
.meta({
id: "OcrCommitResponse",
description:
'The rows written (`source: "OCR"`) and those skipped as commit-time duplicates.',
});
export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/labs/ocr/capability": {
get: {
tags: ["Labs"],
summary: "Probe Lab-OCR availability",
description:
"Cheap probe (no provider call) the Labs UI uses to decide whether to show the scan affordance. Reports whether the caller's configured AI provider can read images, why not, and whether PDFs are accepted.",
responses: {
"200": {
description: "Capability flags.",
content: {
"application/json": {
schema: dataEnvelope(capabilityResponse, "OcrCapabilityEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/labs/ocr/extract": {
post: {
tags: ["Labs"],
summary: "Extract lab readings from a photo or PDF",
description:
"Read-only (NOT idempotent) vision extraction. Accepts a multipart `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB). Gated by AI consent, a 6/hour rate bucket, and the per-day token budget. The raw upload lives in memory only and is never persisted or logged. Returns proposed rows for the mandatory human review screen — nothing is written. Extracted text is treated as untrusted (prompt-injection); the review step is the safety boundary.",
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: z.object({
file: z.string().meta({
format: "binary",
description:
"The lab-report image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.",
}),
}),
},
},
},
responses: {
"200": {
description: "Proposed rows for review.",
content: {
"application/json": {
schema: dataEnvelope(extractResponse, "OcrExtractEnvelope"),
},
},
},
...stdResponses,
},
},
},
"/api/labs/ocr/commit": {
post: {
tags: ["Labs"],
summary: "Commit confirmed Lab-OCR rows",
description:
'Writes ONLY the rows the human confirmed on the review screen. Each row resolves-or-mints a user-scoped biomarker and creates a `LabResult` with `source: "OCR"`; a row that duplicates a live reading is skipped. Idempotent (Idempotency-Key). Audits as `labs.ocr.commit`. `userId` is narrowed from the session, never a body field.',
requestBody: {
required: true,
content: { "application/json": { schema: ocrCommitSchema } },
},
responses: {
"200": {
description: "Inserted + skipped rows.",
content: {
"application/json": {
schema: dataEnvelope(commitResponse, "OcrCommitEnvelope"),
},
},
},
...stdResponses,
},
},
},
};