mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(documents): review-first AI assist and session-only summary in the vault detail sheet
Add an optional AI layer to a stored document, gated on the existing AI provider and consent — no new toggle. All of it rides the shared OCR capability probe so the client only posts a transport the endpoint accepts (vision empty-body POST, or local-OCR text posted from the browser). - "Suggest details" prefills the title / type / date as reviewable DRAFTS. Nothing is written on its own: the title lands in the editable field and is saved through the existing commit path; type and date each apply on an explicit tap. The card carries a visible "review before saving" affordance. - A summary / extracted-text button opens a transient panel that renders the result once and always shows a "not saved / not a diagnosis" note; closing it discards the text. Nothing persists beyond the view. - When no provider is configured the AI area is a calm pointer to the AI settings, never an error; the manual metadata form stays fully usable. The detail-sheet AI area is lifted into a presentational section component so the availability gate and the review-first wiring render without the sheet's dialog portal and are pinned by static-render tests. Strings land in all six locales.
This commit is contained in:
1 parent
2653647f97
commit
afeb693562
14 files changed
+1723
-157
No files matched your search
+76
-26
@@ -7838,48 +7838,48 @@
|
||||
},
|
||||
"validatedInEnglishNote": "Dieser Fragebogen ist in englischer Sprache validiert; die Fragen erscheinen im englischen Original.",
|
||||
"who5Options": {
|
||||
"5": "Die ganze Zeit",
|
||||
"4": "Meistens",
|
||||
"3": "Etwas mehr als die Hälfte der Zeit",
|
||||
"2": "Etwas weniger als die Hälfte der Zeit",
|
||||
"0": "Zu keinem Zeitpunkt",
|
||||
"1": "Ab und zu",
|
||||
"0": "Zu keinem Zeitpunkt"
|
||||
"2": "Etwas weniger als die Hälfte der Zeit",
|
||||
"3": "Etwas mehr als die Hälfte der Zeit",
|
||||
"4": "Meistens",
|
||||
"5": "Die ganze Zeit"
|
||||
},
|
||||
"sciOptions": {
|
||||
"duration": {
|
||||
"4": "0–15 min",
|
||||
"3": "16–30 min",
|
||||
"2": "31–45 min",
|
||||
"0": "≥ 61 min",
|
||||
"1": "46–60 min",
|
||||
"0": "≥ 61 min"
|
||||
"2": "31–45 min",
|
||||
"3": "16–30 min",
|
||||
"4": "0–15 min"
|
||||
},
|
||||
"nights": {
|
||||
"4": "0–1",
|
||||
"3": "2",
|
||||
"2": "3",
|
||||
"0": "5–7",
|
||||
"1": "4",
|
||||
"0": "5–7"
|
||||
"2": "3",
|
||||
"3": "2",
|
||||
"4": "0–1"
|
||||
},
|
||||
"quality": {
|
||||
"4": "Very good",
|
||||
"3": "Good",
|
||||
"2": "Average",
|
||||
"0": "Very poor",
|
||||
"1": "Poor",
|
||||
"0": "Very poor"
|
||||
"2": "Average",
|
||||
"3": "Good",
|
||||
"4": "Very good"
|
||||
},
|
||||
"impact": {
|
||||
"4": "Not at all",
|
||||
"3": "A little",
|
||||
"2": "Somewhat",
|
||||
"0": "Very much",
|
||||
"1": "Much",
|
||||
"0": "Very much"
|
||||
"2": "Somewhat",
|
||||
"3": "A little",
|
||||
"4": "Not at all"
|
||||
},
|
||||
"problemDuration": {
|
||||
"4": "I don't have a problem / < 1 month",
|
||||
"3": "1–2 months",
|
||||
"2": "3–6 months",
|
||||
"0": "> 1 year",
|
||||
"1": "7–12 months",
|
||||
"0": "> 1 year"
|
||||
"2": "3–6 months",
|
||||
"3": "1–2 months",
|
||||
"4": "I don't have a problem / < 1 month"
|
||||
}
|
||||
},
|
||||
"stems": {
|
||||
@@ -8052,7 +8052,8 @@
|
||||
"openLabel": "{title} öffnen",
|
||||
"attachmentBadge": "Nur Download",
|
||||
"uploading": "Wird hochgeladen…",
|
||||
"dismissFailed": "Fehlgeschlagenen Upload ausblenden"
|
||||
"dismissFailed": "Fehlgeschlagenen Upload ausblenden",
|
||||
"searchableBadge": "Inhalt durchsuchbar"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
|
||||
@@ -8137,6 +8138,55 @@
|
||||
"description": "Wähle Dokumente aus deinem Bestand, die zu dieser Erkrankung gehören.",
|
||||
"searchPlaceholder": "Nach Titel oder Dateiname suchen",
|
||||
"empty": "Keine Dokumente gefunden."
|
||||
},
|
||||
"assist": {
|
||||
"suggest": "Details vorschlagen",
|
||||
"suggesting": "Wird gelesen…",
|
||||
"reviewTitle": "KI-Vorschlag — vor dem Speichern prüfen",
|
||||
"reviewHint": "Es wird nichts gespeichert, bis du es übernimmst. Vorschläge können falsch sein — prüfe sie.",
|
||||
"suggestedTitle": "Vorgeschlagener Titel",
|
||||
"suggestedKind": "Vorgeschlagener Typ",
|
||||
"suggestedDate": "Vorgeschlagenes Datum",
|
||||
"apply": "Übernehmen",
|
||||
"useTitle": "Titel übernehmen",
|
||||
"applied": "Übernommen",
|
||||
"dismiss": "Verwerfen",
|
||||
"empty": "Aus diesem Dokument konnte nichts gelesen werden.",
|
||||
"unavailableBody": "Richte einen KI-Anbieter ein, um Titel, Typ und Datum vorzuschlagen.",
|
||||
"unavailableLocalOcr": "Aktiviere lokale Texterkennung in den KI-Einstellungen, um Bilder auf diesem Gerät zu lesen.",
|
||||
"unavailableAction": "KI-Einstellungen öffnen",
|
||||
"errorGeneric": "Dokument konnte nicht gelesen werden. Bitte erneut versuchen.",
|
||||
"errorOcr": "Aus diesem Bild konnte kein Text gelesen werden.",
|
||||
"errorRateLimited": "Zu viele KI-Anfragen — bitte in ein paar Minuten erneut versuchen.",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Zusammenfassen",
|
||||
"showText": "Erkannten Text anzeigen",
|
||||
"summaryTitle": "Zusammenfassung",
|
||||
"textTitle": "Erkannter Text",
|
||||
"notSaved": "Nicht gespeichert · keine Diagnose. Nur einmalig für diese Ansicht angezeigt.",
|
||||
"close": "Ausblenden"
|
||||
},
|
||||
"contentIndex": {
|
||||
"searchHint": "Die Suche durchsucht jetzt auch den Inhalt deiner Dokumente (ganze Wörter).",
|
||||
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
|
||||
"searchable": "Inhalt durchsuchbar",
|
||||
"notSearchable": "Inhalt noch nicht durchsuchbar",
|
||||
"indexAction": "Für Suche indizieren",
|
||||
"reindexAction": "Neu indizieren",
|
||||
"indexing": "Wird indiziert…",
|
||||
"indexed": "Für die Suche indiziert.",
|
||||
"indexAll": "Alle für Suche indizieren",
|
||||
"indexAllPending": "Wird gestartet…",
|
||||
"indexAllQueued": "{count} Dokument(e) werden im Hintergrund indiziert.",
|
||||
"indexAllNothing": "Alles ist bereits indiziert.",
|
||||
"indexAllError": "Indizierung konnte nicht gestartet werden."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+76
-26
@@ -7838,48 +7838,48 @@
|
||||
},
|
||||
"validatedInEnglishNote": "This questionnaire is validated in English; the questions are shown in the original English wording.",
|
||||
"who5Options": {
|
||||
"5": "All of the time",
|
||||
"4": "Most of the time",
|
||||
"3": "More than half of the time",
|
||||
"2": "Less than half of the time",
|
||||
"0": "At no time",
|
||||
"1": "Some of the time",
|
||||
"0": "At no time"
|
||||
"2": "Less than half of the time",
|
||||
"3": "More than half of the time",
|
||||
"4": "Most of the time",
|
||||
"5": "All of the time"
|
||||
},
|
||||
"sciOptions": {
|
||||
"duration": {
|
||||
"4": "0–15 min",
|
||||
"3": "16–30 min",
|
||||
"2": "31–45 min",
|
||||
"0": "≥ 61 min",
|
||||
"1": "46–60 min",
|
||||
"0": "≥ 61 min"
|
||||
"2": "31–45 min",
|
||||
"3": "16–30 min",
|
||||
"4": "0–15 min"
|
||||
},
|
||||
"nights": {
|
||||
"4": "0–1",
|
||||
"3": "2",
|
||||
"2": "3",
|
||||
"0": "5–7",
|
||||
"1": "4",
|
||||
"0": "5–7"
|
||||
"2": "3",
|
||||
"3": "2",
|
||||
"4": "0–1"
|
||||
},
|
||||
"quality": {
|
||||
"4": "Very good",
|
||||
"3": "Good",
|
||||
"2": "Average",
|
||||
"0": "Very poor",
|
||||
"1": "Poor",
|
||||
"0": "Very poor"
|
||||
"2": "Average",
|
||||
"3": "Good",
|
||||
"4": "Very good"
|
||||
},
|
||||
"impact": {
|
||||
"4": "Not at all",
|
||||
"3": "A little",
|
||||
"2": "Somewhat",
|
||||
"0": "Very much",
|
||||
"1": "Much",
|
||||
"0": "Very much"
|
||||
"2": "Somewhat",
|
||||
"3": "A little",
|
||||
"4": "Not at all"
|
||||
},
|
||||
"problemDuration": {
|
||||
"4": "I don't have a problem / < 1 month",
|
||||
"3": "1–2 months",
|
||||
"2": "3–6 months",
|
||||
"0": "> 1 year",
|
||||
"1": "7–12 months",
|
||||
"0": "> 1 year"
|
||||
"2": "3–6 months",
|
||||
"3": "1–2 months",
|
||||
"4": "I don't have a problem / < 1 month"
|
||||
}
|
||||
},
|
||||
"stems": {
|
||||
@@ -8052,7 +8052,8 @@
|
||||
"openLabel": "Open {title}",
|
||||
"attachmentBadge": "Download only",
|
||||
"uploading": "Uploading…",
|
||||
"dismissFailed": "Dismiss failed upload"
|
||||
"dismissFailed": "Dismiss failed upload",
|
||||
"searchableBadge": "Contents searchable"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "This file is larger than the {maxSize} limit.",
|
||||
@@ -8137,6 +8138,55 @@
|
||||
"description": "Pick documents from your vault that belong to this condition.",
|
||||
"searchPlaceholder": "Search by title or filename",
|
||||
"empty": "No documents found."
|
||||
},
|
||||
"assist": {
|
||||
"suggest": "Suggest details",
|
||||
"suggesting": "Reading…",
|
||||
"reviewTitle": "AI suggestion — review before saving",
|
||||
"reviewHint": "Nothing is saved until you apply it. Suggestions can be wrong — check them.",
|
||||
"suggestedTitle": "Suggested title",
|
||||
"suggestedKind": "Suggested type",
|
||||
"suggestedDate": "Suggested date",
|
||||
"apply": "Apply",
|
||||
"useTitle": "Use title",
|
||||
"applied": "Applied",
|
||||
"dismiss": "Dismiss",
|
||||
"empty": "Nothing could be read from this document.",
|
||||
"unavailableBody": "Set up an AI provider to suggest a title, type, and date.",
|
||||
"unavailableLocalOcr": "Turn on local OCR in the AI settings to read images on this device.",
|
||||
"unavailableAction": "Open AI settings",
|
||||
"errorGeneric": "Couldn't read the document. Try again.",
|
||||
"errorOcr": "Couldn't read text from this image.",
|
||||
"errorRateLimited": "Too many AI requests — try again in a few minutes.",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Summarise",
|
||||
"showText": "Show extracted text",
|
||||
"summaryTitle": "Summary",
|
||||
"textTitle": "Extracted text",
|
||||
"notSaved": "Not saved · not a diagnosis. Shown once for this view only.",
|
||||
"close": "Hide"
|
||||
},
|
||||
"contentIndex": {
|
||||
"searchHint": "Search now looks inside your documents too (whole words).",
|
||||
"indexPrompt": "Make document contents searchable.",
|
||||
"searchable": "Contents searchable",
|
||||
"notSearchable": "Contents not searchable yet",
|
||||
"indexAction": "Index for search",
|
||||
"reindexAction": "Re-index",
|
||||
"indexing": "Indexing…",
|
||||
"indexed": "Indexed for search.",
|
||||
"indexAll": "Index all for search",
|
||||
"indexAllPending": "Starting…",
|
||||
"indexAllQueued": "Indexing {count} document(s) in the background.",
|
||||
"indexAllNothing": "Everything is already indexed.",
|
||||
"indexAllError": "Couldn't start indexing."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+76
-26
@@ -7838,48 +7838,48 @@
|
||||
},
|
||||
"validatedInEnglishNote": "Este cuestionario está validado en inglés; las preguntas se muestran en su versión original en inglés.",
|
||||
"who5Options": {
|
||||
"5": "Todo el tiempo",
|
||||
"4": "La mayor parte del tiempo",
|
||||
"3": "Más de la mitad del tiempo",
|
||||
"2": "Menos de la mitad del tiempo",
|
||||
"0": "Nunca",
|
||||
"1": "De vez en cuando",
|
||||
"0": "Nunca"
|
||||
"2": "Menos de la mitad del tiempo",
|
||||
"3": "Más de la mitad del tiempo",
|
||||
"4": "La mayor parte del tiempo",
|
||||
"5": "Todo el tiempo"
|
||||
},
|
||||
"sciOptions": {
|
||||
"duration": {
|
||||
"4": "0–15 min",
|
||||
"3": "16–30 min",
|
||||
"2": "31–45 min",
|
||||
"0": "≥ 61 min",
|
||||
"1": "46–60 min",
|
||||
"0": "≥ 61 min"
|
||||
"2": "31–45 min",
|
||||
"3": "16–30 min",
|
||||
"4": "0–15 min"
|
||||
},
|
||||
"nights": {
|
||||
"4": "0–1",
|
||||
"3": "2",
|
||||
"2": "3",
|
||||
"0": "5–7",
|
||||
"1": "4",
|
||||
"0": "5–7"
|
||||
"2": "3",
|
||||
"3": "2",
|
||||
"4": "0–1"
|
||||
},
|
||||
"quality": {
|
||||
"4": "Very good",
|
||||
"3": "Good",
|
||||
"2": "Average",
|
||||
"0": "Very poor",
|
||||
"1": "Poor",
|
||||
"0": "Very poor"
|
||||
"2": "Average",
|
||||
"3": "Good",
|
||||
"4": "Very good"
|
||||
},
|
||||
"impact": {
|
||||
"4": "Not at all",
|
||||
"3": "A little",
|
||||
"2": "Somewhat",
|
||||
"0": "Very much",
|
||||
"1": "Much",
|
||||
"0": "Very much"
|
||||
"2": "Somewhat",
|
||||
"3": "A little",
|
||||
"4": "Not at all"
|
||||
},
|
||||
"problemDuration": {
|
||||
"4": "I don't have a problem / < 1 month",
|
||||
"3": "1–2 months",
|
||||
"2": "3–6 months",
|
||||
"0": "> 1 year",
|
||||
"1": "7–12 months",
|
||||
"0": "> 1 year"
|
||||
"2": "3–6 months",
|
||||
"3": "1–2 months",
|
||||
"4": "I don't have a problem / < 1 month"
|
||||
}
|
||||
},
|
||||
"stems": {
|
||||
@@ -8052,7 +8052,8 @@
|
||||
"openLabel": "Abrir {title}",
|
||||
"attachmentBadge": "Solo descarga",
|
||||
"uploading": "Subiendo…",
|
||||
"dismissFailed": "Descartar subida fallida"
|
||||
"dismissFailed": "Descartar subida fallida",
|
||||
"searchableBadge": "Contenido buscable"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
|
||||
@@ -8137,6 +8138,55 @@
|
||||
"description": "Elige documentos de tu archivo que pertenezcan a esta afección.",
|
||||
"searchPlaceholder": "Buscar por título o nombre de archivo",
|
||||
"empty": "No se encontraron documentos."
|
||||
},
|
||||
"assist": {
|
||||
"suggest": "Sugerir detalles",
|
||||
"suggesting": "Leyendo…",
|
||||
"reviewTitle": "Sugerencia de IA: revísala antes de guardar",
|
||||
"reviewHint": "No se guarda nada hasta que la apliques. Las sugerencias pueden fallar: compruébalas.",
|
||||
"suggestedTitle": "Título sugerido",
|
||||
"suggestedKind": "Tipo sugerido",
|
||||
"suggestedDate": "Fecha sugerida",
|
||||
"apply": "Aplicar",
|
||||
"useTitle": "Usar título",
|
||||
"applied": "Aplicado",
|
||||
"dismiss": "Descartar",
|
||||
"empty": "No se pudo leer nada de este documento.",
|
||||
"unavailableBody": "Configura un proveedor de IA para sugerir título, tipo y fecha.",
|
||||
"unavailableLocalOcr": "Activa el OCR local en los ajustes de IA para leer imágenes en este dispositivo.",
|
||||
"unavailableAction": "Abrir ajustes de IA",
|
||||
"errorGeneric": "No se pudo leer el documento. Inténtalo de nuevo.",
|
||||
"errorOcr": "No se pudo leer texto de esta imagen.",
|
||||
"errorRateLimited": "Demasiadas solicitudes de IA: inténtalo en unos minutos.",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Resumir",
|
||||
"showText": "Mostrar texto extraído",
|
||||
"summaryTitle": "Resumen",
|
||||
"textTitle": "Texto extraído",
|
||||
"notSaved": "No se guarda · no es un diagnóstico. Se muestra solo una vez en esta vista.",
|
||||
"close": "Ocultar"
|
||||
},
|
||||
"contentIndex": {
|
||||
"searchHint": "La búsqueda ahora también mira dentro de tus documentos (palabras completas).",
|
||||
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
|
||||
"searchable": "Contenido buscable",
|
||||
"notSearchable": "Contenido aún no buscable",
|
||||
"indexAction": "Indexar para búsqueda",
|
||||
"reindexAction": "Reindexar",
|
||||
"indexing": "Indexando…",
|
||||
"indexed": "Indexado para búsqueda.",
|
||||
"indexAll": "Indexar todo para búsqueda",
|
||||
"indexAllPending": "Iniciando…",
|
||||
"indexAllQueued": "Indexando {count} documento(s) en segundo plano.",
|
||||
"indexAllNothing": "Todo ya está indexado.",
|
||||
"indexAllError": "No se pudo iniciar la indexación."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+76
-26
@@ -7838,48 +7838,48 @@
|
||||
},
|
||||
"validatedInEnglishNote": "Ce questionnaire est validé en anglais ; les questions sont affichées dans leur version originale anglaise.",
|
||||
"who5Options": {
|
||||
"5": "Tout le temps",
|
||||
"4": "La plupart du temps",
|
||||
"3": "Plus de la moitié du temps",
|
||||
"2": "Moins de la moitié du temps",
|
||||
"0": "Jamais",
|
||||
"1": "De temps en temps",
|
||||
"0": "Jamais"
|
||||
"2": "Moins de la moitié du temps",
|
||||
"3": "Plus de la moitié du temps",
|
||||
"4": "La plupart du temps",
|
||||
"5": "Tout le temps"
|
||||
},
|
||||
"sciOptions": {
|
||||
"duration": {
|
||||
"4": "0–15 min",
|
||||
"3": "16–30 min",
|
||||
"2": "31–45 min",
|
||||
"0": "≥ 61 min",
|
||||
"1": "46–60 min",
|
||||
"0": "≥ 61 min"
|
||||
"2": "31–45 min",
|
||||
"3": "16–30 min",
|
||||
"4": "0–15 min"
|
||||
},
|
||||
"nights": {
|
||||
"4": "0–1",
|
||||
"3": "2",
|
||||
"2": "3",
|
||||
"0": "5–7",
|
||||
"1": "4",
|
||||
"0": "5–7"
|
||||
"2": "3",
|
||||
"3": "2",
|
||||
"4": "0–1"
|
||||
},
|
||||
"quality": {
|
||||
"4": "Very good",
|
||||
"3": "Good",
|
||||
"2": "Average",
|
||||
"0": "Very poor",
|
||||
"1": "Poor",
|
||||
"0": "Very poor"
|
||||
"2": "Average",
|
||||
"3": "Good",
|
||||
"4": "Very good"
|
||||
},
|
||||
"impact": {
|
||||
"4": "Not at all",
|
||||
"3": "A little",
|
||||
"2": "Somewhat",
|
||||
"0": "Very much",
|
||||
"1": "Much",
|
||||
"0": "Very much"
|
||||
"2": "Somewhat",
|
||||
"3": "A little",
|
||||
"4": "Not at all"
|
||||
},
|
||||
"problemDuration": {
|
||||
"4": "I don't have a problem / < 1 month",
|
||||
"3": "1–2 months",
|
||||
"2": "3–6 months",
|
||||
"0": "> 1 year",
|
||||
"1": "7–12 months",
|
||||
"0": "> 1 year"
|
||||
"2": "3–6 months",
|
||||
"3": "1–2 months",
|
||||
"4": "I don't have a problem / < 1 month"
|
||||
}
|
||||
},
|
||||
"stems": {
|
||||
@@ -8052,7 +8052,8 @@
|
||||
"openLabel": "Ouvrir {title}",
|
||||
"attachmentBadge": "Téléchargement uniquement",
|
||||
"uploading": "Téléversement…",
|
||||
"dismissFailed": "Ignorer le téléversement échoué"
|
||||
"dismissFailed": "Ignorer le téléversement échoué",
|
||||
"searchableBadge": "Contenu consultable"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
|
||||
@@ -8137,6 +8138,55 @@
|
||||
"description": "Choisissez dans vos documents ceux qui concernent cette affection.",
|
||||
"searchPlaceholder": "Rechercher par titre ou nom de fichier",
|
||||
"empty": "Aucun document trouvé."
|
||||
},
|
||||
"assist": {
|
||||
"suggest": "Suggérer des détails",
|
||||
"suggesting": "Lecture…",
|
||||
"reviewTitle": "Suggestion IA — à vérifier avant d'enregistrer",
|
||||
"reviewHint": "Rien n'est enregistré tant que vous ne l'appliquez pas. Les suggestions peuvent être erronées — vérifiez-les.",
|
||||
"suggestedTitle": "Titre suggéré",
|
||||
"suggestedKind": "Type suggéré",
|
||||
"suggestedDate": "Date suggérée",
|
||||
"apply": "Appliquer",
|
||||
"useTitle": "Utiliser le titre",
|
||||
"applied": "Appliqué",
|
||||
"dismiss": "Ignorer",
|
||||
"empty": "Impossible de lire ce document.",
|
||||
"unavailableBody": "Configurez un fournisseur d'IA pour suggérer un titre, un type et une date.",
|
||||
"unavailableLocalOcr": "Activez l'OCR local dans les paramètres d'IA pour lire les images sur cet appareil.",
|
||||
"unavailableAction": "Ouvrir les paramètres d'IA",
|
||||
"errorGeneric": "Impossible de lire le document. Réessayez.",
|
||||
"errorOcr": "Impossible de lire le texte de cette image.",
|
||||
"errorRateLimited": "Trop de requêtes IA — réessayez dans quelques minutes.",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Résumer",
|
||||
"showText": "Afficher le texte extrait",
|
||||
"summaryTitle": "Résumé",
|
||||
"textTitle": "Texte extrait",
|
||||
"notSaved": "Non enregistré · pas un diagnostic. Affiché une seule fois pour cette vue.",
|
||||
"close": "Masquer"
|
||||
},
|
||||
"contentIndex": {
|
||||
"searchHint": "La recherche explore désormais aussi le contenu de vos documents (mots entiers).",
|
||||
"indexPrompt": "Rendre le contenu des documents consultable.",
|
||||
"searchable": "Contenu consultable",
|
||||
"notSearchable": "Contenu pas encore consultable",
|
||||
"indexAction": "Indexer pour la recherche",
|
||||
"reindexAction": "Réindexer",
|
||||
"indexing": "Indexation…",
|
||||
"indexed": "Indexé pour la recherche.",
|
||||
"indexAll": "Tout indexer pour la recherche",
|
||||
"indexAllPending": "Démarrage…",
|
||||
"indexAllQueued": "Indexation de {count} document(s) en arrière-plan.",
|
||||
"indexAllNothing": "Tout est déjà indexé.",
|
||||
"indexAllError": "Impossible de démarrer l'indexation."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+76
-26
@@ -7838,48 +7838,48 @@
|
||||
},
|
||||
"validatedInEnglishNote": "Questo questionario è validato in inglese; le domande sono mostrate nella versione originale inglese.",
|
||||
"who5Options": {
|
||||
"5": "Sempre",
|
||||
"4": "La maggior parte del tempo",
|
||||
"3": "Più della metà del tempo",
|
||||
"2": "Meno della metà del tempo",
|
||||
"0": "Mai",
|
||||
"1": "A volte",
|
||||
"0": "Mai"
|
||||
"2": "Meno della metà del tempo",
|
||||
"3": "Più della metà del tempo",
|
||||
"4": "La maggior parte del tempo",
|
||||
"5": "Sempre"
|
||||
},
|
||||
"sciOptions": {
|
||||
"duration": {
|
||||
"4": "0–15 min",
|
||||
"3": "16–30 min",
|
||||
"2": "31–45 min",
|
||||
"0": "≥ 61 min",
|
||||
"1": "46–60 min",
|
||||
"0": "≥ 61 min"
|
||||
"2": "31–45 min",
|
||||
"3": "16–30 min",
|
||||
"4": "0–15 min"
|
||||
},
|
||||
"nights": {
|
||||
"4": "0–1",
|
||||
"3": "2",
|
||||
"2": "3",
|
||||
"0": "5–7",
|
||||
"1": "4",
|
||||
"0": "5–7"
|
||||
"2": "3",
|
||||
"3": "2",
|
||||
"4": "0–1"
|
||||
},
|
||||
"quality": {
|
||||
"4": "Very good",
|
||||
"3": "Good",
|
||||
"2": "Average",
|
||||
"0": "Very poor",
|
||||
"1": "Poor",
|
||||
"0": "Very poor"
|
||||
"2": "Average",
|
||||
"3": "Good",
|
||||
"4": "Very good"
|
||||
},
|
||||
"impact": {
|
||||
"4": "Not at all",
|
||||
"3": "A little",
|
||||
"2": "Somewhat",
|
||||
"0": "Very much",
|
||||
"1": "Much",
|
||||
"0": "Very much"
|
||||
"2": "Somewhat",
|
||||
"3": "A little",
|
||||
"4": "Not at all"
|
||||
},
|
||||
"problemDuration": {
|
||||
"4": "I don't have a problem / < 1 month",
|
||||
"3": "1–2 months",
|
||||
"2": "3–6 months",
|
||||
"0": "> 1 year",
|
||||
"1": "7–12 months",
|
||||
"0": "> 1 year"
|
||||
"2": "3–6 months",
|
||||
"3": "1–2 months",
|
||||
"4": "I don't have a problem / < 1 month"
|
||||
}
|
||||
},
|
||||
"stems": {
|
||||
@@ -8052,7 +8052,8 @@
|
||||
"openLabel": "Apri {title}",
|
||||
"attachmentBadge": "Solo download",
|
||||
"uploading": "Caricamento…",
|
||||
"dismissFailed": "Ignora caricamento non riuscito"
|
||||
"dismissFailed": "Ignora caricamento non riuscito",
|
||||
"searchableBadge": "Contenuto ricercabile"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
|
||||
@@ -8137,6 +8138,55 @@
|
||||
"description": "Scegli i documenti del tuo archivio che riguardano questa patologia.",
|
||||
"searchPlaceholder": "Cerca per titolo o nome del file",
|
||||
"empty": "Nessun documento trovato."
|
||||
},
|
||||
"assist": {
|
||||
"suggest": "Suggerisci dettagli",
|
||||
"suggesting": "Lettura…",
|
||||
"reviewTitle": "Suggerimento IA — verifica prima di salvare",
|
||||
"reviewHint": "Nulla viene salvato finché non lo applichi. I suggerimenti possono essere errati: controllali.",
|
||||
"suggestedTitle": "Titolo suggerito",
|
||||
"suggestedKind": "Tipo suggerito",
|
||||
"suggestedDate": "Data suggerita",
|
||||
"apply": "Applica",
|
||||
"useTitle": "Usa titolo",
|
||||
"applied": "Applicato",
|
||||
"dismiss": "Ignora",
|
||||
"empty": "Impossibile leggere questo documento.",
|
||||
"unavailableBody": "Configura un provider IA per suggerire titolo, tipo e data.",
|
||||
"unavailableLocalOcr": "Attiva l'OCR locale nelle impostazioni IA per leggere le immagini su questo dispositivo.",
|
||||
"unavailableAction": "Apri impostazioni IA",
|
||||
"errorGeneric": "Impossibile leggere il documento. Riprova.",
|
||||
"errorOcr": "Impossibile leggere il testo da questa immagine.",
|
||||
"errorRateLimited": "Troppe richieste IA — riprova tra qualche minuto.",
|
||||
"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.",
|
||||
"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."
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Riassumi",
|
||||
"showText": "Mostra testo estratto",
|
||||
"summaryTitle": "Riepilogo",
|
||||
"textTitle": "Testo estratto",
|
||||
"notSaved": "Non salvato · non è una diagnosi. Mostrato una sola volta per questa vista.",
|
||||
"close": "Nascondi"
|
||||
},
|
||||
"contentIndex": {
|
||||
"searchHint": "La ricerca ora esamina anche il contenuto dei tuoi documenti (parole intere).",
|
||||
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
|
||||
"searchable": "Contenuto ricercabile",
|
||||
"notSearchable": "Contenuto non ancora ricercabile",
|
||||
"indexAction": "Indicizza per la ricerca",
|
||||
"reindexAction": "Reindicizza",
|
||||
"indexing": "Indicizzazione…",
|
||||
"indexed": "Indicizzato per la ricerca.",
|
||||
"indexAll": "Indicizza tutto per la ricerca",
|
||||
"indexAllPending": "Avvio…",
|
||||
"indexAllQueued": "Indicizzazione di {count} documento/i in background.",
|
||||
"indexAllNothing": "Tutto è già indicizzato.",
|
||||
"indexAllError": "Impossibile avviare l'indicizzazione."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
+76
-26
@@ -7838,48 +7838,48 @@
|
||||
},
|
||||
"validatedInEnglishNote": "Ten kwestionariusz jest zwalidowany w języku angielskim; pytania są wyświetlane w angielskim oryginale.",
|
||||
"who5Options": {
|
||||
"5": "Cały czas",
|
||||
"4": "Prawie cały czas",
|
||||
"3": "Więcej niż połowę czasu",
|
||||
"2": "Mniej niż połowę czasu",
|
||||
"0": "Nigdy",
|
||||
"1": "Od czasu do czasu",
|
||||
"0": "Nigdy"
|
||||
"2": "Mniej niż połowę czasu",
|
||||
"3": "Więcej niż połowę czasu",
|
||||
"4": "Prawie cały czas",
|
||||
"5": "Cały czas"
|
||||
},
|
||||
"sciOptions": {
|
||||
"duration": {
|
||||
"4": "0–15 min",
|
||||
"3": "16–30 min",
|
||||
"2": "31–45 min",
|
||||
"0": "≥ 61 min",
|
||||
"1": "46–60 min",
|
||||
"0": "≥ 61 min"
|
||||
"2": "31–45 min",
|
||||
"3": "16–30 min",
|
||||
"4": "0–15 min"
|
||||
},
|
||||
"nights": {
|
||||
"4": "0–1",
|
||||
"3": "2",
|
||||
"2": "3",
|
||||
"0": "5–7",
|
||||
"1": "4",
|
||||
"0": "5–7"
|
||||
"2": "3",
|
||||
"3": "2",
|
||||
"4": "0–1"
|
||||
},
|
||||
"quality": {
|
||||
"4": "Very good",
|
||||
"3": "Good",
|
||||
"2": "Average",
|
||||
"0": "Very poor",
|
||||
"1": "Poor",
|
||||
"0": "Very poor"
|
||||
"2": "Average",
|
||||
"3": "Good",
|
||||
"4": "Very good"
|
||||
},
|
||||
"impact": {
|
||||
"4": "Not at all",
|
||||
"3": "A little",
|
||||
"2": "Somewhat",
|
||||
"0": "Very much",
|
||||
"1": "Much",
|
||||
"0": "Very much"
|
||||
"2": "Somewhat",
|
||||
"3": "A little",
|
||||
"4": "Not at all"
|
||||
},
|
||||
"problemDuration": {
|
||||
"4": "I don't have a problem / < 1 month",
|
||||
"3": "1–2 months",
|
||||
"2": "3–6 months",
|
||||
"0": "> 1 year",
|
||||
"1": "7–12 months",
|
||||
"0": "> 1 year"
|
||||
"2": "3–6 months",
|
||||
"3": "1–2 months",
|
||||
"4": "I don't have a problem / < 1 month"
|
||||
}
|
||||
},
|
||||
"stems": {
|
||||
@@ -8052,7 +8052,8 @@
|
||||
"openLabel": "Otwórz {title}",
|
||||
"attachmentBadge": "Tylko do pobrania",
|
||||
"uploading": "Przesyłanie…",
|
||||
"dismissFailed": "Odrzuć nieudane przesyłanie"
|
||||
"dismissFailed": "Odrzuć nieudane przesyłanie",
|
||||
"searchableBadge": "Treść przeszukiwalna"
|
||||
},
|
||||
"error": {
|
||||
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
|
||||
@@ -8137,6 +8138,55 @@
|
||||
"description": "Wybierz dokumenty z archiwum, które dotyczą tego schorzenia.",
|
||||
"searchPlaceholder": "Szukaj po tytule lub nazwie pliku",
|
||||
"empty": "Nie znaleziono dokumentów."
|
||||
},
|
||||
"assist": {
|
||||
"suggest": "Zaproponuj szczegóły",
|
||||
"suggesting": "Odczytywanie…",
|
||||
"reviewTitle": "Sugestia AI — sprawdź przed zapisaniem",
|
||||
"reviewHint": "Nic nie zostanie zapisane, dopóki tego nie zastosujesz. Sugestie mogą być błędne — sprawdź je.",
|
||||
"suggestedTitle": "Sugerowany tytuł",
|
||||
"suggestedKind": "Sugerowany typ",
|
||||
"suggestedDate": "Sugerowana data",
|
||||
"apply": "Zastosuj",
|
||||
"useTitle": "Użyj tytułu",
|
||||
"applied": "Zastosowano",
|
||||
"dismiss": "Odrzuć",
|
||||
"empty": "Nie udało się nic odczytać z tego dokumentu.",
|
||||
"unavailableBody": "Skonfiguruj dostawcę AI, aby zaproponować tytuł, typ i datę.",
|
||||
"unavailableLocalOcr": "Włącz lokalne OCR w ustawieniach AI, aby odczytywać obrazy na tym urządzeniu.",
|
||||
"unavailableAction": "Otwórz ustawienia AI",
|
||||
"errorGeneric": "Nie udało się odczytać dokumentu. Spróbuj ponownie.",
|
||||
"errorOcr": "Nie udało się odczytać tekstu z tego obrazu.",
|
||||
"errorRateLimited": "Zbyt wiele żądań AI — spróbuj za kilka minut.",
|
||||
"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ć.",
|
||||
"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."
|
||||
},
|
||||
"summary": {
|
||||
"summarise": "Podsumuj",
|
||||
"showText": "Pokaż wyodrębniony tekst",
|
||||
"summaryTitle": "Podsumowanie",
|
||||
"textTitle": "Wyodrębniony tekst",
|
||||
"notSaved": "Nie zapisano · to nie diagnoza. Wyświetlane tylko raz w tym widoku.",
|
||||
"close": "Ukryj"
|
||||
},
|
||||
"contentIndex": {
|
||||
"searchHint": "Wyszukiwanie przeszukuje teraz także treść Twoich dokumentów (całe słowa).",
|
||||
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
|
||||
"searchable": "Treść przeszukiwalna",
|
||||
"notSearchable": "Treść jeszcze nieprzeszukiwalna",
|
||||
"indexAction": "Zindeksuj do wyszukiwania",
|
||||
"reindexAction": "Zindeksuj ponownie",
|
||||
"indexing": "Indeksowanie…",
|
||||
"indexed": "Zindeksowano do wyszukiwania.",
|
||||
"indexAll": "Zindeksuj wszystko do wyszukiwania",
|
||||
"indexAllPending": "Uruchamianie…",
|
||||
"indexAllQueued": "Indeksowanie {count} dokumentu/ów w tle.",
|
||||
"indexAllNothing": "Wszystko jest już zindeksowane.",
|
||||
"indexAllError": "Nie udało się uruchomić indeksowania."
|
||||
}
|
||||
},
|
||||
"selfDescription": {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
|
||||
import {
|
||||
AiUnavailableHint,
|
||||
AssistSuggestionReview,
|
||||
ContentIndexStatus,
|
||||
DocumentSummaryPanel,
|
||||
} from "../document-ai-panels";
|
||||
|
||||
/**
|
||||
* The AI panels' render contract:
|
||||
* - the unavailable state is a CALM pointer to the AI settings, never an
|
||||
* error, and adapts its copy to the actionable reason;
|
||||
* - the suggestion card carries the review-first affordance ("review before
|
||||
* saving") and applies nothing on its own — every value has its own tap;
|
||||
* - the summary panel ALWAYS carries the "not saved · not a diagnosis" note,
|
||||
* even while loading, so the session-only contract is never off-screen.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const suggestion = (
|
||||
overrides: Partial<DocumentSuggestionDto> = {},
|
||||
): DocumentSuggestionDto => ({
|
||||
title: "Blood panel — March 2026",
|
||||
kind: "LAB_RESULT",
|
||||
documentDate: "2026-03-01",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("<AiUnavailableHint>", () => {
|
||||
it("points calmly at the AI settings when no provider is configured", () => {
|
||||
const html = render(<AiUnavailableHint reason="no-provider" />);
|
||||
expect(html).toContain('data-slot="assist-unavailable"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
expect(html).toContain("Set up an AI provider");
|
||||
// Never an error affordance.
|
||||
expect(html).not.toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("nudges toward local OCR when a text-only provider needs it", () => {
|
||||
const html = render(<AiUnavailableHint reason="enable-local-ocr" />);
|
||||
expect(html).toContain("local OCR");
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("<AssistSuggestionReview>", () => {
|
||||
it("presents drafts behind an explicit review-before-saving affordance", () => {
|
||||
const html = render(
|
||||
<AssistSuggestionReview
|
||||
suggestion={suggestion()}
|
||||
kindLabel="Lab result"
|
||||
dateLabel="1 Mar 2026"
|
||||
applied={{ title: false, kind: false, date: false }}
|
||||
onUseTitle={noop}
|
||||
onUseKind={noop}
|
||||
onUseDate={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="assist-suggestion-review"');
|
||||
expect(html).toContain("AI suggestion — review before saving");
|
||||
expect(html).toContain("Nothing is saved until you apply it");
|
||||
// The suggested values are shown as reviewable drafts.
|
||||
expect(html).toContain("Blood panel — March 2026");
|
||||
expect(html).toContain("Lab result");
|
||||
expect(html).toContain("1 Mar 2026");
|
||||
// Each field has an explicit apply control.
|
||||
expect(html).toContain("Use title");
|
||||
expect(html).toContain("Apply");
|
||||
});
|
||||
|
||||
it("marks an applied field instead of re-offering it", () => {
|
||||
const html = render(
|
||||
<AssistSuggestionReview
|
||||
suggestion={suggestion()}
|
||||
kindLabel="Lab result"
|
||||
dateLabel="1 Mar 2026"
|
||||
applied={{ title: true, kind: false, date: false }}
|
||||
onUseTitle={noop}
|
||||
onUseKind={noop}
|
||||
onUseDate={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Applied");
|
||||
});
|
||||
|
||||
it("shows an empty state when nothing could be read", () => {
|
||||
const html = render(
|
||||
<AssistSuggestionReview
|
||||
suggestion={suggestion({ title: null, kind: null, documentDate: null })}
|
||||
kindLabel={null}
|
||||
dateLabel={null}
|
||||
applied={{ title: false, kind: false, date: false }}
|
||||
onUseTitle={noop}
|
||||
onUseKind={noop}
|
||||
onUseDate={noop}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Nothing could be read from this document");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<DocumentSummaryPanel>", () => {
|
||||
it("keeps the not-saved / not-a-diagnosis note on screen while loading", () => {
|
||||
const html = render(
|
||||
<DocumentSummaryPanel
|
||||
output="summary"
|
||||
result={null}
|
||||
isPending
|
||||
errorKey={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="document-summary-panel"');
|
||||
expect(html).toContain('data-slot="document-summary-loading"');
|
||||
expect(html).toContain("Not saved");
|
||||
expect(html).toContain("not a diagnosis");
|
||||
});
|
||||
|
||||
it("renders the summary body and the persistent note", () => {
|
||||
const html = render(
|
||||
<DocumentSummaryPanel
|
||||
output="summary"
|
||||
result={{ summary: "A lipid panel dated 1 March 2026." }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("A lipid panel dated 1 March 2026.");
|
||||
expect(html).toContain("Not saved");
|
||||
});
|
||||
|
||||
it("renders extracted text in a monospace block", () => {
|
||||
const html = render(
|
||||
<DocumentSummaryPanel
|
||||
output="text"
|
||||
result={{ text: "LDL 3.1 mmol/L" }}
|
||||
isPending={false}
|
||||
errorKey={null}
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("LDL 3.1 mmol/L");
|
||||
expect(html).toContain("Extracted text");
|
||||
expect(html).toContain("font-mono");
|
||||
});
|
||||
|
||||
it("surfaces an error via role=alert but keeps the note", () => {
|
||||
const html = render(
|
||||
<DocumentSummaryPanel
|
||||
output="summary"
|
||||
result={null}
|
||||
isPending={false}
|
||||
errorKey="documents.assist.errorGeneric"
|
||||
onClose={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("read the document");
|
||||
expect(html).toContain("Not saved");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ContentIndexStatus>", () => {
|
||||
it("offers indexing when the document is not yet searchable", () => {
|
||||
const html = render(
|
||||
<ContentIndexStatus
|
||||
hasContentIndex={false}
|
||||
isPending={false}
|
||||
onIndex={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="content-index-status"');
|
||||
expect(html).toContain("Contents not searchable yet");
|
||||
expect(html).toContain("Index for search");
|
||||
});
|
||||
|
||||
it("offers a re-index when the document is already searchable", () => {
|
||||
const html = render(
|
||||
<ContentIndexStatus hasContentIndex isPending={false} onIndex={noop} />,
|
||||
);
|
||||
expect(html).toContain("Contents searchable");
|
||||
expect(html).toContain("Re-index");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DocumentSuggestionDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "../document-ai-section";
|
||||
|
||||
/**
|
||||
* The detail sheet's AI area is availability-gated: with a provider it shows the
|
||||
* assist / summary toolbar; without one it shows ONLY the calm settings pointer
|
||||
* (never an action the endpoint would 422). The summary panel, when open, keeps
|
||||
* the session-only note visible.
|
||||
*/
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const suggestion: DocumentSuggestionDto = {
|
||||
title: "MRI knee report",
|
||||
kind: "IMAGING",
|
||||
documentDate: "2026-02-14",
|
||||
};
|
||||
|
||||
function base(
|
||||
overrides: Partial<React.ComponentProps<typeof DocumentAiSection>> = {},
|
||||
) {
|
||||
const props: React.ComponentProps<typeof DocumentAiSection> = {
|
||||
aiEnabled: true,
|
||||
indexEnabled: true,
|
||||
unavailableReason: null,
|
||||
actionsDisabled: false,
|
||||
onSuggest: noop,
|
||||
suggestPending: false,
|
||||
suggestErrorKey: null,
|
||||
onSummarise: noop,
|
||||
summaryPending: false,
|
||||
suggestion: null,
|
||||
suggestionKindLabel: null,
|
||||
suggestionDateLabel: null,
|
||||
appliedFields: { title: false, kind: false, date: false },
|
||||
onUseTitle: noop,
|
||||
onUseKind: noop,
|
||||
onUseDate: noop,
|
||||
onDismissSuggestion: noop,
|
||||
summaryOutput: null,
|
||||
summaryResult: null,
|
||||
summaryErrorKey: null,
|
||||
onCloseSummary: noop,
|
||||
hasContentIndex: false,
|
||||
indexPending: false,
|
||||
onIndex: noop,
|
||||
...overrides,
|
||||
};
|
||||
return <DocumentAiSection {...props} />;
|
||||
}
|
||||
|
||||
describe("<DocumentAiSection>", () => {
|
||||
it("offers the assist + summary toolbar when a provider is available", () => {
|
||||
const html = render(base({ aiEnabled: true }));
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
expect(html).toContain("Suggest details");
|
||||
expect(html).toContain("Summarise");
|
||||
expect(html).toContain("Show extracted text");
|
||||
// No unavailable pointer when the affordance is offered.
|
||||
expect(html).not.toContain('data-slot="assist-unavailable"');
|
||||
});
|
||||
|
||||
it("shows ONLY the calm settings pointer when no provider is available", () => {
|
||||
const html = render(
|
||||
base({ aiEnabled: false, unavailableReason: "no-provider" }),
|
||||
);
|
||||
expect(html).toContain('data-slot="assist-unavailable"');
|
||||
expect(html).toContain('href="/settings/ai"');
|
||||
// The assist / index actions must be absent — never a 422-guaranteed tap.
|
||||
expect(html).not.toContain('data-slot="assist-suggest"');
|
||||
expect(html).not.toContain('data-slot="content-index-status"');
|
||||
});
|
||||
|
||||
it("renders the reviewed suggestion drafts when present", () => {
|
||||
const html = render(
|
||||
base({
|
||||
suggestion,
|
||||
suggestionKindLabel: "Imaging",
|
||||
suggestionDateLabel: "14 Feb 2026",
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-slot="assist-suggestion-review"');
|
||||
expect(html).toContain("MRI knee report");
|
||||
expect(html).toContain("review before saving");
|
||||
});
|
||||
|
||||
it("renders the transient summary panel with the session-only note", () => {
|
||||
const html = render(
|
||||
base({
|
||||
summaryOutput: "summary",
|
||||
summaryResult: { summary: "An MRI report of the left knee." },
|
||||
}),
|
||||
);
|
||||
expect(html).toContain('data-slot="document-summary-panel"');
|
||||
expect(html).toContain("An MRI report of the left knee.");
|
||||
expect(html).toContain("Not saved");
|
||||
});
|
||||
|
||||
it("hides the content-index row when indexing is unavailable", () => {
|
||||
const html = render(base({ aiEnabled: true, indexEnabled: false }));
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
expect(html).not.toContain('data-slot="content-index-status"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.27.22 (Document vault P2) — the presentational AI panels for the document
|
||||
* detail sheet. Pure (props in, markup out) so the review-first contract and
|
||||
* the session-only note are pinned by static-render tests.
|
||||
*
|
||||
* - `AiUnavailableHint` — the calm "set up an AI provider" pointer shown in
|
||||
* place of the AI toolbar when no provider is configured (never an error).
|
||||
* - `AssistSuggestionReview` — the reviewed DRAFT card: suggested title / type
|
||||
* / date, each applied only by an explicit tap. Nothing is written until the
|
||||
* user applies it (and the title lands in the editable field, not on disk).
|
||||
* - `DocumentSummaryPanel` — the transient summary / extracted-text panel with
|
||||
* the persistent "not saved · not a diagnosis" note.
|
||||
* - `ContentIndexStatus` — the per-document searchable state + index action.
|
||||
*/
|
||||
import { Check, FileText, ScanSearch, Sparkles, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DocumentSuggestionDto,
|
||||
DocumentSummaryMode,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import type { DocumentDescribeResult } from "./use-document-assist";
|
||||
|
||||
/** Calm pointer to the AI settings, shown when assist is unavailable. */
|
||||
export function AiUnavailableHint({
|
||||
reason,
|
||||
}: {
|
||||
reason: "no-provider" | "enable-local-ocr" | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const body =
|
||||
reason === "enable-local-ocr"
|
||||
? t("documents.assist.unavailableLocalOcr")
|
||||
: t("documents.assist.unavailableBody");
|
||||
return (
|
||||
<div
|
||||
data-slot="assist-unavailable"
|
||||
className="border-border text-muted-foreground flex items-start gap-2 rounded-lg border border-dashed px-3 py-2.5 text-xs"
|
||||
>
|
||||
<Sparkles className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">
|
||||
{body}{" "}
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="assist-settings-link"
|
||||
className="text-primary font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("documents.assist.unavailableAction")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One reviewed draft row: a suggested value + an explicit apply control. */
|
||||
function ReviewRow({
|
||||
label,
|
||||
value,
|
||||
applied,
|
||||
onApply,
|
||||
applyLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
applied: boolean;
|
||||
onApply: () => void;
|
||||
applyLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-muted-foreground text-xs">{label}</p>
|
||||
<p className="truncate text-sm font-medium">{value}</p>
|
||||
</div>
|
||||
{applied ? (
|
||||
<span className="text-muted-foreground inline-flex items-center gap-1 text-xs">
|
||||
<Check className="size-3.5" aria-hidden />
|
||||
{t("documents.assist.applied")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2.5 text-xs"
|
||||
onClick={onApply}
|
||||
>
|
||||
{applyLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The review-first suggestion card. Suggestions are DRAFTS: applying the title
|
||||
* seeds the editable title field (the user still saves it); applying the type
|
||||
* or date commits that single field through the existing edit-on-commit path —
|
||||
* always an explicit tap, never automatic.
|
||||
*/
|
||||
export function AssistSuggestionReview({
|
||||
suggestion,
|
||||
kindLabel,
|
||||
dateLabel,
|
||||
applied,
|
||||
onUseTitle,
|
||||
onUseKind,
|
||||
onUseDate,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestion: DocumentSuggestionDto;
|
||||
/** Translated label for the suggested kind, or null when none was read. */
|
||||
kindLabel: string | null;
|
||||
/** Formatted suggested date, or null when none was read. */
|
||||
dateLabel: string | null;
|
||||
applied: { title: boolean; kind: boolean; date: boolean };
|
||||
onUseTitle: () => void;
|
||||
onUseKind: () => void;
|
||||
onUseDate: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const hasAny =
|
||||
suggestion.title !== null ||
|
||||
suggestion.kind !== null ||
|
||||
suggestion.documentDate !== null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="assist-suggestion-review"
|
||||
className="border-primary/30 bg-primary/5 space-y-3 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles
|
||||
className="text-primary mt-0.5 size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("documents.assist.reviewTitle")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.assist.reviewHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("documents.assist.dismiss")}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasAny ? (
|
||||
<div className="space-y-2.5">
|
||||
{suggestion.title !== null ? (
|
||||
<ReviewRow
|
||||
label={t("documents.assist.suggestedTitle")}
|
||||
value={suggestion.title}
|
||||
applied={applied.title}
|
||||
onApply={onUseTitle}
|
||||
applyLabel={t("documents.assist.useTitle")}
|
||||
/>
|
||||
) : null}
|
||||
{suggestion.kind !== null && kindLabel !== null ? (
|
||||
<ReviewRow
|
||||
label={t("documents.assist.suggestedKind")}
|
||||
value={kindLabel}
|
||||
applied={applied.kind}
|
||||
onApply={onUseKind}
|
||||
applyLabel={t("documents.assist.apply")}
|
||||
/>
|
||||
) : null}
|
||||
{suggestion.documentDate !== null && dateLabel !== null ? (
|
||||
<ReviewRow
|
||||
label={t("documents.assist.suggestedDate")}
|
||||
value={dateLabel}
|
||||
applied={applied.date}
|
||||
onApply={onUseDate}
|
||||
applyLabel={t("documents.assist.apply")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("documents.assist.empty")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The transient summary / extracted-text panel. The result is shown once and
|
||||
* never persisted; the "not saved · not a diagnosis" note is always present.
|
||||
*/
|
||||
export function DocumentSummaryPanel({
|
||||
output,
|
||||
result,
|
||||
isPending,
|
||||
errorKey,
|
||||
onClose,
|
||||
}: {
|
||||
output: DocumentSummaryMode;
|
||||
result: DocumentDescribeResult | null;
|
||||
isPending: boolean;
|
||||
errorKey: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const heading =
|
||||
output === "text"
|
||||
? t("documents.summary.textTitle")
|
||||
: t("documents.summary.summaryTitle");
|
||||
const body =
|
||||
result === null ? null : "summary" in result ? result.summary : result.text;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="document-summary-panel"
|
||||
className="border-border bg-muted/40 space-y-2 rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-sm font-medium">
|
||||
<FileText className="text-muted-foreground size-4" aria-hidden />
|
||||
{heading}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0"
|
||||
onClick={onClose}
|
||||
aria-label={t("documents.summary.close")}
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<div className="space-y-2" data-slot="document-summary-loading">
|
||||
<Skeleton className="h-3.5 w-full" />
|
||||
<Skeleton className="h-3.5 w-11/12" />
|
||||
<Skeleton className="h-3.5 w-3/4" />
|
||||
</div>
|
||||
) : errorKey ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(errorKey)}
|
||||
</p>
|
||||
) : body !== null ? (
|
||||
<p
|
||||
className={cn(
|
||||
"text-foreground text-sm",
|
||||
output === "text" && "font-mono text-xs whitespace-pre-wrap",
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p className="text-muted-foreground border-border/60 border-t pt-2 text-xs">
|
||||
{t("documents.summary.notSaved")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-document content-search state: whether the body is searchable, and an
|
||||
* explicit index / re-index action. Shown only when indexing is available.
|
||||
*/
|
||||
export function ContentIndexStatus({
|
||||
hasContentIndex,
|
||||
isPending,
|
||||
onIndex,
|
||||
}: {
|
||||
hasContentIndex: boolean;
|
||||
isPending: boolean;
|
||||
onIndex: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="content-index-status"
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 text-xs",
|
||||
hasContentIndex ? "text-muted-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
|
||||
{hasContentIndex
|
||||
? t("documents.contentIndex.searchable")
|
||||
: t("documents.contentIndex.notSearchable")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2.5 text-xs"
|
||||
onClick={onIndex}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending
|
||||
? t("documents.contentIndex.indexing")
|
||||
: hasContentIndex
|
||||
? t("documents.contentIndex.reindexAction")
|
||||
: t("documents.contentIndex.indexAction")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.27.22 (Document vault P2) — the AI area of the document detail sheet,
|
||||
* lifted out of the sheet so the availability gate (toolbar vs. calm pointer)
|
||||
* and the review-first wiring render without the sheet's dialog portal and are
|
||||
* pinned by static-render tests.
|
||||
*
|
||||
* Presentational: the sheet owns the mutations + capability probe and passes
|
||||
* their state + handlers down. When `aiEnabled` is false the whole area is the
|
||||
* calm "set up an AI provider" pointer — never an error, and the manual form
|
||||
* below stays fully usable.
|
||||
*/
|
||||
import { FileText, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
DocumentSuggestionDto,
|
||||
DocumentSummaryMode,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import {
|
||||
AiUnavailableHint,
|
||||
AssistSuggestionReview,
|
||||
ContentIndexStatus,
|
||||
DocumentSummaryPanel,
|
||||
} from "./document-ai-panels";
|
||||
import type { DocumentDescribeResult } from "./use-document-assist";
|
||||
|
||||
export function DocumentAiSection({
|
||||
aiEnabled,
|
||||
indexEnabled,
|
||||
unavailableReason,
|
||||
actionsDisabled,
|
||||
onSuggest,
|
||||
suggestPending,
|
||||
suggestErrorKey,
|
||||
onSummarise,
|
||||
summaryPending,
|
||||
suggestion,
|
||||
suggestionKindLabel,
|
||||
suggestionDateLabel,
|
||||
appliedFields,
|
||||
onUseTitle,
|
||||
onUseKind,
|
||||
onUseDate,
|
||||
onDismissSuggestion,
|
||||
summaryOutput,
|
||||
summaryResult,
|
||||
summaryErrorKey,
|
||||
onCloseSummary,
|
||||
hasContentIndex,
|
||||
indexPending,
|
||||
onIndex,
|
||||
}: {
|
||||
aiEnabled: boolean;
|
||||
indexEnabled: boolean;
|
||||
unavailableReason: "no-provider" | "enable-local-ocr" | null;
|
||||
/** Capability still resolving — the transport mode isn't known yet. */
|
||||
actionsDisabled: boolean;
|
||||
onSuggest: () => void;
|
||||
suggestPending: boolean;
|
||||
suggestErrorKey: string | null;
|
||||
onSummarise: (output: DocumentSummaryMode) => void;
|
||||
summaryPending: boolean;
|
||||
suggestion: DocumentSuggestionDto | null;
|
||||
suggestionKindLabel: string | null;
|
||||
suggestionDateLabel: string | null;
|
||||
appliedFields: { title: boolean; kind: boolean; date: boolean };
|
||||
onUseTitle: () => void;
|
||||
onUseKind: () => void;
|
||||
onUseDate: () => void;
|
||||
onDismissSuggestion: () => void;
|
||||
summaryOutput: DocumentSummaryMode | null;
|
||||
summaryResult: DocumentDescribeResult | null;
|
||||
summaryErrorKey: string | null;
|
||||
onCloseSummary: () => void;
|
||||
hasContentIndex: boolean;
|
||||
indexPending: boolean;
|
||||
onIndex: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (!aiEnabled) {
|
||||
return (
|
||||
<div className="space-y-3" data-slot="document-ai-section">
|
||||
<AiUnavailableHint reason={unavailableReason} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="document-ai-section">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="assist-suggest"
|
||||
onClick={onSuggest}
|
||||
disabled={suggestPending || actionsDisabled}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden />
|
||||
{suggestPending
|
||||
? t("documents.assist.suggesting")
|
||||
: t("documents.assist.suggest")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSummarise("summary")}
|
||||
disabled={summaryPending || actionsDisabled}
|
||||
>
|
||||
<FileText className="size-4" aria-hidden />
|
||||
{t("documents.summary.summarise")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSummarise("text")}
|
||||
disabled={summaryPending || actionsDisabled}
|
||||
>
|
||||
{t("documents.summary.showText")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{suggestErrorKey ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{t(suggestErrorKey)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{suggestion ? (
|
||||
<AssistSuggestionReview
|
||||
suggestion={suggestion}
|
||||
kindLabel={suggestionKindLabel}
|
||||
dateLabel={suggestionDateLabel}
|
||||
applied={appliedFields}
|
||||
onUseTitle={onUseTitle}
|
||||
onUseKind={onUseKind}
|
||||
onUseDate={onUseDate}
|
||||
onDismiss={onDismissSuggestion}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{summaryOutput ? (
|
||||
<DocumentSummaryPanel
|
||||
output={summaryOutput}
|
||||
result={summaryResult}
|
||||
isPending={summaryPending}
|
||||
errorKey={summaryErrorKey}
|
||||
onClose={onCloseSummary}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{indexEnabled ? (
|
||||
<ContentIndexStatus
|
||||
hasContentIndex={hasContentIndex}
|
||||
isPending={indexPending}
|
||||
onIndex={onIndex}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.27.22 (Document vault P2) — the shared client transport for the document
|
||||
* AI routes (suggest / summary / index). One place decides VISION vs local-OCR
|
||||
* TEXT so every affordance posts a shape the endpoint accepts:
|
||||
* - `mode: "vision"` → an empty-body POST; the server decrypts the stored
|
||||
* original and runs one provider call.
|
||||
* - `mode: "text"` → the browser OCR's the image on-device (tesseract, lazy
|
||||
* loaded) and posts ONLY the text. Images only — a PDF/attachment in text
|
||||
* mode is refused client-side before any download/OCR work.
|
||||
*
|
||||
* A provider call is slow, so requests opt out of the default 15 s fetch window
|
||||
* in favour of a generous ceiling.
|
||||
*/
|
||||
import { apiFetchRaw, apiPost } from "@/lib/api/api-fetch";
|
||||
import { ocrImageToText, LocalOcrError } from "@/lib/labs/local-ocr";
|
||||
|
||||
/** The transport an AI call uses, resolved from the OCR capability probe. */
|
||||
export type DocumentAiMode = "vision" | "text";
|
||||
|
||||
/** The document facts the transport needs to fetch + OCR the original. */
|
||||
export interface DocumentAiTarget {
|
||||
documentId: string;
|
||||
mimeType: string;
|
||||
filename: string | null;
|
||||
servingClass: "inline" | "attachment";
|
||||
}
|
||||
|
||||
/**
|
||||
* A client-side precondition failure that never reached the server (the image
|
||||
* couldn't be fetched, the file isn't an image for text mode, or local OCR
|
||||
* failed). Carries a stable `reason` the UI maps to a translated message.
|
||||
*/
|
||||
export class DocumentAssistClientError extends Error {
|
||||
readonly reason: "textImageOnly" | "originalFetch" | "ocr";
|
||||
constructor(reason: DocumentAssistClientError["reason"]) {
|
||||
super(reason);
|
||||
this.name = "DocumentAssistClientError";
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
function isImageMime(mime: string): boolean {
|
||||
return mime.startsWith("image/");
|
||||
}
|
||||
|
||||
/** Fetch the stored original as a `File` for in-browser OCR (same-origin). */
|
||||
async function fetchOriginalAsFile(target: DocumentAiTarget): Promise<File> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await apiFetchRaw(
|
||||
`/api/documents/inbound/${target.documentId}/original`,
|
||||
{ signal: AbortSignal.timeout(30_000) },
|
||||
);
|
||||
} catch {
|
||||
throw new DocumentAssistClientError("originalFetch");
|
||||
}
|
||||
if (!res.ok) throw new DocumentAssistClientError("originalFetch");
|
||||
const blob = await res.blob();
|
||||
return new File([blob], target.filename ?? "document", {
|
||||
type: blob.type || target.mimeType,
|
||||
});
|
||||
}
|
||||
|
||||
/** Run one document AI call over the chosen transport. */
|
||||
export async function runDocumentAi<T>(opts: {
|
||||
path: string;
|
||||
mode: DocumentAiMode;
|
||||
target: DocumentAiTarget;
|
||||
}): Promise<T> {
|
||||
if (opts.mode === "text") {
|
||||
if (
|
||||
opts.target.servingClass !== "inline" ||
|
||||
!isImageMime(opts.target.mimeType)
|
||||
) {
|
||||
// tesseract.js reads images only — refuse before any download/OCR work.
|
||||
throw new DocumentAssistClientError("textImageOnly");
|
||||
}
|
||||
const file = await fetchOriginalAsFile(opts.target);
|
||||
let text: string;
|
||||
try {
|
||||
text = await ocrImageToText(file);
|
||||
} catch (err) {
|
||||
throw err instanceof LocalOcrError
|
||||
? new DocumentAssistClientError("ocr")
|
||||
: err;
|
||||
}
|
||||
return apiPost<T>(
|
||||
opts.path,
|
||||
{ mode: "text", text },
|
||||
{ signal: AbortSignal.timeout(120_000) },
|
||||
);
|
||||
}
|
||||
// Vision: empty body → the route dispatches to its vision handler.
|
||||
return apiPost<T>(opts.path, undefined, {
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 }>({
|
||||
path: `/api/documents/inbound/${opts.target.documentId}/index`,
|
||||
mode: opts.mode,
|
||||
target: opts.target,
|
||||
});
|
||||
}
|
||||
@@ -52,10 +52,21 @@ import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
INBOUND_DOCUMENT_KINDS,
|
||||
type DocumentSuggestionDto,
|
||||
type DocumentSummaryMode,
|
||||
type InboundDocumentDetailDto,
|
||||
type InboundDocumentKindValue,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
import { DocumentAiSection } from "./document-ai-section";
|
||||
import type { DocumentAiTarget } from "./document-ai-transport";
|
||||
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
|
||||
import { useIndexDocument } from "./use-content-index";
|
||||
import {
|
||||
documentAiErrorKey,
|
||||
useDocumentAiCapability,
|
||||
useDocumentSummary,
|
||||
useSuggestDetails,
|
||||
} from "./use-document-assist";
|
||||
import { formatBytes } from "./vault-utils";
|
||||
|
||||
type PatchInput = {
|
||||
@@ -173,10 +184,20 @@ export function DocumentDetailSheet({
|
||||
documentId,
|
||||
open,
|
||||
onOpenChange,
|
||||
assistAvailable,
|
||||
contentIndexEnabled,
|
||||
}: {
|
||||
documentId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/**
|
||||
* Whether the AI "Suggest details" / summary actions can run (from
|
||||
* `usage.assistAvailable`). When false the AI area shows a calm pointer to
|
||||
* the AI settings instead of an error — the manual form stays fully usable.
|
||||
*/
|
||||
assistAvailable?: boolean;
|
||||
/** Whether content indexing is available (from `usage.contentIndex.enabled`). */
|
||||
contentIndexEnabled?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const format = useFormatters();
|
||||
@@ -192,19 +213,51 @@ export function DocumentDetailSheet({
|
||||
|
||||
const episodes = useIllnessEpisodes(true);
|
||||
|
||||
// AI layer — capability drives the vision/text transport; the mutations run
|
||||
// the review-first assist, the session-only summary, and content indexing.
|
||||
// The affordance itself is gated on `assistAvailable` (from usage); the
|
||||
// probe only resolves the transport mode + the actionable unavailable reason.
|
||||
const capability = useDocumentAiCapability(open && documentId !== null);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
const indexDoc = useIndexDocument();
|
||||
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [suggestion, setSuggestion] = useState<DocumentSuggestionDto | null>(
|
||||
null,
|
||||
);
|
||||
const [appliedFields, setAppliedFields] = useState({
|
||||
title: false,
|
||||
kind: false,
|
||||
date: false,
|
||||
});
|
||||
const [summaryOutput, setSummaryOutput] =
|
||||
useState<DocumentSummaryMode | null>(null);
|
||||
|
||||
// Reset transient edit state whenever another document opens —
|
||||
// Reset transient edit + AI state whenever another document opens —
|
||||
// render-phase derived-state adjustment, not an effect.
|
||||
const [lastDocumentId, setLastDocumentId] = useState(documentId);
|
||||
if (lastDocumentId !== documentId) {
|
||||
setLastDocumentId(documentId);
|
||||
setEditingTitle(false);
|
||||
setMutationError(null);
|
||||
setSuggestion(null);
|
||||
setAppliedFields({ title: false, kind: false, date: false });
|
||||
setSummaryOutput(null);
|
||||
}
|
||||
|
||||
// Clear stale mutation state (a prior document's assist error / summary)
|
||||
// when the sheet switches documents or closes — the mutation stores live
|
||||
// outside render, so they reset in an effect rather than during render.
|
||||
useEffect(() => {
|
||||
suggest.reset();
|
||||
summary.reset();
|
||||
indexDoc.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documentId]);
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: (input: PatchInput) =>
|
||||
apiPatch<InboundDocumentDetailDto>(
|
||||
@@ -272,6 +325,75 @@ export function DocumentDetailSheet({
|
||||
patch.mutate({ episodeIds: next });
|
||||
};
|
||||
|
||||
// The affordance gate falls back to the capability probe when the sheet is
|
||||
// rendered without the usage-derived props (deep link before usage loads).
|
||||
const aiEnabled = assistAvailable ?? capability.data?.available ?? false;
|
||||
const indexEnabled =
|
||||
contentIndexEnabled ?? capability.data?.available ?? false;
|
||||
const aiMode = capability.data?.mode === "text" ? "text" : "vision";
|
||||
const aiTarget: DocumentAiTarget | null = doc
|
||||
? {
|
||||
documentId: doc.id,
|
||||
mimeType: doc.mimeType,
|
||||
filename: doc.filename,
|
||||
servingClass: doc.servingClass,
|
||||
}
|
||||
: null;
|
||||
|
||||
const runSuggest = () => {
|
||||
if (!aiTarget) return;
|
||||
setAppliedFields({ title: false, kind: false, date: false });
|
||||
suggest.mutate(
|
||||
{ mode: aiMode, target: aiTarget },
|
||||
{ onSuccess: (result) => setSuggestion(result) },
|
||||
);
|
||||
};
|
||||
|
||||
const runSummary = (output: DocumentSummaryMode) => {
|
||||
if (!aiTarget) return;
|
||||
summary.reset();
|
||||
setSummaryOutput(output);
|
||||
summary.mutate({ mode: aiMode, target: aiTarget, output });
|
||||
};
|
||||
|
||||
const runIndex = () => {
|
||||
if (!aiTarget) return;
|
||||
indexDoc.mutate(
|
||||
{ mode: aiMode, target: aiTarget },
|
||||
{
|
||||
onSuccess: () => toast.success(t("documents.contentIndex.indexed")),
|
||||
onError: (error) => toast.error(t(documentAiErrorKey(error))),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Review-first apply: the title lands in the EDITABLE field (the user still
|
||||
// saves it via blur/Enter — the existing commit path); type and date each
|
||||
// commit that single field on an explicit tap. Never automatic.
|
||||
const applyTitle = () => {
|
||||
if (!suggestion?.title) return;
|
||||
setTitleDraft(suggestion.title);
|
||||
setEditingTitle(true);
|
||||
setAppliedFields((prev) => ({ ...prev, title: true }));
|
||||
};
|
||||
const applyKind = () => {
|
||||
if (!suggestion?.kind) return;
|
||||
patch.mutate({ kind: suggestion.kind });
|
||||
setAppliedFields((prev) => ({ ...prev, kind: true }));
|
||||
};
|
||||
const applyDate = () => {
|
||||
if (!suggestion?.documentDate) return;
|
||||
patch.mutate({ documentDate: suggestion.documentDate });
|
||||
setAppliedFields((prev) => ({ ...prev, date: true }));
|
||||
};
|
||||
|
||||
const suggestionKindLabel = suggestion?.kind
|
||||
? t(`documents.kind.${suggestion.kind}`)
|
||||
: null;
|
||||
const suggestionDateLabel = suggestion?.documentDate
|
||||
? format.date(`${suggestion.documentDate}T12:00:00.000Z`)
|
||||
: null;
|
||||
|
||||
const title = doc?.title ?? doc?.filename ?? t("documents.card.untitled");
|
||||
const Icon = doc ? DOCUMENT_KIND_ICONS[doc.kind] : null;
|
||||
const originalHref = doc ? `/api/documents/inbound/${doc.id}/original` : "#";
|
||||
@@ -352,6 +474,40 @@ export function DocumentDetailSheet({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DocumentAiSection
|
||||
aiEnabled={aiEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
suggestErrorKey={
|
||||
suggest.isError ? documentAiErrorKey(suggest.error) : null
|
||||
}
|
||||
onSummarise={runSummary}
|
||||
summaryPending={summary.isPending}
|
||||
suggestion={suggestion}
|
||||
suggestionKindLabel={suggestionKindLabel}
|
||||
suggestionDateLabel={suggestionDateLabel}
|
||||
appliedFields={appliedFields}
|
||||
onUseTitle={applyTitle}
|
||||
onUseKind={applyKind}
|
||||
onUseDate={applyDate}
|
||||
onDismissSuggestion={() => setSuggestion(null)}
|
||||
summaryOutput={summaryOutput}
|
||||
summaryResult={summary.data ?? null}
|
||||
summaryErrorKey={
|
||||
summary.isError ? documentAiErrorKey(summary.error) : null
|
||||
}
|
||||
onCloseSummary={() => {
|
||||
setSummaryOutput(null);
|
||||
summary.reset();
|
||||
}}
|
||||
hasContentIndex={doc.hasContentIndex}
|
||||
indexPending={indexDoc.isPending}
|
||||
onIndex={runIndex}
|
||||
/>
|
||||
|
||||
{mutationError ? (
|
||||
<p role="alert" className="text-destructive text-sm">
|
||||
{mutationError}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.27.22 (Document vault P2) — content-search indexing hooks.
|
||||
*
|
||||
* - `useIndexDocument()` populates / refreshes ONE document's blind content
|
||||
* index (encrypted extracted text + opaque HMAC token array). Vision decrypts
|
||||
* the stored original server-side; text OCR's the image on-device and posts
|
||||
* only the text — the same transport split the assist hooks use.
|
||||
* - `useReindexAll()` fires the per-user corpus backfill (its own rate bucket);
|
||||
* the worker indexes every not-yet-indexed document off-request.
|
||||
*
|
||||
* Both invalidate the `["documents"]` prefix so the timeline's per-row
|
||||
* `hasContentIndex`, the open detail sheet, and the usage coverage gauge all
|
||||
* refresh in lockstep.
|
||||
*/
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import {
|
||||
runDocumentIndex,
|
||||
type DocumentAiMode,
|
||||
type DocumentAiTarget,
|
||||
} from "./document-ai-transport";
|
||||
|
||||
/** Index / re-index one document for content search. */
|
||||
export function useIndexDocument() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
{ indexed: boolean; tokenCount: number },
|
||||
Error,
|
||||
{ mode: DocumentAiMode; target: DocumentAiTarget }
|
||||
>({
|
||||
mutationFn: ({ mode, target }) => runDocumentIndex({ mode, target }),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Fire the corpus backfill; resolves with how many docs were enqueued. */
|
||||
export function useReindexAll() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<{ enqueued: number }, Error, void>({
|
||||
mutationFn: () =>
|
||||
apiPost<{ enqueued: number }>(
|
||||
"/api/documents/inbound/reindex",
|
||||
undefined,
|
||||
),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.27.22 (Document vault P2) — client hooks for the review-first AI layer on
|
||||
* a stored document: the shared capability probe, filing-metadata suggestions,
|
||||
* and the session-only summary / extracted text.
|
||||
*
|
||||
* Every affordance is gated on `usage.assistAvailable` at the call site; these
|
||||
* hooks only RUN the call once the surface has decided to offer it. The
|
||||
* transport (VISION vs local-OCR TEXT) comes from `document-ai-transport`.
|
||||
*
|
||||
* NOTHING here writes a document row. Suggestions are drafts the detail sheet
|
||||
* prefills; the summary is transient. The only mutation is the caller applying
|
||||
* a reviewed draft through the existing edit-on-commit machinery.
|
||||
*/
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet, ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { OcrCapabilityDto } from "@/lib/validations/labs-ocr";
|
||||
import type {
|
||||
DocumentSuggestionDto,
|
||||
DocumentSummaryMode,
|
||||
} from "@/lib/validations/inbound-documents";
|
||||
|
||||
import {
|
||||
DocumentAssistClientError,
|
||||
runDocumentAi,
|
||||
type DocumentAiMode,
|
||||
type DocumentAiTarget,
|
||||
} from "./document-ai-transport";
|
||||
|
||||
/** The session-only describe result — a summary XOR the raw extracted text. */
|
||||
export type DocumentDescribeResult = { summary: string } | { text: string };
|
||||
|
||||
/**
|
||||
* The shared OCR capability probe (`/api/labs/ocr/capability`) reused verbatim:
|
||||
* the same server resolution both the lab-OCR and the document AI routes gate
|
||||
* on, so the client picks the transport the endpoint accepts. Availability of
|
||||
* the affordance itself is gated on `usage.assistAvailable`; this read only
|
||||
* resolves the transport `mode` + PDF support.
|
||||
*/
|
||||
export function useDocumentAiCapability(enabled: boolean) {
|
||||
return useQuery<OcrCapabilityDto>({
|
||||
queryKey: queryKeys.ocrCapability(),
|
||||
queryFn: () => apiGet<OcrCapabilityDto>("/api/labs/ocr/capability"),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest filing metadata for a stored document. Returns DRAFTS only — the
|
||||
* detail sheet prefills its edit fields and the user saves; this call writes
|
||||
* nothing.
|
||||
*/
|
||||
export function useSuggestDetails() {
|
||||
return useMutation<
|
||||
DocumentSuggestionDto,
|
||||
Error,
|
||||
{ mode: DocumentAiMode; target: DocumentAiTarget }
|
||||
>({
|
||||
mutationFn: async ({ mode, target }) => {
|
||||
const data = await runDocumentAi<{ suggestions: DocumentSuggestionDto }>({
|
||||
path: `/api/documents/inbound/${target.documentId}/suggest`,
|
||||
mode,
|
||||
target,
|
||||
});
|
||||
return data.suggestions;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand, session-only summary or extracted text. The result is rendered
|
||||
* once and never persisted (P2-D4); closing the panel discards it.
|
||||
*/
|
||||
export function useDocumentSummary() {
|
||||
return useMutation<
|
||||
DocumentDescribeResult,
|
||||
Error,
|
||||
{
|
||||
mode: DocumentAiMode;
|
||||
target: DocumentAiTarget;
|
||||
output: DocumentSummaryMode;
|
||||
}
|
||||
>({
|
||||
mutationFn: ({ mode, target, output }) =>
|
||||
runDocumentAi<DocumentDescribeResult>({
|
||||
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}`,
|
||||
mode,
|
||||
target,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Map an AI error to a translation key. Server errors carry a stable
|
||||
* `meta.errorCode`; client-side precondition failures carry a `reason`. The
|
||||
* default is the calm "try again" message — never a raw provider string.
|
||||
*/
|
||||
export function documentAiErrorKey(err: unknown): string {
|
||||
if (err instanceof DocumentAssistClientError) {
|
||||
switch (err.reason) {
|
||||
case "textImageOnly":
|
||||
return "documents.assist.errorTextImageOnly";
|
||||
case "ocr":
|
||||
return "documents.assist.errorOcr";
|
||||
default:
|
||||
return "documents.assist.errorGeneric";
|
||||
}
|
||||
}
|
||||
if (err instanceof ApiError) {
|
||||
const code =
|
||||
typeof err.meta?.errorCode === "string" ? err.meta.errorCode : "";
|
||||
switch (code) {
|
||||
case "documents.inbound.rateLimited":
|
||||
return "documents.assist.errorRateLimited";
|
||||
case "documents.inbound.budgetExceeded":
|
||||
return "documents.assist.errorBudget";
|
||||
case "documents.inbound.pdfNeedsAnthropic":
|
||||
return "documents.assist.errorPdfNeedsVision";
|
||||
case "documents.inbound.fileType":
|
||||
return "documents.assist.errorFileType";
|
||||
case "documents.inbound.localOcrDisabled":
|
||||
return "documents.assist.errorLocalOcrDisabled";
|
||||
case "documents.inbound.providerUnsupported":
|
||||
return "documents.assist.errorProvider";
|
||||
default:
|
||||
return "documents.assist.errorGeneric";
|
||||
}
|
||||
}
|
||||
return "documents.assist.errorGeneric";
|
||||
}
|
||||
Reference in new issue
Block a user