Labs pulled forward to M2 (was milestone 4) per Marcus. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
68 KiB
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:webgreen 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/biomarkersexcept the confirm endpoint, after user review. - Uploads: PDF only, ≤ 15 MB, stored under
<DATA_DIR>/uploads/, served only through authed API withContent-Type: application/pdfandContent-Disposition: inline; filename=.... - All new API bodies validated with Zod schemas in
shared/src/types.ts. - Raw extraction values are preserved verbatim (
valueas text); canonical values are additive, never destructive. ~/health-apiis 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:idtext PK,filenametext notNull,filePathtext notNull,statustext notNull ('pending' | 'confirmed' | 'discarded'),extractedtext (JSON string, nullable),errortext nullable,createdAtinteger notNull.labDraws:idtext PK,collectedAttext notNull (ISO dateYYYY-MM-DD),labNametext nullable,draftIdtext nullable,createdAtinteger notNull.biomarkers:idinteger PK autoincrement,drawIdtext notNull,paneltext notNull,nametext notNull,markertext notNull,analyteKeytext nullable,valuetext notNull,valueNumreal nullable,unittext nullable,referenceRangetext nullable,flaggedinteger notNull default 0,valueCanonicalreal nullable,canonicalUnittext 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 | nullresolveAnalyte(name: string): Analyte | nullwhereAnalyte = { key, display, canonicalUnit, molarMass, panel, aliases }normalizeMarker(raw: RawMarker): NormMarker—RawMarker = { panel?, name, value: string, unit?, referenceRange?, flagged? };NormMarkermirrors thebiomarkerscolumns (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(exportDEFAULTS) - 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>whereLlmDeps = { db: Db; key: Buffer; fetchImpl?: typeof fetch }. Readsllm_base_url/llm_model(falling back to exportedDEFAULTS) and decryptedllm_keyviagetSetting. Sends OpenAI-compatiblePOST {base}/chat/completionswithresponse_format: { type: "json_object" };Authorization: Bearer <key>only when a key is set (Ollama needs none). Parseschoices[0].message.contentas JSON, stripping ```json fences if present. ThrowsError("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(mountlabsRoutes(deps)),server/package.json(addunpdf),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 fieldfile) → 201{ id }; rejects non-PDF (400not_a_pdf), > 15 MB (400too_large), missing LLM outcome recorded on the draft (statusstays "pending",errorset, 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/confirmbodyConfirmDraftBody = { collectedDate: string (YYYY-MM-DD), labName: string | null, markers: ExtractedMarker[] (min 1) }→ normalizes every marker vianormalizeMarker, inserts onelab_drawsrow +biomarkersrows in a transaction, marks draftconfirmed, returns 201{ drawId }. 409 if draft notpending. 404 unknown id.POST /api/labs/drafts/:id/discard→ marksdiscarded, 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[] }[] }whereBiomarkerRowmirrors 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 }[] }usingvalueCanonical(skip nulls), sorted by collectedAt ascending;display/canonicalUnitfrom 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'plusX-Content-Type-Options: nosniffandReferrer-Policy: no-referrer. API responses getX-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:
createAppdeps grow to{ db, key, dataDir, llmFetch? }in Task 4 and stay that shape;flaggedis integer in DB, boolean at API/UI edges (converted in draw detail + confirm);ExtractedMarker/ExtractedDraft/ConfirmDraftBodyshared 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).