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

@@ -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" }));

View File

@@ -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 };

15
server/src/lib/extract.ts Normal file
View File

@@ -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<ExtractedDraft> {
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;
}

7
server/src/lib/pdf.ts Normal file
View File

@@ -0,0 +1,7 @@
import { extractText, getDocumentProxy } from "unpdf";
export async function pdfToText(data: Uint8Array): Promise<string> {
const doc = await getDocumentProxy(data);
const { text } = await extractText(doc, { mergePages: true });
return text;
}

68
server/src/routes/labs.ts Normal file
View File

@@ -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;
}