feat(labs): PDF upload, text extraction, LLM draft pipeline
This commit is contained in:
@@ -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" }));
|
||||
|
||||
@@ -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
15
server/src/lib/extract.ts
Normal 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
7
server/src/lib/pdf.ts
Normal 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
68
server/src/routes/labs.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user