feat(labs): PDF upload, text extraction, LLM draft pipeline
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -15,6 +15,7 @@
|
|||||||
"@helios/shared": "workspace:*",
|
"@helios/shared": "workspace:*",
|
||||||
"drizzle-orm": "^0.44.0",
|
"drizzle-orm": "^0.44.0",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
|
"unpdf": "^1.8.1",
|
||||||
"zod": "^3.24.0",
|
"zod": "^3.24.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -322,6 +323,8 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@@ -5,7 +5,11 @@
|
|||||||
"@helios/shared": "workspace:*",
|
"@helios/shared": "workspace:*",
|
||||||
"drizzle-orm": "^0.44.0",
|
"drizzle-orm": "^0.44.0",
|
||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
|
"unpdf": "^1.8.1",
|
||||||
"zod": "^3.24.0"
|
"zod": "^3.24.0"
|
||||||
},
|
},
|
||||||
"devDependencies": { "bun-types": "latest", "drizzle-kit": "^0.31.0" }
|
"devDependencies": {
|
||||||
|
"bun-types": "latest",
|
||||||
|
"drizzle-kit": "^0.31.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { getCookie } from "hono/cookie";
|
|||||||
import { serveStatic } from "hono/bun";
|
import { serveStatic } from "hono/bun";
|
||||||
import type { Db } from "./db";
|
import type { Db } from "./db";
|
||||||
import { authRoutes, isAuthenticated } from "./routes/auth";
|
import { authRoutes, isAuthenticated } from "./routes/auth";
|
||||||
|
import { labsRoutes } from "./routes/labs";
|
||||||
import { settingsRoutes } from "./routes/settings";
|
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"]);
|
const PUBLIC = new Set(["/api/health", "/api/me", "/api/setup", "/api/login"]);
|
||||||
|
|
||||||
export function createApp(deps: Deps) {
|
export function createApp(deps: Deps) {
|
||||||
@@ -20,6 +21,7 @@ export function createApp(deps: Deps) {
|
|||||||
});
|
});
|
||||||
app.route("/api", authRoutes({ db: deps.db }));
|
app.route("/api", authRoutes({ db: deps.db }));
|
||||||
app.route("/api", settingsRoutes(deps));
|
app.route("/api", settingsRoutes(deps));
|
||||||
|
app.route("/api", labsRoutes(deps));
|
||||||
// Later route groups (connectors, chat) mount here.
|
// Later route groups (connectors, chat) mount here.
|
||||||
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
|
app.all("/api/*", (c) => c.json({ error: "not found" }, 404));
|
||||||
app.use("/*", serveStatic({ root: "./web/dist" }));
|
app.use("/*", serveStatic({ root: "./web/dist" }));
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ import { loadOrCreateKey } from "./lib/crypto";
|
|||||||
import { createApp } from "./app";
|
import { createApp } from "./app";
|
||||||
|
|
||||||
const dataDir = process.env.DATA_DIR ?? "./data";
|
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 };
|
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;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { loadOrCreateKey } from "../src/lib/crypto";
|
|||||||
|
|
||||||
function makeApp() {
|
function makeApp() {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
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) => ({
|
const json = (body: unknown) => ({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
46
server/test/extract.test.ts
Normal file
46
server/test/extract.test.ts
Normal file
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
server/test/labs-upload.test.ts
Normal file
70
server/test/labs-upload.test.ts
Normal file
@@ -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<</Type/Catalog/Pages 2 0 R>>endobj
|
||||||
|
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||||
|
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
|
||||||
|
4 0 obj<</Length 60>>stream
|
||||||
|
BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
|
||||||
|
trailer<</Root 1 0 R>>`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@ import { loadOrCreateKey } from "../src/lib/crypto";
|
|||||||
async function authedApp() {
|
async function authedApp() {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||||||
const db = openDb(dir);
|
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) });
|
const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
|
||||||
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
|
await app.request("/api/setup", j({ password: "hunter2hunter2" }));
|
||||||
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
|
const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
|
||||||
|
|||||||
@@ -20,3 +20,20 @@ export const SettingsUpdate = z.object({
|
|||||||
llmKey: z.string().min(1).optional(),
|
llmKey: z.string().min(1).optional(),
|
||||||
});
|
});
|
||||||
export type SettingsUpdate = z.infer<typeof SettingsUpdate>;
|
export type SettingsUpdate = z.infer<typeof SettingsUpdate>;
|
||||||
|
|
||||||
|
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<typeof ExtractedMarker>;
|
||||||
|
|
||||||
|
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<typeof ExtractedDraft>;
|
||||||
|
|||||||
Reference in New Issue
Block a user