mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
feat(documents): review and extract lab values on the document itself
The server chain existed end to end (extract stages PENDING facts, per-fact
edit clears the low-confidence gate, confirm is the only write into the
structured stores) but the document detail offered no control over any of
it: it counted observation facts and linked to Labs. A user whose automatic
staging run was skipped had to re-upload the same PDF through Labs > Scan a
report.
- The detail sheet gains a staged-facts block with honest states: pending
facts open an inline review (approve/reject per fact; a low-confidence
fact is checked and saved first via the existing per-fact edit); a
lab-filed, already-read document with nothing staged offers "Extract lab
values" over its stored text; otherwise nothing renders.
- The extract route gains { mode: "stored" }: the same text-structuring
pass the automatic worker runs, over the document's own content index.
No re-upload, no second read of the original; full gauntlet including
the shared document-AI bucket with charge-at-dispatch honesty; 422
notIndexed when no stored text exists.
- A manual "Read with AI" / "Read again" now continues into the same
lab auto-staging the background worker performs; the response reports
labFactsStaged and the toast names the values waiting for review.
- Unchecked values are rejected and the copy says so; an unverified
low-confidence fact receives no decision and stays pending rather than
being silently discarded. Confirmation remains the only write into
Labs, conditions, and medications; approved facts carry the
committedRecordId link back to the source document (existing model, no
schema change).
- The "lab values from this document" link now counts APPROVED facts
only — it claims values that actually exist in Labs.
- Copy in all six locales; OpenAPI regenerated for the new extract mode
and the index response field.
This commit is contained in:
1 parent
8e16df6894
commit
fd104dd0ac
20 files changed
+1681
-64
No files matched your search
+48
-29
@@ -12453,10 +12453,12 @@ paths:
|
||||
description: "Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the
|
||||
stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider
|
||||
configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only
|
||||
the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original
|
||||
(PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }`
|
||||
structures browser-OCR'd text. Extraction reproduces what the document states — it never interprets. Nothing
|
||||
reaches the structured stores here; the confirm route is the only write path."
|
||||
the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (PDF needs an
|
||||
Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: \"text\", text }` structures
|
||||
browser-OCR'd text; STORED (`application/json`) `{ mode: \"stored\" }` structures the document's own stored
|
||||
extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422
|
||||
`documents.inbound.notIndexed` when no stored text exists. Extraction reproduces what the document states — it
|
||||
never interprets. Nothing reaches the structured stores here; the confirm route is the only write path."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -12468,7 +12470,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InboundTextExtractRequest"
|
||||
$ref: "#/components/schemas/InboundExtractRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: The document with its newly staged facts.
|
||||
@@ -12631,7 +12633,10 @@ paths:
|
||||
gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with
|
||||
no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no
|
||||
plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). Idempotent;
|
||||
re-indexing overwrites in place."
|
||||
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."
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -17106,29 +17111,37 @@ components:
|
||||
required:
|
||||
- mode
|
||||
- text
|
||||
InboundTextExtractRequest:
|
||||
type: object
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
const: text
|
||||
text:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- REFERRAL
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
required:
|
||||
- mode
|
||||
- text
|
||||
InboundExtractRequest:
|
||||
anyOf:
|
||||
- type: object
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
const: text
|
||||
text:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- DOCTOR_REPORT
|
||||
- DISCHARGE_LETTER
|
||||
- LAB_RESULT
|
||||
- IMAGING
|
||||
- PRESCRIPTION
|
||||
- REFERRAL
|
||||
- INSURANCE
|
||||
- VACCINATION
|
||||
- OTHER
|
||||
required:
|
||||
- mode
|
||||
- text
|
||||
- type: object
|
||||
properties:
|
||||
mode:
|
||||
type: string
|
||||
const: stored
|
||||
required:
|
||||
- mode
|
||||
DocumentChatRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -38498,10 +38511,16 @@ components:
|
||||
type: boolean
|
||||
tokenCount:
|
||||
type: number
|
||||
labFactsStaged:
|
||||
type: number
|
||||
description: 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.
|
||||
required:
|
||||
- documentId
|
||||
- indexed
|
||||
- tokenCount
|
||||
- labFactsStaged
|
||||
additionalProperties: false
|
||||
DocumentChatHistoryEnvelope:
|
||||
type: object
|
||||
|
||||
@@ -9567,6 +9567,7 @@
|
||||
"errorBudget": "Dein KI-Budget für heute ist aufgebraucht.",
|
||||
"errorPdfNeedsVision": "Zum Scannen von PDFs wird ein Claude-Vision-Anbieter benötigt.",
|
||||
"errorFileType": "Dieser Dateityp kann nicht gelesen werden.",
|
||||
"errorNotIndexed": "Lies das Dokument zuerst aus, dann extrahiere.",
|
||||
"errorLocalOcrDisabled": "Aktiviere zuerst die lokale Texterkennung in den KI-Einstellungen.",
|
||||
"errorProvider": "Dafür ist kein KI-Anbieter eingerichtet.",
|
||||
"errorTextImageOnly": "Lokale Texterkennung liest nur Bilder — nicht diese Datei."
|
||||
@@ -9580,11 +9581,55 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "Die KI hat dein Dokument gelesen.",
|
||||
"readStagedOne": "Die KI hat dein Dokument gelesen. 1 Laborwert wartet auf deine Prüfung.",
|
||||
"readStagedFew": "Die KI hat dein Dokument gelesen. {count} Laborwerte warten auf deine Prüfung.",
|
||||
"readStagedOther": "Die KI hat dein Dokument gelesen. {count} Laborwerte warten auf deine Prüfung.",
|
||||
"read": "Mit KI auslesen",
|
||||
"reread": "Erneut lesen",
|
||||
"reading": "Liest…",
|
||||
"notReadYet": "Dieses Dokument wurde noch nicht gelesen."
|
||||
},
|
||||
"review": {
|
||||
"title": "Ausgelesene Werte prüfen",
|
||||
"hint": "Bestätige, was mit dem Dokument übereinstimmt. Nicht angehakte Werte werden verworfen; ohne deine Bestätigung wird nichts gespeichert.",
|
||||
"openOne": "Ausgelesenen Wert prüfen",
|
||||
"openFew": "Ausgelesene Werte prüfen ({count})",
|
||||
"openOther": "Ausgelesene Werte prüfen ({count})",
|
||||
"includeAria": "{value} übernehmen",
|
||||
"type": {
|
||||
"OBSERVATION": "Laborwert",
|
||||
"CONDITION": "Erkrankung",
|
||||
"MEDICATION_STATEMENT": "Medikament"
|
||||
},
|
||||
"statedRange": "angegebener Bereich {range}",
|
||||
"needsCheck": "Prüfe diesen Wert gegen das Dokument und speichere ihn dann.",
|
||||
"verify": "Prüfen",
|
||||
"verifySave": "Wert speichern",
|
||||
"verifyInvalid": "Gib einen Namen und entweder einen Zahlenwert oder ein Textergebnis an, nicht beides.",
|
||||
"verifyFailed": "Die Korrektur konnte nicht gespeichert werden. Versuche es erneut.",
|
||||
"fieldName": "Name",
|
||||
"fieldDate": "Datum",
|
||||
"fieldValue": "Wert",
|
||||
"fieldValueText": "Ergebnis (Text)",
|
||||
"fieldUnit": "Einheit",
|
||||
"fieldDose": "Dosis",
|
||||
"unverifiedNoteOne": "1 Wert muss noch geprüft werden und bleibt offen.",
|
||||
"unverifiedNoteFew": "{count} Werte müssen noch geprüft werden und bleiben offen.",
|
||||
"unverifiedNoteOther": "{count} Werte müssen noch geprüft werden und bleiben offen.",
|
||||
"finish": "Prüfung abschließen",
|
||||
"confirmFailed": "Die Prüfung konnte nicht gespeichert werden. Versuche es erneut.",
|
||||
"savedToastOne": "1 Wert gespeichert.",
|
||||
"savedToastFew": "{count} Werte gespeichert.",
|
||||
"savedToastOther": "{count} Werte gespeichert.",
|
||||
"nothingSavedToast": "Prüfung gespeichert. Es wurden keine Werte übernommen.",
|
||||
"extract": "Laborwerte auslesen",
|
||||
"extracting": "Liest aus…",
|
||||
"extractHint": "Nutzt den bereits ausgelesenen Text dieses Dokuments. Ausgelesene Werte warten auf deine Prüfung; nichts wird automatisch gespeichert.",
|
||||
"extractEmpty": "Aus diesem Dokument konnten keine Werte ausgelesen werden.",
|
||||
"failUnitRequired": "Ein Zahlenwert braucht eine Einheit, bevor er gespeichert werden kann.",
|
||||
"failUnitMismatch": "Die angegebene Einheit passt nicht zur gespeicherten Einheit dieses Markers. Passe sie an und schließe die Prüfung erneut ab.",
|
||||
"failGeneric": "Dieser Wert konnte nicht gespeichert werden."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
|
||||
"indexAll": "Alle für Suche indizieren",
|
||||
|
||||
@@ -9567,6 +9567,7 @@
|
||||
"errorBudget": "Your AI budget for today is used up.",
|
||||
"errorPdfNeedsVision": "Scanning PDFs needs a Claude vision provider.",
|
||||
"errorFileType": "This file type can't be read.",
|
||||
"errorNotIndexed": "Read the document first, then extract.",
|
||||
"errorLocalOcrDisabled": "Turn on local OCR in the AI settings first.",
|
||||
"errorProvider": "No AI provider is set up for this.",
|
||||
"errorTextImageOnly": "Local OCR reads images only — not this file."
|
||||
@@ -9580,11 +9581,55 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "The AI read your document.",
|
||||
"readStagedOne": "The AI read your document. 1 lab value is ready to review.",
|
||||
"readStagedFew": "The AI read your document. {count} lab values are ready to review.",
|
||||
"readStagedOther": "The AI read your document. {count} lab values are ready to review.",
|
||||
"read": "Read with AI",
|
||||
"reread": "Read again",
|
||||
"reading": "Reading…",
|
||||
"notReadYet": "This document has not been read yet."
|
||||
},
|
||||
"review": {
|
||||
"title": "Review extracted values",
|
||||
"hint": "Approve what matches the document. Unchecked values are discarded; nothing is saved without your confirmation.",
|
||||
"openOne": "Review extracted value",
|
||||
"openFew": "Review extracted values ({count})",
|
||||
"openOther": "Review extracted values ({count})",
|
||||
"includeAria": "Include {value}",
|
||||
"type": {
|
||||
"OBSERVATION": "Lab value",
|
||||
"CONDITION": "Condition",
|
||||
"MEDICATION_STATEMENT": "Medication"
|
||||
},
|
||||
"statedRange": "stated range {range}",
|
||||
"needsCheck": "Check this value against the document, then save it.",
|
||||
"verify": "Check",
|
||||
"verifySave": "Save value",
|
||||
"verifyInvalid": "Enter a name and either a numeric value or a text result, not both.",
|
||||
"verifyFailed": "The correction couldn't be saved. Try again.",
|
||||
"fieldName": "Name",
|
||||
"fieldDate": "Date",
|
||||
"fieldValue": "Value",
|
||||
"fieldValueText": "Result (text)",
|
||||
"fieldUnit": "Unit",
|
||||
"fieldDose": "Dose",
|
||||
"unverifiedNoteOne": "1 value still needs checking and stays pending.",
|
||||
"unverifiedNoteFew": "{count} values still need checking and stay pending.",
|
||||
"unverifiedNoteOther": "{count} values still need checking and stay pending.",
|
||||
"finish": "Finish review",
|
||||
"confirmFailed": "The review couldn't be saved. Try again.",
|
||||
"savedToastOne": "1 value saved.",
|
||||
"savedToastFew": "{count} values saved.",
|
||||
"savedToastOther": "{count} values saved.",
|
||||
"nothingSavedToast": "Review saved. No values were added.",
|
||||
"extract": "Extract lab values",
|
||||
"extracting": "Extracting…",
|
||||
"extractHint": "Uses the text already read from this document. Extracted values wait for your review; nothing is saved automatically.",
|
||||
"extractEmpty": "No values could be extracted from this document.",
|
||||
"failUnitRequired": "A numeric value needs a unit before it can be saved.",
|
||||
"failUnitMismatch": "The stated unit does not match the saved unit for this marker. Adjust it, then finish the review again.",
|
||||
"failGeneric": "This value could not be saved."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Make document contents searchable.",
|
||||
"indexAll": "Index all for search",
|
||||
|
||||
@@ -9567,6 +9567,7 @@
|
||||
"errorBudget": "Tu presupuesto de IA de hoy se ha agotado.",
|
||||
"errorPdfNeedsVision": "Escanear PDF requiere un proveedor de visión Claude.",
|
||||
"errorFileType": "Este tipo de archivo no se puede leer.",
|
||||
"errorNotIndexed": "Primero lee el documento y luego extrae.",
|
||||
"errorLocalOcrDisabled": "Activa primero el OCR local en los ajustes de IA.",
|
||||
"errorProvider": "No hay ningún proveedor de IA configurado para esto.",
|
||||
"errorTextImageOnly": "El OCR local solo lee imágenes, no este archivo."
|
||||
@@ -9580,11 +9581,55 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "La IA leyó tu documento.",
|
||||
"readStagedOne": "La IA leyó tu documento. 1 valor de laboratorio espera tu revisión.",
|
||||
"readStagedFew": "La IA leyó tu documento. {count} valores de laboratorio esperan tu revisión.",
|
||||
"readStagedOther": "La IA leyó tu documento. {count} valores de laboratorio esperan tu revisión.",
|
||||
"read": "Leer con IA",
|
||||
"reread": "Leer de nuevo",
|
||||
"reading": "Leyendo…",
|
||||
"notReadYet": "Este documento aún no se ha leído."
|
||||
},
|
||||
"review": {
|
||||
"title": "Revisar valores extraídos",
|
||||
"hint": "Aprueba lo que coincida con el documento. Los valores sin marcar se descartan; nada se guarda sin tu confirmación.",
|
||||
"openOne": "Revisar valor extraído",
|
||||
"openFew": "Revisar valores extraídos ({count})",
|
||||
"openOther": "Revisar valores extraídos ({count})",
|
||||
"includeAria": "Incluir {value}",
|
||||
"type": {
|
||||
"OBSERVATION": "Valor de laboratorio",
|
||||
"CONDITION": "Afección",
|
||||
"MEDICATION_STATEMENT": "Medicamento"
|
||||
},
|
||||
"statedRange": "rango indicado {range}",
|
||||
"needsCheck": "Comprueba este valor contra el documento y luego guárdalo.",
|
||||
"verify": "Comprobar",
|
||||
"verifySave": "Guardar valor",
|
||||
"verifyInvalid": "Indica un nombre y un valor numérico o un resultado en texto, no ambos.",
|
||||
"verifyFailed": "No se pudo guardar la corrección. Inténtalo de nuevo.",
|
||||
"fieldName": "Nombre",
|
||||
"fieldDate": "Fecha",
|
||||
"fieldValue": "Valor",
|
||||
"fieldValueText": "Resultado (texto)",
|
||||
"fieldUnit": "Unidad",
|
||||
"fieldDose": "Dosis",
|
||||
"unverifiedNoteOne": "1 valor aún necesita comprobación y queda pendiente.",
|
||||
"unverifiedNoteFew": "{count} valores aún necesitan comprobación y quedan pendientes.",
|
||||
"unverifiedNoteOther": "{count} valores aún necesitan comprobación y quedan pendientes.",
|
||||
"finish": "Terminar revisión",
|
||||
"confirmFailed": "No se pudo guardar la revisión. Inténtalo de nuevo.",
|
||||
"savedToastOne": "1 valor guardado.",
|
||||
"savedToastFew": "{count} valores guardados.",
|
||||
"savedToastOther": "{count} valores guardados.",
|
||||
"nothingSavedToast": "Revisión guardada. No se añadió ningún valor.",
|
||||
"extract": "Extraer valores de laboratorio",
|
||||
"extracting": "Extrayendo…",
|
||||
"extractHint": "Usa el texto ya leído de este documento. Los valores extraídos esperan tu revisión; nada se guarda automáticamente.",
|
||||
"extractEmpty": "No se pudieron extraer valores de este documento.",
|
||||
"failUnitRequired": "Un valor numérico necesita una unidad antes de poder guardarse.",
|
||||
"failUnitMismatch": "La unidad indicada no coincide con la unidad guardada de este marcador. Ajústala y termina la revisión de nuevo.",
|
||||
"failGeneric": "Este valor no se pudo guardar."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
|
||||
"indexAll": "Indexar todo para búsqueda",
|
||||
|
||||
@@ -9567,6 +9567,7 @@
|
||||
"errorBudget": "Votre budget IA du jour est épuisé.",
|
||||
"errorPdfNeedsVision": "La lecture des PDF nécessite un fournisseur de vision Claude.",
|
||||
"errorFileType": "Ce type de fichier ne peut pas être lu.",
|
||||
"errorNotIndexed": "Lisez d’abord le document, puis extrayez.",
|
||||
"errorLocalOcrDisabled": "Activez d'abord l'OCR local dans les paramètres d'IA.",
|
||||
"errorProvider": "Aucun fournisseur d'IA n'est configuré pour cela.",
|
||||
"errorTextImageOnly": "L'OCR local ne lit que les images, pas ce fichier."
|
||||
@@ -9580,11 +9581,55 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "L’IA a lu votre document.",
|
||||
"readStagedOne": "L’IA a lu votre document. 1 valeur de laboratoire attend votre vérification.",
|
||||
"readStagedFew": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.",
|
||||
"readStagedOther": "L’IA a lu votre document. {count} valeurs de laboratoire attendent votre vérification.",
|
||||
"read": "Lire avec l’IA",
|
||||
"reread": "Relire",
|
||||
"reading": "Lecture…",
|
||||
"notReadYet": "Ce document n’a pas encore été lu."
|
||||
},
|
||||
"review": {
|
||||
"title": "Vérifier les valeurs extraites",
|
||||
"hint": "Approuvez ce qui correspond au document. Les valeurs non cochées sont écartées ; rien n’est enregistré sans votre confirmation.",
|
||||
"openOne": "Vérifier la valeur extraite",
|
||||
"openFew": "Vérifier les valeurs extraites ({count})",
|
||||
"openOther": "Vérifier les valeurs extraites ({count})",
|
||||
"includeAria": "Inclure {value}",
|
||||
"type": {
|
||||
"OBSERVATION": "Valeur de laboratoire",
|
||||
"CONDITION": "Affection",
|
||||
"MEDICATION_STATEMENT": "Médicament"
|
||||
},
|
||||
"statedRange": "plage indiquée {range}",
|
||||
"needsCheck": "Vérifiez cette valeur par rapport au document, puis enregistrez-la.",
|
||||
"verify": "Vérifier",
|
||||
"verifySave": "Enregistrer la valeur",
|
||||
"verifyInvalid": "Indiquez un nom et soit une valeur numérique, soit un résultat en texte, pas les deux.",
|
||||
"verifyFailed": "La correction n’a pas pu être enregistrée. Réessayez.",
|
||||
"fieldName": "Nom",
|
||||
"fieldDate": "Date",
|
||||
"fieldValue": "Valeur",
|
||||
"fieldValueText": "Résultat (texte)",
|
||||
"fieldUnit": "Unité",
|
||||
"fieldDose": "Dose",
|
||||
"unverifiedNoteOne": "1 valeur reste à vérifier et demeure en attente.",
|
||||
"unverifiedNoteFew": "{count} valeurs restent à vérifier et demeurent en attente.",
|
||||
"unverifiedNoteOther": "{count} valeurs restent à vérifier et demeurent en attente.",
|
||||
"finish": "Terminer la vérification",
|
||||
"confirmFailed": "La vérification n’a pas pu être enregistrée. Réessayez.",
|
||||
"savedToastOne": "1 valeur enregistrée.",
|
||||
"savedToastFew": "{count} valeurs enregistrées.",
|
||||
"savedToastOther": "{count} valeurs enregistrées.",
|
||||
"nothingSavedToast": "Vérification enregistrée. Aucune valeur n’a été ajoutée.",
|
||||
"extract": "Extraire les valeurs de laboratoire",
|
||||
"extracting": "Extraction…",
|
||||
"extractHint": "Utilise le texte déjà lu de ce document. Les valeurs extraites attendent votre vérification ; rien n’est enregistré automatiquement.",
|
||||
"extractEmpty": "Aucune valeur n’a pu être extraite de ce document.",
|
||||
"failUnitRequired": "Une valeur numérique a besoin d’une unité avant de pouvoir être enregistrée.",
|
||||
"failUnitMismatch": "L’unité indiquée ne correspond pas à l’unité enregistrée pour ce marqueur. Ajustez-la, puis terminez à nouveau la vérification.",
|
||||
"failGeneric": "Cette valeur n’a pas pu être enregistrée."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Rendre le contenu des documents consultable.",
|
||||
"indexAll": "Tout indexer pour la recherche",
|
||||
|
||||
@@ -9567,6 +9567,7 @@
|
||||
"errorBudget": "Il tuo budget IA di oggi è esaurito.",
|
||||
"errorPdfNeedsVision": "La scansione dei PDF richiede un provider di visione Claude.",
|
||||
"errorFileType": "Questo tipo di file non può essere letto.",
|
||||
"errorNotIndexed": "Leggi prima il documento, poi estrai.",
|
||||
"errorLocalOcrDisabled": "Attiva prima l'OCR locale nelle impostazioni IA.",
|
||||
"errorProvider": "Nessun provider IA è configurato per questo.",
|
||||
"errorTextImageOnly": "L'OCR locale legge solo immagini, non questo file."
|
||||
@@ -9580,11 +9581,55 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "L’IA ha letto il tuo documento.",
|
||||
"readStagedOne": "L’IA ha letto il tuo documento. 1 valore di laboratorio attende la tua revisione.",
|
||||
"readStagedFew": "L’IA ha letto il tuo documento. {count} valori di laboratorio attendono la tua revisione.",
|
||||
"readStagedOther": "L’IA ha letto il tuo documento. {count} valori di laboratorio attendono la tua revisione.",
|
||||
"read": "Leggi con l’IA",
|
||||
"reread": "Leggi di nuovo",
|
||||
"reading": "Lettura…",
|
||||
"notReadYet": "Questo documento non è ancora stato letto."
|
||||
},
|
||||
"review": {
|
||||
"title": "Rivedi i valori estratti",
|
||||
"hint": "Approva ciò che corrisponde al documento. I valori non selezionati vengono scartati; nulla viene salvato senza la tua conferma.",
|
||||
"openOne": "Rivedi il valore estratto",
|
||||
"openFew": "Rivedi i valori estratti ({count})",
|
||||
"openOther": "Rivedi i valori estratti ({count})",
|
||||
"includeAria": "Includi {value}",
|
||||
"type": {
|
||||
"OBSERVATION": "Valore di laboratorio",
|
||||
"CONDITION": "Condizione",
|
||||
"MEDICATION_STATEMENT": "Farmaco"
|
||||
},
|
||||
"statedRange": "intervallo indicato {range}",
|
||||
"needsCheck": "Controlla questo valore rispetto al documento, poi salvalo.",
|
||||
"verify": "Controlla",
|
||||
"verifySave": "Salva valore",
|
||||
"verifyInvalid": "Indica un nome e un valore numerico oppure un risultato testuale, non entrambi.",
|
||||
"verifyFailed": "La correzione non è stata salvata. Riprova.",
|
||||
"fieldName": "Nome",
|
||||
"fieldDate": "Data",
|
||||
"fieldValue": "Valore",
|
||||
"fieldValueText": "Risultato (testo)",
|
||||
"fieldUnit": "Unità",
|
||||
"fieldDose": "Dose",
|
||||
"unverifiedNoteOne": "1 valore deve ancora essere controllato e resta in sospeso.",
|
||||
"unverifiedNoteFew": "{count} valori devono ancora essere controllati e restano in sospeso.",
|
||||
"unverifiedNoteOther": "{count} valori devono ancora essere controllati e restano in sospeso.",
|
||||
"finish": "Concludi la revisione",
|
||||
"confirmFailed": "La revisione non è stata salvata. Riprova.",
|
||||
"savedToastOne": "1 valore salvato.",
|
||||
"savedToastFew": "{count} valori salvati.",
|
||||
"savedToastOther": "{count} valori salvati.",
|
||||
"nothingSavedToast": "Revisione salvata. Nessun valore è stato aggiunto.",
|
||||
"extract": "Estrai i valori di laboratorio",
|
||||
"extracting": "Estrazione…",
|
||||
"extractHint": "Usa il testo già letto da questo documento. I valori estratti attendono la tua revisione; nulla viene salvato automaticamente.",
|
||||
"extractEmpty": "Non è stato possibile estrarre valori da questo documento.",
|
||||
"failUnitRequired": "Un valore numerico ha bisogno di un’unità prima di poter essere salvato.",
|
||||
"failUnitMismatch": "L’unità indicata non corrisponde all’unità salvata per questo marcatore. Correggila e concludi di nuovo la revisione.",
|
||||
"failGeneric": "Questo valore non è stato salvato."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
|
||||
"indexAll": "Indicizza tutto per la ricerca",
|
||||
|
||||
@@ -9567,6 +9567,7 @@
|
||||
"errorBudget": "Twój dzisiejszy budżet AI został wyczerpany.",
|
||||
"errorPdfNeedsVision": "Skanowanie plików PDF wymaga dostawcy wizji Claude.",
|
||||
"errorFileType": "Tego typu pliku nie można odczytać.",
|
||||
"errorNotIndexed": "Najpierw odczytaj dokument, potem wyodrębnij.",
|
||||
"errorLocalOcrDisabled": "Najpierw włącz lokalne OCR w ustawieniach AI.",
|
||||
"errorProvider": "Nie skonfigurowano dostawcy AI do tego celu.",
|
||||
"errorTextImageOnly": "Lokalne OCR odczytuje tylko obrazy, nie ten plik."
|
||||
@@ -9580,11 +9581,55 @@
|
||||
},
|
||||
"ai": {
|
||||
"readDone": "AI odczytało Twój dokument.",
|
||||
"readStagedOne": "AI odczytało Twój dokument. 1 wynik laboratoryjny czeka na Twoją weryfikację.",
|
||||
"readStagedFew": "AI odczytało Twój dokument. {count} wyniki laboratoryjne czekają na Twoją weryfikację.",
|
||||
"readStagedOther": "AI odczytało Twój dokument. {count} wyników laboratoryjnych czeka na Twoją weryfikację.",
|
||||
"read": "Odczytaj z AI",
|
||||
"reread": "Odczytaj ponownie",
|
||||
"reading": "Odczytywanie…",
|
||||
"notReadYet": "Ten dokument nie został jeszcze odczytany."
|
||||
},
|
||||
"review": {
|
||||
"title": "Przejrzyj odczytane wartości",
|
||||
"hint": "Zatwierdź to, co zgadza się z dokumentem. Niezaznaczone wartości zostaną odrzucone; nic nie zapisuje się bez Twojego potwierdzenia.",
|
||||
"openOne": "Przejrzyj odczytaną wartość",
|
||||
"openFew": "Przejrzyj odczytane wartości ({count})",
|
||||
"openOther": "Przejrzyj odczytane wartości ({count})",
|
||||
"includeAria": "Uwzględnij {value}",
|
||||
"type": {
|
||||
"OBSERVATION": "Wynik laboratoryjny",
|
||||
"CONDITION": "Schorzenie",
|
||||
"MEDICATION_STATEMENT": "Lek"
|
||||
},
|
||||
"statedRange": "podany zakres {range}",
|
||||
"needsCheck": "Sprawdź tę wartość z dokumentem, a potem ją zapisz.",
|
||||
"verify": "Sprawdź",
|
||||
"verifySave": "Zapisz wartość",
|
||||
"verifyInvalid": "Podaj nazwę oraz wartość liczbową albo wynik tekstowy, nie oba naraz.",
|
||||
"verifyFailed": "Nie udało się zapisać poprawki. Spróbuj ponownie.",
|
||||
"fieldName": "Nazwa",
|
||||
"fieldDate": "Data",
|
||||
"fieldValue": "Wartość",
|
||||
"fieldValueText": "Wynik (tekst)",
|
||||
"fieldUnit": "Jednostka",
|
||||
"fieldDose": "Dawka",
|
||||
"unverifiedNoteOne": "1 wartość wymaga jeszcze sprawdzenia i pozostaje w oczekiwaniu.",
|
||||
"unverifiedNoteFew": "{count} wartości wymagają jeszcze sprawdzenia i pozostają w oczekiwaniu.",
|
||||
"unverifiedNoteOther": "{count} wartości wymaga jeszcze sprawdzenia i pozostaje w oczekiwaniu.",
|
||||
"finish": "Zakończ przegląd",
|
||||
"confirmFailed": "Nie udało się zapisać przeglądu. Spróbuj ponownie.",
|
||||
"savedToastOne": "Zapisano 1 wartość.",
|
||||
"savedToastFew": "Zapisano {count} wartości.",
|
||||
"savedToastOther": "Zapisano {count} wartości.",
|
||||
"nothingSavedToast": "Przegląd zapisany. Nie dodano żadnych wartości.",
|
||||
"extract": "Odczytaj wyniki laboratoryjne",
|
||||
"extracting": "Odczytywanie…",
|
||||
"extractHint": "Korzysta z tekstu już odczytanego z tego dokumentu. Odczytane wartości czekają na Twoją weryfikację; nic nie zapisuje się automatycznie.",
|
||||
"extractEmpty": "Z tego dokumentu nie udało się odczytać żadnych wartości.",
|
||||
"failUnitRequired": "Wartość liczbowa potrzebuje jednostki, zanim będzie można ją zapisać.",
|
||||
"failUnitMismatch": "Podana jednostka nie zgadza się z zapisaną jednostką tego wskaźnika. Popraw ją i zakończ przegląd ponownie.",
|
||||
"failGeneric": "Nie udało się zapisać tej wartości."
|
||||
},
|
||||
"contentIndex": {
|
||||
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
|
||||
"indexAll": "Zindeksuj wszystko do wyszukiwania",
|
||||
|
||||
@@ -8,16 +8,31 @@ import { NextRequest } from "next/server";
|
||||
* 422s on the ENHANCEMENT only — the already-stored document is left intact
|
||||
* (no fact-staging transaction, no status flip). This is the inverse of the
|
||||
* old behaviour where the absence of a provider blocked the upload itself.
|
||||
*
|
||||
* STORED mode — `{ mode: "stored" }` structures the document's OWN stored
|
||||
* extracted text (its content index) into staged facts: the manual recovery
|
||||
* for a skipped/failed automatic staging run. Pins that the stored text is
|
||||
* the input (the encrypted original is never touched — no re-upload), that
|
||||
* no stored text 422s (`documents.inbound.notIndexed`) before any bucket
|
||||
* charge, that the shared document-AI bucket applies, and that the
|
||||
* approved-facts re-extraction refusal covers this mode too.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { deleteMany: vi.fn(), count: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/db", () => {
|
||||
const tx = {
|
||||
extractedFact: { deleteMany: vi.fn() },
|
||||
inboundDocument: { update: vi.fn(), findUniqueOrThrow: vi.fn() },
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
extractedFact: { deleteMany: vi.fn(), count: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
__tx: tx,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: vi.fn((s: string) => `v1.${s}`),
|
||||
@@ -28,6 +43,19 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn(() => "{}"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
|
||||
encryptFactData: vi.fn(() => new Uint8Array([1])),
|
||||
encryptFactProvenance: vi.fn(() => new Uint8Array([2])),
|
||||
serialiseDocumentDetail: vi.fn(() => ({ id: "doc-1", facts: [] })),
|
||||
}));
|
||||
vi.mock("@/lib/documents/content-index", () => ({
|
||||
loadDocumentChatText: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
resolveDocumentTextProvider: vi.fn(),
|
||||
@@ -54,6 +82,7 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 5, resetAt: Date.now() }),
|
||||
refundRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
@@ -76,8 +105,28 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { runInboundExtraction } from "@/lib/documents/extract";
|
||||
import { decryptDocumentContent } from "@/lib/documents/store";
|
||||
import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
} from "@/lib/documents/provider-order";
|
||||
|
||||
const tx = (
|
||||
prisma as unknown as {
|
||||
__tx: {
|
||||
extractedFact: { deleteMany: ReturnType<typeof vi.fn> };
|
||||
inboundDocument: {
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
findUniqueOrThrow: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
}
|
||||
).__tx;
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -94,6 +143,17 @@ function visionReq(id: string): NextRequest {
|
||||
);
|
||||
}
|
||||
|
||||
function storedReq(id: string): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}/extract`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "stored" }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
@@ -106,6 +166,11 @@ beforeEach(() => {
|
||||
status: "STORED",
|
||||
} as never);
|
||||
vi.mocked(prisma.extractedFact.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 5,
|
||||
resetAt: Date.now() + 60 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract", () => {
|
||||
@@ -161,3 +226,96 @@ describe("POST /api/documents/inbound/[id]/extract", () => {
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/extract — stored mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadDocumentChatText).mockResolvedValue({
|
||||
text: "Hemoglobin 13.9 g/dL reference range 12-16",
|
||||
source: "verbatim",
|
||||
} as never);
|
||||
vi.mocked(resolveDocumentTextProvider).mockResolvedValue({
|
||||
pick: {
|
||||
providerType: "anthropic",
|
||||
entry: { providerType: "anthropic", instance: {} },
|
||||
},
|
||||
} as never);
|
||||
vi.mocked(runInboundExtraction).mockResolvedValue({
|
||||
providerType: "anthropic",
|
||||
reportDate: "2026-08-01",
|
||||
facts: [
|
||||
{
|
||||
factType: "OBSERVATION",
|
||||
confidence: 0.95,
|
||||
needsReview: false,
|
||||
data: { label: "Hemoglobin", value: 13.9, unit: "g/dL" },
|
||||
provenance: {
|
||||
sourceText: "",
|
||||
anchored: false,
|
||||
sourceOffset: null,
|
||||
page: null,
|
||||
confidence: 0.95,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
tx.inboundDocument.findUniqueOrThrow.mockResolvedValue({
|
||||
id: "doc-1",
|
||||
facts: [{ id: "fact-1" }],
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("structures the stored text and stages PENDING facts, never touching the original", async () => {
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
// The stored extracted text is the input…
|
||||
expect(runInboundExtraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ocrText: "Hemoglobin 13.9 g/dL reference range 12-16",
|
||||
}),
|
||||
);
|
||||
// …and the encrypted original is never read — no re-upload, no re-scan.
|
||||
expect(decryptDocumentContent).not.toHaveBeenCalled();
|
||||
// Staged through the guarded transaction (prior PENDING leftovers cleared,
|
||||
// status flipped) — nothing here writes a structured store.
|
||||
expect(tx.extractedFact.deleteMany).toHaveBeenCalled();
|
||||
expect(tx.inboundDocument.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ status: "EXTRACTED" }),
|
||||
}),
|
||||
);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"documents.inbound.extract",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ mode: "stored" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("422s (notIndexed) without stored text, before any bucket charge", async () => {
|
||||
vi.mocked(loadDocumentChatText).mockResolvedValue(null as never);
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(body.meta?.errorCode).toBe("documents.inbound.notIndexed");
|
||||
expect(checkRateLimit).not.toHaveBeenCalled();
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("consumes the shared document-AI bucket honestly", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30 * 60_000,
|
||||
});
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(429);
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses once any fact is APPROVED, like every other extract mode", async () => {
|
||||
vi.mocked(prisma.extractedFact.count).mockResolvedValue(1 as never);
|
||||
const res = await POST(storedReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(409);
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,14 @@
|
||||
* facts — but now against a row that already exists. Absent a provider this 422s
|
||||
* the ENHANCEMENT only; the stored document is untouched and remains filed.
|
||||
*
|
||||
* Two modes, dispatched on content-type (mirroring the original upload):
|
||||
* Three modes:
|
||||
* - VISION (no JSON body): decrypt the stored original, re-derive its MIME,
|
||||
* and run the vision-capable provider over it.
|
||||
* - TEXT (application/json, opt-in local OCR): `{ mode: "text", text }` — the
|
||||
* browser OCR'd the document locally and posts only the text to structure.
|
||||
* - STORED (application/json): `{ mode: "stored" }` — structure the
|
||||
* document's own stored extracted text (its content index), the manual
|
||||
* recovery for a skipped/failed automatic staging run. No re-upload.
|
||||
*
|
||||
* The document is UNTRUSTED (prompt-injection): the server never acts on an
|
||||
* instruction inside it. The staged facts land PENDING for the mandatory
|
||||
@@ -61,10 +64,14 @@ import {
|
||||
DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
refundDocumentAiSlot,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { detectOcrMimeType } from "@/lib/labs/ocr-upload";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { inboundTextExtractSchema } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
inboundStoredExtractSchema,
|
||||
inboundTextExtractSchema,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -177,7 +184,16 @@ export const POST = apiHandler(
|
||||
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return handleTextExtract(request, user.id, document);
|
||||
// One body read for both JSON modes, BEFORE any bucket charge — a
|
||||
// malformed body never needs a refund because nothing was charged yet.
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
if (inboundStoredExtractSchema.safeParse(body).success) {
|
||||
return handleStoredExtract(request, user.id, document);
|
||||
}
|
||||
return handleTextExtract(request, user.id, document, body);
|
||||
}
|
||||
return handleVisionExtract(request, user.id, document);
|
||||
},
|
||||
@@ -188,6 +204,7 @@ async function handleTextExtract(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
body: unknown,
|
||||
): Promise<Response> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -216,14 +233,6 @@ async function handleTextExtract(
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: DOCUMENT_AI_TEXT_BODY_MAX_BYTES,
|
||||
});
|
||||
if (jsonError) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return jsonError;
|
||||
}
|
||||
|
||||
const parsed = inboundTextExtractSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
@@ -289,6 +298,103 @@ async function handleTextExtract(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STORED mode — structure the document's OWN stored extracted text (the
|
||||
* content index the read/index step produced) into staged facts.
|
||||
*
|
||||
* This is the manual recovery for a skipped or failed automatic staging run:
|
||||
* the same text-structuring pass the auto worker performs, but user-triggered
|
||||
* from the document detail. No re-upload, no second read of the original, no
|
||||
* browser OCR — so the local-OCR opt-in is not required here. Everything else
|
||||
* runs the full gauntlet: provider resolve → consent → rate limit (charged
|
||||
* only when the provider is actually dispatched) → budget → stage PENDING for
|
||||
* the mandatory review-then-confirm step. Nothing is committed here.
|
||||
*/
|
||||
async function handleStoredExtract(
|
||||
request: NextRequest,
|
||||
userId: string,
|
||||
document: LoadedDocument,
|
||||
): Promise<Response> {
|
||||
const chat = await loadDocumentChatText(userId, document.id);
|
||||
if (!chat || !chat.text.trim()) {
|
||||
return apiError("Read the document first, then extract.", 422, {
|
||||
errorCode: "documents.inbound.notIndexed",
|
||||
});
|
||||
}
|
||||
|
||||
const { pick } = await resolveDocumentTextProvider(userId);
|
||||
if (!pick) {
|
||||
return apiError("No AI provider is configured", 422, {
|
||||
errorCode: "documents.inbound.providerUnsupported",
|
||||
});
|
||||
}
|
||||
|
||||
await assertDocumentEgressConsent({
|
||||
userId,
|
||||
providerType: pick.providerType,
|
||||
surface: "insights",
|
||||
});
|
||||
|
||||
const rl = await checkDocumentAiRateLimit(userId);
|
||||
if (!rl.allowed) return documentAiRateLimited(rl);
|
||||
|
||||
const dateKey = buildDateKey();
|
||||
const reservation = await reserveBudget(
|
||||
userId,
|
||||
AI_BUDGETS.ocrExtractText.maxTokens,
|
||||
dateKey,
|
||||
resolveDailyCap([{ providerType: pick.entry.providerType }]),
|
||||
);
|
||||
if (!reservation.allowed) {
|
||||
await refundDocumentAiSlot(userId);
|
||||
return apiError("Your AI usage budget for today is reached.", 429, {
|
||||
errorCode: "documents.inbound.budgetExceeded",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runInboundExtraction({
|
||||
provider: pick.entry.instance,
|
||||
providerType: pick.providerType,
|
||||
ocrText: chat.text,
|
||||
});
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
reservation.reserved,
|
||||
reservation.reserved,
|
||||
dateKey,
|
||||
);
|
||||
|
||||
const updated = await stageExtraction(document.id, userId, result);
|
||||
|
||||
await auditLog("documents.inbound.extract", {
|
||||
userId,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
documentId: document.id,
|
||||
facts: updated.facts.length,
|
||||
mode: "stored",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(serialiseDocumentDetail(updated, updated.facts));
|
||||
} catch (err) {
|
||||
await reconcileSpend(userId, reservation.reserved, 0, dateKey);
|
||||
if (err instanceof InboundExtractError) {
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 422, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.inbound.extractFailed" },
|
||||
meta: { reason: "provider_error", mode: "stored" },
|
||||
});
|
||||
return apiError("Couldn't read the document. Try a clearer copy.", 502, {
|
||||
errorCode: "documents.inbound.extractFailed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** VISION mode — run the stored original through the vision provider. */
|
||||
async function handleVisionExtract(
|
||||
request: NextRequest,
|
||||
|
||||
@@ -38,6 +38,11 @@ vi.mock("@/lib/documents/content-index", () => ({
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
resolveDocumentVisionProvider: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/documents/auto-stage-labs", () => ({
|
||||
maybeAutoStageLabFacts: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ staged: false, reason: "not-lab" }),
|
||||
}));
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
assertDocumentEgressConsent: vi.fn().mockResolvedValue(undefined),
|
||||
ConsentRequiredError: class ConsentRequiredError extends Error {},
|
||||
@@ -79,6 +84,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
|
||||
import { checkRateLimit, refundRateLimit } from "@/lib/rate-limit";
|
||||
import { reserveBudget } from "@/lib/ai/coach/budget";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
@@ -260,3 +266,54 @@ describe("POST /api/documents/inbound/[id]/index — bucket honesty", () => {
|
||||
expect(refundRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/index — staging continuation", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue(PICK as never);
|
||||
vi.mocked(transcribeDocument).mockResolvedValue({
|
||||
text: "hemoglobin 14.2 reference range 12-16",
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("a manual read continues into the same lab staging the auto worker performs", async () => {
|
||||
vi.mocked(maybeAutoStageLabFacts).mockResolvedValue({
|
||||
staged: true,
|
||||
facts: 4,
|
||||
} as never);
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
expect(maybeAutoStageLabFacts).toHaveBeenCalledWith("user-1", "doc-1");
|
||||
const body = await res.json();
|
||||
expect(body.data.labFactsStaged).toBe(4);
|
||||
});
|
||||
|
||||
it("reports zero staged facts as an honest zero, never an error", async () => {
|
||||
vi.mocked(maybeAutoStageLabFacts).mockResolvedValue({
|
||||
staged: false,
|
||||
reason: "not-lab",
|
||||
} as never);
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.labFactsStaged).toBe(0);
|
||||
});
|
||||
|
||||
it("a staging failure never fails the index that just succeeded", async () => {
|
||||
vi.mocked(maybeAutoStageLabFacts).mockRejectedValue(new Error("boom"));
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toMatchObject({ indexed: true, labFactsStaged: 0 });
|
||||
});
|
||||
|
||||
it("does not reach staging when the index itself was refused", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(visionReq("doc-1") as never, ctx("doc-1") as never);
|
||||
expect(res.status).toBe(429);
|
||||
expect(maybeAutoStageLabFacts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
refundDocumentAiSlot,
|
||||
type LoadedDocument,
|
||||
} from "@/lib/documents/ai-route-support";
|
||||
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
|
||||
import { upsertContentIndex } from "@/lib/documents/content-index";
|
||||
import {
|
||||
DocumentDescribeError,
|
||||
@@ -101,7 +102,17 @@ async function finishIndex(
|
||||
action: { name: "documents.contentIndex.upsert" },
|
||||
meta: { documentId, source, tokens: tokenCount },
|
||||
});
|
||||
return apiSuccess({ documentId, indexed: true, tokenCount });
|
||||
// A manual read continues into the SAME lab staging the automatic index
|
||||
// worker performs, so a skipped or failed auto run is recoverable per
|
||||
// document without a re-upload. Every guard lives inside the helper (both
|
||||
// modules on, still STORED with no facts, provider + consent, looks like a
|
||||
// lab report) — a non-lab document is a tagged no-op, and a staging failure
|
||||
// never fails the index that just succeeded.
|
||||
const staging = await maybeAutoStageLabFacts(userId, documentId).catch(
|
||||
() => null,
|
||||
);
|
||||
const labFactsStaged = staging?.staged === true ? staging.facts : 0;
|
||||
return apiSuccess({ documentId, indexed: true, tokenCount, labFactsStaged });
|
||||
}
|
||||
|
||||
/** TEXT mode — index browser-OCR'd text (no provider egress). */
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type {
|
||||
ExtractedFactDto,
|
||||
InboundDocumentDetailDto,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { DocumentFactsSection } from "../document-facts-review";
|
||||
|
||||
/**
|
||||
* The staged-facts block's honest states, pinned by static render:
|
||||
*
|
||||
* - a document with PENDING facts shows the review action (the control the
|
||||
* detail view never had — its absence forced a re-upload through Labs);
|
||||
* - a lab-filed, already-read document with nothing staged offers the
|
||||
* stored-text extract (recovery for a skipped automatic run);
|
||||
* - a delegate inside somebody else's record sees neither (staging and
|
||||
* confirming write to the owner's record);
|
||||
* - a fully confirmed document renders nothing — the surface stays calm.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
const queryClient = new QueryClient();
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function fact(overrides: Partial<ExtractedFactDto> = {}): ExtractedFactDto {
|
||||
return {
|
||||
id: "fact-1",
|
||||
factType: "OBSERVATION",
|
||||
status: "PENDING",
|
||||
confidence: 0.95,
|
||||
needsReview: false,
|
||||
data: {
|
||||
label: "Hemoglobin",
|
||||
code: null,
|
||||
codeSystem: null,
|
||||
value: 13.9,
|
||||
valueText: null,
|
||||
unit: "g/dL",
|
||||
referenceLow: 12,
|
||||
referenceHigh: 16,
|
||||
referenceText: "12-16",
|
||||
effectiveDate: "2026-08-01",
|
||||
},
|
||||
provenance: {
|
||||
sourceText: "Hemoglobin 13.9 g/dL",
|
||||
anchored: true,
|
||||
sourceOffset: 10,
|
||||
page: 0,
|
||||
confidence: 0.95,
|
||||
},
|
||||
committedRecordId: null,
|
||||
committedRecordType: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function doc(
|
||||
overrides: Partial<InboundDocumentDetailDto> = {},
|
||||
): InboundDocumentDetailDto {
|
||||
return {
|
||||
id: "doc-1",
|
||||
kind: "LAB_RESULT",
|
||||
title: "Blood panel",
|
||||
filename: "panel.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 1000,
|
||||
status: "EXTRACTED",
|
||||
providerType: "anthropic",
|
||||
reportDate: "2026-08-01",
|
||||
documentDate: null,
|
||||
errorReason: null,
|
||||
factCount: 1,
|
||||
pendingCount: 1,
|
||||
conditionLinks: [],
|
||||
encounterLinks: [],
|
||||
servingClass: "inline",
|
||||
hasContentIndex: true,
|
||||
contentIndexSource: "vision",
|
||||
hasThumbnail: false,
|
||||
createdAt: "2026-08-01T10:00:00.000Z",
|
||||
updatedAt: "2026-08-01T10:00:00.000Z",
|
||||
facts: [fact()],
|
||||
summary: null,
|
||||
summaryGeneratedAt: null,
|
||||
summaryState: "NONE",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
canManage: true,
|
||||
aiEnabled: true,
|
||||
labsModuleEnabled: true,
|
||||
};
|
||||
|
||||
describe("<DocumentFactsSection>", () => {
|
||||
it("shows the review action for a lab document with pending facts", () => {
|
||||
const html = render(<DocumentFactsSection doc={doc()} {...baseProps} />);
|
||||
expect(html).toContain('data-slot="document-facts-review-open"');
|
||||
expect(html).toContain("Review extracted value");
|
||||
});
|
||||
|
||||
it("offers the stored-text extract for a read lab document with nothing staged", () => {
|
||||
const html = render(
|
||||
<DocumentFactsSection
|
||||
doc={doc({ status: "STORED", facts: [], factCount: 0 })}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="document-facts-extract"');
|
||||
expect(html).toContain("Extract lab values");
|
||||
// Honest about the mechanism: stored text, review-first, no auto-save.
|
||||
expect(html).toContain("nothing is saved automatically");
|
||||
});
|
||||
|
||||
it("withdraws the extract offer once facts were approved", () => {
|
||||
const html = render(
|
||||
<DocumentFactsSection
|
||||
doc={doc({
|
||||
facts: [fact({ status: "APPROVED", committedRecordId: "lab-1" })],
|
||||
})}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="document-facts-extract"');
|
||||
expect(html).not.toContain('data-slot="document-facts-review-open"');
|
||||
});
|
||||
|
||||
it("renders nothing for a delegate inside somebody else's record", () => {
|
||||
const html = render(
|
||||
<DocumentFactsSection doc={doc()} {...baseProps} canManage={false} />,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing for an unread non-lab document", () => {
|
||||
const html = render(
|
||||
<DocumentFactsSection
|
||||
doc={doc({ kind: "DOCTOR_REPORT", facts: [], factCount: 0 })}
|
||||
{...baseProps}
|
||||
/>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -98,12 +98,24 @@ export async function runDocumentAi<T>(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
/** The index route's result, including the lab-staging continuation. */
|
||||
export interface DocumentIndexResult {
|
||||
indexed: boolean;
|
||||
tokenCount: number;
|
||||
/**
|
||||
* How many lab facts the read continued into staging (PENDING, for the
|
||||
* review step). 0 when the document is not a lab report or staging was not
|
||||
* eligible. Confirmation stays the only write into Labs.
|
||||
*/
|
||||
labFactsStaged: number;
|
||||
}
|
||||
|
||||
/** Populate / refresh one document's content index over the chosen transport. */
|
||||
export function runDocumentIndex(opts: {
|
||||
mode: DocumentAiMode;
|
||||
target: DocumentAiTarget;
|
||||
}): Promise<{ indexed: boolean; tokenCount: number }> {
|
||||
return runDocumentAi<{ indexed: boolean; tokenCount: number }>({
|
||||
}): Promise<DocumentIndexResult> {
|
||||
return runDocumentAi<DocumentIndexResult>({
|
||||
path: `/api/documents/inbound/${opts.target.documentId}/index`,
|
||||
mode: opts.mode,
|
||||
target: opts.target,
|
||||
|
||||
@@ -80,6 +80,7 @@ import {
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "./document-ai-section";
|
||||
import { DocumentEncounterSuggestion } from "./document-encounter-suggestion";
|
||||
import { DocumentFactsSection } from "./document-facts-review";
|
||||
import { VaccinationDocumentSuggestion } from "@/components/vaccinations/vaccination-document-suggestion";
|
||||
import { DocumentSummaryBlock } from "./document-summary-block";
|
||||
import type { DocumentAiTarget } from "./document-ai-transport";
|
||||
@@ -338,7 +339,7 @@ export function DocumentDetailSheet({
|
||||
/** Whether content indexing is available (from `usage.contentIndex.enabled`). */
|
||||
contentIndexEnabled?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t, tCount, locale } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
// v1.28.52 (Documents R3) — the vault "Ask the Coach" action opens the REAL
|
||||
@@ -363,9 +364,13 @@ export function DocumentDetailSheet({
|
||||
// deep-link — see the labs page's own "no per-value relation" comment),
|
||||
// so this links to the labs list rather than a single biomarker; still a
|
||||
// real jump where there was none before.
|
||||
//
|
||||
// APPROVED only: the link claims values that exist in Labs, and a PENDING
|
||||
// fact is not there yet — it belongs to the review block above, which is
|
||||
// the control that turns it into one.
|
||||
const labFactCount =
|
||||
doc?.facts.filter(
|
||||
(fact) => fact.factType === "OBSERVATION" && fact.status !== "REJECTED",
|
||||
(fact) => fact.factType === "OBSERVATION" && fact.status === "APPROVED",
|
||||
).length ?? 0;
|
||||
const { user } = useAuth();
|
||||
const { canManage } = useRecordCapabilities();
|
||||
@@ -606,7 +611,15 @@ export function DocumentDetailSheet({
|
||||
indexDoc.mutate(
|
||||
{ mode: aiMode, target: aiTarget },
|
||||
{
|
||||
onSuccess: () => toast.success(t("documents.ai.readDone")),
|
||||
// A read that continued into lab staging says so — the staged values
|
||||
// are waiting in the review block, not silently in a queue. Staged
|
||||
// facts stay PENDING; confirmation remains the only write into Labs.
|
||||
onSuccess: (result) =>
|
||||
toast.success(
|
||||
result.labFactsStaged > 0
|
||||
? tCount("documents.ai.readStaged", result.labFactsStaged)
|
||||
: t("documents.ai.readDone"),
|
||||
),
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
},
|
||||
);
|
||||
@@ -1090,6 +1103,18 @@ export function DocumentDetailSheet({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Staged-facts review + the stored-text extract recovery. The
|
||||
existing extract/confirm chain finally gets its control on
|
||||
the document itself: pending facts open the review, a read
|
||||
lab document with nothing staged offers "Extract lab
|
||||
values" over its stored text — never a re-upload. */}
|
||||
<DocumentFactsSection
|
||||
doc={doc}
|
||||
canManage={canManage}
|
||||
aiEnabled={aiEnabled}
|
||||
labsModuleEnabled={labsModuleEnabled}
|
||||
/>
|
||||
|
||||
{labsModuleEnabled && labFactCount > 0 ? (
|
||||
<Link
|
||||
href="/labs"
|
||||
|
||||
@@ -0,0 +1,663 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Review-then-confirm for a document's staged facts, ON the document detail —
|
||||
* the UI leg of a server chain that already existed end to end (extract →
|
||||
* PENDING `ExtractedFact` → per-fact edit → confirm) but had no control on
|
||||
* this surface: the sheet counted observation facts and offered nothing, so a
|
||||
* skipped automatic run forced a re-upload through Labs → Scan a report.
|
||||
*
|
||||
* Three honest states, all docked onto the existing sheet (no new menu item):
|
||||
*
|
||||
* - PENDING facts exist → "Review extracted values (N)" opens the inline
|
||||
* review: approve/reject per fact, a low-confidence fact is checked and
|
||||
* saved first (the existing per-fact edit, which clears `needsReview`).
|
||||
* - No facts yet, but the document is filed as a lab result and has stored
|
||||
* extracted text → "Extract lab values" runs the SAME text-structuring
|
||||
* pass the automatic worker uses ({ mode: "stored" }) — reusing the
|
||||
* stored text, never asking for a re-upload — and opens the review.
|
||||
* - Neither → nothing renders; the surface stays calm.
|
||||
*
|
||||
* HARD RULE: nothing here writes into Labs / conditions / medications on its
|
||||
* own. The confirm mutation carries the person's explicit decisions and the
|
||||
* server's confirm route remains the only write path. Unchecked values are
|
||||
* rejected (stated in the copy); an unverified low-confidence fact gets NO
|
||||
* decision and stays pending rather than being silently discarded.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { FlaskConical, Loader2, ScanSearch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ConditionFactData,
|
||||
ExtractedFactDto,
|
||||
InboundDocumentDetailDto,
|
||||
InboundFactEdit,
|
||||
MedicationStatementFactData,
|
||||
ObservationFactData,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import { useDocumentAiErrorText } from "./use-document-assist";
|
||||
import {
|
||||
useConfirmFacts,
|
||||
useEditFact,
|
||||
useStoredExtract,
|
||||
} from "./use-document-facts";
|
||||
|
||||
/** Map a per-fact commit refusal to its translated sentence. */
|
||||
function commitFailureKey(reason: string): string {
|
||||
switch (reason) {
|
||||
case "observation.unitRequired":
|
||||
return "documents.review.failUnitRequired";
|
||||
case "observation.unitMismatch":
|
||||
return "documents.review.failUnitMismatch";
|
||||
default:
|
||||
return "documents.review.failGeneric";
|
||||
}
|
||||
}
|
||||
|
||||
/** The headline line of a fact (what the document stated). */
|
||||
function factHeadline(fact: ExtractedFactDto): string {
|
||||
if (fact.factType === "OBSERVATION") {
|
||||
const data = fact.data as ObservationFactData;
|
||||
const value =
|
||||
typeof data.value === "number"
|
||||
? `${data.value}${data.unit ? ` ${data.unit}` : ""}`
|
||||
: (data.valueText ?? "");
|
||||
return value ? `${data.label}: ${value}` : data.label;
|
||||
}
|
||||
if (fact.factType === "CONDITION") {
|
||||
return (fact.data as ConditionFactData).label;
|
||||
}
|
||||
const data = fact.data as MedicationStatementFactData;
|
||||
return data.dose ? `${data.name} · ${data.dose}` : data.name;
|
||||
}
|
||||
|
||||
/** The date the fact states, if any (YYYY-MM-DD). */
|
||||
function factDate(fact: ExtractedFactDto): string | null {
|
||||
if (fact.factType === "OBSERVATION") {
|
||||
return (fact.data as ObservationFactData).effectiveDate;
|
||||
}
|
||||
if (fact.factType === "CONDITION") {
|
||||
return (fact.data as ConditionFactData).onsetDate;
|
||||
}
|
||||
return (fact.data as MedicationStatementFactData).effectiveDate;
|
||||
}
|
||||
|
||||
/** Build the full edit payload for a fact from its data + the form draft. */
|
||||
function buildEdit(
|
||||
fact: ExtractedFactDto,
|
||||
draft: VerifyDraft,
|
||||
): InboundFactEdit | null {
|
||||
if (fact.factType === "OBSERVATION") {
|
||||
const data = fact.data as ObservationFactData;
|
||||
const label = draft.label.trim();
|
||||
if (!label) return null;
|
||||
const numeric = draft.value.trim();
|
||||
const qualitative = draft.valueText.trim();
|
||||
// Numeric XOR qualitative, mirroring the server schema.
|
||||
if (numeric && qualitative) return null;
|
||||
const value = numeric ? Number(numeric.replace(",", ".")) : null;
|
||||
if (numeric && !Number.isFinite(value)) return null;
|
||||
return {
|
||||
factType: "OBSERVATION",
|
||||
label,
|
||||
code: data.code,
|
||||
codeSystem: data.codeSystem,
|
||||
value,
|
||||
valueText: qualitative || null,
|
||||
unit: draft.unit.trim() || null,
|
||||
referenceLow: data.referenceLow,
|
||||
referenceHigh: data.referenceHigh,
|
||||
referenceText: data.referenceText ?? null,
|
||||
effectiveDate: draft.date || null,
|
||||
};
|
||||
}
|
||||
if (fact.factType === "CONDITION") {
|
||||
const data = fact.data as ConditionFactData;
|
||||
const label = draft.label.trim();
|
||||
if (!label) return null;
|
||||
return {
|
||||
factType: "CONDITION",
|
||||
label,
|
||||
code: data.code,
|
||||
codeSystem: data.codeSystem,
|
||||
clinicalStatus: data.clinicalStatus,
|
||||
verificationStatus: data.verificationStatus,
|
||||
onsetDate: draft.date || null,
|
||||
};
|
||||
}
|
||||
const data = fact.data as MedicationStatementFactData;
|
||||
const name = draft.label.trim();
|
||||
if (!name) return null;
|
||||
return {
|
||||
factType: "MEDICATION_STATEMENT",
|
||||
name,
|
||||
dose: draft.unit.trim() || null,
|
||||
rxNormCode: data.rxNormCode,
|
||||
atcCode: data.atcCode,
|
||||
statusStated: data.statusStated,
|
||||
effectiveDate: draft.date || null,
|
||||
};
|
||||
}
|
||||
|
||||
interface VerifyDraft {
|
||||
/** Observation/condition label, or the medication name. */
|
||||
label: string;
|
||||
/** Observation numeric value as typed; unused for the other types. */
|
||||
value: string;
|
||||
/** Observation qualitative result; unused for the other types. */
|
||||
valueText: string;
|
||||
/** Observation unit, or the medication dose. */
|
||||
unit: string;
|
||||
/** Effective/onset date (YYYY-MM-DD) or "". */
|
||||
date: string;
|
||||
}
|
||||
|
||||
function draftFor(fact: ExtractedFactDto): VerifyDraft {
|
||||
if (fact.factType === "OBSERVATION") {
|
||||
const data = fact.data as ObservationFactData;
|
||||
return {
|
||||
label: data.label,
|
||||
value: typeof data.value === "number" ? String(data.value) : "",
|
||||
valueText: data.valueText ?? "",
|
||||
unit: data.unit ?? "",
|
||||
date: data.effectiveDate ?? "",
|
||||
};
|
||||
}
|
||||
if (fact.factType === "CONDITION") {
|
||||
const data = fact.data as ConditionFactData;
|
||||
return {
|
||||
label: data.label,
|
||||
value: "",
|
||||
valueText: "",
|
||||
unit: "",
|
||||
date: data.onsetDate ?? "",
|
||||
};
|
||||
}
|
||||
const data = fact.data as MedicationStatementFactData;
|
||||
return {
|
||||
label: data.name,
|
||||
value: "",
|
||||
valueText: "",
|
||||
unit: data.dose ?? "",
|
||||
date: data.effectiveDate ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/** One pending fact: include-toggle, stated values, verify-first when low-confidence. */
|
||||
function FactRow({
|
||||
documentId,
|
||||
fact,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
failure,
|
||||
}: {
|
||||
documentId: string;
|
||||
fact: ExtractedFactDto;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
/** A per-fact commit refusal from the last confirm attempt, or null. */
|
||||
failure: string | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
const editFact = useEditFact();
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [draft, setDraft] = useState<VerifyDraft>(() => draftFor(fact));
|
||||
const [draftInvalid, setDraftInvalid] = useState(false);
|
||||
|
||||
const date = factDate(fact);
|
||||
const typeLabel = t(`documents.review.type.${fact.factType}`);
|
||||
const isObservation = fact.factType === "OBSERVATION";
|
||||
|
||||
const saveVerification = () => {
|
||||
const edit = buildEdit(fact, draft);
|
||||
if (!edit) {
|
||||
setDraftInvalid(true);
|
||||
return;
|
||||
}
|
||||
setDraftInvalid(false);
|
||||
editFact.mutate(
|
||||
{ documentId, factId: fact.id, edit },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setVerifying(false);
|
||||
// The refetched fact arrives with needsReview cleared; include it —
|
||||
// the person just asserted these values.
|
||||
onCheckedChange(true);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
data-slot="document-fact-row"
|
||||
className="border-border space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(state) => onCheckedChange(state === true)}
|
||||
disabled={fact.needsReview}
|
||||
aria-label={t("documents.review.includeAria", {
|
||||
value: factHeadline(fact),
|
||||
})}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-sm font-medium break-words">
|
||||
{factHeadline(fact)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{typeLabel}
|
||||
{date ? ` · ${format.date(`${date}T12:00:00.000Z`)}` : ""}
|
||||
{isObservation &&
|
||||
(fact.data as ObservationFactData).referenceText ? (
|
||||
<>
|
||||
{" · "}
|
||||
{t("documents.review.statedRange", {
|
||||
range: (fact.data as ObservationFactData)
|
||||
.referenceText as string,
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
{fact.provenance.anchored && fact.provenance.sourceText ? (
|
||||
<p className="text-muted-foreground text-xs break-words italic">
|
||||
“{fact.provenance.sourceText}”
|
||||
</p>
|
||||
) : null}
|
||||
{failure ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t(commitFailureKey(failure))}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{fact.needsReview && !verifying ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 shrink-0 sm:min-h-8"
|
||||
onClick={() => setVerifying(true)}
|
||||
>
|
||||
{t("documents.review.verify")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{fact.needsReview && !verifying ? (
|
||||
<p className="text-warning text-xs">
|
||||
{t("documents.review.needsCheck")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{verifying ? (
|
||||
<div className="space-y-3" data-slot="document-fact-verify">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`fact-label-${fact.id}`}>
|
||||
{t("documents.review.fieldName")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`fact-label-${fact.id}`}
|
||||
value={draft.label}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, label: e.target.value }))
|
||||
}
|
||||
maxLength={fact.factType === "MEDICATION_STATEMENT" ? 200 : 300}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`fact-date-${fact.id}`}>
|
||||
{t("documents.review.fieldDate")}
|
||||
</Label>
|
||||
<DateField
|
||||
id={`fact-date-${fact.id}`}
|
||||
value={draft.date}
|
||||
onChange={(value) => setDraft((d) => ({ ...d, date: value }))}
|
||||
aria-label={t("documents.review.fieldDate")}
|
||||
/>
|
||||
</div>
|
||||
{isObservation ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`fact-value-${fact.id}`}>
|
||||
{t("documents.review.fieldValue")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`fact-value-${fact.id}`}
|
||||
inputMode="decimal"
|
||||
value={draft.value}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
value: e.target.value,
|
||||
// Numeric XOR qualitative — typing a number clears text.
|
||||
valueText: e.target.value ? "" : d.valueText,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`fact-value-text-${fact.id}`}>
|
||||
{t("documents.review.fieldValueText")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`fact-value-text-${fact.id}`}
|
||||
value={draft.valueText}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
valueText: e.target.value,
|
||||
value: e.target.value ? "" : d.value,
|
||||
}))
|
||||
}
|
||||
maxLength={300}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{isObservation || fact.factType === "MEDICATION_STATEMENT" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`fact-unit-${fact.id}`}>
|
||||
{isObservation
|
||||
? t("documents.review.fieldUnit")
|
||||
: t("documents.review.fieldDose")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`fact-unit-${fact.id}`}
|
||||
value={draft.unit}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({ ...d, unit: e.target.value }))
|
||||
}
|
||||
maxLength={isObservation ? 80 : 120}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{draftInvalid ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("documents.review.verifyInvalid")}
|
||||
</p>
|
||||
) : null}
|
||||
{editFact.isError ? (
|
||||
<p role="alert" className="text-destructive text-xs">
|
||||
{t("documents.review.verifyFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-8"
|
||||
onClick={() => {
|
||||
setVerifying(false);
|
||||
setDraftInvalid(false);
|
||||
setDraft(draftFor(fact));
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-8"
|
||||
onClick={saveVerification}
|
||||
disabled={editFact.isPending}
|
||||
>
|
||||
{editFact.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("documents.review.verifySave")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The staged-facts block of the document detail. Renders the review (pending
|
||||
* facts), the stored-text extract action (lab document, nothing staged yet),
|
||||
* or nothing. Owner-only: staging and confirming write to the caller's own
|
||||
* record, so a delegate never sees these controls.
|
||||
*/
|
||||
export function DocumentFactsSection({
|
||||
doc,
|
||||
canManage,
|
||||
aiEnabled,
|
||||
labsModuleEnabled,
|
||||
}: {
|
||||
doc: InboundDocumentDetailDto;
|
||||
canManage: boolean;
|
||||
aiEnabled: boolean;
|
||||
labsModuleEnabled: boolean;
|
||||
}) {
|
||||
const { t, tCount } = useTranslations();
|
||||
const storedExtract = useStoredExtract();
|
||||
const confirmFacts = useConfirmFacts();
|
||||
const aiErrorText = useDocumentAiErrorText();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
// Approve-set as explicit state, seeded per fact on first sight: a
|
||||
// confident fact starts included, a low-confidence one starts excluded
|
||||
// until it is verified (which checks it).
|
||||
const [included, setIncluded] = useState<Record<string, boolean>>({});
|
||||
const [failures, setFailures] = useState<Record<string, string>>({});
|
||||
|
||||
const pending = doc.facts.filter((fact) => fact.status === "PENDING");
|
||||
const approvedCount = doc.facts.filter(
|
||||
(fact) => fact.status === "APPROVED",
|
||||
).length;
|
||||
|
||||
if (!canManage) return null;
|
||||
|
||||
const isChecked = (fact: ExtractedFactDto): boolean =>
|
||||
included[fact.id] ?? !fact.needsReview;
|
||||
|
||||
const setChecked = (factId: string, checked: boolean) =>
|
||||
setIncluded((prev) => ({ ...prev, [factId]: checked }));
|
||||
|
||||
const finishReview = () => {
|
||||
// A still-unverified low-confidence fact gets NO decision — it stays
|
||||
// pending instead of being silently discarded.
|
||||
const decidable = pending.filter((fact) => !fact.needsReview);
|
||||
if (decidable.length === 0) return;
|
||||
const decisions = decidable.map((fact) => ({
|
||||
factId: fact.id,
|
||||
action: isChecked(fact) ? ("approve" as const) : ("reject" as const),
|
||||
}));
|
||||
confirmFacts.mutate(
|
||||
{ documentId: doc.id, decisions },
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setFailures(
|
||||
Object.fromEntries(
|
||||
result.failed.map((entry) => [entry.factId, entry.reason]),
|
||||
),
|
||||
);
|
||||
// The outcome module owns the affordance: green only for a run
|
||||
// that actually wrote, neutral for an all-rejected review (an
|
||||
// honest non-event), a warning when some values were refused.
|
||||
if (result.approved.length > 0) {
|
||||
toastWrittenOutcome(
|
||||
result.failed.length > 0 ? "partial" : "success",
|
||||
tCount("documents.review.savedToast", result.approved.length),
|
||||
);
|
||||
} else if (result.failed.length === 0) {
|
||||
toastWrittenOutcome(
|
||||
"empty",
|
||||
t("documents.review.nothingSavedToast"),
|
||||
);
|
||||
}
|
||||
if (result.failed.length === 0) setOpen(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// The stored-text extract offer: a lab-filed, already-read document with
|
||||
// nothing staged and nothing confirmed yet. `alreadyPartlyConfirmed` is the
|
||||
// server's refusal once anything is APPROVED, so the offer withdraws then.
|
||||
const canExtract =
|
||||
labsModuleEnabled &&
|
||||
aiEnabled &&
|
||||
doc.kind === "LAB_RESULT" &&
|
||||
doc.hasContentIndex &&
|
||||
doc.status !== "CONFIRMED" &&
|
||||
pending.length === 0 &&
|
||||
approvedCount === 0;
|
||||
|
||||
if (pending.length === 0 && !canExtract) return null;
|
||||
|
||||
const unverified = pending.filter((fact) => fact.needsReview).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="document-facts-section">
|
||||
{pending.length > 0 ? (
|
||||
<>
|
||||
{!open ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="document-facts-review-open"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<FlaskConical className="size-4" aria-hidden />
|
||||
{tCount("documents.review.open", pending.length)}
|
||||
</Button>
|
||||
) : (
|
||||
<div
|
||||
data-slot="document-facts-review"
|
||||
className="border-border bg-muted/30 space-y-3 rounded-lg border p-3 md:p-4"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t("documents.review.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.review.hint")}
|
||||
</p>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{pending.map((fact) => (
|
||||
<FactRow
|
||||
key={fact.id}
|
||||
documentId={doc.id}
|
||||
fact={fact}
|
||||
checked={isChecked(fact)}
|
||||
onCheckedChange={(checked) => setChecked(fact.id, checked)}
|
||||
failure={failures[fact.id] ?? null}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{unverified > 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{tCount("documents.review.unverifiedNote", unverified)}
|
||||
</p>
|
||||
) : null}
|
||||
{confirmFacts.isError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t("documents.review.confirmFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-8"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
data-slot="document-facts-confirm"
|
||||
className="min-h-11 sm:min-h-8"
|
||||
onClick={finishReview}
|
||||
disabled={
|
||||
confirmFacts.isPending ||
|
||||
pending.every((fact) => fact.needsReview)
|
||||
}
|
||||
>
|
||||
{confirmFacts.isPending ? (
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
{t("documents.review.finish")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="document-facts-extract"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() =>
|
||||
storedExtract.mutate(
|
||||
{ documentId: doc.id },
|
||||
{
|
||||
onSuccess: (detail) => {
|
||||
setFailures({});
|
||||
setIncluded({});
|
||||
if (
|
||||
detail.facts.some((fact) => fact.status === "PENDING")
|
||||
) {
|
||||
setOpen(true);
|
||||
} else {
|
||||
toast.info(t("documents.review.extractEmpty"));
|
||||
}
|
||||
},
|
||||
onError: (error) => toast.error(aiErrorText(error)),
|
||||
},
|
||||
)
|
||||
}
|
||||
disabled={storedExtract.isPending}
|
||||
>
|
||||
<ScanSearch
|
||||
className={cn(
|
||||
"size-4",
|
||||
storedExtract.isPending &&
|
||||
"animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
{storedExtract.isPending
|
||||
? t("documents.review.extracting")
|
||||
: t("documents.review.extract")}
|
||||
</Button>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.review.extractHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,13 +23,14 @@ import {
|
||||
runDocumentIndex,
|
||||
type DocumentAiMode,
|
||||
type DocumentAiTarget,
|
||||
type DocumentIndexResult,
|
||||
} from "./document-ai-transport";
|
||||
|
||||
/** Index / re-index one document for content search. */
|
||||
export function useIndexDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
{ indexed: boolean; tokenCount: number },
|
||||
DocumentIndexResult,
|
||||
Error,
|
||||
{ mode: DocumentAiMode; target: DocumentAiTarget }
|
||||
>({
|
||||
|
||||
@@ -198,6 +198,8 @@ export function documentAiErrorKey(err: unknown): string {
|
||||
return "documents.assist.errorLocalOcrDisabled";
|
||||
case "documents.inbound.providerUnsupported":
|
||||
return "documents.assist.errorProvider";
|
||||
case "documents.inbound.notIndexed":
|
||||
return "documents.assist.errorNotIndexed";
|
||||
default:
|
||||
return "documents.assist.errorGeneric";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Client hooks for the review-then-confirm chain on a stored document's
|
||||
* staged facts — the missing UI leg of the server chain that already existed:
|
||||
*
|
||||
* extract (`POST …/extract`, incl. `{ mode: "stored" }` over the document's
|
||||
* own indexed text) → PENDING `ExtractedFact` rows → optional per-fact edit
|
||||
* (`PATCH …/facts/{factId}`, clears `needsReview`) → confirm
|
||||
* (`POST …/confirm`) — the ONLY write into the structured stores.
|
||||
*
|
||||
* Nothing here commits on its own. The confirm mutation carries the explicit
|
||||
* approve/reject decisions the person made; a fact without a decision stays
|
||||
* pending on the server.
|
||||
*/
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import type {
|
||||
ExtractedFactDto,
|
||||
InboundDocumentDetailDto,
|
||||
InboundFactEdit,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
/** The confirm route's per-batch outcome (per-fact misses never fail it). */
|
||||
export interface ConfirmFactsResult {
|
||||
approved: { factId: string; recordType: string; recordId: string }[];
|
||||
rejected: string[];
|
||||
needsReview: string[];
|
||||
failed: { factId: string; reason: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure the document's own stored extracted text into staged facts —
|
||||
* the manual recovery when the automatic staging run was skipped or failed.
|
||||
* Stages PENDING facts only; the review + confirm steps stay mandatory.
|
||||
* A provider call is slow, so the request opts out of the 15 s default.
|
||||
*/
|
||||
export function useStoredExtract() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<InboundDocumentDetailDto, Error, { documentId: string }>({
|
||||
mutationFn: ({ documentId }) =>
|
||||
apiPost<InboundDocumentDetailDto>(
|
||||
`/api/documents/inbound/${documentId}/extract`,
|
||||
{ mode: "stored" },
|
||||
{ signal: AbortSignal.timeout(120_000) },
|
||||
),
|
||||
onSuccess: (_data, { documentId }) => {
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.inboundDocument(documentId),
|
||||
queryKeys.documents(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct a staged fact before approval. A successful edit clears
|
||||
* `needsReview` server-side — the values become user-asserted and the fact
|
||||
* turns approvable. The fact's resource type can never change.
|
||||
*/
|
||||
export function useEditFact() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
ExtractedFactDto,
|
||||
Error,
|
||||
{ documentId: string; factId: string; edit: InboundFactEdit }
|
||||
>({
|
||||
mutationFn: ({ documentId, factId, edit }) =>
|
||||
apiPatch<ExtractedFactDto>(
|
||||
`/api/documents/inbound/${documentId}/facts/${factId}`,
|
||||
edit,
|
||||
),
|
||||
onSuccess: (_data, { documentId }) => {
|
||||
void invalidateKeys(queryClient, [queryKeys.inboundDocument(documentId)]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the review decisions. Approved facts land in their structured
|
||||
* stores (labs / conditions / medications) through the server's existing
|
||||
* field-by-field commit; rejected facts are discarded. Invalidates every
|
||||
* read the commit can touch, plus the document itself (status + fact states).
|
||||
*/
|
||||
export function useConfirmFacts() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
ConfirmFactsResult,
|
||||
Error,
|
||||
{
|
||||
documentId: string;
|
||||
decisions: { factId: string; action: "approve" | "reject" }[];
|
||||
}
|
||||
>({
|
||||
mutationFn: ({ documentId, decisions }) =>
|
||||
apiPost<ConfirmFactsResult>(
|
||||
`/api/documents/inbound/${documentId}/confirm`,
|
||||
{ decisions },
|
||||
),
|
||||
onSuccess: (_data, { documentId }) => {
|
||||
void invalidateKeys(queryClient, [
|
||||
queryKeys.inboundDocument(documentId),
|
||||
queryKeys.documents(),
|
||||
queryKeys.labResults(),
|
||||
queryKeys.biomarkers(),
|
||||
queryKeys.illness(),
|
||||
queryKeys.medications(),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -720,7 +720,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Documents"],
|
||||
summary: "Extract facts from a stored document",
|
||||
description:
|
||||
'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only the enhancement fails. Two modes by content-type: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
||||
'Optional AI enhancement on an already-stored document. Runs the dedicated OCR/vision provider over the stored original and stages STRUCTURED FACTS for review. AI-consent / rate / budget gated. With no provider configured this returns 422 (`documents.inbound.providerUnsupported`) — the stored document is untouched, only the enhancement fails. Three modes: VISION (empty body) decrypts and scans the stored original (PDF needs an Anthropic vision provider); TEXT (`application/json`, opt-in local OCR) `{ mode: "text", text }` structures browser-OCR\'d text; STORED (`application/json`) `{ mode: "stored" }` structures the document\'s own stored extracted text (its content index) — the manual recovery for a skipped/failed automatic staging run, 422 `documents.inbound.notIndexed` when no stored text exists. Extraction reproduces what the document states — it never interprets. Nothing reaches the structured stores here; the confirm route is the only write path.',
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
@@ -734,12 +734,17 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z
|
||||
.object({
|
||||
mode: z.literal("text"),
|
||||
text: z.string(),
|
||||
kind: kindEnum.optional(),
|
||||
})
|
||||
.meta({ id: "InboundTextExtractRequest" }),
|
||||
.union([
|
||||
z.object({
|
||||
mode: z.literal("text"),
|
||||
text: z.string(),
|
||||
kind: kindEnum.optional(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("stored"),
|
||||
}),
|
||||
])
|
||||
.meta({ id: "InboundExtractRequest" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -888,7 +893,7 @@ 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 (AI-consent / rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). Idempotent; re-indexing overwrites in place.",
|
||||
"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 (AI-consent / rate / budget gated); TEXT (`application/json` `{ mode: \"text\", text }`, opt-in local OCR) indexes browser-OCR'd text with no provider egress. Persists ONLY AES-256-GCM ciphertext of the text plus opaque HMAC token hashes — no plaintext body, no plaintext token. Gated on the existing AI consent (no separate per-user toggle). 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.",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
@@ -915,6 +920,11 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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" }),
|
||||
"DocumentIndexEnvelope",
|
||||
|
||||
@@ -488,6 +488,22 @@ export const inboundTextExtractSchema = z.object({
|
||||
|
||||
export type InboundTextExtractInput = z.infer<typeof inboundTextExtractSchema>;
|
||||
|
||||
/**
|
||||
* The stored-text extract body: structure the document's OWN stored extracted
|
||||
* text (the content index the read/index step already produced) into staged
|
||||
* facts — no re-upload, no second read of the original. The document detail's
|
||||
* "Extract lab values" action posts this when an automatic staging run was
|
||||
* skipped or failed; the server refuses with `documents.inbound.notIndexed`
|
||||
* when there is no stored text to structure.
|
||||
*/
|
||||
export const inboundStoredExtractSchema = z.object({
|
||||
mode: z.literal("stored"),
|
||||
});
|
||||
|
||||
export type InboundStoredExtractInput = z.infer<
|
||||
typeof inboundStoredExtractSchema
|
||||
>;
|
||||
|
||||
// ─── Library: store / edit / list ──────────────────────────────────────────
|
||||
|
||||
/** A bare YYYY-MM-DD date string (no time component). */
|
||||
|
||||
Reference in new issue
Block a user