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

1534 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`:
```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<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).
```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<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`:
```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<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`:
```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`:
```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.
```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<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`:
```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`:
```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<string> {
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<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`:
```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`:
```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<typeof ConfirmDraftBody>;
```
- [ ] **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<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:
```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<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:
```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 (
<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:
```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<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:
```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<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:
```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<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:
```tsx
<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`:
```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).