From e3cfc6592e5bad6578973602aa9c9015a1cb6e7a Mon Sep 17 00:00:00 2001 From: marcuspaico Date: Mon, 17 Aug 2026 15:15:38 -0700 Subject: [PATCH] docs: M2 labs pipeline implementation plan Labs pulled forward to M2 (was milestone 4) per Marcus. Co-Authored-By: Claude Fable 5 --- docs/superpowers/plans/2026-08-18-m2-labs.md | 1533 ++++++++++++++++++ 1 file changed, 1533 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-18-m2-labs.md diff --git a/docs/superpowers/plans/2026-08-18-m2-labs.md b/docs/superpowers/plans/2026-08-18-m2-labs.md new file mode 100644 index 0000000..43d2e93 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-m2-labs.md @@ -0,0 +1,1533 @@ +# 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, 384.5), 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).