From 33bf952d9df988f90276d8f6e5424e11d1b71948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 16:57:24 +0200 Subject: [PATCH] feat(documents): OpenAPI contract and translations for inbound documents Document the /api/documents/inbound/* surface in the OpenAPI registry and regenerate the spec. Add the inbound-documents copy across all six locales, including the non-diagnostic disclaimer and the module label/description. --- docs/api/openapi.yaml | 715 ++++++++++++++++++++++++++++ messages/de.json | 71 ++- messages/en.json | 71 ++- messages/es.json | 71 ++- messages/fr.json | 71 ++- messages/it.json | 71 ++- messages/pl.json | 71 ++- src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/documents.ts | 304 ++++++++++++ src/lib/openapi/routes/index.ts | 2 + 10 files changed, 1442 insertions(+), 6 deletions(-) create mode 100644 src/lib/openapi/routes/documents.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 15d669cd8..42800ea58 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -42,6 +42,7 @@ tags: - name: MeasurementReminders - name: Labs - name: Illness + - name: Documents - name: Records - name: Admin - name: Meta @@ -8567,6 +8568,331 @@ paths: "404": *a13 "422": *a3 "429": *a4 + /api/documents/inbound: + get: + tags: + - Documents + summary: List inbound clinical documents + description: The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on + the opt-in `inboundDocuments` module. + responses: + "200": + description: The document list. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentListEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + post: + tags: + - Documents + summary: Upload + extract a clinical document + description: 'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw + document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type. + VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus + an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the + extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what + the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores + here; the confirm route is the only write path.' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + description: The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type. + kind: + description: Optional document-type label. + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - OTHER + required: + - file + application/json: + schema: + $ref: "#/components/schemas/InboundTextUploadRequest" + responses: + "201": + description: The created document + staging summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}: + get: + tags: + - Documents + summary: Get a document + its staged facts + description: The document plus every staged fact for the review screen. Owner-scoped. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Document detail. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDocumentDetailEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + delete: + tags: + - Documents + summary: Discard an inbound document + description: Soft-deletes the document + its staging. Facts already approved into the structured stores are independent + rows and are NOT affected. + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Discarded. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundDiscardEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}/facts/{factId}: + patch: + tags: + - Documents + summary: Edit a staged fact before approval + description: Corrects OCR / units / dates / codes on a pending fact. Clears `needsReview` (the values become + user-asserted). The fact's resource type cannot change. + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: factId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + properties: + factType: + type: string + const: CONDITION + label: + type: string + minLength: 1 + maxLength: 300 + code: + anyOf: + - type: string + maxLength: 64 + - type: "null" + codeSystem: + anyOf: + - type: string + enum: + - SNOMED + - ICD10 + - LOINC + - RXNORM + - ATC + - type: "null" + clinicalStatus: + anyOf: + - type: string + maxLength: 64 + - type: "null" + verificationStatus: + anyOf: + - type: string + maxLength: 64 + - type: "null" + onsetDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + required: + - factType + - label + - type: object + properties: + factType: + type: string + const: OBSERVATION + label: + type: string + minLength: 1 + maxLength: 300 + code: + anyOf: + - type: string + maxLength: 64 + - type: "null" + codeSystem: + anyOf: + - type: string + enum: + - SNOMED + - ICD10 + - LOINC + - RXNORM + - ATC + - type: "null" + value: + anyOf: + - type: number + - type: "null" + valueText: + anyOf: + - type: string + maxLength: 300 + - type: "null" + unit: + anyOf: + - type: string + maxLength: 80 + - type: "null" + referenceLow: + anyOf: + - type: number + - type: "null" + referenceHigh: + anyOf: + - type: number + - type: "null" + effectiveDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + required: + - factType + - label + - type: object + properties: + factType: + type: string + const: MEDICATION_STATEMENT + name: + type: string + minLength: 1 + maxLength: 200 + dose: + anyOf: + - type: string + maxLength: 120 + - type: "null" + rxNormCode: + anyOf: + - type: string + maxLength: 20 + - type: "null" + atcCode: + anyOf: + - type: string + maxLength: 16 + - type: "null" + statusStated: + anyOf: + - type: string + maxLength: 64 + - type: "null" + effectiveDate: + anyOf: + - type: string + pattern: ^\d{4}-\d{2}-\d{2}$ + - type: "null" + required: + - factType + - name + type: object + responses: + "200": + description: The updated fact. + content: + application/json: + schema: + $ref: "#/components/schemas/ExtractedFactEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/documents/inbound/{id}/confirm: + post: + tags: + - Documents + summary: Confirm the review decisions + description: The ONLY write path out of staging. Commits the user's approvals into the structured stores (labs / + conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be + approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`. + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + decisions: + minItems: 1 + maxItems: 120 + type: array + items: + type: object + properties: + factId: + type: string + minLength: 1 + maxLength: 40 + action: + type: string + enum: + - approve + - reject + required: + - factId + - action + required: + - decisions + responses: + "200": + description: Per-decision outcome. + content: + application/json: + schema: + $ref: "#/components/schemas/InboundConfirmEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/onboarding/disclaimer: post: tags: @@ -9739,6 +10065,8 @@ components: type: boolean mcp: type: boolean + inboundDocuments: + type: boolean additionalProperties: false description: Partial module enable/disable update. Each key is an optional boolean; an omitted key is left untouched, `false` disables the module, `true` (re-)enables it. Only the toggleable "secondary domains" are accepted — a @@ -10268,6 +10596,23 @@ components: required: - mode - text + InboundTextUploadRequest: + type: object + properties: + mode: + type: string + const: text + text: + type: string + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - OTHER + required: + - mode + - text DisclaimerAckRequest: type: object properties: @@ -16778,6 +17123,9 @@ components: mcp: type: boolean description: Whether the "mcp" module is enabled for this account. + inboundDocuments: + type: boolean + description: Whether the "inboundDocuments" module is enabled for this account. required: - cycle - mood @@ -16793,6 +17141,7 @@ components: - medications - doctorReport - mcp + - inboundDocuments additionalProperties: false description: Fully-resolved per-user module enable/disable map. Every toggleable module key is present. `false` means the surface should disappear end-to-end (nav, dashboard, insights, …). `cycle` mirrors `cycleTrackingEnabled`; @@ -24493,6 +24842,372 @@ components: - data - error additionalProperties: false + InboundDocumentListEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocumentList" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundDocumentList: + type: object + properties: + documents: + type: array + items: + $ref: "#/components/schemas/InboundDocument" + required: + - documents + additionalProperties: false + InboundDocument: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - OTHER + filename: + anyOf: + - type: string + - type: "null" + mimeType: + type: string + byteSize: + type: number + status: + type: string + enum: + - EXTRACTING + - EXTRACTED + - FAILED + - CONFIRMED + - DISCARDED + providerType: + anyOf: + - type: string + - type: "null" + reportDate: + anyOf: + - type: string + - type: "null" + errorReason: + anyOf: + - type: string + - type: "null" + factCount: + type: number + pendingCount: + type: number + createdAt: + type: string + updatedAt: + type: string + required: + - id + - kind + - filename + - mimeType + - byteSize + - status + - providerType + - reportDate + - errorReason + - factCount + - pendingCount + - createdAt + - updatedAt + additionalProperties: false + description: An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the + metadata + staging summary. + InboundDocumentEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocument" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundDocumentDetailEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundDocumentDetail" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundDocumentDetail: + type: object + properties: + id: + type: string + kind: + type: string + enum: + - DOCTOR_REPORT + - DISCHARGE_LETTER + - OTHER + filename: + anyOf: + - type: string + - type: "null" + mimeType: + type: string + byteSize: + type: number + status: + type: string + enum: + - EXTRACTING + - EXTRACTED + - FAILED + - CONFIRMED + - DISCARDED + providerType: + anyOf: + - type: string + - type: "null" + reportDate: + anyOf: + - type: string + - type: "null" + errorReason: + anyOf: + - type: string + - type: "null" + factCount: + type: number + pendingCount: + type: number + createdAt: + type: string + updatedAt: + type: string + facts: + type: array + items: + $ref: "#/components/schemas/ExtractedFact" + required: + - id + - kind + - filename + - mimeType + - byteSize + - status + - providerType + - reportDate + - errorReason + - factCount + - pendingCount + - createdAt + - updatedAt + - facts + additionalProperties: false + ExtractedFact: + type: object + properties: + id: + type: string + factType: + type: string + enum: + - CONDITION + - OBSERVATION + - MEDICATION_STATEMENT + status: + type: string + enum: + - PENDING + - APPROVED + - REJECTED + confidence: + type: number + needsReview: + type: boolean + data: + type: object + propertyNames: + type: string + additionalProperties: {} + provenance: + type: object + properties: + sourceText: + type: string + page: + anyOf: + - type: number + - type: "null" + confidence: + type: number + required: + - sourceText + - page + - confidence + additionalProperties: false + committedRecordId: + anyOf: + - type: string + - type: "null" + committedRecordType: + anyOf: + - type: string + - type: "null" + required: + - id + - factType + - status + - confidence + - needsReview + - data + - provenance + - committedRecordId + - committedRecordType + additionalProperties: false + description: One STATED fact transcribed from the document, FHIR-staged (Condition / Observation / MedicationStatement) + with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are + present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact + scored below the confidence floor (it cannot be approved until edited). + InboundDiscardEnvelope: + type: object + properties: + data: + type: object + properties: + id: + type: string + discarded: + type: boolean + required: + - id + - discarded + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ExtractedFactEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ExtractedFact" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundConfirmEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InboundConfirmResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InboundConfirmResponse: + type: object + properties: + approved: + type: array + items: + type: object + properties: + factId: + type: string + recordType: + type: string + recordId: + type: string + required: + - factId + - recordType + - recordId + additionalProperties: false + rejected: + type: array + items: + type: string + needsReview: + type: array + items: + type: string + failed: + type: array + items: + type: object + properties: + factId: + type: string + reason: + type: string + required: + - factId + - reason + additionalProperties: false + required: + - approved + - rejected + - needsReview + - failed + additionalProperties: false + description: "The outcome per decision: `approved` (committed, with the new record ref), `rejected` (discarded), + `needsReview` (refused — edit first, fail-closed), `failed` (a per-fact commit miss, e.g. a numeric observation + with no unit)." DisclaimerAckEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index d1b80c95b..5725567d8 100644 --- a/messages/de.json +++ b/messages/de.json @@ -239,7 +239,8 @@ "cycle": "Zyklus", "labs": "Laborwerte", "vorsorge": "Vorsorge", - "illness": "Krankheit" + "illness": "Krankheit", + "documents": "Dokumente" }, "auth": { "login": "Anmelden", @@ -7220,6 +7221,10 @@ "mcp": { "label": "Externer Assistentenzugriff (MCP)", "description": "Einem MCP-fähigen Assistenten Lesezugriff auf deine Gesundheitsdaten über ein begrenztes Token erlauben. Standardmäßig aus." + }, + "inboundDocuments": { + "label": "Eingehende Dokumente", + "description": "Lade einen Arztbericht oder Entlassbrief hoch; prüfe die erkannten Angaben, bevor sie gespeichert werden. Standardmäßig aus." } }, "illness": { @@ -7671,5 +7676,69 @@ "saveError": "Der Eintrag konnte nicht gespeichert werden." } } + }, + "documents": { + "title": "Eingehende Dokumente", + "subtitle": "Lade ein klinisches Dokument hoch und prüfe die Angaben, die wir daraus gelesen haben.", + "disclaimer": { + "title": "Wir lesen, wir deuten nicht", + "body": "Wir übernehmen nur das, was dein Dokument angibt — Werte, Codes und Daten — und stellen keine Diagnose, deuten nichts und bewerten nichts. Nichts wird gespeichert, bevor du es geprüft und bestätigt hast. Nur deine Ärztin oder dein Arzt kann diese Ergebnisse beurteilen." + }, + "upload": { + "label": "Dokument hochladen", + "kindLabel": "Dokumenttyp", + "button": "Hochladen", + "uploading": "Wird gelesen …" + }, + "kind": { + "DOCTOR_REPORT": "Arztbericht", + "DISCHARGE_LETTER": "Entlassbrief", + "OTHER": "Sonstiges" + }, + "list": { + "empty": "Noch keine Dokumente." + }, + "status": { + "EXTRACTING": "Wird gelesen", + "EXTRACTED": "Bereit zur Prüfung", + "FAILED": "Konnte nicht gelesen werden", + "CONFIRMED": "Geprüft", + "DISCARDED": "Verworfen" + }, + "review": { + "title": "Erkannte Angaben prüfen", + "discard": "Verwerfen", + "confirm": "Bestätigte Angaben speichern", + "confirming": "Wird gespeichert …", + "needsReview": "Prüfung nötig", + "approve": "Übernehmen", + "reject": "Ablehnen", + "edit": "Bearbeiten", + "cancel": "Abbrechen", + "save": "Speichern" + }, + "empty": { + "noFacts": "Nichts mehr zu prüfen." + }, + "factType": { + "CONDITION": "Diagnose", + "OBSERVATION": "Wert", + "MEDICATION_STATEMENT": "Medikament" + }, + "fields": { + "label": "Name", + "value": "Wert", + "unit": "Einheit", + "date": "Datum", + "dose": "Dosis" + }, + "toast": { + "uploaded": "Dokument gelesen. Prüfe die Angaben unten.", + "uploadFailed": "Dokument konnte nicht gelesen werden.", + "confirmed": "Bestätigte Angaben gespeichert.", + "confirmFailed": "Deine Entscheidungen konnten nicht gespeichert werden.", + "saved": "Angabe aktualisiert.", + "discarded": "Dokument verworfen." + } } } diff --git a/messages/en.json b/messages/en.json index 6b637b7f9..47e30d4ea 100644 --- a/messages/en.json +++ b/messages/en.json @@ -239,7 +239,8 @@ "cycle": "Cycle", "labs": "Labs", "vorsorge": "Checkups", - "illness": "Illness" + "illness": "Illness", + "documents": "Documents" }, "auth": { "login": "Sign in", @@ -7220,6 +7221,10 @@ "mcp": { "label": "External assistant access (MCP)", "description": "Let an MCP-capable assistant read your health data over a scoped token. Off by default." + }, + "inboundDocuments": { + "label": "Inbound documents", + "description": "Upload a doctor report or discharge letter; review the extracted facts before they're saved. Off by default." } }, "illness": { @@ -7671,5 +7676,69 @@ "saveError": "Couldn't save the entry." } } + }, + "documents": { + "title": "Inbound documents", + "subtitle": "Upload a clinical document and review the facts we read from it.", + "disclaimer": { + "title": "We read, we don't interpret", + "body": "We only transcribe what your document states — values, codes, and dates — and never diagnose, interpret, or flag anything. Nothing is saved until you review and approve it. Only your clinician can interpret these results." + }, + "upload": { + "label": "Upload a document", + "kindLabel": "Document type", + "button": "Upload", + "uploading": "Reading…" + }, + "kind": { + "DOCTOR_REPORT": "Doctor report", + "DISCHARGE_LETTER": "Discharge letter", + "OTHER": "Other" + }, + "list": { + "empty": "No documents yet." + }, + "status": { + "EXTRACTING": "Reading", + "EXTRACTED": "Ready to review", + "FAILED": "Couldn't read", + "CONFIRMED": "Reviewed", + "DISCARDED": "Discarded" + }, + "review": { + "title": "Review extracted facts", + "discard": "Discard", + "confirm": "Save approved facts", + "confirming": "Saving…", + "needsReview": "Needs review", + "approve": "Approve", + "reject": "Reject", + "edit": "Edit", + "cancel": "Cancel", + "save": "Save" + }, + "empty": { + "noFacts": "Nothing left to review." + }, + "factType": { + "CONDITION": "Diagnosis", + "OBSERVATION": "Value", + "MEDICATION_STATEMENT": "Medication" + }, + "fields": { + "label": "Name", + "value": "Value", + "unit": "Unit", + "date": "Date", + "dose": "Dose" + }, + "toast": { + "uploaded": "Document read. Review the facts below.", + "uploadFailed": "Couldn't read the document.", + "confirmed": "Approved facts saved.", + "confirmFailed": "Couldn't save your decisions.", + "saved": "Fact updated.", + "discarded": "Document discarded." + } } } diff --git a/messages/es.json b/messages/es.json index 511245644..e987de171 100644 --- a/messages/es.json +++ b/messages/es.json @@ -239,7 +239,8 @@ "cycle": "Ciclo", "labs": "Análisis", "vorsorge": "Revisiones", - "illness": "Enfermedad" + "illness": "Enfermedad", + "documents": "Documentos" }, "auth": { "login": "Iniciar sesión", @@ -7220,6 +7221,10 @@ "mcp": { "label": "Acceso de asistente externo (MCP)", "description": "Permite que un asistente compatible con MCP lea tus datos de salud mediante un token con alcance limitado. Desactivado de forma predeterminada." + }, + "inboundDocuments": { + "label": "Documentos entrantes", + "description": "Sube un informe médico o un alta; revisa los datos extraídos antes de guardarlos. Desactivado de forma predeterminada." } }, "illness": { @@ -7671,5 +7676,69 @@ "saveError": "No se ha podido guardar la entrada." } } + }, + "documents": { + "title": "Documentos entrantes", + "subtitle": "Sube un documento clínico y revisa los datos que hemos leído.", + "disclaimer": { + "title": "Leemos, no interpretamos", + "body": "Solo transcribimos lo que indica tu documento — valores, códigos y fechas — y nunca diagnosticamos, interpretamos ni marcamos nada. No se guarda nada hasta que lo revises y lo apruebes. Solo tu profesional sanitario puede interpretar estos resultados." + }, + "upload": { + "label": "Subir un documento", + "kindLabel": "Tipo de documento", + "button": "Subir", + "uploading": "Leyendo…" + }, + "kind": { + "DOCTOR_REPORT": "Informe médico", + "DISCHARGE_LETTER": "Alta hospitalaria", + "OTHER": "Otro" + }, + "list": { + "empty": "Aún no hay documentos." + }, + "status": { + "EXTRACTING": "Leyendo", + "EXTRACTED": "Listo para revisar", + "FAILED": "No se pudo leer", + "CONFIRMED": "Revisado", + "DISCARDED": "Descartado" + }, + "review": { + "title": "Revisar datos extraídos", + "discard": "Descartar", + "confirm": "Guardar datos aprobados", + "confirming": "Guardando…", + "needsReview": "Requiere revisión", + "approve": "Aprobar", + "reject": "Rechazar", + "edit": "Editar", + "cancel": "Cancelar", + "save": "Guardar" + }, + "empty": { + "noFacts": "Nada más que revisar." + }, + "factType": { + "CONDITION": "Diagnóstico", + "OBSERVATION": "Valor", + "MEDICATION_STATEMENT": "Medicación" + }, + "fields": { + "label": "Nombre", + "value": "Valor", + "unit": "Unidad", + "date": "Fecha", + "dose": "Dosis" + }, + "toast": { + "uploaded": "Documento leído. Revisa los datos a continuación.", + "uploadFailed": "No se pudo leer el documento.", + "confirmed": "Datos aprobados guardados.", + "confirmFailed": "No se pudieron guardar tus decisiones.", + "saved": "Dato actualizado.", + "discarded": "Documento descartado." + } } } diff --git a/messages/fr.json b/messages/fr.json index 722bb3c26..5243fc31f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -239,7 +239,8 @@ "cycle": "Cycle", "labs": "Analyses", "vorsorge": "Examens", - "illness": "Maladie" + "illness": "Maladie", + "documents": "Documents" }, "auth": { "login": "Se connecter", @@ -7220,6 +7221,10 @@ "mcp": { "label": "Accès assistant externe (MCP)", "description": "Autorise un assistant compatible MCP à lire vos données de santé via un jeton à portée limitée. Désactivé par défaut." + }, + "inboundDocuments": { + "label": "Documents entrants", + "description": "Importez un compte rendu médical ou une lettre de sortie ; vérifiez les données extraites avant de les enregistrer. Désactivé par défaut." } }, "illness": { @@ -7671,5 +7676,69 @@ "saveError": "Impossible d'enregistrer l'entrée." } } + }, + "documents": { + "title": "Documents entrants", + "subtitle": "Importez un document clinique et vérifiez les données que nous y avons lues.", + "disclaimer": { + "title": "Nous lisons, nous n'interprétons pas", + "body": "Nous transcrivons uniquement ce qu'indique votre document — valeurs, codes et dates — et nous ne posons aucun diagnostic, n'interprétons et ne signalons rien. Rien n'est enregistré tant que vous ne l'avez pas vérifié et approuvé. Seul votre professionnel de santé peut interpréter ces résultats." + }, + "upload": { + "label": "Importer un document", + "kindLabel": "Type de document", + "button": "Importer", + "uploading": "Lecture…" + }, + "kind": { + "DOCTOR_REPORT": "Compte rendu médical", + "DISCHARGE_LETTER": "Lettre de sortie", + "OTHER": "Autre" + }, + "list": { + "empty": "Aucun document pour l'instant." + }, + "status": { + "EXTRACTING": "Lecture", + "EXTRACTED": "Prêt à vérifier", + "FAILED": "Lecture impossible", + "CONFIRMED": "Vérifié", + "DISCARDED": "Écarté" + }, + "review": { + "title": "Vérifier les données extraites", + "discard": "Écarter", + "confirm": "Enregistrer les données approuvées", + "confirming": "Enregistrement…", + "needsReview": "À vérifier", + "approve": "Approuver", + "reject": "Rejeter", + "edit": "Modifier", + "cancel": "Annuler", + "save": "Enregistrer" + }, + "empty": { + "noFacts": "Plus rien à vérifier." + }, + "factType": { + "CONDITION": "Diagnostic", + "OBSERVATION": "Valeur", + "MEDICATION_STATEMENT": "Médicament" + }, + "fields": { + "label": "Nom", + "value": "Valeur", + "unit": "Unité", + "date": "Date", + "dose": "Dose" + }, + "toast": { + "uploaded": "Document lu. Vérifiez les données ci-dessous.", + "uploadFailed": "Lecture du document impossible.", + "confirmed": "Données approuvées enregistrées.", + "confirmFailed": "Impossible d'enregistrer vos décisions.", + "saved": "Donnée mise à jour.", + "discarded": "Document écarté." + } } } diff --git a/messages/it.json b/messages/it.json index 8ae1aef9a..7e5541302 100644 --- a/messages/it.json +++ b/messages/it.json @@ -239,7 +239,8 @@ "cycle": "Ciclo", "labs": "Esami", "vorsorge": "Controlli", - "illness": "Malattia" + "illness": "Malattia", + "documents": "Documenti" }, "auth": { "login": "Accedi", @@ -7220,6 +7221,10 @@ "mcp": { "label": "Accesso assistente esterno (MCP)", "description": "Consente a un assistente compatibile con MCP di leggere i tuoi dati sanitari tramite un token con ambito limitato. Disattivato per impostazione predefinita." + }, + "inboundDocuments": { + "label": "Documenti in entrata", + "description": "Carica un referto medico o una lettera di dimissione; rivedi i dati estratti prima di salvarli. Disattivato per impostazione predefinita." } }, "illness": { @@ -7671,5 +7676,69 @@ "saveError": "Impossibile salvare la voce." } } + }, + "documents": { + "title": "Documenti in entrata", + "subtitle": "Carica un documento clinico e rivedi i dati che abbiamo letto.", + "disclaimer": { + "title": "Leggiamo, non interpretiamo", + "body": "Trascriviamo solo ciò che il documento indica — valori, codici e date — e non formuliamo diagnosi, non interpretiamo e non segnaliamo nulla. Niente viene salvato finché non lo verifichi e lo approvi. Solo il tuo medico può interpretare questi risultati." + }, + "upload": { + "label": "Carica un documento", + "kindLabel": "Tipo di documento", + "button": "Carica", + "uploading": "Lettura…" + }, + "kind": { + "DOCTOR_REPORT": "Referto medico", + "DISCHARGE_LETTER": "Lettera di dimissione", + "OTHER": "Altro" + }, + "list": { + "empty": "Ancora nessun documento." + }, + "status": { + "EXTRACTING": "Lettura", + "EXTRACTED": "Pronto per la revisione", + "FAILED": "Impossibile leggere", + "CONFIRMED": "Rivisto", + "DISCARDED": "Scartato" + }, + "review": { + "title": "Rivedi i dati estratti", + "discard": "Scarta", + "confirm": "Salva i dati approvati", + "confirming": "Salvataggio…", + "needsReview": "Da rivedere", + "approve": "Approva", + "reject": "Rifiuta", + "edit": "Modifica", + "cancel": "Annulla", + "save": "Salva" + }, + "empty": { + "noFacts": "Niente altro da rivedere." + }, + "factType": { + "CONDITION": "Diagnosi", + "OBSERVATION": "Valore", + "MEDICATION_STATEMENT": "Farmaco" + }, + "fields": { + "label": "Nome", + "value": "Valore", + "unit": "Unità", + "date": "Data", + "dose": "Dose" + }, + "toast": { + "uploaded": "Documento letto. Rivedi i dati qui sotto.", + "uploadFailed": "Impossibile leggere il documento.", + "confirmed": "Dati approvati salvati.", + "confirmFailed": "Impossibile salvare le tue scelte.", + "saved": "Dato aggiornato.", + "discarded": "Documento scartato." + } } } diff --git a/messages/pl.json b/messages/pl.json index 382247918..9a9baaf7e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -239,7 +239,8 @@ "cycle": "Cykl", "labs": "Wyniki badań", "vorsorge": "Badania", - "illness": "Choroba" + "illness": "Choroba", + "documents": "Dokumenty" }, "auth": { "login": "Zaloguj się", @@ -7220,6 +7221,10 @@ "mcp": { "label": "Dostęp asystenta zewnętrznego (MCP)", "description": "Pozwól asystentowi obsługującemu MCP czytać Twoje dane zdrowotne za pomocą tokena o ograniczonym zakresie. Domyślnie wyłączone." + }, + "inboundDocuments": { + "label": "Dokumenty przychodzące", + "description": "Prześlij raport lekarski lub wypis; sprawdź wyodrębnione dane przed zapisaniem. Domyślnie wyłączone." } }, "illness": { @@ -7671,5 +7676,69 @@ "saveError": "Nie udało się zapisać wpisu." } } + }, + "documents": { + "title": "Dokumenty przychodzące", + "subtitle": "Prześlij dokument kliniczny i sprawdź dane, które z niego odczytaliśmy.", + "disclaimer": { + "title": "Odczytujemy, nie interpretujemy", + "body": "Przepisujemy tylko to, co podaje dokument — wartości, kody i daty — i nigdy nie stawiamy diagnozy, nie interpretujemy ani niczego nie oznaczamy. Nic nie zostaje zapisane, dopóki tego nie sprawdzisz i nie zatwierdzisz. Tylko lekarz może zinterpretować te wyniki." + }, + "upload": { + "label": "Prześlij dokument", + "kindLabel": "Typ dokumentu", + "button": "Prześlij", + "uploading": "Odczytywanie…" + }, + "kind": { + "DOCTOR_REPORT": "Raport lekarski", + "DISCHARGE_LETTER": "Wypis", + "OTHER": "Inny" + }, + "list": { + "empty": "Brak dokumentów." + }, + "status": { + "EXTRACTING": "Odczytywanie", + "EXTRACTED": "Gotowe do przeglądu", + "FAILED": "Nie udało się odczytać", + "CONFIRMED": "Sprawdzone", + "DISCARDED": "Odrzucone" + }, + "review": { + "title": "Przejrzyj wyodrębnione dane", + "discard": "Odrzuć", + "confirm": "Zapisz zatwierdzone dane", + "confirming": "Zapisywanie…", + "needsReview": "Wymaga przeglądu", + "approve": "Zatwierdź", + "reject": "Odrzuć", + "edit": "Edytuj", + "cancel": "Anuluj", + "save": "Zapisz" + }, + "empty": { + "noFacts": "Nic więcej do przejrzenia." + }, + "factType": { + "CONDITION": "Diagnoza", + "OBSERVATION": "Wartość", + "MEDICATION_STATEMENT": "Lek" + }, + "fields": { + "label": "Nazwa", + "value": "Wartość", + "unit": "Jednostka", + "date": "Data", + "dose": "Dawka" + }, + "toast": { + "uploaded": "Dokument odczytany. Sprawdź dane poniżej.", + "uploadFailed": "Nie udało się odczytać dokumentu.", + "confirmed": "Zatwierdzone dane zapisane.", + "confirmFailed": "Nie udało się zapisać Twoich decyzji.", + "saved": "Dane zaktualizowane.", + "discarded": "Dokument odrzucony." + } } } diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 6a6edc7d4..02d9d5d9c 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -75,6 +75,7 @@ const openApiBase: Pick< { name: "MeasurementReminders" }, { name: "Labs" }, { name: "Illness" }, + { name: "Documents" }, { name: "Records" }, { name: "Admin" }, { name: "Meta" }, diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts new file mode 100644 index 000000000..4853192b7 --- /dev/null +++ b/src/lib/openapi/routes/documents.ts @@ -0,0 +1,304 @@ +/** + * OpenAPI route table for inbound clinical documents (`/api/documents/inbound/*`). + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The confirm + + * edit request bodies reuse the runtime schemas from + * `@/lib/validations/inbound-documents` so the wire contract stays + * single-source. The upload is a `multipart/form-data` binary body (or a JSON + * text-mode body); the response shapes are declared here. + * + * Safety contract surfaced in the descriptions: extraction reproduces what the + * document states (stated codes/status only) into a staging area — it never + * interprets or diagnoses. Nothing reaches the structured stores until the + * confirm route commits the user's approvals; a low-confidence fact fails + * closed. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { z } from "zod/v4"; + +import { + inboundConfirmSchema, + inboundFactEditSchema, +} from "@/lib/validations/inbound-documents"; + +import { dataEnvelope, stdResponses } from "./shared"; + +inboundConfirmSchema.meta({ + id: "InboundConfirmRequest", + description: + "The approve/reject decisions a human made on the review screen. Each names a staged fact by id. Approved facts are committed to the structured stores (labs / conditions / medications) through their normal create paths; rejected facts are discarded. A fact still flagged `needsReview` (below the confidence floor, never edited) cannot be approved — it is reported back as `needsReview`. No `userId` field — it is narrowed from the session; every fact id is re-scoped to the document + caller.", +}); + +inboundFactEditSchema.meta({ + id: "InboundFactEditRequest", + description: + "A correction to a staged fact before approval (fixes OCR / units / dates / codes). Discriminated by `factType` so the edit cannot change the fact's resource type. A successful edit clears `needsReview` — the values become user-asserted.", +}); + +const factProvenance = z.object({ + sourceText: z.string(), + page: z.number().nullable(), + confidence: z.number(), +}); + +const extractedFact = z + .object({ + id: z.string(), + factType: z.enum(["CONDITION", "OBSERVATION", "MEDICATION_STATEMENT"]), + status: z.enum(["PENDING", "APPROVED", "REJECTED"]), + confidence: z.number(), + needsReview: z.boolean(), + data: z.record(z.string(), z.unknown()), + provenance: factProvenance, + committedRecordId: z.string().nullable(), + committedRecordType: z.string().nullable(), + }) + .meta({ + id: "ExtractedFact", + description: + "One STATED fact transcribed from the document, FHIR-staged (Condition / Observation / MedicationStatement) with per-field provenance + a confidence gate. STATED status only — codes (SNOMED/ICD-10/LOINC/RxNorm/ATC) are present only when the document wrote them; no range-flag, no inferred links. `needsReview` is true when the fact scored below the confidence floor (it cannot be approved until edited).", + }); + +const inboundDocument = z + .object({ + id: z.string(), + kind: z.enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"]), + filename: z.string().nullable(), + mimeType: z.string(), + byteSize: z.number(), + status: z.enum([ + "EXTRACTING", + "EXTRACTED", + "FAILED", + "CONFIRMED", + "DISCARDED", + ]), + providerType: z.string().nullable(), + reportDate: z.string().nullable(), + errorReason: z.string().nullable(), + factCount: z.number(), + pendingCount: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + }) + .meta({ + id: "InboundDocument", + description: + "An uploaded clinical document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary.", + }); + +const inboundDocumentDetail = inboundDocument + .extend({ facts: z.array(extractedFact) }) + .meta({ id: "InboundDocumentDetail" }); + +const listResponse = z + .object({ documents: z.array(inboundDocument) }) + .meta({ id: "InboundDocumentList" }); + +const confirmResponse = z + .object({ + approved: z.array( + z.object({ + factId: z.string(), + recordType: z.string(), + recordId: z.string(), + }), + ), + rejected: z.array(z.string()), + needsReview: z.array(z.string()), + failed: z.array(z.object({ factId: z.string(), reason: z.string() })), + }) + .meta({ + id: "InboundConfirmResponse", + description: + "The outcome per decision: `approved` (committed, with the new record ref), `rejected` (discarded), `needsReview` (refused — edit first, fail-closed), `failed` (a per-fact commit miss, e.g. a numeric observation with no unit).", + }); + +export const inboundDocumentPaths: NonNullable = { + "/api/documents/inbound": { + get: { + tags: ["Documents"], + summary: "List inbound clinical documents", + description: + "The caller's uploaded doctor reports / discharge letters (newest first, live only). Owner-scoped; gated on the opt-in `inboundDocuments` module.", + responses: { + "200": { + description: "The document list.", + content: { + "application/json": { + schema: dataEnvelope(listResponse, "InboundDocumentListEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Documents"], + summary: "Upload + extract a clinical document", + description: + 'Ingests a doctor report / discharge letter through the dedicated OCR/vision provider, stores the raw document ENCRYPTED at rest, and stages the extracted STRUCTURED FACTS for review. Two modes by content-type. VISION (`multipart/form-data`): a `file` (JPEG/PNG/WebP, or PDF on an Anthropic vision provider; ≤ 12 MiB) plus an optional `kind`. TEXT (`application/json`, opt-in local OCR): `{ mode: "text", text, kind? }` — only the extracted text reaches the server. Both modes are AI-consent / rate / budget gated. Extraction reproduces what the document states (stated codes/status only) — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.', + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: z.object({ + file: z.string().meta({ + format: "binary", + description: + "The clinical-document image or PDF. Validated by magic-byte MIME sniff, not the wire Content-Type.", + }), + kind: z + .enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"]) + .optional() + .meta({ description: "Optional document-type label." }), + }), + }, + "application/json": { + schema: z + .object({ + mode: z.literal("text"), + text: z.string(), + kind: z + .enum(["DOCTOR_REPORT", "DISCHARGE_LETTER", "OTHER"]) + .optional(), + }) + .meta({ id: "InboundTextUploadRequest" }), + }, + }, + }, + responses: { + "201": { + description: "The created document + staging summary.", + content: { + "application/json": { + schema: dataEnvelope(inboundDocument, "InboundDocumentEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/documents/inbound/{id}": { + get: { + tags: ["Documents"], + summary: "Get a document + its staged facts", + description: + "The document plus every staged fact for the review screen. Owner-scoped.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Document detail.", + content: { + "application/json": { + schema: dataEnvelope( + inboundDocumentDetail, + "InboundDocumentDetailEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Documents"], + summary: "Discard an inbound document", + description: + "Soft-deletes the document + its staging. Facts already approved into the structured stores are independent rows and are NOT affected.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": { + description: "Discarded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string(), discarded: z.boolean() }), + "InboundDiscardEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/documents/inbound/{id}/facts/{factId}": { + patch: { + tags: ["Documents"], + summary: "Edit a staged fact before approval", + description: + "Corrects OCR / units / dates / codes on a pending fact. Clears `needsReview` (the values become user-asserted). The fact's resource type cannot change.", + parameters: [ + { name: "id", in: "path", required: true, schema: { type: "string" } }, + { + name: "factId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { "application/json": { schema: inboundFactEditSchema } }, + }, + responses: { + "200": { + description: "The updated fact.", + content: { + "application/json": { + schema: dataEnvelope(extractedFact, "ExtractedFactEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/documents/inbound/{id}/confirm": { + post: { + tags: ["Documents"], + summary: "Confirm the review decisions", + description: + "The ONLY write path out of staging. Commits the user's approvals into the structured stores (labs / conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: { + required: true, + content: { "application/json": { schema: inboundConfirmSchema } }, + }, + responses: { + "200": { + description: "Per-decision outcome.", + content: { + "application/json": { + schema: dataEnvelope(confirmResponse, "InboundConfirmEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 12da080ef..1b90a0961 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -34,6 +34,7 @@ import { import { consentPaths } from "./consent"; import { cyclePaths } from "./cycle"; import { biomarkerPaths } from "./biomarkers"; +import { inboundDocumentPaths } from "./documents"; import { devicePaths } from "./devices"; import { exportPaths } from "./export"; import { familyHistoryPaths } from "./family-history"; @@ -89,6 +90,7 @@ export const openApiPaths: NonNullable = { ...mentalHealthPaths, ...allergyPaths, ...familyHistoryPaths, + ...inboundDocumentPaths, ...onboardingPaths, };