diff --git a/messages/de.json b/messages/de.json index 4f004de08..91fab32a0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index ccd34b44c..3eddae1fa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index a640f86eb..c11a90160 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/fr.json b/messages/fr.json index 87256c3e2..5b1e1aa2c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 43ae6aefa..8039367fe 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 27cfe24be..a7802c80b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/components/documents/__tests__/document-ai-panels.test.tsx b/src/components/documents/__tests__/document-ai-panels.test.tsx new file mode 100644 index 000000000..33dbf1ea0 --- /dev/null +++ b/src/components/documents/__tests__/document-ai-panels.test.tsx @@ -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( + {node}, + ); +} + +const noop = () => {}; + +const suggestion = ( + overrides: Partial = {}, +): DocumentSuggestionDto => ({ + title: "Blood panel — March 2026", + kind: "LAB_RESULT", + documentDate: "2026-03-01", + ...overrides, +}); + +describe("", () => { + it("points calmly at the AI settings when no provider is configured", () => { + const html = render(); + 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(); + expect(html).toContain("local OCR"); + expect(html).toContain('href="/settings/ai"'); + }); +}); + +describe("", () => { + it("presents drafts behind an explicit review-before-saving affordance", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).toContain("Applied"); + }); + + it("shows an empty state when nothing could be read", () => { + const html = render( + , + ); + expect(html).toContain("Nothing could be read from this document"); + }); +}); + +describe("", () => { + it("keeps the not-saved / not-a-diagnosis note on screen while loading", () => { + const html = render( + , + ); + 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toContain('role="alert"'); + expect(html).toContain("read the document"); + expect(html).toContain("Not saved"); + }); +}); + +describe("", () => { + it("offers indexing when the document is not yet searchable", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).toContain("Contents searchable"); + expect(html).toContain("Re-index"); + }); +}); diff --git a/src/components/documents/__tests__/document-ai-section.test.tsx b/src/components/documents/__tests__/document-ai-section.test.tsx new file mode 100644 index 000000000..9a304f2f3 --- /dev/null +++ b/src/components/documents/__tests__/document-ai-section.test.tsx @@ -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( + {node}, + ); +} + +const noop = () => {}; + +const suggestion: DocumentSuggestionDto = { + title: "MRI knee report", + kind: "IMAGING", + documentDate: "2026-02-14", +}; + +function base( + overrides: Partial> = {}, +) { + const props: React.ComponentProps = { + 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 ; +} + +describe("", () => { + 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"'); + }); +}); diff --git a/src/components/documents/document-ai-panels.tsx b/src/components/documents/document-ai-panels.tsx new file mode 100644 index 000000000..d24b0b1f2 --- /dev/null +++ b/src/components/documents/document-ai-panels.tsx @@ -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 ( +
+ +

+ {body}{" "} + + {t("documents.assist.unavailableAction")} + +

+
+ ); +} + +/** 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 ( +
+
+

{label}

+

{value}

+
+ {applied ? ( + + + {t("documents.assist.applied")} + + ) : ( + + )} +
+ ); +} + +/** + * 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 ( +
+
+
+ +
+

+ {t("documents.assist.reviewTitle")} +

+

+ {t("documents.assist.reviewHint")} +

+
+
+ +
+ + {hasAny ? ( +
+ {suggestion.title !== null ? ( + + ) : null} + {suggestion.kind !== null && kindLabel !== null ? ( + + ) : null} + {suggestion.documentDate !== null && dateLabel !== null ? ( + + ) : null} +
+ ) : ( +

+ {t("documents.assist.empty")} +

+ )} +
+ ); +} + +/** + * 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 ( +
+
+

+ + {heading} +

+ +
+ + {isPending ? ( +
+ + + +
+ ) : errorKey ? ( +

+ {t(errorKey)} +

+ ) : body !== null ? ( +

+ {body} +

+ ) : null} + +

+ {t("documents.summary.notSaved")} +

+
+ ); +} + +/** + * 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 ( +
+

+ + {hasContentIndex + ? t("documents.contentIndex.searchable") + : t("documents.contentIndex.notSearchable")} +

+ +
+ ); +} diff --git a/src/components/documents/document-ai-section.tsx b/src/components/documents/document-ai-section.tsx new file mode 100644 index 000000000..10070d160 --- /dev/null +++ b/src/components/documents/document-ai-section.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+
+ + + +
+ + {suggestErrorKey ? ( +

+ {t(suggestErrorKey)} +

+ ) : null} + + {suggestion ? ( + + ) : null} + + {summaryOutput ? ( + + ) : null} + + {indexEnabled ? ( + + ) : null} +
+ ); +} diff --git a/src/components/documents/document-ai-transport.ts b/src/components/documents/document-ai-transport.ts new file mode 100644 index 000000000..f1399a902 --- /dev/null +++ b/src/components/documents/document-ai-transport.ts @@ -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 { + 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(opts: { + path: string; + mode: DocumentAiMode; + target: DocumentAiTarget; +}): Promise { + 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( + opts.path, + { mode: "text", text }, + { signal: AbortSignal.timeout(120_000) }, + ); + } + // Vision: empty body → the route dispatches to its vision handler. + return apiPost(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, + }); +} diff --git a/src/components/documents/document-detail-sheet.tsx b/src/components/documents/document-detail-sheet.tsx index 1c89bb981..633f51c70 100644 --- a/src/components/documents/document-detail-sheet.tsx +++ b/src/components/documents/document-detail-sheet.tsx @@ -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(null); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(""); + const [suggestion, setSuggestion] = useState( + null, + ); + const [appliedFields, setAppliedFields] = useState({ + title: false, + kind: false, + date: false, + }); + const [summaryOutput, setSummaryOutput] = + useState(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( @@ -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({ )} + 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 ? (

{mutationError} diff --git a/src/components/documents/use-content-index.ts b/src/components/documents/use-content-index.ts new file mode 100644 index 000000000..bfbe9d8ca --- /dev/null +++ b/src/components/documents/use-content-index.ts @@ -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()]); + }, + }); +} diff --git a/src/components/documents/use-document-assist.ts b/src/components/documents/use-document-assist.ts new file mode 100644 index 000000000..d0b0c6b53 --- /dev/null +++ b/src/components/documents/use-document-assist.ts @@ -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({ + queryKey: queryKeys.ocrCapability(), + queryFn: () => apiGet("/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({ + 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"; +}