diff --git a/bun.lock b/bun.lock index 802fcd1..db2ad2b 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@helios/shared": "workspace:*", "drizzle-orm": "^0.44.0", "hono": "^4.6.0", + "unpdf": "^1.8.1", "zod": "^3.24.0", }, "devDependencies": { @@ -322,6 +323,8 @@ "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "unpdf": ["unpdf@1.8.1", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69 || ^1.0.0" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ=="], + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], "vite": ["vite@6.4.3", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A=="], diff --git a/server/package.json b/server/package.json index 1958201..9d3da56 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,11 @@ "@helios/shared": "workspace:*", "drizzle-orm": "^0.44.0", "hono": "^4.6.0", + "unpdf": "^1.8.1", "zod": "^3.24.0" }, - "devDependencies": { "bun-types": "latest", "drizzle-kit": "^0.31.0" } + "devDependencies": { + "bun-types": "latest", + "drizzle-kit": "^0.31.0" + } } diff --git a/server/src/app.ts b/server/src/app.ts index 81f913f..6a7194d 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -3,9 +3,10 @@ import { getCookie } from "hono/cookie"; import { serveStatic } from "hono/bun"; import type { Db } from "./db"; import { authRoutes, isAuthenticated } from "./routes/auth"; +import { labsRoutes } from "./routes/labs"; import { settingsRoutes } from "./routes/settings"; -export type Deps = { db: Db; key: Buffer }; +export type Deps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }; const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]); export function createApp(deps: Deps) { @@ -20,6 +21,7 @@ export function createApp(deps: Deps) { }); app.route("/api", authRoutes({ db: deps.db })); app.route("/api", settingsRoutes(deps)); + app.route("/api", labsRoutes(deps)); // Later route groups (connectors, chat) mount here. app.all("/api/*", (c) => c.json({ error: "not found" }, 404)); app.use("/*", serveStatic({ root: "./web/dist" })); diff --git a/server/src/index.ts b/server/src/index.ts index a589232..64cfba9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,6 @@ import { loadOrCreateKey } from "./lib/crypto"; import { createApp } from "./app"; const dataDir = process.env.DATA_DIR ?? "./data"; -const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir) }); +const app = createApp({ db: openDb(dataDir), key: loadOrCreateKey(dataDir), dataDir }); export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch }; diff --git a/server/src/lib/extract.ts b/server/src/lib/extract.ts new file mode 100644 index 0000000..6a3c91e --- /dev/null +++ b/server/src/lib/extract.ts @@ -0,0 +1,15 @@ +import { ExtractedDraft } from "@helios/shared"; +import { chatJSON, type LlmDeps } from "./llm"; + +const SYSTEM = `You extract structured blood-test results from the raw text of a lab report. +Return ONLY a JSON object: {"collectedDate": "YYYY-MM-DD" or null, "labName": string or null, "markers": [{"panel": string or null, "name": string, "value": string, "unit": string or null, "referenceRange": string or null, "flagged": boolean}]}. +Rules: copy names, values, units, and reference ranges EXACTLY as printed — do not convert units or round values. "value" is always a string (keep comparators like "<0.3"). Set "flagged" true only when the report marks the result abnormal (H, L, *, bold, out-of-range annotation). Use the specimen collection date, not the report date. Skip commentary, footers, and reference-only rows with no result.`; + +const MAX_CHARS = 40_000; + +export async function extractFromText(deps: LlmDeps, text: string): Promise { + const out = await chatJSON(deps, { system: SYSTEM, user: text.slice(0, MAX_CHARS) }); + const parsed = ExtractedDraft.safeParse(out); + if (!parsed.success) throw new Error("llm_error: draft failed schema validation"); + return parsed.data; +} diff --git a/server/src/lib/pdf.ts b/server/src/lib/pdf.ts new file mode 100644 index 0000000..cf48d54 --- /dev/null +++ b/server/src/lib/pdf.ts @@ -0,0 +1,7 @@ +import { extractText, getDocumentProxy } from "unpdf"; + +export async function pdfToText(data: Uint8Array): Promise { + const doc = await getDocumentProxy(data); + const { text } = await extractText(doc, { mergePages: true }); + return text; +} diff --git a/server/src/routes/labs.ts b/server/src/routes/labs.ts new file mode 100644 index 0000000..cc77273 --- /dev/null +++ b/server/src/routes/labs.ts @@ -0,0 +1,68 @@ +import { desc, eq } from "drizzle-orm"; +import { Hono } from "hono"; +import { randomUUID } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import { join } from "node:path"; +import type { Db } from "../db"; +import { labDrafts } from "../db/schema"; +import { extractFromText } from "../lib/extract"; +import { pdfToText } from "../lib/pdf"; + +export type LabsDeps = { db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }; + +const MAX_UPLOAD = 15 * 1024 * 1024; + +export function labsRoutes(deps: LabsDeps) { + const app = new Hono(); + + app.post("/labs/upload", async (c) => { + const body = await c.req.parseBody(); + const file = body.file; + if (!(file instanceof File)) return c.json({ error: "file field required" }, 400); + if (!file.name.toLowerCase().endsWith(".pdf") && file.type !== "application/pdf") { + return c.json({ error: "not_a_pdf" }, 400); + } + if (file.size > MAX_UPLOAD) return c.json({ error: "too_large" }, 400); + + const id = randomUUID(); + const uploadsDir = join(deps.dataDir, "uploads"); + mkdirSync(uploadsDir, { recursive: true }); + const filePath = join(uploadsDir, `${id}.pdf`); + const bytes = new Uint8Array(await file.arrayBuffer()); + await Bun.write(filePath, bytes); + + // Extraction failures land on the draft row so the user sees them in the + // review UI instead of the upload 500ing. + let extracted: string | null = null; + let error: string | null = null; + try { + const text = await pdfToText(bytes); + if (text.trim().length < 20) throw new Error("pdf_error: no text layer (scanned PDFs are not supported yet)"); + const draft = await extractFromText({ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch }, text); + extracted = JSON.stringify(draft); + } catch (e) { + error = e instanceof Error ? e.message : "extraction failed"; + } + + await deps.db.insert(labDrafts).values({ + id, filename: file.name, filePath, status: "pending", extracted, error, createdAt: Date.now(), + }); + return c.json({ id }, 201); + }); + + app.get("/labs/drafts", async (c) => { + const rows = await deps.db.select().from(labDrafts).orderBy(desc(labDrafts.createdAt)); + return c.json({ + drafts: rows.map((r) => ({ + id: r.id, + filename: r.filename, + status: r.status, + error: r.error, + markerCount: r.extracted ? (JSON.parse(r.extracted).markers?.length ?? 0) : 0, + createdAt: r.createdAt, + })), + }); + }); + + return app; +} diff --git a/server/test/auth.test.ts b/server/test/auth.test.ts index a0e3f55..d20436c 100644 --- a/server/test/auth.test.ts +++ b/server/test/auth.test.ts @@ -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", diff --git a/server/test/extract.test.ts b/server/test/extract.test.ts new file mode 100644 index 0000000..a80faa6 --- /dev/null +++ b/server/test/extract.test.ts @@ -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/); + }); +}); diff --git a/server/test/labs-upload.test.ts b/server/test/labs-upload.test.ts new file mode 100644 index 0000000..12f69cf --- /dev/null +++ b/server/test/labs-upload.test.ts @@ -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<>endobj +2 0 obj<>endobj +3 0 obj<>>>>>endobj +4 0 obj<>stream +BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET +endstream +endobj +5 0 obj<>endobj +trailer<>`; + +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); + }); +}); diff --git a/server/test/settings.test.ts b/server/test/settings.test.ts index 56832fb..dbc4b45 100644 --- a/server/test/settings.test.ts +++ b/server/test/settings.test.ts @@ -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")!; diff --git a/shared/src/types.ts b/shared/src/types.ts index 1557441..fd352b8 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -20,3 +20,20 @@ export const SettingsUpdate = z.object({ llmKey: z.string().min(1).optional(), }); export type SettingsUpdate = z.infer; + +export const ExtractedMarker = z.object({ + panel: z.string().nullable().default(null), + name: z.string().min(1), + value: z.string().min(1), + unit: z.string().nullable().default(null), + referenceRange: z.string().nullable().default(null), + flagged: z.boolean().default(false), +}); +export type ExtractedMarker = z.infer; + +export const ExtractedDraft = z.object({ + collectedDate: z.string().nullable().default(null), + labName: z.string().nullable().default(null), + markers: z.array(ExtractedMarker).default([]), +}); +export type ExtractedDraft = z.infer;