mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
ebfebe1fe3
commit
238aac85df
4 files changed
+577
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user