mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/main' into wip/v140-I
# Conflicts: # docs/api/openapi.yaml
This commit is contained in:
commit
ef0028c020
45 files changed
+3161
-1010
No files matched your search
@@ -3958,3 +3958,55 @@ describe("the account's own settings survive a real restore", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a document kind added after the backup format", () => {
|
||||
it("restores a sick note as a sick note", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedAdminSession(prisma);
|
||||
await createOwner(prisma);
|
||||
const documentBytes = encryptBytes(Buffer.from("sick note fixture"));
|
||||
const contentEncrypted = new Uint8Array(
|
||||
new ArrayBuffer(documentBytes.byteLength),
|
||||
);
|
||||
contentEncrypted.set(documentBytes);
|
||||
await prisma.inboundDocument.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
kind: "SICK_NOTE",
|
||||
title: "Certificate of incapacity",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: documentBytes.byteLength,
|
||||
contentEncrypted,
|
||||
contentCodec: "binary2",
|
||||
},
|
||||
});
|
||||
|
||||
const { payload } = await buildFullBackupPayload(prisma, OWNER_ID, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
await prisma.user.delete({ where: { id: OWNER_ID } });
|
||||
await createOwner(prisma);
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
type: "TWO_ENDED_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(payload)),
|
||||
},
|
||||
});
|
||||
const response = await POST(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as never,
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
expect(response.status, JSON.stringify(await response.json())).toBe(200);
|
||||
|
||||
const restored = await prisma.inboundDocument.findFirstOrThrow({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect(restored.kind).toBe("SICK_NOTE");
|
||||
expect(restored.title).toBe("Certificate of incapacity");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,13 @@
|
||||
* anything reaches a model.
|
||||
* 6. The reaper fails a run no worker took (and hands its reservation back)
|
||||
* and deletes a finished run an hour after it ended.
|
||||
* 8. A stored-text extraction stages its facts from the worker, and a
|
||||
* review finished while the read ran is never overwritten: staging
|
||||
* re-checks the document under a row lock and fails the run instead.
|
||||
* 7. Summary and suggestion runs (0370) round-trip the routes' own bodies; a
|
||||
* summary that is to be stored marks the document PENDING while queued,
|
||||
* READY when it lands and UNAVAILABLE when the run fails; a transient one
|
||||
* leaves the state alone and never attaches to a stored one.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
@@ -68,6 +75,18 @@ vi.mock("@/lib/documents/describe", async (importOriginal) => ({
|
||||
transcribeDocument: vi.fn(async () => ({
|
||||
text: "Entlassbrief Leukozyten erhoeht",
|
||||
})),
|
||||
runDocumentSummary: vi.fn(async () => ({
|
||||
summary: "A hospital discharge letter.",
|
||||
blocked: null,
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/documents/assist", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/documents/assist")>()),
|
||||
runDocumentAssist: vi.fn(async () => ({
|
||||
title: "Discharge letter",
|
||||
kind: "DISCHARGE_LETTER",
|
||||
documentDate: "2027-01-10",
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/labs/ocr-extract")>()),
|
||||
@@ -77,6 +96,29 @@ vi.mock("@/lib/labs/ocr-extract", async (importOriginal) => ({
|
||||
rows: [],
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/documents/extract", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/documents/extract")>()),
|
||||
runInboundExtraction: vi.fn(async () => ({
|
||||
reportDate: "2027-01-10",
|
||||
kind: "OTHER",
|
||||
providerType: "anthropic",
|
||||
facts: [
|
||||
{
|
||||
factType: "OBSERVATION",
|
||||
confidence: 0.95,
|
||||
needsReview: false,
|
||||
data: { label: "Hemoglobin", value: 13.9, unit: "g/dL" },
|
||||
provenance: {
|
||||
sourceText: "Hemoglobin 13.9 g/dL",
|
||||
anchored: true,
|
||||
sourceOffset: 0,
|
||||
page: null,
|
||||
confidence: 0.95,
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
}));
|
||||
vi.mock("@/lib/documents/auto-stage-labs", () => ({
|
||||
maybeAutoStageLabFacts: vi.fn(async () => ({
|
||||
staged: false,
|
||||
@@ -84,7 +126,12 @@ vi.mock("@/lib/documents/auto-stage-labs", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { runDocumentAssist } from "@/lib/documents/assist";
|
||||
import {
|
||||
runDocumentSummary,
|
||||
transcribeDocument,
|
||||
} from "@/lib/documents/describe";
|
||||
import { runInboundExtraction } from "@/lib/documents/extract";
|
||||
import { runOcrExtraction } from "@/lib/labs/ocr-extract";
|
||||
|
||||
const PNG = Buffer.from(
|
||||
@@ -186,6 +233,40 @@ async function postLabScanText(text: string) {
|
||||
return { status: res.status, body: (await res.json()) as Envelope };
|
||||
}
|
||||
|
||||
async function postDocumentAi(
|
||||
documentId: string,
|
||||
path: string,
|
||||
prefer: boolean,
|
||||
) {
|
||||
const route = path.startsWith("summary")
|
||||
? await import("@/app/api/documents/inbound/[id]/summary/route")
|
||||
: await import("@/app/api/documents/inbound/[id]/suggest/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const res = await route.POST(
|
||||
new NextRequest(
|
||||
`http://localhost/api/documents/inbound/${documentId}/${path}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: prefer ? { prefer: "respond-async" } : {},
|
||||
},
|
||||
) as never,
|
||||
{ params: Promise.resolve({ id: documentId }) } as never,
|
||||
);
|
||||
return {
|
||||
status: res.status,
|
||||
headers: res.headers,
|
||||
body: (await res.json()) as Envelope,
|
||||
};
|
||||
}
|
||||
|
||||
async function summaryState(documentId: string) {
|
||||
const row = await getPrismaClient().inboundDocument.findUniqueOrThrow({
|
||||
where: { id: documentId },
|
||||
select: { summaryState: true, summaryEncrypted: true },
|
||||
});
|
||||
return { state: row.summaryState, stored: row.summaryEncrypted !== null };
|
||||
}
|
||||
|
||||
async function poll(runId: string) {
|
||||
const { GET } = await import("@/app/api/ai-runs/[id]/route");
|
||||
const res = await GET(
|
||||
@@ -211,6 +292,9 @@ beforeEach(async () => {
|
||||
send.mockClear();
|
||||
vi.mocked(transcribeDocument).mockClear();
|
||||
vi.mocked(runOcrExtraction).mockClear();
|
||||
vi.mocked(runDocumentSummary).mockClear();
|
||||
vi.mocked(runDocumentAssist).mockClear();
|
||||
vi.mocked(runInboundExtraction).mockClear();
|
||||
});
|
||||
|
||||
describe("Read with AI in the background", () => {
|
||||
@@ -449,3 +533,318 @@ describe("the reaper", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("a summary in the background", () => {
|
||||
it("marks a stored summary PENDING while queued and READY when it lands", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const queued = await postDocumentAi(
|
||||
document.id,
|
||||
"summary?mode=summary&persist=true",
|
||||
true,
|
||||
);
|
||||
expect(queued.status).toBe(202);
|
||||
expect(queued.headers.get("preference-applied")).toBe("respond-async");
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
expect(runDocumentSummary).not.toHaveBeenCalled();
|
||||
expect(await summaryState(document.id)).toEqual({
|
||||
state: "PENDING",
|
||||
stored: false,
|
||||
});
|
||||
|
||||
// A second press for the same stored summary attaches; a transient
|
||||
// summary of the same document is its own run.
|
||||
const again = await postDocumentAi(
|
||||
document.id,
|
||||
"summary?mode=summary&persist=true",
|
||||
true,
|
||||
);
|
||||
expect((again.body.data as { runId: string }).runId).toBe(runId);
|
||||
const transient = await postDocumentAi(
|
||||
document.id,
|
||||
"summary?mode=summary",
|
||||
true,
|
||||
);
|
||||
expect(transient.status).toBe(202);
|
||||
expect((transient.body.data as { runId: string }).runId).not.toBe(runId);
|
||||
|
||||
expect(await work(runId)).toBe("succeeded");
|
||||
expect(runDocumentSummary).toHaveBeenCalledOnce();
|
||||
const done = await poll(runId);
|
||||
expect(done.body.data).toMatchObject({
|
||||
status: "SUCCEEDED",
|
||||
kind: "DOCUMENT_SUMMARY",
|
||||
documentId: document.id,
|
||||
result: {
|
||||
summary: "A hospital discharge letter.",
|
||||
persistence: "stored",
|
||||
},
|
||||
});
|
||||
expect(await summaryState(document.id)).toEqual({
|
||||
state: "READY",
|
||||
stored: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a stored summary UNAVAILABLE when its run fails", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
vi.mocked(runDocumentSummary).mockRejectedValueOnce(
|
||||
new Error("provider down"),
|
||||
);
|
||||
|
||||
const queued = await postDocumentAi(
|
||||
document.id,
|
||||
"summary?mode=summary&persist=true",
|
||||
true,
|
||||
);
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
expect(await work(runId)).toBe("failed");
|
||||
const done = await poll(runId);
|
||||
expect(done.body.data).toMatchObject({
|
||||
status: "FAILED",
|
||||
error: { code: "documents.inbound.extractFailed", status: 502 },
|
||||
});
|
||||
expect(await summaryState(document.id)).toEqual({
|
||||
state: "UNAVAILABLE",
|
||||
stored: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the document's summary state alone for a transient summary", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
vi.mocked(runDocumentSummary).mockRejectedValueOnce(
|
||||
new Error("provider down"),
|
||||
);
|
||||
|
||||
const queued = await postDocumentAi(
|
||||
document.id,
|
||||
"summary?mode=summary",
|
||||
true,
|
||||
);
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
expect(await summaryState(document.id)).toEqual({
|
||||
state: "NONE",
|
||||
stored: false,
|
||||
});
|
||||
expect(await work(runId)).toBe("failed");
|
||||
expect(await summaryState(document.id)).toEqual({
|
||||
state: "NONE",
|
||||
stored: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers synchronously, exactly as before, without the header", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const res = await postDocumentAi(document.id, "summary?mode=text", false);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("preference-applied")).toBeNull();
|
||||
expect(res.body.data).toEqual({ text: "Entlassbrief Leukozyten erhoeht" });
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
expect(await getPrismaClient().documentAiRun.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filing suggestions in the background", () => {
|
||||
it("queues, reads in the worker, writes nothing to the document", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await storeDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const queued = await postDocumentAi(document.id, "suggest", true);
|
||||
expect(queued.status).toBe(202);
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
expect(await work(runId)).toBe("succeeded");
|
||||
expect(runDocumentAssist).toHaveBeenCalledOnce();
|
||||
|
||||
const done = await poll(runId);
|
||||
expect(done.body.data).toMatchObject({
|
||||
status: "SUCCEEDED",
|
||||
kind: "DOCUMENT_SUGGEST",
|
||||
result: {
|
||||
suggestions: {
|
||||
title: "Discharge letter",
|
||||
kind: "DISCHARGE_LETTER",
|
||||
documentDate: "2027-01-10",
|
||||
},
|
||||
},
|
||||
});
|
||||
const after = await getPrismaClient().inboundDocument.findUniqueOrThrow({
|
||||
where: { id: document.id },
|
||||
});
|
||||
expect(after.kind).toBe("OTHER");
|
||||
expect(after.title).toBeNull();
|
||||
});
|
||||
|
||||
it("is a 404 for another account's document and its run", async () => {
|
||||
const owner = await makeUser();
|
||||
await grantConsent(owner.id);
|
||||
const document = await storeDocument(owner.id);
|
||||
await signIn(owner.id);
|
||||
const queued = await postDocumentAi(document.id, "suggest", true);
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
|
||||
const other = await makeUser();
|
||||
await grantConsent(other.id);
|
||||
await signIn(other.id);
|
||||
const foreignPost = await postDocumentAi(document.id, "suggest", true);
|
||||
expect(foreignPost.status).toBe(404);
|
||||
expect(foreignPost.body.meta?.errorCode).toBe("documents.inbound.notFound");
|
||||
const foreignPoll = await poll(runId);
|
||||
expect(foreignPoll.status).toBe(404);
|
||||
expect(foreignPoll.body.meta?.errorCode).toBe("aiRuns.notFound");
|
||||
});
|
||||
});
|
||||
|
||||
async function postStoredExtract(documentId: string, prefer: boolean) {
|
||||
const { POST } =
|
||||
await import("@/app/api/documents/inbound/[id]/extract/route");
|
||||
const { NextRequest } = await import("next/server");
|
||||
const headers: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (prefer) headers.prefer = "respond-async";
|
||||
const res = await POST(
|
||||
new NextRequest(
|
||||
`http://localhost/api/documents/inbound/${documentId}/extract`,
|
||||
{ method: "POST", headers, body: JSON.stringify({ mode: "stored" }) },
|
||||
) as never,
|
||||
{ params: Promise.resolve({ id: documentId }) } as never,
|
||||
);
|
||||
return { status: res.status, body: (await res.json()) as Envelope };
|
||||
}
|
||||
|
||||
async function indexedDocument(userId: string) {
|
||||
const document = await storeDocument(userId);
|
||||
const { upsertContentIndex } = await import("@/lib/documents/content-index");
|
||||
await upsertContentIndex({
|
||||
userId,
|
||||
documentId: document.id,
|
||||
text: "Hemoglobin 13.9 g/dL reference range 12-16",
|
||||
source: "text-ocr",
|
||||
providerType: null,
|
||||
});
|
||||
return document;
|
||||
}
|
||||
|
||||
describe("fact extraction in the background", () => {
|
||||
it("stages the facts in the worker and serves the count", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await indexedDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const queued = await postStoredExtract(document.id, true);
|
||||
expect(queued.status).toBe(202);
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
const stored = await getPrismaClient().documentAiRun.findUniqueOrThrow({
|
||||
where: { id: runId },
|
||||
});
|
||||
// The worker reads the content index itself; nothing is sealed.
|
||||
expect(stored.inputEncrypted).toBeNull();
|
||||
expect(runInboundExtraction).not.toHaveBeenCalled();
|
||||
|
||||
expect(await work(runId)).toBe("succeeded");
|
||||
expect(runInboundExtraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ocrText: expect.stringContaining("Hemoglobin 13.9"),
|
||||
}),
|
||||
);
|
||||
const done = await poll(runId);
|
||||
expect(done.body.data).toMatchObject({
|
||||
status: "SUCCEEDED",
|
||||
kind: "DOCUMENT_EXTRACT",
|
||||
result: {
|
||||
documentId: document.id,
|
||||
factsStaged: 1,
|
||||
status: "EXTRACTED",
|
||||
},
|
||||
});
|
||||
const facts = await getPrismaClient().extractedFact.findMany({
|
||||
where: { documentId: document.id },
|
||||
});
|
||||
expect(facts.map((f) => f.status)).toEqual(["PENDING"]);
|
||||
});
|
||||
|
||||
it("never replaces a review that was finished while the read ran", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await indexedDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const queued = await postStoredExtract(document.id, true);
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
|
||||
// The person approves a fact from an earlier staging before the worker
|
||||
// gets to the run.
|
||||
const { encryptFactData, encryptFactProvenance } =
|
||||
await import("@/lib/documents/store");
|
||||
const approved = await getPrismaClient().extractedFact.create({
|
||||
data: {
|
||||
documentId: document.id,
|
||||
userId: user.id,
|
||||
factType: "OBSERVATION",
|
||||
status: "APPROVED",
|
||||
confidence: 0.9,
|
||||
needsReview: false,
|
||||
dataEncrypted: encryptFactData({
|
||||
label: "Glucose",
|
||||
value: 95,
|
||||
unit: "mg/dL",
|
||||
} as never),
|
||||
provenanceEncrypted: encryptFactProvenance({
|
||||
sourceText: "Glucose 95",
|
||||
anchored: true,
|
||||
sourceOffset: 0,
|
||||
page: null,
|
||||
confidence: 0.9,
|
||||
} as never),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await work(runId)).toBe("failed");
|
||||
const done = await poll(runId);
|
||||
expect(done.body.data).toMatchObject({
|
||||
status: "FAILED",
|
||||
error: {
|
||||
code: "documents.inbound.alreadyPartlyConfirmed",
|
||||
status: 409,
|
||||
},
|
||||
});
|
||||
const facts = await getPrismaClient().extractedFact.findMany({
|
||||
where: { documentId: document.id },
|
||||
});
|
||||
expect(facts.map((f) => [f.id, f.status])).toEqual([
|
||||
[approved.id, "APPROVED"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers with the document detail, exactly as before, without the header", async () => {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
const document = await indexedDocument(user.id);
|
||||
await signIn(user.id);
|
||||
|
||||
const res = await postStoredExtract(document.id, false);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.data).toMatchObject({
|
||||
id: document.id,
|
||||
status: "EXTRACTED",
|
||||
});
|
||||
expect((res.body.data as { facts: unknown[] }).facts).toHaveLength(1);
|
||||
expect(await getPrismaClient().documentAiRun.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -735,3 +735,42 @@ describe("document vault — bulk + usage", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("document vault — the sick note kind", () => {
|
||||
it("is accepted at upload and on edit, and filters like every other kind", async () => {
|
||||
await seedVaultUser("vault-sick-note");
|
||||
const { post, get, patch } = await routes();
|
||||
|
||||
const uploaded = await post(
|
||||
uploadRequest(PDF_SMALL, "au-bescheinigung.pdf", { kind: "SICK_NOTE" }),
|
||||
);
|
||||
expect(uploaded.status).toBe(201);
|
||||
expect((await uploaded.json()).data.kind).toBe("SICK_NOTE");
|
||||
|
||||
const other = await post(uploadRequest(PNG_1X1, "scan.png"));
|
||||
expect(other.status).toBe(201);
|
||||
const otherDoc = (await other.json()).data;
|
||||
expect(otherDoc.kind).toBe("OTHER");
|
||||
|
||||
const edited = await patch(
|
||||
jsonRequest(
|
||||
`http://localhost/api/documents/inbound/${otherDoc.id}`,
|
||||
"PATCH",
|
||||
{ kind: "SICK_NOTE" },
|
||||
),
|
||||
ctx(otherDoc.id),
|
||||
);
|
||||
expect(edited.status).toBe(200);
|
||||
expect((await edited.json()).data.kind).toBe("SICK_NOTE");
|
||||
|
||||
const sickNotes = await get(
|
||||
new Request("http://localhost/api/documents/inbound?kind=SICK_NOTE"),
|
||||
);
|
||||
expect(sickNotes.status).toBe(200);
|
||||
expect((await sickNotes.json()).data.documents).toHaveLength(2);
|
||||
const others = await get(
|
||||
new Request("http://localhost/api/documents/inbound?kind=OTHER"),
|
||||
);
|
||||
expect((await others.json()).data.documents).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user