From fd104dd0ac7b720ed70e7ffd910bf2bc4f05e525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 12 Aug 2026 23:36:20 +0200 Subject: [PATCH] feat(documents): review and extract lab values on the document itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 77 +- messages/de.json | 45 ++ messages/en.json | 45 ++ messages/es.json | 45 ++ messages/fr.json | 45 ++ messages/it.json | 45 ++ messages/pl.json | 45 ++ .../[id]/extract/__tests__/route.test.ts | 176 ++++- .../documents/inbound/[id]/extract/route.ts | 128 +++- .../[id]/index/__tests__/route.test.ts | 57 ++ .../api/documents/inbound/[id]/index/route.ts | 13 +- .../__tests__/document-facts-review.test.tsx | 154 ++++ .../documents/document-ai-transport.ts | 16 +- .../documents/document-detail-sheet.tsx | 31 +- .../documents/document-facts-review.tsx | 663 ++++++++++++++++++ src/components/documents/use-content-index.ts | 3 +- .../documents/use-document-assist.ts | 2 + .../documents/use-document-facts.ts | 113 +++ src/lib/openapi/routes/documents.ts | 26 +- src/lib/validations/inbound-documents.ts | 16 + 20 files changed, 1681 insertions(+), 64 deletions(-) create mode 100644 src/components/documents/__tests__/document-facts-review.test.tsx create mode 100644 src/components/documents/document-facts-review.tsx create mode 100644 src/components/documents/use-document-facts.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 47fc1f345..e93ce866d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/messages/de.json b/messages/de.json index 437a2d668..a5af150dd 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 3c98234b1..c8463c98b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 2446eaffa..a381829e1 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 419eaa2dc..f1e10c144 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index acde06dc0..d9e6db62a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index 098808b07..a90cd2997 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts index b3a264742..c5b3ba303 100644 --- a/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/extract/__tests__/route.test.ts @@ -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 }; + inboundDocument: { + update: ReturnType; + findUniqueOrThrow: ReturnType; + }; + }; + } +).__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(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 05ea711eb..93de5f7e9 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -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 { 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 { + 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, diff --git a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts index 0ec7a3fa9..5b6540011 100644 --- a/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/index/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index 3d47dfea9..072271263 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -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). */ diff --git a/src/components/documents/__tests__/document-facts-review.test.tsx b/src/components/documents/__tests__/document-facts-review.test.tsx new file mode 100644 index 000000000..a90b630b5 --- /dev/null +++ b/src/components/documents/__tests__/document-facts-review.test.tsx @@ -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( + + {node} + , + ); +} + +function fact(overrides: Partial = {}): 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 { + 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("", () => { + it("shows the review action for a lab document with pending facts", () => { + const html = render(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toBe(""); + }); + + it("renders nothing for an unread non-lab document", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); +}); diff --git a/src/components/documents/document-ai-transport.ts b/src/components/documents/document-ai-transport.ts index f1399a902..a698e0c2a 100644 --- a/src/components/documents/document-ai-transport.ts +++ b/src/components/documents/document-ai-transport.ts @@ -98,12 +98,24 @@ export async function runDocumentAi(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 { + return runDocumentAi({ path: `/api/documents/inbound/${opts.target.documentId}/index`, mode: opts.mode, target: opts.target, diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 4d1707585..fd37ddc07 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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({ ) : 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. */} + + {labsModuleEnabled && labFactCount > 0 ? ( 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(() => 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 ( +
  • +
    + onCheckedChange(state === true)} + disabled={fact.needsReview} + aria-label={t("documents.review.includeAria", { + value: factHeadline(fact), + })} + className="mt-0.5" + /> +
    +

    + {factHeadline(fact)} +

    +

    + {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} +

    + {fact.provenance.anchored && fact.provenance.sourceText ? ( +

    + “{fact.provenance.sourceText}” +

    + ) : null} + {failure ? ( +

    + {t(commitFailureKey(failure))} +

    + ) : null} +
    + {fact.needsReview && !verifying ? ( + + ) : null} +
    + + {fact.needsReview && !verifying ? ( +

    + {t("documents.review.needsCheck")} +

    + ) : null} + + {verifying ? ( +
    +
    +
    + + + setDraft((d) => ({ ...d, label: e.target.value })) + } + maxLength={fact.factType === "MEDICATION_STATEMENT" ? 200 : 300} + /> +
    +
    + + setDraft((d) => ({ ...d, date: value }))} + aria-label={t("documents.review.fieldDate")} + /> +
    + {isObservation ? ( + <> +
    + + + setDraft((d) => ({ + ...d, + value: e.target.value, + // Numeric XOR qualitative — typing a number clears text. + valueText: e.target.value ? "" : d.valueText, + })) + } + /> +
    +
    + + + setDraft((d) => ({ + ...d, + valueText: e.target.value, + value: e.target.value ? "" : d.value, + })) + } + maxLength={300} + /> +
    + + ) : null} + {isObservation || fact.factType === "MEDICATION_STATEMENT" ? ( +
    + + + setDraft((d) => ({ ...d, unit: e.target.value })) + } + maxLength={isObservation ? 80 : 120} + /> +
    + ) : null} +
    + {draftInvalid ? ( +

    + {t("documents.review.verifyInvalid")} +

    + ) : null} + {editFact.isError ? ( +

    + {t("documents.review.verifyFailed")} +

    + ) : null} +
    + + +
    +
    + ) : null} +
  • + ); +} + +/** + * 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>({}); + const [failures, setFailures] = useState>({}); + + 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 ( +
    + {pending.length > 0 ? ( + <> + {!open ? ( + + ) : ( +
    +
    +

    + {t("documents.review.title")} +

    +

    + {t("documents.review.hint")} +

    +
    +
      + {pending.map((fact) => ( + setChecked(fact.id, checked)} + failure={failures[fact.id] ?? null} + /> + ))} +
    + {unverified > 0 ? ( +

    + {tCount("documents.review.unverifiedNote", unverified)} +

    + ) : null} + {confirmFacts.isError ? ( +

    + {t("documents.review.confirmFailed")} +

    + ) : null} +
    + + +
    +
    + )} + + ) : ( +
    + +

    + {t("documents.review.extractHint")} +

    +
    + )} +
    + ); +} diff --git a/src/components/documents/use-content-index.ts b/src/components/documents/use-content-index.ts index bfbe9d8ca..3d9434cac 100644 --- a/src/components/documents/use-content-index.ts +++ b/src/components/documents/use-content-index.ts @@ -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 } >({ diff --git a/src/components/documents/use-document-assist.ts b/src/components/documents/use-document-assist.ts index e1da97b87..d87a56274 100644 --- a/src/components/documents/use-document-assist.ts +++ b/src/components/documents/use-document-assist.ts @@ -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"; } diff --git a/src/components/documents/use-document-facts.ts b/src/components/documents/use-document-facts.ts new file mode 100644 index 000000000..497724761 --- /dev/null +++ b/src/components/documents/use-document-facts.ts @@ -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({ + mutationFn: ({ documentId }) => + apiPost( + `/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( + `/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( + `/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(), + ]); + }, + }); +} diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 2462f681f..5c52046f8 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -720,7 +720,7 @@ export const inboundDocumentPaths: NonNullable = { 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 = { 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 = { 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 = { 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", diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 7f325bf07..2c8c70a25 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -488,6 +488,22 @@ export const inboundTextExtractSchema = z.object({ export type InboundTextExtractInput = z.infer; +/** + * 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). */