mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix: localize document summaries and refine mobile coach
This commit is contained in:
1 parent
f1a1887a01
commit
3aa8ccd541
22 files changed
+434
-417
No files matched your search
@@ -2,6 +2,19 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.31.3] — 2026-07-20
|
||||
|
||||
- **Document summaries now stay in the language you chose.** The same account
|
||||
language is used whether a summary is created in the background or requested
|
||||
from the document sheet. A saved summary remains visible and can be replaced
|
||||
with **Generate again** after you switch languages.
|
||||
- **The Coach composer has more room on phones.** The writing field now gets
|
||||
its own line, with document and conversation actions below it and Send on the
|
||||
right. The unreliable browser dictation button and its dead-end permission
|
||||
states are gone.
|
||||
|
||||
No migrations. No breaking changes.
|
||||
|
||||
## [1.31.2] — 2026-07-20
|
||||
|
||||
- **Provider imports now commit identity and progress atomically.** Withings,
|
||||
|
||||
+29
-9
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.31.2
|
||||
version: 1.31.3
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -10219,13 +10219,13 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Documents
|
||||
summary: Summarise or transcribe a document (session-only)
|
||||
description: 'On-demand, SESSION-ONLY description of a stored document. `?mode=summary` (default) returns a short
|
||||
plain-language summary of WHAT the document is; `?mode=text` returns its raw transcribed text. The result is
|
||||
transient — nothing is persisted except the AI-budget ledger + audit log; it never reaches coach memory,
|
||||
snapshots, the structured stores, or the search index. The summary is descriptive only and never a diagnosis.
|
||||
Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as
|
||||
extract. AI-consent / rate / budget gated. 422 with no provider.'
|
||||
summary: Summarise or transcribe a document
|
||||
description: 'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language
|
||||
summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot,
|
||||
while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw
|
||||
transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The
|
||||
summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode:
|
||||
"text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.'
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
@@ -10242,6 +10242,20 @@ paths:
|
||||
- text
|
||||
default: summary
|
||||
description: "`summary` (plain-language description) or `text` (raw)."
|
||||
- name: persist
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: "For summary mode only: persist into an empty stored-summary slot."
|
||||
- name: replace
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: With `persist=true`, explicitly replace an existing stored summary.
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
@@ -10271,7 +10285,7 @@ paths:
|
||||
- text
|
||||
responses:
|
||||
"200":
|
||||
description: The session-only summary (`{ summary }`) or extracted text (`{ text }`).
|
||||
description: The summary and its persistence outcome, or transient extracted text.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -30389,6 +30403,12 @@ components:
|
||||
type: string
|
||||
text:
|
||||
type: string
|
||||
persistence:
|
||||
type: string
|
||||
enum:
|
||||
- stored
|
||||
- withheld
|
||||
- failed
|
||||
additionalProperties: false
|
||||
DocumentIndexEnvelope:
|
||||
type: object
|
||||
|
||||
+1
-4
@@ -2469,10 +2469,6 @@
|
||||
"settingsClustersHint": "Wähle, welche Datencluster der Coach lesen darf. Mehr Daten bedeuten reichhaltigere Antworten und einen größeren Prompt.",
|
||||
"settingsClusterNoData": "Noch keine Daten",
|
||||
"sourcesMemberCount": "{count} Werte",
|
||||
"dictate": "Diktieren",
|
||||
"dictateStop": "Diktat beenden",
|
||||
"voiceUnsupported": "Spracheingabe wird in diesem Browser nicht unterstützt",
|
||||
"dictateError": "Spracheingabe konnte nicht gestartet werden. Prüfe die Mikrofonberechtigung und versuche es erneut.",
|
||||
"showConversations": "Unterhaltungen anzeigen",
|
||||
"hideConversations": "Unterhaltungen ausblenden",
|
||||
"heroGreeting": "Frage mich etwas zu deinen Daten",
|
||||
@@ -8536,6 +8532,7 @@
|
||||
"withheld": "Eine Zusammenfassung wurde erstellt, hat die Sicherheitsprüfung aber nicht bestanden und wird deshalb nicht angezeigt. Du kannst es erneut versuchen oder das Dokument selbst lesen.",
|
||||
"unavailable": "Für dieses Dokument konnte keine Zusammenfassung erstellt werden.",
|
||||
"generate": "Zusammenfassung erstellen",
|
||||
"regenerate": "Neu erstellen",
|
||||
"generating": "Wird erstellt…"
|
||||
},
|
||||
"loadError": "Dieses Dokument konnte nicht geladen werden.",
|
||||
|
||||
+1
-4
@@ -2469,10 +2469,6 @@
|
||||
"settingsClustersHint": "Pick which clusters of your data the Coach can read. More data means richer answers and a larger prompt.",
|
||||
"settingsClusterNoData": "No data yet",
|
||||
"sourcesMemberCount": "{count} metrics",
|
||||
"dictate": "Dictate",
|
||||
"dictateStop": "Stop dictation",
|
||||
"voiceUnsupported": "Voice input is not supported in this browser",
|
||||
"dictateError": "Could not start voice input. Check microphone permission and try again.",
|
||||
"showConversations": "Show conversations",
|
||||
"hideConversations": "Hide conversations",
|
||||
"heroGreeting": "Ask me anything about your data",
|
||||
@@ -8536,6 +8532,7 @@
|
||||
"withheld": "A summary was generated but did not pass the safety check, so it is not shown. You can try again, or read the document itself.",
|
||||
"unavailable": "No summary could be generated for this document.",
|
||||
"generate": "Generate summary",
|
||||
"regenerate": "Generate again",
|
||||
"generating": "Generating…"
|
||||
},
|
||||
"loadError": "Couldn't load this document.",
|
||||
|
||||
+1
-4
@@ -2469,10 +2469,6 @@
|
||||
"settingsClustersHint": "Elige qué grupos de datos puede leer el Coach. Más datos significan respuestas más ricas y un prompt mayor.",
|
||||
"settingsClusterNoData": "Aún no hay datos",
|
||||
"sourcesMemberCount": "{count} métricas",
|
||||
"dictate": "Dictar",
|
||||
"dictateStop": "Detener el dictado",
|
||||
"voiceUnsupported": "La entrada de voz no es compatible con este navegador",
|
||||
"dictateError": "No se pudo iniciar la entrada de voz. Comprueba el permiso del micrófono e inténtalo de nuevo.",
|
||||
"showConversations": "Mostrar conversaciones",
|
||||
"hideConversations": "Ocultar conversaciones",
|
||||
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
|
||||
@@ -8536,6 +8532,7 @@
|
||||
"withheld": "Se generó un resumen, pero no superó la comprobación de seguridad, así que no se muestra. Puedes intentarlo de nuevo o leer el documento.",
|
||||
"unavailable": "No se pudo generar ningún resumen para este documento.",
|
||||
"generate": "Generar resumen",
|
||||
"regenerate": "Volver a generar",
|
||||
"generating": "Generando…"
|
||||
},
|
||||
"loadError": "No se pudo cargar este documento.",
|
||||
|
||||
+1
-4
@@ -2469,10 +2469,6 @@
|
||||
"settingsClustersHint": "Choisissez les groupes de données que le Coach peut lire. Plus de données, des réponses plus riches et un prompt plus grand.",
|
||||
"settingsClusterNoData": "Pas encore de données",
|
||||
"sourcesMemberCount": "{count} mesures",
|
||||
"dictate": "Dicter",
|
||||
"dictateStop": "Arrêter la dictée",
|
||||
"voiceUnsupported": "La saisie vocale n'est pas prise en charge par ce navigateur",
|
||||
"dictateError": "Impossible de démarrer la saisie vocale. Vérifiez l'autorisation du microphone et réessayez.",
|
||||
"showConversations": "Afficher les conversations",
|
||||
"hideConversations": "Masquer les conversations",
|
||||
"heroGreeting": "Pose-moi une question sur tes données",
|
||||
@@ -8536,6 +8532,7 @@
|
||||
"withheld": "Un résumé a été généré mais n’a pas passé le contrôle de sécurité, il n’est donc pas affiché. Vous pouvez réessayer ou lire le document lui-même.",
|
||||
"unavailable": "Aucun résumé n’a pu être généré pour ce document.",
|
||||
"generate": "Générer le résumé",
|
||||
"regenerate": "Regénérer",
|
||||
"generating": "Génération…"
|
||||
},
|
||||
"loadError": "Impossible de charger ce document.",
|
||||
|
||||
+1
-4
@@ -2469,10 +2469,6 @@
|
||||
"settingsClustersHint": "Scegli quali gruppi di dati il Coach può leggere. Più dati significano risposte più ricche e un prompt più grande.",
|
||||
"settingsClusterNoData": "Ancora nessun dato",
|
||||
"sourcesMemberCount": "{count} metriche",
|
||||
"dictate": "Detta",
|
||||
"dictateStop": "Interrompi dettatura",
|
||||
"voiceUnsupported": "L'inserimento vocale non è supportato in questo browser",
|
||||
"dictateError": "Impossibile avviare l'inserimento vocale. Controlla l'autorizzazione del microfono e riprova.",
|
||||
"showConversations": "Mostra conversazioni",
|
||||
"hideConversations": "Nascondi conversazioni",
|
||||
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
|
||||
@@ -8536,6 +8532,7 @@
|
||||
"withheld": "Un riepilogo è stato generato ma non ha superato il controllo di sicurezza, quindi non viene mostrato. Puoi riprovare o leggere il documento stesso.",
|
||||
"unavailable": "Non è stato possibile generare un riepilogo per questo documento.",
|
||||
"generate": "Genera riepilogo",
|
||||
"regenerate": "Genera di nuovo",
|
||||
"generating": "Generazione…"
|
||||
},
|
||||
"loadError": "Impossibile caricare questo documento.",
|
||||
|
||||
+1
-4
@@ -2469,10 +2469,6 @@
|
||||
"settingsClustersHint": "Wybierz, które grupy danych Coach może czytać. Więcej danych to bogatsze odpowiedzi i większy prompt.",
|
||||
"settingsClusterNoData": "Brak danych",
|
||||
"sourcesMemberCount": "{count} wskaźników",
|
||||
"dictate": "Dyktuj",
|
||||
"dictateStop": "Zatrzymaj dyktowanie",
|
||||
"voiceUnsupported": "Wprowadzanie głosowe nie jest obsługiwane w tej przeglądarce",
|
||||
"dictateError": "Nie udało się uruchomić wprowadzania głosowego. Sprawdź uprawnienia mikrofonu i spróbuj ponownie.",
|
||||
"showConversations": "Pokaż rozmowy",
|
||||
"hideConversations": "Ukryj rozmowy",
|
||||
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
|
||||
@@ -8536,6 +8532,7 @@
|
||||
"withheld": "Podsumowanie zostało wygenerowane, ale nie przeszło kontroli bezpieczeństwa, więc nie jest wyświetlane. Możesz spróbować ponownie lub przeczytać sam dokument.",
|
||||
"unavailable": "Nie udało się wygenerować podsumowania dla tego dokumentu.",
|
||||
"generate": "Wygeneruj podsumowanie",
|
||||
"regenerate": "Wygeneruj ponownie",
|
||||
"generating": "Generowanie…"
|
||||
},
|
||||
"loadError": "Nie udało się wczytać tego dokumentu.",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.31.2",
|
||||
"version": "1.31.3",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.31.2";
|
||||
/* @sw-version-fallback */ "v1.31.3";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -93,18 +93,25 @@ const SESSION_OK = {
|
||||
user: { id: "user-1", username: "tester", role: "USER" as const },
|
||||
};
|
||||
const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
|
||||
const req = (id: string, mode?: string) =>
|
||||
new NextRequest(
|
||||
new URL(
|
||||
`http://localhost/api/documents/inbound/${id}/summary${mode ? `?mode=${mode}` : ""}`,
|
||||
),
|
||||
const req = (
|
||||
id: string,
|
||||
mode?: string,
|
||||
options: { persist?: boolean; replace?: boolean } = {},
|
||||
) => {
|
||||
const query = new URLSearchParams();
|
||||
if (mode) query.set("mode", mode);
|
||||
if (options.persist) query.set("persist", "true");
|
||||
if (options.replace) query.set("replace", "true");
|
||||
const suffix = query.size > 0 ? `?${query.toString()}` : "";
|
||||
return new NextRequest(
|
||||
new URL(`http://localhost/api/documents/inbound/${id}/summary${suffix}`),
|
||||
{ method: "POST" },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Nothing beyond the summary column may be written. `mode=summary` DOES persist
|
||||
* onto the document since v1.30.31 (see the route header) — that leg is asserted
|
||||
* explicitly below; everything else stays session-only.
|
||||
* Nothing beyond the explicitly requested summary column may be written.
|
||||
* Transcription and ordinary action-row summaries stay session-only.
|
||||
*/
|
||||
function assertNoPersistence(): void {
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
@@ -124,6 +131,9 @@ beforeEach(() => {
|
||||
mimeType: "image/png",
|
||||
status: "STORED",
|
||||
} as never);
|
||||
vi.mocked(prisma.inboundDocument.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
});
|
||||
vi.mocked(resolveDocumentVisionProvider).mockResolvedValue({
|
||||
chain: [{ providerType: "anthropic", instance: {} }],
|
||||
pick: {
|
||||
@@ -135,9 +145,9 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
it("mode=summary returns { summary } and STORES it, so a second open is free", async () => {
|
||||
it("keeps an ordinary requested summary session-only", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "A blood panel from a lab.",
|
||||
summary: "A transient summary.",
|
||||
blocked: null,
|
||||
} as never);
|
||||
|
||||
@@ -145,16 +155,34 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
req("doc-1", "summary") as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toEqual({
|
||||
summary: "A transient summary.",
|
||||
});
|
||||
expect(prisma.inboundDocument.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores the first requested summary without replacing an existing one", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "A blood panel from a lab.",
|
||||
blocked: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req("doc-1", "summary", { persist: true }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual({ summary: "A blood panel from a lab." });
|
||||
expect(body.data).toEqual({
|
||||
summary: "A blood panel from a lab.",
|
||||
persistence: "stored",
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"documents.inbound.summary",
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Persisted READY, owner-scoped, and only onto a document without one —
|
||||
// an explicit re-run must not replace a summary already on screen.
|
||||
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "doc-1",
|
||||
@@ -167,6 +195,49 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
assertNoPersistence();
|
||||
});
|
||||
|
||||
it("replaces a stored summary only when replacement is explicit", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "A newly generated summary.",
|
||||
blocked: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req("doc-1", "summary", { persist: true, replace: true }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
},
|
||||
data: expect.objectContaining({ summaryState: "READY" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the signed-in user's locale for a requested summary", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
...SESSION_OK,
|
||||
user: { ...SESSION_OK.user, locale: "de" },
|
||||
} as never);
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "Ein Laborbericht.",
|
||||
blocked: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req("doc-1", "summary") as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(runDocumentSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ locale: "de" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("records WITHHELD without ever storing the blocked prose", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "raise the dose to 10 mg",
|
||||
@@ -174,7 +245,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req("doc-1", "summary") as never,
|
||||
req("doc-1", "summary", { persist: true }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -182,6 +253,7 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
// The user gets the honest statement, never the blocked text.
|
||||
const body = await res.json();
|
||||
expect(body.data.summary).toBe("The summary was withheld.");
|
||||
expect(body.data.persistence).toBe("withheld");
|
||||
expect(body.data.summary).not.toContain("10 mg");
|
||||
|
||||
// Only the state lands. No ciphertext column is touched on this path.
|
||||
@@ -201,6 +273,27 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
assertNoPersistence();
|
||||
});
|
||||
|
||||
it("reports a failed save when the document changed before the write", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "A blood panel from a lab.",
|
||||
blocked: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.inboundDocument.updateMany).mockResolvedValueOnce({
|
||||
count: 0,
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
req("doc-1", "summary", { persist: true }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toEqual({
|
||||
summary: "A blood panel from a lab.",
|
||||
persistence: "failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("still answers when the summary write fails", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "A blood panel from a lab.",
|
||||
@@ -213,12 +306,13 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
// The user is waiting on a summary they can already read; a failed write
|
||||
// must not turn that into an error.
|
||||
const res = await POST(
|
||||
req("doc-1", "summary") as never,
|
||||
req("doc-1", "summary", { persist: true }) as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toEqual({
|
||||
summary: "A blood panel from a lab.",
|
||||
persistence: "failed",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -86,39 +86,56 @@ function rateLimited(rl: Awaited<ReturnType<typeof checkRateLimit>>): Response {
|
||||
return response;
|
||||
}
|
||||
|
||||
type SummaryPersistenceTarget = {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
replaceExisting: boolean;
|
||||
};
|
||||
|
||||
function resolveMode(request: NextRequest): DocumentSummaryMode {
|
||||
const raw = new URL(request.url).searchParams.get("mode");
|
||||
return (DOCUMENT_SUMMARY_MODES as readonly string[]).includes(raw ?? "")
|
||||
? (raw as DocumentSummaryMode)
|
||||
: "summary";
|
||||
}
|
||||
function resolvePersistRequested(request: NextRequest): boolean {
|
||||
return new URL(request.url).searchParams.get("persist") === "true";
|
||||
}
|
||||
|
||||
function resolveReplaceExisting(request: NextRequest): boolean {
|
||||
return new URL(request.url).searchParams.get("replace") === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the requested describe call (summary or raw text) over the input.
|
||||
*
|
||||
* `mode=summary` PERSISTS a clean result onto the document (v1.30.31). The
|
||||
* route was session-only by design (P2-D4), and the transcription leg still is
|
||||
* — but a summary that the user explicitly asked for and that passed the screen
|
||||
* is exactly the artefact the background job stores, and re-deriving it on every
|
||||
* open would mean paying a provider call to show the same paragraph twice. This
|
||||
* is a deliberate user action, not a warm-on-mount: nothing generates because a
|
||||
* view rendered. It is also the only repair path for the documents the
|
||||
* background job never ran for — those uploaded before the auto-read opt-in
|
||||
* went on, which no backfill ever reaches.
|
||||
* Ordinary action-row summaries and raw transcription remain session-only.
|
||||
* The document summary block opts into persistence so an empty document can be
|
||||
* repaired and an existing stored summary can be explicitly regenerated.
|
||||
*/
|
||||
async function describe(
|
||||
mode: DocumentSummaryMode,
|
||||
input: DescribeInput,
|
||||
locale: Locale,
|
||||
persist: { userId: string; documentId: string } | null,
|
||||
): Promise<{ summary: string } | { text: string }> {
|
||||
persist: SummaryPersistenceTarget | null,
|
||||
): Promise<
|
||||
| {
|
||||
summary: string;
|
||||
persistence?: "stored" | "withheld" | "failed";
|
||||
}
|
||||
| { text: string }
|
||||
> {
|
||||
if (mode === "text") return transcribeDocument(input);
|
||||
// REPLACE policy: the user clicked "summarise" and is waiting, so a screened
|
||||
// summary becomes a short honest statement rather than an empty panel. The
|
||||
// document itself and the extracted text remain reachable and unaltered.
|
||||
// A normal summary request fills an empty slot only. Replacement is reserved
|
||||
// for the document detail's explicit "Generate again" action.
|
||||
const { summary, blocked } = await runDocumentSummary({ ...input, locale });
|
||||
if (persist) await persistSummary(persist, summary, blocked);
|
||||
return { summary: blocked ? documentSummaryBlockedCopy(locale) : summary };
|
||||
const persistence = persist
|
||||
? await persistSummary(persist, summary, blocked)
|
||||
: undefined;
|
||||
return {
|
||||
summary: blocked ? documentSummaryBlockedCopy(locale) : summary,
|
||||
...(persistence ? { persistence } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,10 +150,10 @@ async function describe(
|
||||
* read in the response; a failed write must not turn that into an error.
|
||||
*/
|
||||
async function persistSummary(
|
||||
target: { userId: string; documentId: string },
|
||||
target: SummaryPersistenceTarget,
|
||||
summary: string,
|
||||
blocked: OutboundReason | null,
|
||||
): Promise<void> {
|
||||
): Promise<"stored" | "withheld" | "failed"> {
|
||||
try {
|
||||
if (blocked) {
|
||||
await prisma.inboundDocument.updateMany({
|
||||
@@ -148,16 +165,17 @@ async function persistSummary(
|
||||
},
|
||||
data: { summaryState: "WITHHELD" },
|
||||
});
|
||||
return;
|
||||
return "withheld";
|
||||
}
|
||||
// Scoped to a document without a stored summary: an explicit re-run must
|
||||
// not silently replace a summary the user already has on screen.
|
||||
// Preserve a previously stored summary unless the caller explicitly chose
|
||||
// the replacement action. Failed or screened attempts never reach this
|
||||
// write, so the previous clean summary remains available in those cases.
|
||||
const written = await prisma.inboundDocument.updateMany({
|
||||
where: {
|
||||
id: target.documentId,
|
||||
userId: target.userId,
|
||||
deletedAt: null,
|
||||
summaryEncrypted: null,
|
||||
...(target.replaceExisting ? {} : { summaryEncrypted: null }),
|
||||
},
|
||||
data: {
|
||||
summaryEncrypted: encryptDocumentSummary(summary),
|
||||
@@ -169,11 +187,13 @@ async function persistSummary(
|
||||
action: { name: "documents.summary.persisted" },
|
||||
meta: { documentId: target.documentId, stored: written.count > 0 },
|
||||
});
|
||||
return written.count > 0 ? "stored" : "failed";
|
||||
} catch {
|
||||
annotate({
|
||||
action: { name: "documents.summary.persistFailed" },
|
||||
meta: { documentId: target.documentId },
|
||||
});
|
||||
return blocked ? "withheld" : "failed";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +220,14 @@ export const POST = apiHandler(
|
||||
}
|
||||
|
||||
const mode = resolveMode(request);
|
||||
const persist: SummaryPersistenceTarget | null =
|
||||
mode === "summary" && resolvePersistRequested(request)
|
||||
? {
|
||||
userId: user.id,
|
||||
documentId: document.id,
|
||||
replaceExisting: resolveReplaceExisting(request),
|
||||
}
|
||||
: null;
|
||||
// The outbound screen on the summary needs the reader's locale to pick its
|
||||
// pattern banks; resolve it once here and thread it into both describe legs.
|
||||
const locale = await resolveServerLocale({
|
||||
@@ -208,9 +236,23 @@ export const POST = apiHandler(
|
||||
});
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return handleTextSummary(request, user.id, document, mode, locale);
|
||||
return handleTextSummary(
|
||||
request,
|
||||
user.id,
|
||||
document,
|
||||
mode,
|
||||
locale,
|
||||
persist,
|
||||
);
|
||||
}
|
||||
return handleVisionSummary(request, user.id, document, mode, locale);
|
||||
return handleVisionSummary(
|
||||
request,
|
||||
user.id,
|
||||
document,
|
||||
mode,
|
||||
locale,
|
||||
persist,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -241,6 +283,7 @@ async function handleTextSummary(
|
||||
document: LoadedDocument,
|
||||
mode: DocumentSummaryMode,
|
||||
locale: Locale,
|
||||
persist: SummaryPersistenceTarget | null,
|
||||
): Promise<Response> {
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -315,7 +358,7 @@ async function handleTextSummary(
|
||||
ocrText: parsed.data.text,
|
||||
},
|
||||
locale,
|
||||
{ userId, documentId: document.id },
|
||||
persist,
|
||||
);
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
@@ -337,6 +380,7 @@ async function handleVisionSummary(
|
||||
document: LoadedDocument,
|
||||
mode: DocumentSummaryMode,
|
||||
locale: Locale,
|
||||
persist: SummaryPersistenceTarget | null,
|
||||
): Promise<Response> {
|
||||
const { pick } = await resolveDocumentVisionProvider(userId);
|
||||
if (!pick) {
|
||||
@@ -401,7 +445,7 @@ async function handleVisionSummary(
|
||||
documents: vision.documents,
|
||||
},
|
||||
locale,
|
||||
{ userId, documentId: document.id },
|
||||
persist,
|
||||
);
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
|
||||
@@ -55,11 +55,32 @@ describe("DocumentSummaryBlock — a stored summary", () => {
|
||||
expect(html).toContain("A discharge letter from a city hospital.");
|
||||
expect(html).toContain("14 Feb 2026");
|
||||
expect(html).toContain('data-slot="document-detail-summary"');
|
||||
// Nothing claims it is being made — it already exists.
|
||||
// A stored result stays visible and can be explicitly regenerated.
|
||||
expect(html).not.toContain("being prepared");
|
||||
expect(html).toContain('data-slot="document-detail-summary-regenerate"');
|
||||
expect(html).toContain("Generate again");
|
||||
const header = html.match(
|
||||
/<div[^>]*data-slot="document-detail-summary-header"[^>]*>/,
|
||||
);
|
||||
expect(header?.[0]).toMatch(/\bflex-col\b/);
|
||||
expect(header?.[0]).toMatch(/\bsm:flex-row\b/);
|
||||
expect(html).not.toContain("document-detail-summary-generate");
|
||||
});
|
||||
|
||||
it("disables regeneration while a replacement is being generated", () => {
|
||||
const html = base({
|
||||
summary: "Stored prose.",
|
||||
summaryState: "READY",
|
||||
isGenerating: true,
|
||||
});
|
||||
|
||||
const action = html.match(
|
||||
/<button[^>]*data-slot="document-detail-summary-regenerate"[^>]*>/,
|
||||
);
|
||||
expect(action?.[0]).toMatch(/\sdisabled(=""|\s|>)/);
|
||||
expect(html).toContain("Generating");
|
||||
});
|
||||
|
||||
it("shows a stored summary even if the state disagrees", () => {
|
||||
// The bytes are the truth. A stale state must never hide a real summary.
|
||||
const html = base({ summary: "Stored prose.", summaryState: "PENDING" });
|
||||
|
||||
@@ -260,6 +260,9 @@ export function DocumentDetailSheet({
|
||||
const autoRead = useDocumentsAutoAiRead(open && documentId !== null);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
// Stored generation has an independent mutation state so transient
|
||||
// Summarise/Show text requests cannot change this block's spinner or result.
|
||||
const storedSummary = useDocumentSummary();
|
||||
const indexDoc = useIndexDocument();
|
||||
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
@@ -296,6 +299,7 @@ export function DocumentDetailSheet({
|
||||
useEffect(() => {
|
||||
suggest.reset();
|
||||
summary.reset();
|
||||
storedSummary.reset();
|
||||
indexDoc.reset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documentId]);
|
||||
@@ -399,17 +403,29 @@ export function DocumentDetailSheet({
|
||||
summary.mutate({ mode: aiMode, target: aiTarget, output });
|
||||
};
|
||||
|
||||
// Generate the STORED summary from the detail block. Same endpoint as the
|
||||
// action row, but the result is not routed into the transient panel: the
|
||||
// route persists a screened-clean summary, so refetching the document shows
|
||||
// it in place. This runs only on a click — nothing here generates on open.
|
||||
// Generate or regenerate the STORED summary from the detail block. Only the
|
||||
// stored-summary branch requests replacement; the empty-state action keeps
|
||||
// first-write-wins if another worker or tab finishes while AI is running.
|
||||
const generateStoredSummary = () => {
|
||||
if (!aiTarget || !documentId) return;
|
||||
summary.reset();
|
||||
summary.mutate(
|
||||
{ mode: aiMode, target: aiTarget, output: "summary" },
|
||||
storedSummary.reset();
|
||||
storedSummary.mutate(
|
||||
{
|
||||
onSuccess: () => {
|
||||
mode: aiMode,
|
||||
target: aiTarget,
|
||||
output: "summary",
|
||||
persist: true,
|
||||
replace: doc?.summary != null,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if ("summary" in result) {
|
||||
if (result.persistence === "withheld") {
|
||||
toast.error(result.summary);
|
||||
} else if (result.persistence === "failed") {
|
||||
toast.error(t("documents.detail.saveError"));
|
||||
}
|
||||
}
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.inboundDocument(documentId),
|
||||
});
|
||||
@@ -611,7 +627,7 @@ export function DocumentDetailSheet({
|
||||
: null
|
||||
}
|
||||
aiEnabled={aiEnabled}
|
||||
isGenerating={summary.isPending}
|
||||
isGenerating={storedSummary.isPending}
|
||||
actionsDisabled={capability.isPending}
|
||||
onGenerate={generateStoredSummary}
|
||||
/>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
* provider call. With no AI provider configured the block renders the state
|
||||
* without an action rather than an button that would 422.
|
||||
*/
|
||||
import { FileText } from "lucide-react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -63,13 +63,42 @@ export function DocumentSummaryBlock({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// A stored summary wins over every state: it is shown from storage.
|
||||
// A stored summary wins over every state: it is shown from storage. The
|
||||
// adjacent action is explicit, so opening the sheet never spends a provider
|
||||
// call; a successful replacement is persisted by the summary route.
|
||||
if (summary) {
|
||||
return (
|
||||
<section className="space-y-1.5" data-slot="document-detail-summary">
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.summary.title")}
|
||||
</p>
|
||||
<section className="space-y-2" data-slot="document-detail-summary">
|
||||
<div
|
||||
data-slot="document-detail-summary-header"
|
||||
className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p className="text-sm leading-none font-medium">
|
||||
{t("documents.detail.summary.title")}
|
||||
</p>
|
||||
{aiEnabled ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
data-slot="document-detail-summary-regenerate"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating || actionsDisabled}
|
||||
>
|
||||
<RefreshCw
|
||||
className={
|
||||
isGenerating
|
||||
? "size-4 animate-spin motion-reduce:animate-none"
|
||||
: "size-4"
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
{isGenerating
|
||||
? t("documents.detail.summary.generating")
|
||||
: t("documents.detail.summary.regenerate")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-foreground text-sm">{summary}</p>
|
||||
{generatedAtLabel ? (
|
||||
<p className="text-muted-foreground text-xs">{generatedAtLabel}</p>
|
||||
|
||||
@@ -30,8 +30,13 @@ import {
|
||||
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 describe result: raw text, or a summary plus its storage outcome. */
|
||||
export type DocumentDescribeResult =
|
||||
| {
|
||||
summary: string;
|
||||
persistence?: "stored" | "withheld" | "failed";
|
||||
}
|
||||
| { text: string };
|
||||
|
||||
/**
|
||||
* The document-scoped AI capability probe (`/api/documents/inbound/capability`).
|
||||
@@ -95,8 +100,8 @@ export function useSuggestDetails() {
|
||||
}
|
||||
|
||||
/**
|
||||
* On-demand, session-only summary or extracted text. The result is rendered
|
||||
* once and never persisted (P2-D4); closing the panel discards it.
|
||||
* On-demand summary or extracted text. Both stay transient unless the document
|
||||
* summary block explicitly requests persistence or replacement.
|
||||
*/
|
||||
export function useDocumentSummary() {
|
||||
return useMutation<
|
||||
@@ -106,11 +111,13 @@ export function useDocumentSummary() {
|
||||
mode: DocumentAiMode;
|
||||
target: DocumentAiTarget;
|
||||
output: DocumentSummaryMode;
|
||||
persist?: boolean;
|
||||
replace?: boolean;
|
||||
}
|
||||
>({
|
||||
mutationFn: ({ mode, target, output }) =>
|
||||
mutationFn: ({ mode, target, output, persist = false, replace = false }) =>
|
||||
runDocumentAi<DocumentDescribeResult>({
|
||||
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}`,
|
||||
path: `/api/documents/inbound/${target.documentId}/summary?mode=${output}${persist ? "&persist=true" : ""}${replace ? "&replace=true" : ""}`,
|
||||
mode,
|
||||
target,
|
||||
}),
|
||||
|
||||
@@ -24,12 +24,7 @@ describe("<CoachInput>", () => {
|
||||
expect(html).not.toContain("Coach replies are generated");
|
||||
});
|
||||
|
||||
it("hides the mic in SSR markup (dictation is client-and-secure-context only)", () => {
|
||||
// v1.25.0 — the mic only mounts once the client confirms the Web Speech
|
||||
// API exists AND the page runs in a secure context. SSR (and any
|
||||
// unsupported / non-secure environment) renders NO mic at all, so the
|
||||
// composer never ships an advertised-but-erroring control. The earlier
|
||||
// "render it disabled" affordance is gone.
|
||||
it("does not render the retired voice-input control", () => {
|
||||
const html = render(
|
||||
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
|
||||
);
|
||||
@@ -233,11 +228,7 @@ describe("<CoachInput>", () => {
|
||||
expect(html).toContain('data-slot="coach-input-send"');
|
||||
});
|
||||
|
||||
it("renders the one-row page composer with showHub", () => {
|
||||
// v1.21.0 — the page composer is ONE baseline row: a leading `+` actions
|
||||
// menu (new chat + open conversations), the textarea, then mic + send.
|
||||
// The settings gear moved OUT of the composer (now the page toolbar's
|
||||
// top-right), so the composer's front is only the `+`.
|
||||
it("puts the textarea above a split control row on phones", () => {
|
||||
const html = render(
|
||||
<CoachInput
|
||||
value=""
|
||||
@@ -248,21 +239,29 @@ describe("<CoachInput>", () => {
|
||||
onOpenHistory={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('data-slot="coach-input-hub"');
|
||||
expect(html).toContain('data-slot="coach-input-controls"');
|
||||
expect(html).toContain('data-slot="coach-input-leading"');
|
||||
expect(html).toContain('data-slot="coach-input-actions"');
|
||||
// The settings gear is gone from the composer entirely.
|
||||
expect(html).not.toContain('data-slot="coach-input-settings"');
|
||||
// Send remains in the trailing cluster. The mic is client-and-secure-
|
||||
// context only, so it never appears in SSR markup.
|
||||
expect(html).not.toContain('data-slot="coach-input-mic"');
|
||||
expect(html).toContain('data-slot="coach-input-send"');
|
||||
// The leading `+` precedes the textarea; the textarea precedes the send.
|
||||
const plusIdx = html.indexOf('data-slot="coach-input-actions"');
|
||||
|
||||
// DOM and visual order agree at every breakpoint: the textarea comes first,
|
||||
// followed by the leading actions and then Send.
|
||||
const textareaIdx = html.indexOf('data-slot="coach-input-textarea"');
|
||||
const plusIdx = html.indexOf('data-slot="coach-input-actions"');
|
||||
const sendIdx = html.indexOf('data-slot="coach-input-send"');
|
||||
expect(plusIdx).toBeGreaterThan(-1);
|
||||
expect(plusIdx).toBeLessThan(textareaIdx);
|
||||
expect(textareaIdx).toBeLessThan(sendIdx);
|
||||
expect(textareaIdx).toBeGreaterThan(-1);
|
||||
expect(textareaIdx).toBeLessThan(plusIdx);
|
||||
expect(plusIdx).toBeLessThan(sendIdx);
|
||||
|
||||
const controls = html.match(
|
||||
/<div[^>]*data-slot="coach-input-controls"[^>]*>/,
|
||||
);
|
||||
expect(controls?.[0]).toMatch(/\bw-full\b/);
|
||||
expect(controls?.[0]).toMatch(/\bjustify-between\b/);
|
||||
expect(controls?.[0]).toMatch(/\bsm:w-auto\b/);
|
||||
expect(controls?.[0]).not.toMatch(/\bsm:contents\b/);
|
||||
});
|
||||
|
||||
it("sizes the hub actions trigger to the 44px tap-target floor on phones", () => {
|
||||
|
||||
@@ -6,15 +6,12 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
MessagesSquare,
|
||||
Mic,
|
||||
Paperclip,
|
||||
Plus,
|
||||
Send,
|
||||
@@ -23,7 +20,6 @@ import {
|
||||
Target,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -57,29 +53,14 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* the guard stack (system prompt, prose-grounding check, outbound
|
||||
* refusal), not a rendered disclaimer line.
|
||||
*
|
||||
* v1.18.7 (W-coach C-UI): voice dictation returns to the web composer.
|
||||
* The earlier placeholder mic was dropped in v1.4.25 because it did
|
||||
* nothing on tap; this one is wired to the Web Speech API
|
||||
* (`SpeechRecognition`). While the user dictates, interim + final
|
||||
* transcripts append into the controlled `value`; the button toggles
|
||||
* listening on/off and is fully keyboard- and screen-reader-accessible.
|
||||
*
|
||||
* v1.25.0: the mic only renders when dictation is genuinely usable —
|
||||
* the Web Speech API exists AND the page runs in a secure context (the
|
||||
* recogniser refuses to start over plain HTTP). On SSR, an unsupported
|
||||
* browser (Firefox), a non-secure self-host, or after the user denies the
|
||||
* mic permission, the control HIDES rather than sitting as an
|
||||
* advertised-but-erroring affordance. A real failure mid-use surfaces a
|
||||
* single quiet toast and then retires the button for the session.
|
||||
*
|
||||
* v1.18.11 (W11): the composer is the conversation's control hub on the
|
||||
* full-page Coach surface — ChatGPT-style. When `showHub` is set it grows a
|
||||
* second action row INSIDE the rounded field: a leading `+` actions menu
|
||||
* (new chat + open conversations), a settings deep-link, the dictation mic,
|
||||
* and the send / stop control on the same baseline. The drawer surface omits
|
||||
* `showHub` and keeps the single-row composer (its own header carries new
|
||||
* chat + settings), so this is purely additive — the existing composer
|
||||
* behaviour and markup are untouched when the hub is off.
|
||||
* full-page Coach surface. When `showHub` is set it includes a `+` actions menu
|
||||
* for a new chat or conversation history. The drawer omits `showHub` because
|
||||
* its header already carries those actions.
|
||||
*
|
||||
* Voice input was removed after repeated browser and permission failures made
|
||||
* the control unreliable. The composer now exposes only actions that work
|
||||
* consistently across supported browsers.
|
||||
*/
|
||||
export interface CoachInputProps {
|
||||
value: string;
|
||||
@@ -184,74 +165,6 @@ export function computeAutoGrowHeight(args: {
|
||||
|
||||
const AUTO_GROW_MAX_LINES = 6;
|
||||
|
||||
/**
|
||||
* v1.18.7 — resolve the browser's `SpeechRecognition` constructor
|
||||
* (prefixed `webkit` on Chromium / Safari, unprefixed on the spec
|
||||
* track). Returns `null` server-side and on browsers without the API
|
||||
* (Firefox) so the mic control hides rather than rendering dead. Kept
|
||||
* tiny + typed locally — the project ships no DOM lib entry for the
|
||||
* Web Speech API.
|
||||
*/
|
||||
type SpeechRecognitionLike = {
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
start: () => void;
|
||||
stop: () => void;
|
||||
abort: () => void;
|
||||
onresult: ((event: SpeechRecognitionResultEventLike) => void) | null;
|
||||
onerror: ((event: SpeechRecognitionErrorEventLike) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
};
|
||||
type SpeechRecognitionErrorEventLike = { error?: string };
|
||||
type SpeechRecognitionResultEventLike = {
|
||||
resultIndex: number;
|
||||
results: ArrayLike<ArrayLike<{ transcript: string }> & { isFinal: boolean }>;
|
||||
};
|
||||
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
|
||||
|
||||
function getSpeechRecognitionCtor(): SpeechRecognitionConstructor | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const w = window as unknown as {
|
||||
SpeechRecognition?: SpeechRecognitionConstructor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
};
|
||||
return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;
|
||||
}
|
||||
|
||||
// v1.18.7 — `useSyncExternalStore` resolves Web Speech API support
|
||||
// without a setState-in-effect: the server snapshot is `false` (no
|
||||
// mic at SSR) and the client snapshot reflects whether the constructor
|
||||
// exists. The store never changes after hydration, so the subscribe
|
||||
// callback is a no-op.
|
||||
//
|
||||
// v1.25.0 — the snapshot also requires a SECURE CONTEXT. `SpeechRecognition`
|
||||
// throws / fires a `not-allowed` error the instant it starts over plain HTTP,
|
||||
// so a LAN / Tailscale self-host on `http://` would otherwise advertise a mic
|
||||
// that can never work. Gating support on `window.isSecureContext` hides it
|
||||
// there cleanly instead.
|
||||
const noopSubscribe = () => () => {};
|
||||
function useVoiceSupported(): boolean {
|
||||
return useSyncExternalStore(
|
||||
noopSubscribe,
|
||||
() => getSpeechRecognitionCtor() !== null && window.isSecureContext,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
// v1.25.0 — map the app locale onto a BCP-47 tag with a region the
|
||||
// recogniser accepts. The bare language subtag works in most engines, but
|
||||
// the regioned form ("de-DE", "es-ES", …) yields materially better accuracy
|
||||
// on Chromium / Safari. Unknown locales fall back to the bare subtag.
|
||||
const SPEECH_RECOGNITION_LANG: Record<string, string> = {
|
||||
en: "en-US",
|
||||
de: "de-DE",
|
||||
es: "es-ES",
|
||||
fr: "fr-FR",
|
||||
it: "it-IT",
|
||||
pl: "pl-PL",
|
||||
};
|
||||
|
||||
export function CoachInput({
|
||||
value,
|
||||
onChange,
|
||||
@@ -269,7 +182,7 @@ export function CoachInput({
|
||||
onPickFromVault,
|
||||
onUploadNew,
|
||||
}: CoachInputProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const attachFileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -319,106 +232,6 @@ export function CoachInput({
|
||||
el.style.height = `${height}px`;
|
||||
}, [value]);
|
||||
|
||||
// v1.18.7 — voice dictation. The mic control only mounts once the
|
||||
// client confirms the Web Speech API exists (resolved through
|
||||
// `useSyncExternalStore`, server snapshot `false`), so SSR +
|
||||
// unsupported browsers render no button at all — never a dead
|
||||
// affordance. `onChange` / `value` are mirrored into refs (in an
|
||||
// effect, never during render) so the long-lived recognition handlers
|
||||
// always read the freshest composer state without re-subscribing.
|
||||
const voiceSupported = useVoiceSupported();
|
||||
const [listening, setListening] = useState(false);
|
||||
// v1.25.0 — once the user denies the mic permission (or the engine reports
|
||||
// it cannot serve), retire the control for the session rather than leaving a
|
||||
// button that errors on every tap. Combined with `voiceSupported` this is
|
||||
// the single gate the mic renders behind.
|
||||
const [voiceBlocked, setVoiceBlocked] = useState(false);
|
||||
const voiceAvailable = voiceSupported && !voiceBlocked;
|
||||
const recognitionRef = useRef<SpeechRecognitionLike | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const valueRef = useRef(value);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
valueRef.current = value;
|
||||
});
|
||||
|
||||
// Tear the recogniser down on unmount so a live mic never outlives
|
||||
// the composer (e.g. the drawer closing mid-dictation).
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
recognitionRef.current?.abort();
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stopDictation = useCallback(() => {
|
||||
recognitionRef.current?.stop();
|
||||
}, []);
|
||||
|
||||
const startDictation = useCallback(() => {
|
||||
const Ctor = getSpeechRecognitionCtor();
|
||||
if (!Ctor) return;
|
||||
const recognition = new Ctor();
|
||||
recognition.lang = SPEECH_RECOGNITION_LANG[locale] ?? locale;
|
||||
recognition.continuous = true;
|
||||
recognition.interimResults = true;
|
||||
// Anchor every transcript onto the text present when dictation
|
||||
// began so interim results replace cleanly rather than stacking.
|
||||
const base = valueRef.current;
|
||||
recognition.onresult = (event) => {
|
||||
let finalText = "";
|
||||
let interimText = "";
|
||||
for (let i = event.resultIndex; i < event.results.length; i += 1) {
|
||||
const result = event.results[i];
|
||||
const transcript = result[0]?.transcript ?? "";
|
||||
if (result.isFinal) finalText += transcript;
|
||||
else interimText += transcript;
|
||||
}
|
||||
const spoken = (finalText + interimText).trim();
|
||||
if (!spoken) return;
|
||||
const joiner = base && !base.endsWith(" ") ? " " : "";
|
||||
onChangeRef.current(`${base}${joiner}${spoken}`);
|
||||
};
|
||||
recognition.onerror = (event) => {
|
||||
setListening(false);
|
||||
// `aborted` / `no-speech` are the benign codes that fire on a normal
|
||||
// stop or a quiet pause — stay silent.
|
||||
const code = event?.error;
|
||||
if (!code || code === "aborted" || code === "no-speech") return;
|
||||
// v1.25.0 — a permission refusal (or an engine that reports it cannot
|
||||
// serve) is terminal: surface one quiet toast and HIDE the mic for the
|
||||
// session so the user is not left tapping a control that always errors.
|
||||
if (code === "not-allowed" || code === "service-not-allowed") {
|
||||
setVoiceBlocked(true);
|
||||
}
|
||||
toast.error(t("insights.coach.dictateError"));
|
||||
};
|
||||
recognition.onend = () => {
|
||||
setListening(false);
|
||||
recognitionRef.current = null;
|
||||
};
|
||||
recognitionRef.current = recognition;
|
||||
try {
|
||||
recognition.start();
|
||||
setListening(true);
|
||||
} catch {
|
||||
// start() throws if already running; treat as a no-op.
|
||||
setListening(false);
|
||||
}
|
||||
}, [locale, t]);
|
||||
|
||||
const toggleDictation = useCallback(() => {
|
||||
if (!voiceAvailable) return;
|
||||
if (listening) stopDictation();
|
||||
else startDictation();
|
||||
}, [voiceAvailable, listening, startDictation, stopDictation]);
|
||||
|
||||
// The mic only renders when dictation is available, so the label tracks the
|
||||
// two live states: listening (tap to stop) and idle (tap to dictate).
|
||||
const micLabel = listening
|
||||
? t("insights.coach.dictateStop")
|
||||
: t("insights.coach.dictate");
|
||||
|
||||
const canSubmit = !disabled && value.trim().length > 0;
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -439,42 +252,7 @@ export function CoachInput({
|
||||
[canSubmit, onSubmit],
|
||||
);
|
||||
|
||||
// v1.18.11 — the dictation mic. Lifted to a const so it can sit in the
|
||||
// single-row composer (drawer) OR in the hub action row (page) without
|
||||
// duplicating the wiring. v1.25.0 — `null` when dictation is unavailable
|
||||
// (no API, non-secure context, or a denied permission) so the surface hides
|
||||
// the control rather than advertising a button that errors on tap.
|
||||
const micButton = voiceAvailable ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={toggleDictation}
|
||||
disabled={disabled}
|
||||
data-slot="coach-input-mic"
|
||||
data-listening={listening ? "true" : undefined}
|
||||
aria-label={micLabel}
|
||||
aria-pressed={listening}
|
||||
title={micLabel}
|
||||
className={cn(
|
||||
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
|
||||
listening
|
||||
? "text-brand-pink bg-brand-pink/10 hover:text-brand-pink"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Mic
|
||||
className={cn(
|
||||
"size-4",
|
||||
listening && "animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
// v1.18.11 — the send / stop control. Same const-lift rationale as the
|
||||
// mic: one definition, two mount points.
|
||||
// Shared send / stop control for both page and drawer surfaces.
|
||||
const sendButton =
|
||||
isStreaming && onCancel ? (
|
||||
// While a reply streams, swap the send button for a Stop control bound
|
||||
@@ -513,11 +291,9 @@ export function CoachInput({
|
||||
</Button>
|
||||
);
|
||||
|
||||
// v1.21.0 — the leading `+` actions menu (page surface only). Opens the
|
||||
// attachment/scope menu: new chat + open conversations. The settings gear
|
||||
// no longer lives here — it moved to the page toolbar's top-right corner,
|
||||
// so the composer's front is ONLY the `+`. Const-lifted like the mic / send
|
||||
// so it slots into the single-row composer without forking the layout.
|
||||
// Leading `+` actions menu for the full-page Coach surface. The settings
|
||||
// shortcut remains in this menu while the dedicated gear stays in the page
|
||||
// toolbar.
|
||||
const actionsButton = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -638,33 +414,16 @@ export function CoachInput({
|
||||
onSubmit={handleFormSubmit}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{/* v1.16.1 / v1.18.7 — modern chat-app composer: a single rounded
|
||||
field, ONE baseline row. The drawer surface flanks the textarea with
|
||||
the mic (left) and send / stop (right). The page surface (`showHub`)
|
||||
leads with a `+` actions menu (new chat + open conversations), then
|
||||
the textarea, then the mic + send — same row, vertically centred.
|
||||
The settings gear is NOT in the composer; it lives in the page
|
||||
toolbar's top-right corner. `items-end` keeps the flanking controls
|
||||
pinned to the input's last line as it grows. Enter sends,
|
||||
Shift+Enter inserts a newline. */}
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 group rounded-2xl border",
|
||||
"shadow-sm transition-colors",
|
||||
"focus-within:border-primary/50 focus-within:ring-primary/50 focus-within:bg-background focus-within:ring-2",
|
||||
// Both surfaces share ONE baseline row: the drawer flanks the
|
||||
// textarea with mic (left) + send (right); the page leads with a
|
||||
// `+` actions menu, then the textarea, then mic + send. `items-end`
|
||||
// keeps the flanking controls pinned to the input's last line as it
|
||||
// grows.
|
||||
"flex items-end gap-1.5 p-1.5",
|
||||
// Mobile uses two rows. Desktop keeps the same DOM and visual order
|
||||
// (textarea, actions, Send) so keyboard focus never jumps backwards.
|
||||
"flex flex-col gap-1.5 p-1.5 sm:flex-row sm:items-end",
|
||||
)}
|
||||
>
|
||||
{/* Leading controls. The document-attach paperclip (module-gated)
|
||||
leads, then the surface control: the `+` actions menu (page) or the
|
||||
dictation mic (drawer). */}
|
||||
{attachButton}
|
||||
{showHub ? actionsButton : micButton}
|
||||
<textarea
|
||||
id={inputId}
|
||||
ref={textareaRef}
|
||||
@@ -674,56 +433,36 @@ export function CoachInput({
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
|
||||
disabled={disabled}
|
||||
// v1.4.27 MB3 / CF-30 — surface the "send" virtual-keyboard
|
||||
// return on iOS / Android instead of the generic newline
|
||||
// glyph, and lift autocapitalize to "sentences" so a
|
||||
// question typed on phone reads as one. The submit handler
|
||||
// still preserves Shift+Enter for explicit newlines.
|
||||
enterKeyHint="send"
|
||||
autoCapitalize="sentences"
|
||||
// v1.4.25 W5 — single-line initial state, Claude-web-style.
|
||||
// `rows={1}` is the SSR-stable baseline; the `useEffect`
|
||||
// above grows the textarea up to `AUTO_GROW_MAX_LINES`. Past
|
||||
// the cap the textarea scrolls internally (overflow-auto via
|
||||
// `max-h-[9.5rem]`, ≈6 lines at the current line-height).
|
||||
rows={1}
|
||||
className={cn(
|
||||
// `text-base` (16 px) on mobile so iOS Safari does not
|
||||
// zoom-on-focus — the kerned `text-sm` (14 px) trips the
|
||||
// 16 px floor and yanks the viewport on every Coach tap.
|
||||
// Desktop shrinks back to `text-sm` for the compact
|
||||
// composer.
|
||||
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
// Centre the single-line state against the flanking controls so
|
||||
// the placeholder and the icons share a baseline.
|
||||
"order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
|
||||
"px-2 py-1.5",
|
||||
"max-h-[9.5rem] overflow-auto",
|
||||
// v1.18.7 — calm, thin scrollbar inside the composer when
|
||||
// dictation overruns 6 lines (see also the thread/history
|
||||
// scroll regions). Scoped here, not in globals.css.
|
||||
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]",
|
||||
"placeholder:text-muted-foreground disabled:opacity-60",
|
||||
// Keep the placeholder a single line on narrow phones: a long
|
||||
// hint used to wrap to two lines inside the one-row composer and
|
||||
// read as a cramped block. Clip the PLACEHOLDER pseudo only — the
|
||||
// textarea value still wraps and auto-grows for real typing.
|
||||
"placeholder:overflow-hidden placeholder:text-ellipsis placeholder:whitespace-nowrap",
|
||||
)}
|
||||
/>
|
||||
{showHub ? (
|
||||
// v1.21.0 — page surface trailing cluster: mic + send / stop on the
|
||||
// same baseline as the leading `+` and the textarea. The settings
|
||||
// gear moved out of the composer entirely (now in the page toolbar).
|
||||
<div
|
||||
data-slot="coach-input-controls"
|
||||
className="order-2 flex w-full items-center justify-between gap-1.5 sm:w-auto sm:shrink-0 sm:justify-start"
|
||||
>
|
||||
<div
|
||||
data-slot="coach-input-hub"
|
||||
data-slot="coach-input-leading"
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
{attachButton}
|
||||
{showHub ? actionsButton : null}
|
||||
</div>
|
||||
<div
|
||||
data-slot={showHub ? "coach-input-hub" : undefined}
|
||||
className="flex shrink-0 items-center gap-1.5"
|
||||
>
|
||||
{micButton}
|
||||
{sendButton}
|
||||
</div>
|
||||
) : (
|
||||
sendButton
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@/lib/documents/describe";
|
||||
import type { AIProvider } from "@/lib/ai/types";
|
||||
import { locales } from "@/lib/i18n/config";
|
||||
import { targetLanguageName } from "@/lib/ai/prompts/output-language";
|
||||
|
||||
function providerReturning(content: string): AIProvider {
|
||||
return {
|
||||
@@ -44,6 +45,25 @@ describe("runDocumentSummary — screened prose", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it.each(locales)(
|
||||
"tells the provider to write summaries in %s",
|
||||
async (locale) => {
|
||||
const provider = providerReturning("A short descriptive summary.");
|
||||
|
||||
await runDocumentSummary({
|
||||
provider,
|
||||
providerType: "local",
|
||||
ocrText: "irrelevant",
|
||||
locale,
|
||||
});
|
||||
|
||||
const params = vi.mocked(provider.generateCompletion).mock.calls[0]?.[0];
|
||||
expect(params?.system).toContain(
|
||||
`Write the summary in ${targetLanguageName(locale)}.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("passes a genuinely descriptive summary through untouched", async () => {
|
||||
const clean =
|
||||
"A lab report from a clinic, dated last month, listing routine blood values.";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { singleUserTurn, type AIProvider } from "@/lib/ai/types";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { targetLanguageName } from "@/lib/ai/prompts/output-language";
|
||||
import {
|
||||
screenModelOutput,
|
||||
INSIGHTS_CONTRACTS,
|
||||
@@ -74,10 +75,11 @@ export interface DescribeInput {
|
||||
|
||||
/**
|
||||
* Input for the SCREENED summary pass. `locale` is required here and absent
|
||||
* from `DescribeInput` on purpose: the screen picks its pattern banks by
|
||||
* locale, and an optional field would let a caller silently fall back to the
|
||||
* English banks. The transcription pass carries no locale because it is not
|
||||
* screened (see `transcribeDocument`).
|
||||
* from `DescribeInput` on purpose: it controls both the provider's output
|
||||
* language and the screen's pattern banks. An optional field would let a
|
||||
* caller silently generate and screen as English. The transcription pass
|
||||
* carries no locale because it must reproduce the source without translation
|
||||
* (see `transcribeDocument`).
|
||||
*/
|
||||
export type DocumentSummaryInput = DescribeInput & { locale: Locale };
|
||||
|
||||
@@ -129,7 +131,7 @@ export async function runDocumentSummary(
|
||||
): Promise<{ summary: string; blocked: OutboundReason | null }> {
|
||||
const summary = await runDescribe(
|
||||
input,
|
||||
SUMMARY_SYSTEM_PROMPT,
|
||||
`${SUMMARY_SYSTEM_PROMPT}\n\nWrite the summary in ${targetLanguageName(input.locale)}.`,
|
||||
"Summarise the attached document. Return the summary text only.",
|
||||
(text) =>
|
||||
`Summarise the following OCR'd document text. Return the summary text only.\n\nOCR TEXT:\n${text}`,
|
||||
|
||||
@@ -159,6 +159,18 @@ describe("runDocumentSummaryJob — gating", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("generates the background summary in the owner's selected locale", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
locale: "de",
|
||||
} as never);
|
||||
|
||||
await runDocumentSummaryJob({ userId: "user-1", documentId: "doc-1" });
|
||||
|
||||
expect(runDocumentSummary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ locale: "de" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("SKIPS a document that already has a summary (before reading the opt-in)", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
|
||||
id: "doc-1",
|
||||
|
||||
@@ -778,9 +778,9 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/documents/inbound/{id}/summary": {
|
||||
post: {
|
||||
tags: ["Documents"],
|
||||
summary: "Summarise or transcribe a document (session-only)",
|
||||
summary: "Summarise or transcribe a document",
|
||||
description:
|
||||
'On-demand, SESSION-ONLY description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; `?mode=text` returns its raw transcribed text. The result is transient — nothing is persisted except the AI-budget ledger + audit log; it never reaches coach memory, snapshots, the structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.',
|
||||
'On-demand description of a stored document. `?mode=summary` (default) returns a short plain-language summary of WHAT the document is; it remains transient unless `persist=true` fills the empty stored-summary slot, while `persist=true&replace=true` explicitly replaces an existing summary. `?mode=text` returns transient raw transcribed text. Neither result reaches coach memory, snapshots, structured stores, or the search index. The summary is descriptive only and never a diagnosis. Same VISION (empty body) / TEXT (`application/json` `{ mode: "text", text }`, opt-in local OCR) dispatch as extract. AI-consent / rate / budget gated. 422 with no provider.',
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
{
|
||||
@@ -795,6 +795,22 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
description:
|
||||
"`summary` (plain-language description) or `text` (raw).",
|
||||
},
|
||||
{
|
||||
name: "persist",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "boolean", default: false },
|
||||
description:
|
||||
"For summary mode only: persist into an empty stored-summary slot.",
|
||||
},
|
||||
{
|
||||
name: "replace",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "boolean", default: false },
|
||||
description:
|
||||
"With `persist=true`, explicitly replace an existing stored summary.",
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: false,
|
||||
@@ -811,7 +827,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The session-only summary (`{ summary }`) or extracted text (`{ text }`).",
|
||||
"The summary and its persistence outcome, or transient extracted text.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
@@ -819,6 +835,9 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
.object({
|
||||
summary: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
persistence: z
|
||||
.enum(["stored", "withheld", "failed"])
|
||||
.optional(),
|
||||
})
|
||||
.meta({ id: "DocumentSummaryResponse" }),
|
||||
"DocumentSummaryEnvelope",
|
||||
|
||||
Reference in new issue
Block a user