feat(labs): PDF upload, text extraction, LLM draft pipeline

This commit is contained in:
marcuspaico
2026-08-17 15:41:02 -07:00
parent 89394732fd
commit 8e9f3d5894
12 changed files with 237 additions and 5 deletions

View File

@@ -8,7 +8,7 @@ import { loadOrCreateKey } from "../src/lib/crypto";
function makeApp() {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
return createApp({ db: openDb(dir), key: loadOrCreateKey(dir) });
return createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir });
}
const json = (body: unknown) => ({
method: "POST",

View File

@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { openDb } from "../src/db";
import { loadOrCreateKey } from "../src/lib/crypto";
import { extractFromText } from "../src/lib/extract";
const FIXTURE_TEXT = `
Sample Diagnostics — Final Report
Collected: 15 Jan 2026
CHEMISTRY
Glucose (Fasting) 100 mg/dL (70-99) H
LIPIDS
LDL Cholesterol 3.1 mmol/L (<3.4)
`;
describe("extractFromText", () => {
test("passes text to LLM and validates the draft shape", async () => {
let userPrompt = "";
const mock = (async (_: any, init: any) => {
userPrompt = JSON.parse(String(init.body)).messages[1].content;
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
collectedDate: "2026-01-15",
labName: "Sample Diagnostics",
markers: [
{ panel: "chemistry", name: "Glucose (Fasting)", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true },
{ panel: "lipids", name: "LDL Cholesterol", value: "3.1", unit: "mmol/L", referenceRange: "<3.4", flagged: false },
],
}) } }] }), { status: 200 });
}) as typeof fetch;
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const draft = await extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, FIXTURE_TEXT);
expect(userPrompt).toContain("Glucose (Fasting)");
expect(draft.markers).toHaveLength(2);
expect(draft.collectedDate).toBe("2026-01-15");
expect(draft.markers[0].flagged).toBe(true);
});
test("malformed LLM output → llm_error, not a crash", async () => {
const mock = (async () => new Response(JSON.stringify({ choices: [{ message: { content: '{"markers": "not an array"}' } }] }), { status: 200 })) as unknown as typeof fetch;
const dir = mkdtempSync(join(tmpdir(), "helios-"));
expect(extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, "x")).rejects.toThrow(/llm_error/);
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createApp } from "../src/app";
import { openDb } from "../src/db";
import { loadOrCreateKey } from "../src/lib/crypto";
// Minimal one-page PDF with a text object — enough for unpdf to open and read.
const TINY_PDF = `%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
4 0 obj<</Length 60>>stream
BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET
endstream
endobj
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
trailer<</Root 1 0 R>>`;
async function authedApp(fetchImpl: typeof fetch) {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const app = createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir, llmFetch: fetchImpl });
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
return { app, cookie };
}
const llmOk = (async () => new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
collectedDate: "2026-01-15", labName: "Sample Diagnostics",
markers: [{ panel: null, name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }],
}) } }] }), { status: 200 })) as unknown as typeof fetch;
describe("labs upload", () => {
test("PDF upload → pending draft with extracted markers", async () => {
const { app, cookie } = await authedApp(llmOk);
const fd = new FormData();
fd.append("file", new File([TINY_PDF], "results.pdf", { type: "application/pdf" }));
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
expect(up.status).toBe(201);
const { id } = await up.json();
const list = await (await app.request("/api/labs/drafts", { headers: { cookie } })).json();
expect(list.drafts).toHaveLength(1);
expect(list.drafts[0].id).toBe(id);
expect(list.drafts[0].status).toBe("pending");
expect(list.drafts[0].markerCount).toBe(1);
});
test("non-PDF rejected", async () => {
const { app, cookie } = await authedApp(llmOk);
const fd = new FormData();
fd.append("file", new File(["hi"], "notes.txt", { type: "text/plain" }));
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
expect(up.status).toBe(400);
});
test("LLM failure → draft stored with error, not a 500", async () => {
const llmDown = (async () => new Response("x", { status: 500 })) as unknown as typeof fetch;
const { app, cookie } = await authedApp(llmDown);
const fd = new FormData();
fd.append("file", new File([TINY_PDF], "r.pdf", { type: "application/pdf" }));
const up = await app.request("/api/labs/upload", { method: "POST", headers: { cookie }, body: fd });
expect(up.status).toBe(201);
const list = await (await app.request("/api/labs/drafts", { headers: { cookie } })).json();
expect(list.drafts[0].error).toMatch(/llm_error/);
expect(list.drafts[0].markerCount).toBe(0);
});
});

View File

@@ -11,7 +11,7 @@ import { loadOrCreateKey } from "../src/lib/crypto";
async function authedApp() {
const dir = mkdtempSync(join(tmpdir(), "helios-"));
const db = openDb(dir);
const app = createApp({ db, key: loadOrCreateKey(dir) });
const app = createApp({ db, key: loadOrCreateKey(dir), dataDir: dir });
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;