feat(labs): PDF upload, text extraction, LLM draft pipeline
This commit is contained in:
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