Files
helios/docs/superpowers/plans/2026-08-18-m2-labs.md
marcuspaico e3cfc6592e
All checks were successful
CI / check (push) Successful in 53s
docs: M2 labs pipeline implementation plan
Labs pulled forward to M2 (was milestone 4) per Marcus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 15:15:38 -07:00

68 KiB
Raw Blame History

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 <DATA_DIR>/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:

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:

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
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:

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:

// 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<string, [Kind, number]> = {
  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<string, number> = { 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).

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<string, Analyte>();
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:

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
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<unknown> 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 <key> 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: <detail>") 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:

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<string, string>).authorization).toBe("Bearer sk-test-1234");
  });

  test("no key → no auth header (Ollama)", async () => {
    let headers: Record<string, string> = {};
    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:

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<unknown> {
  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<string, string> = { "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.

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<string> (unpdf; merged pages)
    • extractFromText(deps: LlmDeps, text: string): Promise<ExtractedDraft> — 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:

export const ExtractedMarker = z.object({
  panel: z.string().nullable().default(null),
  name: z.string().min(1),
  value: z.string().min(1),
  unit: z.string().nullable().default(null),
  referenceRange: z.string().nullable().default(null),
  flagged: z.boolean().default(false),
});
export type ExtractedMarker = z.infer<typeof ExtractedMarker>;

export const ExtractedDraft = z.object({
  collectedDate: z.string().nullable().default(null),
  labName: z.string().nullable().default(null),
  markers: z.array(ExtractedMarker).default([]),
});
export type ExtractedDraft = z.infer<typeof ExtractedDraft>;
  • Step 2: Write the failing extraction test

server/test/extract.test.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:

import { extractText, getDocumentProxy } from "unpdf";

export async function pdfToText(data: Uint8Array): Promise<string> {
  const doc = await getDocumentProxy(data);
  const { text } = await extractText(doc, { mergePages: true });
  return text;
}

server/src/lib/extract.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<ExtractedDraft> {
  const out = await chatJSON(deps, { system: SYSTEM, user: text.slice(0, MAX_CHARS) });
  const parsed = ExtractedDraft.safeParse(out);
  if (!parsed.success) throw new Error("llm_error: draft failed schema validation");
  return parsed.data;
}
  • Step 4: Write the failing upload-route test

server/test/labs-upload.test.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<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
4 0 obj<</Length 60>>stream
BT /F1 12 Tf 72 720 Td (Glucose 100 mg/dL 70-99 H) Tj ET
endstream
endobj
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
trailer<</Root 1 0 R>>`;

async function authedApp(fetchImpl: typeof fetch) {
  const dir = mkdtempSync(join(tmpdir(), "helios-"));
  const app = createApp({ db: openDb(dir), key: loadOrCreateKey(dir), dataDir: dir, llmFetch: fetchImpl });
  const j = (b: unknown) => ({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(b) });
  await app.request("/api/setup", j({ password: "hunter2hunter2" }));
  const cookie = (await app.request("/api/login", j({ password: "hunter2hunter2" }))).headers.get("set-cookie")!;
  return { app, cookie };
}

const llmOk = (async () => new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
  collectedDate: "2026-01-15", labName: "Sample Diagnostics",
  markers: [{ panel: null, name: "Glucose", value: "100", unit: "mg/dL", referenceRange: "70-99", flagged: true }],
}) } }] }), { status: 200 })) as 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:

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).

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:

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<typeof ConfirmDraftBody>;
  • Step 2: Write the failing test

server/test/labs-confirm.test.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:

// 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.

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:

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):

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<string, typeof rows>();
  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:

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.

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):

async function uploadFile<T>(path: string, file: File): Promise<T> {
  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<void>(`/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<LabDrawDetail>(`/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:

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:

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 (
    <svg width={width} height={height} className="sparkline" aria-hidden>
      <path d={path} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" />
      <circle cx={(points.length - 1) * step} cy={height - 4 - ((points[points.length - 1] - min) / span) * (height - 8)} r={3} fill="currentColor" />
    </svg>
  );
}
  • Step 3: Pages

web/src/pages/Labs.tsx — draw list + pending drafts + upload:

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<HTMLInputElement>(null);
  const [draws, setDraws] = useState<Awaited<ReturnType<typeof api.labDraws>>["draws"]>([]);
  const [drafts, setDrafts] = useState<Awaited<ReturnType<typeof api.labDrafts>>["drafts"]>([]);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(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 (
    <section>
      <div className="row-between">
        <h2>Labs</h2>
        <button disabled={busy} onClick={() => fileInput.current?.click()}>
          {busy ? "Extracting…" : "Upload lab PDF"}
        </button>
        <input ref={fileInput} type="file" accept="application/pdf" hidden
          onChange={(e) => e.target.files?.[0] && onFile(e.target.files[0])} />
      </div>
      {error && <p className="error">{error}</p>}
      <p className="hint">Upload a blood-test PDF. The AI extracts the values; nothing is saved until you review and confirm them.</p>

      {drafts.length > 0 && (
        <>
          <h3>Awaiting review</h3>
          <ul className="list">
            {drafts.map((d) => (
              <li key={d.id}>
                <Link to={`/labs/draft/${d.id}`}>{d.filename}</Link>
                {d.error ? <span className="error"> extraction failed</span> : <span className="muted"> {d.markerCount} markers</span>}
              </li>
            ))}
          </ul>
        </>
      )}

      <h3>Draws</h3>
      {draws.length === 0 && <p className="muted">No confirmed draws yet.</p>}
      <ul className="list">
        {draws.map((d) => (
          <li key={d.id}>
            <Link to={`/labs/draw/${d.id}`}>{d.collectedAt}</Link>
            <span className="muted"> {d.labName ?? ""} · {d.markerCount} markers</span>
            {d.flaggedCount > 0 && <span className="flag"> {d.flaggedCount} flagged</span>}
          </li>
        ))}
      </ul>
    </section>
  );
}

web/src/pages/LabDraft.tsx — editable review table:

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<ExtractedMarker[]>([]);
  const [error, setError] = useState<string | null>(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<ExtractedMarker>) =>
    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 <p>Loading…</p>;
  return (
    <section>
      <h2>Review: {meta.filename}</h2>
      {meta.error && <p className="error">Extraction failed: {meta.error}. You can still enter values manually below.</p>}
      {meta.status !== "pending" && <p className="muted">This draft is already {meta.status}.</p>}
      <p className="hint">Check every value against the PDF (<a href={`/api/labs/drafts/${id}/file`} target="_blank" rel="noreferrer">open original</a>). Nothing is saved until you confirm.</p>

      <div className="row">
        <label>Collected <input type="date" value={collectedDate} onChange={(e) => setCollectedDate(e.target.value)} /></label>
        <label>Lab <input value={labName} onChange={(e) => setLabName(e.target.value)} placeholder="lab name" /></label>
      </div>

      <table className="review-table">
        <thead><tr><th>Name</th><th>Value</th><th>Unit</th><th>Range</th><th>Flag</th><th /></tr></thead>
        <tbody>
          {markers.map((m, i) => (
            <tr key={i}>
              <td><input value={m.name} onChange={(e) => edit(i, { name: e.target.value })} /></td>
              <td><input value={m.value} onChange={(e) => edit(i, { value: e.target.value })} /></td>
              <td><input value={m.unit ?? ""} onChange={(e) => edit(i, { unit: e.target.value || null })} /></td>
              <td><input value={m.referenceRange ?? ""} onChange={(e) => edit(i, { referenceRange: e.target.value || null })} /></td>
              <td><input type="checkbox" checked={m.flagged} onChange={(e) => edit(i, { flagged: e.target.checked })} /></td>
              <td><button className="link" onClick={() => remove(i)}>✕</button></td>
            </tr>
          ))}
        </tbody>
      </table>
      <button className="link" onClick={addRow}>+ add marker</button>

      {error && <p className="error">{error}</p>}
      {meta.status === "pending" && (
        <div className="row">
          <button disabled={busy || !collectedDate || markers.length === 0} onClick={confirm}>
            {busy ? "Saving…" : `Confirm ${markers.length} markers`}
          </button>
          <button className="danger" onClick={() => id && api.discardDraft(id).then(() => nav("/labs"))}>Discard</button>
        </div>
      )}
    </section>
  );
}

web/src/pages/LabDraw.tsx — panel-grouped detail + history on tap:

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<LabDrawDetail | null>(null);
  const [history, setHistory] = useState<Record<string, { display: string; canonicalUnit: string; points: { collectedAt: string; value: number }[] }>>({});

  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 <p>Loading…</p>;
  return (
    <section>
      <h2>{detail.collectedAt}{detail.labName ? ` — ${detail.labName}` : ""}</h2>
      {detail.panels.map((p) => (
        <div key={p.panel}>
          <h3 className="panel-title">{p.panel}</h3>
          <table className="lab-table">
            <tbody>
              {p.markers.map((m) => (
                <>
                  <tr key={m.id} className={m.flagged ? "flagged" : undefined}>
                    <td>
                      {m.analyteKey
                        ? <button className="link" onClick={() => toggleHistory(m.analyteKey!)}>{m.name}</button>
                        : m.name}
                    </td>
                    <td className="num">{m.value} {m.unit ?? ""}</td>
                    <td className="muted">{m.referenceRange ?? ""}</td>
                    <td>{m.flagged ? <span className="flag">●</span> : null}</td>
                  </tr>
                  {m.analyteKey && history[m.analyteKey] && (
                    <tr key={`${m.id}-h`}>
                      <td colSpan={4}>
                        <Sparkline points={history[m.analyteKey].points.map((pt) => pt.value)} />
                        <span className="muted"> {history[m.analyteKey].points.length} draws, {history[m.analyteKey].canonicalUnit}</span>
                      </td>
                    </tr>
                  )}
                </>
              ))}
            </tbody>
          </table>
        </div>
      ))}
    </section>
  );
}
  • Step 4: Wire routes + nav + styles

In web/src/App.tsx: add <Link to="/labs">Labs</Link> to nav; add routes:

<Route path="/labs" element={<Labs />} />
<Route path="/labs/draft/:id" element={<LabDraft />} />
<Route path="/labs/draw/:id" element={<LabDraw />} />

Append to web/src/styles.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
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:

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):

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):

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.

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).