mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Merge branch 'worktree-agent-a908bda23e0007c88' into release/v1.30.31
This commit is contained in:
commit
cd502d5aef
20 files changed
+818
-82
No files matched your search
@@ -2,14 +2,23 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* On-demand summary / extracted text (Document vault P2). Pins P2-D4 / A3:
|
||||
* session-only — the ONLY persistent side effects are the AI-budget ledger and
|
||||
* the audit log; no row, index, or fact is written or mutated.
|
||||
* On-demand summary / extracted text (Document vault P2).
|
||||
*
|
||||
* `mode=text` stays session-only (P2-D4 / A3): the only persistent side effects
|
||||
* are the AI-budget ledger and the audit log. `mode=summary` additionally stores
|
||||
* the summary on the document since v1.30.31 — the user asked for it, and it is
|
||||
* the repair path for documents the background job never ran for. Nothing else
|
||||
* (index, facts, rows) is ever written, and a WITHHELD summary is never stored
|
||||
* as text.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
inboundDocument: { findFirst: vi.fn(), update: vi.fn() },
|
||||
inboundDocument: {
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
extractedFact: { create: vi.fn(), deleteMany: vi.fn() },
|
||||
documentContentIndex: { upsert: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
@@ -18,6 +27,9 @@ vi.mock("@/lib/db", () => ({
|
||||
|
||||
vi.mock("@/lib/documents/store", () => ({
|
||||
decryptDocumentContent: vi.fn(() => Buffer.from([1, 2, 3])),
|
||||
encryptDocumentSummary: vi.fn((text: string) =>
|
||||
new TextEncoder().encode(`enc:${text}`),
|
||||
),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
detectOcrMimeType: vi.fn(() => "image/png"),
|
||||
@@ -25,6 +37,7 @@ vi.mock("@/lib/labs/ocr-upload", () => ({
|
||||
vi.mock("@/lib/documents/describe", () => ({
|
||||
runDocumentSummary: vi.fn(),
|
||||
transcribeDocument: vi.fn(),
|
||||
documentSummaryBlockedCopy: () => "The summary was withheld.",
|
||||
DocumentDescribeError: class DocumentDescribeError extends Error {},
|
||||
}));
|
||||
vi.mock("@/lib/documents/provider-order", () => ({
|
||||
@@ -88,6 +101,11 @@ const req = (id: string, mode?: string) =>
|
||||
{ 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.
|
||||
*/
|
||||
function assertNoPersistence(): void {
|
||||
expect(prisma.inboundDocument.update).not.toHaveBeenCalled();
|
||||
expect(prisma.documentContentIndex.upsert).not.toHaveBeenCalled();
|
||||
@@ -117,9 +135,10 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
it("mode=summary returns { summary } and persists only budget + audit", async () => {
|
||||
it("mode=summary returns { summary } and STORES it, so a second open is free", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "A blood panel from a lab.",
|
||||
blocked: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
@@ -133,9 +152,76 @@ describe("POST /api/documents/inbound/[id]/summary", () => {
|
||||
"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",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
summaryEncrypted: null,
|
||||
},
|
||||
data: expect.objectContaining({ summaryState: "READY" }),
|
||||
});
|
||||
assertNoPersistence();
|
||||
});
|
||||
|
||||
it("records WITHHELD without ever storing the blocked prose", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "raise the dose to 10 mg",
|
||||
blocked: "dose_directive",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
req("doc-1", "summary") as never,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// 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.summary).not.toContain("10 mg");
|
||||
|
||||
// Only the state lands. No ciphertext column is touched on this path.
|
||||
expect(prisma.inboundDocument.updateMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: "doc-1",
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
summaryState: { not: "READY" },
|
||||
},
|
||||
data: { summaryState: "WITHHELD" },
|
||||
});
|
||||
const calls = vi.mocked(prisma.inboundDocument.updateMany).mock.calls;
|
||||
for (const [arg] of calls) {
|
||||
expect(arg.data).not.toHaveProperty("summaryEncrypted");
|
||||
}
|
||||
assertNoPersistence();
|
||||
});
|
||||
|
||||
it("still answers when the summary write fails", async () => {
|
||||
vi.mocked(runDocumentSummary).mockResolvedValue({
|
||||
summary: "A blood panel from a lab.",
|
||||
blocked: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.inboundDocument.updateMany).mockRejectedValueOnce(
|
||||
new Error("db down"),
|
||||
);
|
||||
|
||||
// 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,
|
||||
ctx("doc-1") as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).data).toEqual({
|
||||
summary: "A blood panel from a lab.",
|
||||
});
|
||||
});
|
||||
|
||||
it("mode=text returns { text } session-only", async () => {
|
||||
vi.mocked(transcribeDocument).mockResolvedValue({
|
||||
text: "raw transcribed body",
|
||||
|
||||
@@ -3,11 +3,17 @@
|
||||
* extracted text.
|
||||
*
|
||||
* `?mode=summary` (default) returns a short plain-language summary of WHAT the
|
||||
* document is; `?mode=text` returns its raw transcribed text. Both are transient
|
||||
* (P2-D4): the result is returned once and NOTHING is persisted except the
|
||||
* AI-budget ledger and the audit log — never coach memory, snapshots, the
|
||||
* structured stores, or the search index. The summary is descriptive only and
|
||||
* is forbidden from diagnosing (interpretation boundary G7).
|
||||
* document is; `?mode=text` returns its raw transcribed text. The summary is
|
||||
* descriptive only and is forbidden from diagnosing (interpretation boundary
|
||||
* G7).
|
||||
*
|
||||
* `mode=text` stays transient (P2-D4) — a transcription is a read-through of the
|
||||
* user's own file and there is nothing to keep. `mode=summary` PERSISTS onto the
|
||||
* document row since v1.30.31: the user asked for it explicitly, it is the same
|
||||
* artefact the background job stores, and keeping it means a second open shows
|
||||
* the paragraph instead of buying it again. Beyond that the old rule holds —
|
||||
* nothing reaches coach memory, snapshots, the structured stores, or the search
|
||||
* index; a summary the safety screen blocked is never stored as text.
|
||||
*
|
||||
* Same VISION/TEXT dispatch and gauntlet as the extract route. The document is
|
||||
* UNTRUSTED (prompt-injection): the server never acts on an instruction inside
|
||||
@@ -47,6 +53,8 @@ import {
|
||||
transcribeDocument,
|
||||
type DescribeInput,
|
||||
} from "@/lib/documents/describe";
|
||||
import { encryptDocumentSummary } from "@/lib/documents/store";
|
||||
import type { OutboundReason } from "@/lib/ai/safety/outbound-screen";
|
||||
import {
|
||||
resolveDocumentTextProvider,
|
||||
resolveDocumentVisionProvider,
|
||||
@@ -85,20 +93,90 @@ function resolveMode(request: NextRequest): DocumentSummaryMode {
|
||||
: "summary";
|
||||
}
|
||||
|
||||
/** Run the requested describe call (summary or raw text) over the input. */
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function describe(
|
||||
mode: DocumentSummaryMode,
|
||||
input: DescribeInput,
|
||||
locale: Locale,
|
||||
persist: { userId: string; documentId: string } | null,
|
||||
): Promise<{ summary: string } | { 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.
|
||||
const { summary, blocked } = await runDocumentSummary({ ...input, locale });
|
||||
if (persist) await persistSummary(persist, summary, blocked);
|
||||
return { summary: blocked ? documentSummaryBlockedCopy(locale) : summary };
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a screened-clean summary, or record the refusal as a state.
|
||||
*
|
||||
* A blocked summary NEVER lands as text — the honest statement the caller shows
|
||||
* is generated copy for this response, not the model's prose, and persisting it
|
||||
* would put a refusal where a summary belongs. Only the WITHHELD state is kept,
|
||||
* and it is not terminal: the user can ask again.
|
||||
*
|
||||
* Storing is best-effort. The user is waiting on the summary they can already
|
||||
* read in the response; a failed write must not turn that into an error.
|
||||
*/
|
||||
async function persistSummary(
|
||||
target: { userId: string; documentId: string },
|
||||
summary: string,
|
||||
blocked: OutboundReason | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (blocked) {
|
||||
await prisma.inboundDocument.updateMany({
|
||||
where: {
|
||||
id: target.documentId,
|
||||
userId: target.userId,
|
||||
deletedAt: null,
|
||||
summaryState: { not: "READY" },
|
||||
},
|
||||
data: { summaryState: "WITHHELD" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Scoped to a document without a stored summary: an explicit re-run must
|
||||
// not silently replace a summary the user already has on screen.
|
||||
const written = await prisma.inboundDocument.updateMany({
|
||||
where: {
|
||||
id: target.documentId,
|
||||
userId: target.userId,
|
||||
deletedAt: null,
|
||||
summaryEncrypted: null,
|
||||
},
|
||||
data: {
|
||||
summaryEncrypted: encryptDocumentSummary(summary),
|
||||
summaryGeneratedAt: new Date(),
|
||||
summaryState: "READY",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "documents.summary.persisted" },
|
||||
meta: { documentId: target.documentId, stored: written.count > 0 },
|
||||
});
|
||||
} catch {
|
||||
annotate({
|
||||
action: { name: "documents.summary.persistFailed" },
|
||||
meta: { documentId: target.documentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The budget the requested mode charges. */
|
||||
function budgetFor(mode: DocumentSummaryMode): number {
|
||||
return mode === "text"
|
||||
@@ -237,6 +315,7 @@ async function handleTextSummary(
|
||||
ocrText: parsed.data.text,
|
||||
},
|
||||
locale,
|
||||
{ userId, documentId: document.id },
|
||||
);
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
@@ -322,6 +401,7 @@ async function handleVisionSummary(
|
||||
documents: vision.documents,
|
||||
},
|
||||
locale,
|
||||
{ userId, documentId: document.id },
|
||||
);
|
||||
await reconcileSpend(
|
||||
userId,
|
||||
|
||||
Reference in new issue
Block a user