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

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