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

@@ -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,