# Helios M2 — Labs Pipeline Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Blood-test core: upload a lab PDF → LLM extracts markers → user reviews/edits/confirms → normalized biomarkers (canonical units via analyte registry) → Labs dashboard with flags and per-marker history. **Architecture:** New `lab_drafts` (pending extractions), `lab_draws` + `biomarkers` tables. A provider-agnostic `chatJSON` LLM helper (OpenAI-compatible, fetch-injectable for tests). PDF→text via `unpdf` (pure JS, works in Bun). Normalization re-implements the health-api concepts cleanly: analyte registry (canonical unit + molar mass + aliases) and two-kind unit conversion (dimensional scaling is analyte-independent; mass↔mole bridging needs molar mass). Nothing writes to `biomarkers` until the user confirms. **Tech Stack:** Existing (Bun, Hono, drizzle, Zod, React). New deps: `unpdf` (server). **Spec:** docs/superpowers/specs/2026-08-18-helios-design.md (Labs section; milestone 4 pulled forward by Marcus 2026-08-18 to be milestone 2). ## Global Constraints - TS strict; `bun run typecheck`, `bun test server`, `bun run build:web` green at every commit. Conventional Commits. - Secrets only via `getSetting`; LLM calls go only to the user-configured endpoint; no telemetry. - Nothing writes to `lab_draws`/`biomarkers` except the confirm endpoint, after user review. - Uploads: PDF only, ≤ 15 MB, stored under `/uploads/`, served only through authed API with `Content-Type: application/pdf` and `Content-Disposition: inline; filename=...`. - All new API bodies validated with Zod schemas in `shared/src/types.ts`. - Raw extraction values are preserved verbatim (`value` as text); canonical values are additive, never destructive. - `~/health-api` is reference only — re-implement, fresh comments, no Marcus-specific data or lab names in code or fixtures. --- ### Task 1: Schema migration — drafts, draws, biomarkers **Files:** - Modify: `server/src/db/schema.ts` - Create: `server/drizzle/0001_labs.sql` (generated) - Test: `server/test/db-labs.test.ts` **Interfaces:** - Produces tables (used by Tasks 4–6): - `labDrafts`: `id` text PK, `filename` text notNull, `filePath` text notNull, `status` text notNull ('pending' | 'confirmed' | 'discarded'), `extracted` text (JSON string, nullable), `error` text nullable, `createdAt` integer notNull. - `labDraws`: `id` text PK, `collectedAt` text notNull (ISO date `YYYY-MM-DD`), `labName` text nullable, `draftId` text nullable, `createdAt` integer notNull. - `biomarkers`: `id` integer PK autoincrement, `drawId` text notNull, `panel` text notNull, `name` text notNull, `marker` text notNull, `analyteKey` text nullable, `value` text notNull, `valueNum` real nullable, `unit` text nullable, `referenceRange` text nullable, `flagged` integer notNull default 0, `valueCanonical` real nullable, `canonicalUnit` text nullable. - [ ] **Step 1: Write the failing test** `server/test/db-labs.test.ts`: ```ts 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 { biomarkers, labDraws } from "../src/db/schema"; describe("labs schema", () => { test("draw + biomarker round-trip", async () => { const db = openDb(mkdtempSync(join(tmpdir(), "helios-"))); await db.insert(labDraws).values({ id: "d1", collectedAt: "2026-01-15", labName: "Acme Lab", draftId: null, createdAt: 1 }); await db.insert(biomarkers).values({ drawId: "d1", panel: "lipids", name: "LDL Cholesterol", marker: "ldl", analyteKey: "ldl", value: "3.1", valueNum: 3.1, unit: "mmol/L", referenceRange: "< 3.4", flagged: 0, valueCanonical: 3.1, canonicalUnit: "mmol/L", }); const rows = await db.select().from(biomarkers); expect(rows).toHaveLength(1); expect(rows[0].marker).toBe("ldl"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test server/test/db-labs.test.ts` — Expected: FAIL (tables missing). - [ ] **Step 3: Implement** Append to `server/src/db/schema.ts`: ```ts import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; // (merge with the existing import line — one import statement total) export const labDrafts = sqliteTable("lab_drafts", { id: text("id").primaryKey(), filename: text("filename").notNull(), filePath: text("file_path").notNull(), status: text("status").notNull(), // pending | confirmed | discarded extracted: text("extracted"), error: text("error"), createdAt: integer("created_at").notNull(), }); export const labDraws = sqliteTable("lab_draws", { id: text("id").primaryKey(), collectedAt: text("collected_at").notNull(), // ISO date YYYY-MM-DD labName: text("lab_name"), draftId: text("draft_id"), createdAt: integer("created_at").notNull(), }); export const biomarkers = sqliteTable("biomarkers", { id: integer("id").primaryKey({ autoIncrement: true }), drawId: text("draw_id").notNull(), panel: text("panel").notNull(), name: text("name").notNull(), marker: text("marker").notNull(), analyteKey: text("analyte_key"), value: text("value").notNull(), valueNum: real("value_num"), unit: text("unit"), referenceRange: text("reference_range"), flagged: integer("flagged").notNull().default(0), valueCanonical: real("value_canonical"), canonicalUnit: text("canonical_unit"), }); ``` Run: `cd server && bunx drizzle-kit generate --name labs` → commit generated files. - [ ] **Step 4: Run test to verify it passes** Run: `bun test server/test/db-labs.test.ts` — Expected: PASS. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(db): lab_drafts, lab_draws, biomarkers tables Refs PAI-91." ``` --- ### Task 2: Unit conversion + analyte registry + normalization **Files:** - Create: `server/src/lib/units.ts`, `server/src/lib/analytes.ts`, `server/src/lib/normalize.ts` - Test: `server/test/normalize.test.ts` **Interfaces:** - Produces (consumed by Task 5's confirm endpoint and Task 6's queries): - `convert(value: number, fromUnit: string, toUnit: string, molarMass?: number | null): number | null` - `resolveAnalyte(name: string): Analyte | null` where `Analyte = { key, display, canonicalUnit, molarMass, panel, aliases }` - `normalizeMarker(raw: RawMarker): NormMarker` — `RawMarker = { panel?, name, value: string, unit?, referenceRange?, flagged? }`; `NormMarker` mirrors the `biomarkers` columns (panel, name, marker, analyteKey, value, valueNum, unit, referenceRange, flagged (boolean), valueCanonical, canonicalUnit). Concepts re-implemented from the reference design: unit conversion splits into analyte-independent dimensional scaling (ng/mL→µg/L) and an analyte-dependent mass↔mole bridge that requires molar mass (mg/dL→mmol/L for glucose needs 180.16 g/mol). Non-bridgeable kinds (%, ratio, IU, counts) convert only within their kind. - [ ] **Step 1: Write the failing test** `server/test/normalize.test.ts`: ```ts import { describe, expect, test } from "bun:test"; import { resolveAnalyte } from "../src/lib/analytes"; import { normalizeMarker } from "../src/lib/normalize"; import { convert } from "../src/lib/units"; describe("units", () => { test("dimensional scaling: ng/mL → µg/L is 1:1", () => { expect(convert(30, "ng/mL", "µg/L")).toBeCloseTo(30); }); test("mass→mole bridge needs molar mass", () => { expect(convert(100, "mg/dL", "mmol/L")).toBeNull(); expect(convert(100, "mg/dL", "mmol/L", 180.16)).toBeCloseTo(5.551, 2); // glucose }); test("mole→mass: testosterone 20 nmol/L → ng/dL", () => { expect(convert(20, "nmol/L", "ng/dL", 288.42)).toBeCloseTo(576.8, 0); }); test("percent only converts to percent", () => { expect(convert(42, "%", "%")).toBe(42); expect(convert(42, "%", "mg/dL")).toBeNull(); }); test("count units: x10^9/L → thousand/uL is 1:1", () => { expect(convert(6.1, "x10^9/L", "thousand/uL")).toBeCloseTo(6.1); }); }); describe("analytes", () => { test("alias resolution is case/space-insensitive", () => { expect(resolveAnalyte("Total Testosterone")?.key).toBe("testosterone_total"); expect(resolveAnalyte("HbA1c")?.key).toBe("hba1c"); expect(resolveAnalyte("definitely not a marker")).toBeNull(); }); }); describe("normalizeMarker", () => { test("known analyte converts to canonical unit and registry panel", () => { const n = normalizeMarker({ panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }); expect(n.analyteKey).toBe("glucose"); expect(n.panel).toBe("metabolic"); // registry panel wins for consistent grouping expect(n.valueCanonical).toBeCloseTo(5.551, 2); expect(n.canonicalUnit).toBe("mmol/L"); expect(n.value).toBe("100"); // raw preserved expect(n.flagged).toBe(true); }); test("unknown marker keeps raw fields, no canonical value", () => { const n = normalizeMarker({ name: "Exotic Marker X", value: "1.2", unit: "u/L" }); expect(n.analyteKey).toBeNull(); expect(n.marker).toBe("Exotic Marker X"); expect(n.valueCanonical).toBeNull(); }); test("comparator values parse numerically", () => { const n = normalizeMarker({ name: "hs-CRP", value: "<0.3", unit: "mg/L" }); expect(n.valueNum).toBeCloseTo(0.3); expect(n.value).toBe("<0.3"); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `bun test server/test/normalize.test.ts` — Expected: FAIL (modules missing). - [ ] **Step 3: Implement** `server/src/lib/units.ts`: ```ts // Lab unit conversion. Two transform kinds: dimensional scaling (prefix/volume, // analyte-independent) and the mass<->mole bridge, which requires the analyte's // molar mass. %, ratios, IU, and cell counts never bridge to mass or mole. type Kind = "mass" | "mole" | "count" | "iu" | "percent" | "ratio" | "other"; const AMOUNT: Record = { g: ["mass", 1], mg: ["mass", 1e-3], ug: ["mass", 1e-6], mcg: ["mass", 1e-6], "µg": ["mass", 1e-6], ng: ["mass", 1e-9], pg: ["mass", 1e-12], mol: ["mole", 1], mmol: ["mole", 1e-3], umol: ["mole", 1e-6], "µmol": ["mole", 1e-6], nmol: ["mole", 1e-9], pmol: ["mole", 1e-12], cells: ["count", 1], k: ["count", 1e3], thousand: ["count", 1e3], million: ["count", 1e6], "x10^9": ["count", 1e9], "x10^12": ["count", 1e12], iu: ["iu", 1], miu: ["iu", 1e-3], uiu: ["iu", 1e-6], "µiu": ["iu", 1e-6], u: ["iu", 1], }; const VOLUME: Record = { l: 1, dl: 0.1, ml: 1e-3, ul: 1e-6, "µl": 1e-6 }; const clean = (u: string) => u.toLowerCase().replace(/\s+/g, "").replace("μ", "µ"); interface Parsed { kind: Kind; amountFactor: number; volumeFactor: number } export function parseUnit(unit: string): Parsed | null { const u = clean(unit); if (u === "%") return { kind: "percent", amountFactor: 1, volumeFactor: 1 }; if (u === "" || u === "ratio" || u === "index") return { kind: "ratio", amountFactor: 1, volumeFactor: 1 }; const m = u.match(/^([a-zµ0-9^]+)\/([a-zµ0-9.]+)$/); if (m) { const amtEntry = AMOUNT[m[1]]; const volFactor = VOLUME[m[2]]; if (amtEntry && volFactor) return { kind: amtEntry[0], amountFactor: amtEntry[1], volumeFactor: volFactor }; } return { kind: "other", amountFactor: 1, volumeFactor: 1 }; } export function convert(value: number, fromUnit: string, toUnit: string, molarMass?: number | null): number | null { const from = parseUnit(fromUnit); const to = parseUnit(toUnit); if (!from || !to) return null; if (clean(fromUnit) === clean(toUnit)) return value; if (from.kind === "other" || to.kind === "other") return null; const fromBasePerL = (value * from.amountFactor) / from.volumeFactor; let toBasePerL = fromBasePerL; if (from.kind !== to.kind) { const bridge = (from.kind === "mass" && to.kind === "mole") || (from.kind === "mole" && to.kind === "mass"); if (!bridge || !molarMass) return null; toBasePerL = from.kind === "mass" ? fromBasePerL / molarMass : fromBasePerL * molarMass; } return (toBasePerL * to.volumeFactor) / to.amountFactor; } ``` `server/src/lib/analytes.ts` — registry with `key, display, canonicalUnit, molarMass, panel, aliases`. Canonical units are SI/molar where labs split between conventions. Include at minimum these analytes (panels: iron, hormones, thyroid, lipids, metabolic, inflammation, liver, kidney, cbc, vitamins, electrolytes): iron, ferritin, transferrin, tibc, transferrin_saturation; testosterone_total (ng/dL, 288.42), testosterone_free, shbg, dhea_s (µmol/L, 368.49), cortisol (nmol/L, 362.46), estradiol (pmol/L, 272.38), prolactin, fsh, lh, insulin (µIU/mL), igf1; tsh (mIU/L), free_t4 (pmol/L), free_t3 (pmol/L); cholesterol_total (mmol/L, 386.65), hdl (mmol/L, 386.65), ldl (mmol/L, 386.65), triglycerides (mmol/L, 885.4), non_hdl, apob (g/L), lipoprotein_a (nmol/L); glucose (mmol/L, 180.16), hba1c (%), uric_acid (mmol/L, 168.11); hs_crp (mg/L); alt (U/L), ast (U/L), ggt (U/L), alp (U/L), bilirubin_total (µmol/L, 584.66), albumin (g/L), total_protein (g/L); creatinine (µmol/L, 113.12), egfr (mL/min/1.73m2), urea (mmol/L, 60.06); hemoglobin (g/L), hematocrit (%), wbc (x10^9/L), rbc (x10^12/L), platelets (x10^9/L), mcv (fL — kind "other", no conversion), neutrophils (x10^9/L), lymphocytes (x10^9/L); vitamin_d (nmol/L, 400.64), vitamin_b12 (pmol/L, 1355.4), folate (nmol/L, 441.4); sodium (mmol/L), potassium (mmol/L), calcium (mmol/L, 40.08), magnesium (mmol/L, 24.31), zinc (µmol/L, 65.38). Aliases: include common lab spellings per analyte (e.g. glucose: "glucose", "glucose fasting", "fasting glucose"; hba1c: "hba1c", "hemoglobin a1c", "haemoglobin a1c"; testosterone_total: "testosterone", "total testosterone", "testosterone total"; vitamin_d: "vitamin d", "25-oh vitamin d", "vitamin d 25-hydroxy", "25-hydroxyvitamin d"; etc. — one sensible alias set per analyte, lowercased). ```ts export interface Analyte { key: string; display: string; canonicalUnit: string; molarMass: number | null; panel: string; aliases: string[]; } export const ANALYTES: Analyte[] = [ /* registry as specified above */ ]; const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9%]+/g, " ").trim(); const INDEX = new Map(); for (const a of ANALYTES) { INDEX.set(norm(a.display), a); for (const al of a.aliases) INDEX.set(norm(al), a); } export function resolveAnalyte(name: string): Analyte | null { return INDEX.get(norm(name)) ?? null; } ``` `server/src/lib/normalize.ts`: ```ts import { resolveAnalyte } from "./analytes"; import { convert } from "./units"; export interface RawMarker { panel?: string | null; name: string; value: string; unit?: string | null; referenceRange?: string | null; flagged?: boolean; } export interface NormMarker { panel: string; name: string; marker: string; analyteKey: string | null; value: string; valueNum: number | null; unit: string | null; referenceRange: string | null; flagged: boolean; valueCanonical: number | null; canonicalUnit: string | null; } const num = (v: string): number | null => { const n = parseFloat(v.replace(/[<>≤≥]/g, "").trim()); return Number.isFinite(n) ? n : null; }; export function normalizeMarker(raw: RawMarker): NormMarker { const analyte = resolveAnalyte(raw.name); const valueNum = num(raw.value); const unit = raw.unit ?? null; let valueCanonical: number | null = null; let canonicalUnit: string | null = null; if (analyte && valueNum !== null && unit) { canonicalUnit = analyte.canonicalUnit; valueCanonical = convert(valueNum, unit, analyte.canonicalUnit, analyte.molarMass); if (valueCanonical === null && unit.toLowerCase().replace(/\s/g, "") === analyte.canonicalUnit.toLowerCase().replace(/\s/g, "")) { valueCanonical = valueNum; } } return { // Registry panel wins for mapped analytes so a marker groups the same way // no matter which section a particular lab filed it under. panel: analyte?.panel ?? raw.panel ?? "other", name: raw.name, marker: analyte?.key ?? raw.name, analyteKey: analyte?.key ?? null, value: raw.value, valueNum, unit, referenceRange: raw.referenceRange ?? null, flagged: raw.flagged === true, valueCanonical, canonicalUnit, }; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `bun test server/test/normalize.test.ts` — Expected: PASS (all cases). - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(labs): unit conversion, analyte registry, marker normalization" ``` --- ### Task 3: LLM client (`chatJSON`) **Files:** - Create: `server/src/lib/llm.ts` - Modify: `server/src/routes/settings.ts` (export `DEFAULTS`) - Test: `server/test/llm.test.ts` **Interfaces:** - Produces (consumed by Task 4, later by chat milestone): `chatJSON(deps: LlmDeps, opts: { system: string; user: string }): Promise` where `LlmDeps = { db: Db; key: Buffer; fetchImpl?: typeof fetch }`. Reads `llm_base_url`/`llm_model` (falling back to exported `DEFAULTS`) and decrypted `llm_key` via `getSetting`. Sends OpenAI-compatible `POST {base}/chat/completions` with `response_format: { type: "json_object" }`; `Authorization: Bearer ` only when a key is set (Ollama needs none). Parses `choices[0].message.content` as JSON, stripping ```json fences if present. Throws `Error("llm_error: ")` on non-2xx or unparseable content. - [ ] **Step 1: Export DEFAULTS** In `server/src/routes/settings.ts` change `const DEFAULTS = {...} as const;` to `export const DEFAULTS = {...} as const;`. - [ ] **Step 2: Write the failing test** `server/test/llm.test.ts`: ```ts 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 { settings } from "../src/db/schema"; import { encrypt, loadOrCreateKey } from "../src/lib/crypto"; import { chatJSON } from "../src/lib/llm"; function deps(fetchImpl: typeof fetch) { const dir = mkdtempSync(join(tmpdir(), "helios-")); const db = openDb(dir); const key = loadOrCreateKey(dir); return { db, key, fetchImpl }; } describe("chatJSON", () => { test("posts OpenAI-compatible request with bearer key and parses JSON content", async () => { let captured: { url: string; init: RequestInit } | null = null; const mock = (async (url: any, init: any) => { captured = { url: String(url), init }; return new Response(JSON.stringify({ choices: [{ message: { content: '{"ok":1}' } }] }), { status: 200 }); }) as typeof fetch; const d = deps(mock); await d.db.insert(settings).values({ key: "llm_key", value: encrypt(d.key, "sk-test-1234") }); const out = await chatJSON(d, { system: "sys", user: "usr" }); expect(out).toEqual({ ok: 1 }); expect(captured!.url).toBe("https://openrouter.ai/api/v1/chat/completions"); const body = JSON.parse(String(captured!.init.body)); expect(body.response_format).toEqual({ type: "json_object" }); expect(body.messages).toEqual([{ role: "system", content: "sys" }, { role: "user", content: "usr" }]); expect((captured!.init.headers as Record).authorization).toBe("Bearer sk-test-1234"); }); test("no key → no auth header (Ollama)", async () => { let headers: Record = {}; const mock = (async (_: any, init: any) => { headers = init.headers; return new Response(JSON.stringify({ choices: [{ message: { content: "```json\n{\"a\":2}\n```" } }] }), { status: 200 }); }) as typeof fetch; const out = await chatJSON(deps(mock), { system: "s", user: "u" }); expect(out).toEqual({ a: 2 }); // fenced JSON stripped expect(headers.authorization).toBeUndefined(); }); test("non-2xx throws llm_error", async () => { const mock = (async () => new Response("nope", { status: 401 })) as typeof fetch; expect(chatJSON(deps(mock), { system: "s", user: "u" })).rejects.toThrow(/llm_error/); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `bun test server/test/llm.test.ts` — Expected: FAIL. - [ ] **Step 4: Implement** `server/src/lib/llm.ts`: ```ts import type { Db } from "../db"; import { DEFAULTS, getSetting } from "../routes/settings"; export type LlmDeps = { db: Db; key: Buffer; fetchImpl?: typeof fetch }; export async function chatJSON(deps: LlmDeps, opts: { system: string; user: string }): Promise { const f = deps.fetchImpl ?? fetch; const base = (await getSetting(deps.db, "llm_base_url", deps.key)) ?? DEFAULTS.llm_base_url; const model = (await getSetting(deps.db, "llm_model", deps.key)) ?? DEFAULTS.llm_model; const apiKey = await getSetting(deps.db, "llm_key", deps.key); const headers: Record = { "content-type": "application/json" }; if (apiKey) headers.authorization = `Bearer ${apiKey}`; const res = await f(`${base.replace(/\/$/, "")}/chat/completions`, { method: "POST", headers, body: JSON.stringify({ model, response_format: { type: "json_object" }, messages: [ { role: "system", content: opts.system }, { role: "user", content: opts.user }, ], }), }); if (!res.ok) throw new Error(`llm_error: provider returned ${res.status}`); const data = (await res.json()) as { choices?: { message?: { content?: string } }[] }; const content = data.choices?.[0]?.message?.content; if (!content) throw new Error("llm_error: empty completion"); const stripped = content.replace(/^\s*```(?:json)?\s*/i, "").replace(/\s*```\s*$/, ""); try { return JSON.parse(stripped); } catch { throw new Error("llm_error: completion was not valid JSON"); } } ``` - [ ] **Step 5: Run tests, commit** Run: `bun test server/test/llm.test.ts` then `bun test server` — Expected: all PASS. ```bash git add -A && git commit -m "feat(llm): OpenAI-compatible chatJSON helper with injectable fetch" ``` --- ### Task 4: PDF upload + LLM extraction → draft **Files:** - Create: `server/src/lib/pdf.ts`, `server/src/lib/extract.ts`, `server/src/routes/labs.ts` - Modify: `server/src/app.ts` (mount `labsRoutes(deps)`), `server/package.json` (add `unpdf`), `shared/src/types.ts` - Test: `server/test/extract.test.ts`, `server/test/labs-upload.test.ts` **Interfaces:** - Produces: - `pdfToText(data: Uint8Array): Promise` (unpdf; merged pages) - `extractFromText(deps: LlmDeps, text: string): Promise` — `ExtractedDraft = { collectedDate: string | null; labName: string | null; markers: ExtractedMarker[] }` - `POST /api/labs/upload` (multipart field `file`) → 201 `{ id }`; rejects non-PDF (400 `not_a_pdf`), > 15 MB (400 `too_large`), missing LLM outcome recorded on the draft (`status` stays "pending", `error` set, markers empty — never a 500). - `GET /api/labs/drafts` → `{ drafts: { id, filename, status, error, markerCount, createdAt }[] }` - shared Zod: `ExtractedMarker = { panel: string|null, name: string, value: string, unit: string|null, referenceRange: string|null, flagged: boolean }` (all nullable fields defaulted), `ExtractedDraft`. - Consumes: `chatJSON` (T3), `labDrafts` (T1). - [ ] **Step 1: Add shared schemas** Append to `shared/src/types.ts`: ```ts 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; 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; ``` - [ ] **Step 2: Write the failing extraction test** `server/test/extract.test.ts`: ```ts 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 typeof fetch; const dir = mkdtempSync(join(tmpdir(), "helios-")); expect(extractFromText({ db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl: mock }, "x")).rejects.toThrow(/llm_error/); }); }); ``` - [ ] **Step 3: Run to verify it fails, then implement extract + pdf** Run: `bun test server/test/extract.test.ts` — FAIL. `cd server && bun add unpdf` `server/src/lib/pdf.ts`: ```ts import { extractText, getDocumentProxy } from "unpdf"; export async function pdfToText(data: Uint8Array): Promise { const doc = await getDocumentProxy(data); const { text } = await extractText(doc, { mergePages: true }); return text; } ``` `server/src/lib/extract.ts`: ```ts 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 { 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; } ``` - [ ] **Step 4: Write the failing upload-route test** `server/test/labs-upload.test.ts`: ```ts 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<>endobj 2 0 obj<>endobj 3 0 obj<>>>>>endobj 4 0 obj<>stream BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET endstream endobj 5 0 obj<>endobj trailer<>`; 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 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 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); }); }); ``` - [ ] **Step 5: Run to verify it fails, then implement route + deps threading** `server/src/routes/labs.ts`: ```ts 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; } ``` In `server/src/app.ts`: extend `Deps` to `{ db: Db; key: Buffer; dataDir: string; llmFetch?: typeof fetch }`, mount `app.route("/api", labsRoutes(deps));` next to the other mounts, and update `server/src/index.ts` to pass `dataDir`. Update the two existing test helpers that call `createApp` (`auth.test.ts`, `settings.test.ts`) to pass `dataDir: dir`. - [ ] **Step 6: Run tests, commit** Run: `bun test server` — Expected: all PASS (including the tiny-PDF path through unpdf). ```bash git add -A && git commit -m "feat(labs): PDF upload, text extraction, LLM draft pipeline" ``` --- ### Task 5: Draft review + confirm **Files:** - Modify: `server/src/routes/labs.ts`, `shared/src/types.ts` - Test: `server/test/labs-confirm.test.ts` **Interfaces:** - Produces: - `GET /api/labs/drafts/:id` → `{ id, filename, status, error, draft: ExtractedDraft | null }` - `POST /api/labs/drafts/:id/confirm` body `ConfirmDraftBody = { collectedDate: string (YYYY-MM-DD), labName: string | null, markers: ExtractedMarker[] (min 1) }` → normalizes every marker via `normalizeMarker`, inserts one `lab_draws` row + `biomarkers` rows in a transaction, marks draft `confirmed`, returns 201 `{ drawId }`. 409 if draft not `pending`. 404 unknown id. - `POST /api/labs/drafts/:id/discard` → marks `discarded`, 204. - shared Zod: `ConfirmDraftBody`. - Consumes: `normalizeMarker` (T2), tables (T1). - [ ] **Step 1: Add shared schema** Append to `shared/src/types.ts`: ```ts export const ConfirmDraftBody = z.object({ collectedDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), labName: z.string().nullable().default(null), markers: z.array(ExtractedMarker).min(1), }); export type ConfirmDraftBody = z.infer; ``` - [ ] **Step 2: Write the failing test** `server/test/labs-confirm.test.ts`: ```ts import { describe, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { eq } from "drizzle-orm"; import { createApp } from "../src/app"; import { openDb } from "../src/db"; import { biomarkers, labDrafts } from "../src/db/schema"; import { loadOrCreateKey } from "../src/lib/crypto"; async function setup() { const dir = mkdtempSync(join(tmpdir(), "helios-")); const db = openDb(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) }); await app.request("/api/setup", j({ password: "hunter2hunter2" })); const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!; await db.insert(labDrafts).values({ id: "draft1", filename: "r.pdf", filePath: "/tmp/none.pdf", status: "pending", extracted: JSON.stringify({ collectedDate: "2026-01-15", labName: "Sample Diagnostics", markers: [ { panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }, ] }), error: null, createdAt: 1, }); return { app, db, cookie }; } describe("draft review", () => { test("get, confirm with edits → normalized biomarkers, draft confirmed", async () => { const { app, db, cookie } = await setup(); const h = { cookie, "content-type": "application/json" }; const got = await (await app.request("/api/labs/drafts/draft1", { headers: { cookie } })).json(); expect(got.draft.markers).toHaveLength(1); const confirm = await app.request("/api/labs/drafts/draft1/confirm", { method: "POST", headers: h, body: JSON.stringify({ collectedDate: "2026-01-15", labName: "Sample Diagnostics", markers: [ { panel: "chemistry", name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }, { panel: null, name: "Ferritin", value: "30", unit: "ng/mL", referenceRange: null, flagged: false }, // user-added row ], }), }); expect(confirm.status).toBe(201); const rows = await db.select().from(biomarkers); expect(rows).toHaveLength(2); const glucose = rows.find((r) => r.analyteKey === "glucose")!; expect(glucose.valueCanonical).toBeCloseTo(5.551, 2); expect(glucose.flagged).toBe(1); const ferritin = rows.find((r) => r.analyteKey === "ferritin")!; expect(ferritin.valueCanonical).toBeCloseTo(30); // ng/mL → µg/L 1:1 expect(ferritin.canonicalUnit).toBe("µg/L"); const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0]; expect(draft.status).toBe("confirmed"); // second confirm → 409 const again = await app.request("/api/labs/drafts/draft1/confirm", { method: "POST", headers: h, body: JSON.stringify({ collectedDate: "2026-01-15", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }), }); expect(again.status).toBe(409); }); test("discard marks draft discarded", async () => { const { app, db, cookie } = await setup(); const r = await app.request("/api/labs/drafts/draft1/discard", { method: "POST", headers: { cookie } }); expect(r.status).toBe(204); const draft = (await db.select().from(labDrafts).where(eq(labDrafts.id, "draft1")))[0]; expect(draft.status).toBe("discarded"); }); test("unknown draft 404; bad date 400", async () => { const { app, cookie } = await setup(); expect((await app.request("/api/labs/drafts/nope", { headers: { cookie } })).status).toBe(404); const bad = await app.request("/api/labs/drafts/draft1/confirm", { method: "POST", headers: { cookie, "content-type": "application/json" }, body: JSON.stringify({ collectedDate: "15/01/2026", labName: null, markers: [{ panel: null, name: "X", value: "1", unit: null, referenceRange: null, flagged: false }] }), }); expect(bad.status).toBe(400); }); }); ``` - [ ] **Step 3: Run to verify it fails, then implement** Add to `labsRoutes` in `server/src/routes/labs.ts`: ```ts // additional imports at top: labDraws, biomarkers from ../db/schema; // ConfirmDraftBody from "@helios/shared"; normalizeMarker from "../lib/normalize"; app.get("/labs/drafts/:id", async (c) => { const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); if (!row) return c.json({ error: "not found" }, 404); return c.json({ id: row.id, filename: row.filename, status: row.status, error: row.error, draft: row.extracted ? JSON.parse(row.extracted) : null, }); }); app.post("/labs/drafts/:id/confirm", async (c) => { const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); if (!row) return c.json({ error: "not found" }, 404); if (row.status !== "pending") return c.json({ error: "draft is not pending" }, 409); const body = ConfirmDraftBody.safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json({ error: body.error.issues[0]?.message ?? "invalid body" }, 400); const drawId = randomUUID(); const norm = body.data.markers.map((m) => normalizeMarker({ panel: m.panel, name: m.name, value: m.value, unit: m.unit, referenceRange: m.referenceRange, flagged: m.flagged, })); await deps.db.transaction(async (tx) => { await tx.insert(labDraws).values({ id: drawId, collectedAt: body.data.collectedDate, labName: body.data.labName, draftId: row.id, createdAt: Date.now(), }); for (const n of norm) { await tx.insert(biomarkers).values({ drawId, panel: n.panel, name: n.name, marker: n.marker, analyteKey: n.analyteKey, value: n.value, valueNum: n.valueNum, unit: n.unit, referenceRange: n.referenceRange, flagged: n.flagged ? 1 : 0, valueCanonical: n.valueCanonical, canonicalUnit: n.canonicalUnit, }); } await tx.update(labDrafts).set({ status: "confirmed" }).where(eq(labDrafts.id, row.id)); }); return c.json({ drawId }, 201); }); app.post("/labs/drafts/:id/discard", async (c) => { const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); if (!row) return c.json({ error: "not found" }, 404); await deps.db.update(labDrafts).set({ status: "discarded" }).where(eq(labDrafts.id, row.id)); return c.body(null, 204); }); ``` - [ ] **Step 4: Run tests, commit** Run: `bun test server` — all PASS. ```bash git add -A && git commit -m "feat(labs): draft review, confirm with normalization, discard" ``` --- ### Task 6: Labs query APIs (draws, detail, history) + original-PDF serving **Files:** - Modify: `server/src/routes/labs.ts` - Test: `server/test/labs-query.test.ts` **Interfaces:** - Produces (consumed by web UI): - `GET /api/labs/draws` → `{ draws: { id, collectedAt, labName, markerCount, flaggedCount }[] }` sorted newest first. - `GET /api/labs/draws/:id` → `{ id, collectedAt, labName, panels: { panel: string, markers: BiomarkerRow[] }[] }` where `BiomarkerRow` mirrors the table (flagged as boolean). Panels sorted alphabetically, markers by name. 404 unknown. - `GET /api/labs/markers/:key/history` → `{ key, display, canonicalUnit, points: { drawId, collectedAt, value: number }[] }` using `valueCanonical` (skip nulls), sorted by collectedAt ascending; `display`/`canonicalUnit` from the registry, 404 for unknown analyte key. - `GET /api/labs/drafts/:id/file` → the stored PDF (Content-Type application/pdf, Content-Disposition inline), 404 if missing. - [ ] **Step 1: Write the failing test** `server/test/labs-query.test.ts`: ```ts 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 { biomarkers, labDraws } from "../src/db/schema"; import { loadOrCreateKey } from "../src/lib/crypto"; async function seeded() { const dir = mkdtempSync(join(tmpdir(), "helios-")); const db = openDb(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) }); await app.request("/api/setup", j({ password: "hunter2hunter2" })); const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!; await db.insert(labDraws).values([ { id: "d1", collectedAt: "2025-06-01", labName: "A", draftId: null, createdAt: 1 }, { id: "d2", collectedAt: "2026-01-15", labName: "B", draftId: null, createdAt: 2 }, ]); await db.insert(biomarkers).values([ { drawId: "d1", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "90", valueNum: 90, unit: "mg/dL", referenceRange: null, flagged: 0, valueCanonical: 4.996, canonicalUnit: "mmol/L" }, { drawId: "d2", panel: "metabolic", name: "Glucose", marker: "glucose", analyteKey: "glucose", value: "100", valueNum: 100, unit: "mg/dL", referenceRange: "70-99", flagged: 1, valueCanonical: 5.551, canonicalUnit: "mmol/L" }, { drawId: "d2", panel: "lipids", name: "LDL Cholesterol", marker: "ldl", analyteKey: "ldl", value: "3.1", valueNum: 3.1, unit: "mmol/L", referenceRange: "<3.4", flagged: 0, valueCanonical: 3.1, canonicalUnit: "mmol/L" }, ]); return { app, cookie }; } describe("labs queries", () => { test("draw list newest first with counts", async () => { const { app, cookie } = await seeded(); const { draws } = await (await app.request("/api/labs/draws", { headers: { cookie } })).json(); expect(draws.map((d: any) => d.id)).toEqual(["d2", "d1"]); expect(draws[0].markerCount).toBe(2); expect(draws[0].flaggedCount).toBe(1); }); test("draw detail grouped by panel", async () => { const { app, cookie } = await seeded(); const detail = await (await app.request("/api/labs/draws/d2", { headers: { cookie } })).json(); expect(detail.panels.map((p: any) => p.panel)).toEqual(["lipids", "metabolic"]); expect(detail.panels[1].markers[0].flagged).toBe(true); expect((await app.request("/api/labs/draws/nope", { headers: { cookie } })).status).toBe(404); }); test("marker history ascending with registry metadata", async () => { const { app, cookie } = await seeded(); const h = await (await app.request("/api/labs/markers/glucose/history", { headers: { cookie } })).json(); expect(h.display).toBe("Glucose"); expect(h.canonicalUnit).toBe("mmol/L"); expect(h.points.map((p: any) => p.value)).toEqual([4.996, 5.551]); expect((await app.request("/api/labs/markers/unknown_thing/history", { headers: { cookie } })).status).toBe(404); }); }); ``` - [ ] **Step 2: Run to verify it fails, then implement** Add to `labsRoutes` (imports: `ANALYTES` or a `getAnalyte(key)` helper from `../lib/analytes` — add `export function getAnalyte(key: string): Analyte | null` there; `existsSync` from node:fs; `asc` from drizzle-orm): ```ts app.get("/labs/draws", async (c) => { const draws = await deps.db.select().from(labDraws).orderBy(desc(labDraws.collectedAt)); const rows = await deps.db.select().from(biomarkers); return c.json({ draws: draws.map((d) => ({ id: d.id, collectedAt: d.collectedAt, labName: d.labName, markerCount: rows.filter((r) => r.drawId === d.id).length, flaggedCount: rows.filter((r) => r.drawId === d.id && r.flagged === 1).length, })), }); }); app.get("/labs/draws/:id", async (c) => { const d = (await deps.db.select().from(labDraws).where(eq(labDraws.id, c.req.param("id")))).at(0); if (!d) return c.json({ error: "not found" }, 404); const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.drawId, d.id)); const byPanel = new Map(); for (const r of rows) byPanel.set(r.panel, [...(byPanel.get(r.panel) ?? []), r]); return c.json({ id: d.id, collectedAt: d.collectedAt, labName: d.labName, panels: [...byPanel.keys()].sort().map((panel) => ({ panel, markers: byPanel.get(panel)! .sort((a, b) => a.name.localeCompare(b.name)) .map((r) => ({ ...r, flagged: r.flagged === 1 })), })), }); }); app.get("/labs/markers/:key/history", async (c) => { const analyte = getAnalyte(c.req.param("key")); if (!analyte) return c.json({ error: "unknown marker" }, 404); const draws = await deps.db.select().from(labDraws); const dates = new Map(draws.map((d) => [d.id, d.collectedAt])); const rows = await deps.db.select().from(biomarkers).where(eq(biomarkers.analyteKey, analyte.key)); const points = rows .filter((r) => r.valueCanonical !== null) .map((r) => ({ drawId: r.drawId, collectedAt: dates.get(r.drawId) ?? "", value: r.valueCanonical! })) .sort((a, b) => a.collectedAt.localeCompare(b.collectedAt)); return c.json({ key: analyte.key, display: analyte.display, canonicalUnit: analyte.canonicalUnit, points }); }); app.get("/labs/drafts/:id/file", async (c) => { const row = (await deps.db.select().from(labDrafts).where(eq(labDrafts.id, c.req.param("id")))).at(0); if (!row || !existsSync(row.filePath)) return c.json({ error: "not found" }, 404); return new Response(Bun.file(row.filePath), { headers: { "content-type": "application/pdf", "content-disposition": `inline; filename="${row.filename.replace(/[^\w.-]/g, "_")}"`, }, }); }); ``` In `server/src/lib/analytes.ts` add: ```ts const BY_KEY = new Map(ANALYTES.map((a) => [a.key, a])); export function getAnalyte(key: string): Analyte | null { return BY_KEY.get(key) ?? null; } ``` - [ ] **Step 3: Run tests, commit** Run: `bun test server` — all PASS. ```bash git add -A && git commit -m "feat(labs): draw list/detail, marker history, PDF file serving" ``` --- ### Task 7: Web UI — Labs upload, review, dashboard **Files:** - Create: `web/src/pages/Labs.tsx`, `web/src/pages/LabDraft.tsx`, `web/src/pages/LabDraw.tsx`, `web/src/components/Sparkline.tsx` - Modify: `web/src/App.tsx` (routes + nav), `web/src/api.ts`, `web/src/styles.css` **Interfaces:** - Consumes every Task 4–6 endpoint. Routes: `/labs` (draw list + pending drafts + upload), `/labs/draft/:id` (review/edit/confirm), `/labs/draw/:id` (panel-grouped detail; tapping a mapped marker shows its history sparkline inline). - [ ] **Step 1: API client additions** Append to the `api` object in `web/src/api.ts` (and add a small `upload` helper): ```ts async function uploadFile(path: string, file: File): Promise { const fd = new FormData(); fd.append("file", file); const res = await fetch(path, { method: "POST", body: fd }); if (!res.ok) throw new Error((await res.json().catch(() => ({ error: res.statusText }))).error ?? res.statusText); return res.json(); } // inside `api`: uploadLab: (file: File) => uploadFile<{ id: string }>("/api/labs/upload", file), labDrafts: () => req<{ drafts: { id: string; filename: string; status: string; error: string | null; markerCount: number; createdAt: number }[] }>("/api/labs/drafts"), labDraft: (id: string) => req<{ id: string; filename: string; status: string; error: string | null; draft: ExtractedDraft | null }>(`/api/labs/drafts/${id}`), confirmDraft: (id: string, body: ConfirmDraftBody) => req<{ drawId: string }>(`/api/labs/drafts/${id}/confirm`, { method: "POST", body: JSON.stringify(body) }), discardDraft: (id: string) => req(`/api/labs/drafts/${id}/discard`, { method: "POST" }), labDraws: () => req<{ draws: { id: string; collectedAt: string; labName: string | null; markerCount: number; flaggedCount: number }[] }>("/api/labs/draws"), labDraw: (id: string) => req(`/api/labs/draws/${id}`), markerHistory: (key: string) => req<{ key: string; display: string; canonicalUnit: string; points: { drawId: string; collectedAt: string; value: number }[] }>(`/api/labs/markers/${key}/history`), ``` Import `ExtractedDraft`, `ConfirmDraftBody` types from `@helios/shared`. Define locally: ```ts export interface LabMarkerRow { id: number; drawId: string; panel: string; name: string; marker: string; analyteKey: string | null; value: string; valueNum: number | null; unit: string | null; referenceRange: string | null; flagged: boolean; valueCanonical: number | null; canonicalUnit: string | null; } export interface LabDrawDetail { id: string; collectedAt: string; labName: string | null; panels: { panel: string; markers: LabMarkerRow[] }[] } ``` - [ ] **Step 2: Sparkline** `web/src/components/Sparkline.tsx`: ```tsx export function Sparkline({ points, width = 220, height = 48 }: { points: number[]; width?: number; height?: number }) { if (points.length < 2) return null; const min = Math.min(...points); const max = Math.max(...points); const span = max - min || 1; const step = width / (points.length - 1); const path = points.map((v, i) => `${i === 0 ? "M" : "L"}${(i * step).toFixed(1)},${(height - 4 - ((v - min) / span) * (height - 8)).toFixed(1)}`).join(" "); return ( ); } ``` - [ ] **Step 3: Pages** `web/src/pages/Labs.tsx` — draw list + pending drafts + upload: ```tsx import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useNavigate } from "react-router"; import { api } from "../api"; export function Labs() { const nav = useNavigate(); const fileInput = useRef(null); const [draws, setDraws] = useState>["draws"]>([]); const [drafts, setDrafts] = useState>["drafts"]>([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const refresh = useCallback(() => { api.labDraws().then((r) => setDraws(r.draws)).catch((e) => setError(e.message)); api.labDrafts().then((r) => setDrafts(r.drafts.filter((d) => d.status === "pending"))).catch(() => {}); }, []); useEffect(refresh, [refresh]); const onFile = async (file: File) => { setBusy(true); setError(null); try { const { id } = await api.uploadLab(file); nav(`/labs/draft/${id}`); } catch (e) { setError(e instanceof Error ? e.message : "upload failed"); } finally { setBusy(false); } }; return (

Labs

e.target.files?.[0] && onFile(e.target.files[0])} />
{error &&

{error}

}

Upload a blood-test PDF. The AI extracts the values; nothing is saved until you review and confirm them.

{drafts.length > 0 && ( <>

Awaiting review

    {drafts.map((d) => (
  • {d.filename} {d.error ? extraction failed : {d.markerCount} markers}
  • ))}
)}

Draws

{draws.length === 0 &&

No confirmed draws yet.

}
    {draws.map((d) => (
  • {d.collectedAt} {d.labName ?? ""} · {d.markerCount} markers {d.flaggedCount > 0 && {d.flaggedCount} flagged}
  • ))}
); } ``` `web/src/pages/LabDraft.tsx` — editable review table: ```tsx import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router"; import type { ExtractedMarker } from "@helios/shared"; import { api } from "../api"; export function LabDraft() { const { id } = useParams<{ id: string }>(); const nav = useNavigate(); const [meta, setMeta] = useState<{ filename: string; status: string; error: string | null } | null>(null); const [collectedDate, setCollectedDate] = useState(""); const [labName, setLabName] = useState(""); const [markers, setMarkers] = useState([]); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); useEffect(() => { if (!id) return; api.labDraft(id).then((r) => { setMeta({ filename: r.filename, status: r.status, error: r.error }); if (r.draft) { setCollectedDate(r.draft.collectedDate ?? ""); setLabName(r.draft.labName ?? ""); setMarkers(r.draft.markers); } }).catch((e) => setError(e.message)); }, [id]); const edit = (i: number, patch: Partial) => setMarkers((m) => m.map((row, j) => (j === i ? { ...row, ...patch } : row))); const remove = (i: number) => setMarkers((m) => m.filter((_, j) => j !== i)); const addRow = () => setMarkers((m) => [...m, { panel: null, name: "", value: "", unit: null, referenceRange: null, flagged: false }]); const confirm = async () => { if (!id) return; setBusy(true); setError(null); try { const { drawId } = await api.confirmDraft(id, { collectedDate, labName: labName || null, markers }); nav(`/labs/draw/${drawId}`); } catch (e) { setError(e instanceof Error ? e.message : "confirm failed"); } finally { setBusy(false); } }; if (!meta) return

Loading…

; return (

Review: {meta.filename}

{meta.error &&

Extraction failed: {meta.error}. You can still enter values manually below.

} {meta.status !== "pending" &&

This draft is already {meta.status}.

}

Check every value against the PDF (open original). Nothing is saved until you confirm.

{markers.map((m, i) => ( ))}
NameValueUnitRangeFlag
edit(i, { name: e.target.value })} /> edit(i, { value: e.target.value })} /> edit(i, { unit: e.target.value || null })} /> edit(i, { referenceRange: e.target.value || null })} /> edit(i, { flagged: e.target.checked })} />
{error &&

{error}

} {meta.status === "pending" && (
)}
); } ``` `web/src/pages/LabDraw.tsx` — panel-grouped detail + history on tap: ```tsx import { useEffect, useState } from "react"; import { useParams } from "react-router"; import { api, type LabDrawDetail } from "../api"; import { Sparkline } from "../components/Sparkline"; export function LabDraw() { const { id } = useParams<{ id: string }>(); const [detail, setDetail] = useState(null); const [history, setHistory] = useState>({}); useEffect(() => { if (id) api.labDraw(id).then(setDetail).catch(() => setDetail(null)); }, [id]); const toggleHistory = async (key: string) => { if (history[key]) { setHistory((h) => { const { [key]: _, ...rest } = h; return rest; }); return; } const h = await api.markerHistory(key); setHistory((prev) => ({ ...prev, [key]: h })); }; if (!detail) return

Loading…

; return (

{detail.collectedAt}{detail.labName ? ` — ${detail.labName}` : ""}

{detail.panels.map((p) => (

{p.panel}

{p.markers.map((m) => ( <> {m.analyteKey && history[m.analyteKey] && ( )} ))}
{m.analyteKey ? : m.name} {m.value} {m.unit ?? ""} {m.referenceRange ?? ""} {m.flagged ? ● : null}
pt.value)} /> {history[m.analyteKey].points.length} draws, {history[m.analyteKey].canonicalUnit}
))}
); } ``` - [ ] **Step 4: Wire routes + nav + styles** In `web/src/App.tsx`: add `Labs` to nav; add routes: ```tsx } /> } /> } /> ``` Append to `web/src/styles.css`: ```css .row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } .row-between { display: flex; justify-content: space-between; align-items: center; } .list { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 8px; } .muted { color: color-mix(in srgb, currentColor 55%, transparent); } .hint { color: color-mix(in srgb, currentColor 55%, transparent); font-size: 0.9em; } .flag { color: crimson; } .flagged td { color: crimson; } .review-table, .lab-table { width: 100%; border-collapse: collapse; margin: 12px 0; } .review-table td, .review-table th, .lab-table td { padding: 6px 8px; text-align: left; } .lab-table tr { border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent); } .review-table input:not([type="checkbox"]) { width: 100%; box-sizing: border-box; } .num { font-variant-numeric: tabular-nums; } .panel-title { text-transform: capitalize; margin-bottom: 4px; } button.link { background: none; border: none; color: inherit; text-decoration: underline; cursor: pointer; padding: 0; font: inherit; } button.danger { background: none; border: 1px solid crimson; color: crimson; } .sparkline { color: #4a9eda; display: block; margin: 4px 0; } ``` - [ ] **Step 5: Verify + manual smoke** Run: `bun run typecheck && bun run build:web && bun test server` — all green. Manual smoke (optional if no OpenRouter key set): `bun server/src/index.ts` + `cd web && bunx vite`, visit /labs, upload any PDF — without a configured key the draft shows the extraction error path, which is itself a feature under test. - [ ] **Step 6: Commit** ```bash git add -A && git commit -m "feat(web): labs upload, draft review, draw dashboard with history sparklines" ``` --- ### Task 8: Security hardening shipping with uploads — CSP + headers **Files:** - Modify: `server/src/app.ts` - Test: `server/test/security-headers.test.ts` **Interfaces:** - Produces: every non-API response carries `Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'` plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. API responses get `X-Content-Type-Options: nosniff`. (Spec Security: "CSP on the SPA; uploads served with safe content-type handling" — lands now because uploads land now.) - [ ] **Step 1: Write the failing test** `server/test/security-headers.test.ts`: ```ts 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/db-app-helper"; // NO — use the same inline helper as other tests: ``` Use the same pattern as the other tests (no new helper file): ```ts 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"; function makeApp() { const dir = mkdtempSync(join(tmpdir(), "helios-")); return createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir }); } describe("security headers", () => { test("API responses: nosniff", async () => { const res = await makeApp().request("/api/health"); expect(res.headers.get("x-content-type-options")).toBe("nosniff"); }); test("non-API responses: CSP + nosniff + referrer policy", async () => { const res = await makeApp().request("/anything"); expect(res.headers.get("content-security-policy")).toContain("default-src 'self'"); expect(res.headers.get("content-security-policy")).toContain("frame-ancestors 'none'"); expect(res.headers.get("x-content-type-options")).toBe("nosniff"); expect(res.headers.get("referrer-policy")).toBe("no-referrer"); }); }); ``` (The first snippet above is a deliberate strike-through of a wrong approach; implement only the second.) - [ ] **Step 2: Run to verify it fails, then implement** In `server/src/app.ts`, register FIRST (before the API middleware): ```ts const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; object-src 'none'; frame-ancestors 'none'"; app.use("*", async (c, next) => { await next(); c.header("x-content-type-options", "nosniff"); if (!c.req.path.startsWith("/api/")) { c.header("content-security-policy", CSP); c.header("referrer-policy", "no-referrer"); } }); ``` - [ ] **Step 3: Run all gates, commit, push** Run: `bun run typecheck && bun test server && bun run build:web` — all green. ```bash git add -A && git commit -m "feat(security): CSP and hardening headers with upload serving" ``` --- ## Self-Review (done at write time) - **Spec coverage:** Labs section of the spec fully covered — upload → LLM extraction → review/confirm gate → normalized biomarkers with canonical units/flags → dashboard + history. CSP item lands with uploads as the final review triaged. Scanned-PDF OCR explicitly out (error path tells the user). - **Placeholders:** none; every step has code. Task 2's registry lists exact analytes/units/molar masses to implement rather than full literal rows — the values are all specified; transcription is the task. - **Type consistency:** `createApp` deps grow to `{ db, key, dataDir, llmFetch? }` in Task 4 and stay that shape; `flagged` is integer in DB, boolean at API/UI edges (converted in draw detail + confirm); `ExtractedMarker`/`ExtractedDraft`/`ConfirmDraftBody` shared across Tasks 4/5/7. - **Known risks:** unpdf under Bun (verified working in similar setups; Task 4's tiny-PDF test proves it in CI); `Date.now()` in routes is fine (server code, not workflow).