mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user