fix: localize document summaries and refine mobile coach

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 22:34:56 +02:00
1 parent f1a1887a01
commit 3aa8ccd541
22 files changed
+434 -417

No files matched your search

+13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.";
+7 -5
View File
@@ -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",
+22 -3
View File
@@ -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",