All checks were successful
CI / check (push) Successful in 1m6s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1060 lines
49 KiB
Markdown
1060 lines
49 KiB
Markdown
# Helios M3 — Chat + Daily Brief 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:** Grounded AI chat over the user's lab data via typed bounded tools, plus a daily brief generated at a user-chosen hour — the Illume-feel core, running on the user's own LLM endpoint.
|
||
|
||
**Architecture:** New `chat_threads`/`chat_messages`/`briefs` tables. `chatWithTools` joins `chatJSON` in `lib/llm.ts`: an OpenAI-compatible function-calling loop with injectable fetch and a round cap. `lib/health-tools.ts` defines the tool schemas and a bounded executor over SQLite (only relevant slices ever reach the LLM — spec mandate). Chat routes persist full conversations including a tool trace. Brief generation is a pure-logic scheduler check (`shouldGenerateBrief`) driven by a 5-minute interval in `index.ts`, plus a manual generate endpoint.
|
||
|
||
**Tech Stack:** Existing only (Bun, Hono, drizzle, Zod, React). No new deps.
|
||
|
||
**Spec:** docs/superpowers/specs/2026-08-18-helios-design.md (AI layer; chat_threads/chat_messages/briefs in the data model). Sleep/metrics/meals tools arrive with their milestones — M3 tools cover labs, the only data that exists.
|
||
|
||
## Global Constraints
|
||
|
||
- TS strict; `bun run typecheck`, `bun test server`, `bun run build:web` green at every commit. Conventional Commits.
|
||
- LLM calls only via `lib/llm.ts` to the user-configured endpoint; tools return bounded slices (hard row caps in this plan), never a full DB dump.
|
||
- Repo transaction convention: `db.transaction((tx) => ...)` callbacks are SYNCHRONOUS with `.run()` — no awaits inside (bun-sqlite commits at first await).
|
||
- Fetch mocks in tests are cast `as unknown as typeof fetch`; `.rejects` assertions are always awaited.
|
||
- Chat/brief UI carries the honest privacy wording: chat context goes to the configured LLM endpoint; only a local endpoint (Ollama) means zero third parties.
|
||
- Not-medical-advice framing in the chat system prompt and brief prompt.
|
||
|
||
---
|
||
|
||
### Task 1: Schema migration — chat + briefs
|
||
|
||
**Files:**
|
||
- Modify: `server/src/db/schema.ts`
|
||
- Create: `server/drizzle/0002_*` (generated)
|
||
- Test: `server/test/db-chat.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces tables:
|
||
- `chatThreads`: `id` text PK, `title` text notNull, `createdAt` integer notNull.
|
||
- `chatMessages`: `id` integer PK autoincrement, `threadId` text notNull, `role` text notNull ('user' | 'assistant'), `content` text notNull, `toolTrace` text (JSON, nullable), `createdAt` integer notNull.
|
||
- `briefs`: `id` integer PK autoincrement, `date` text notNull (YYYY-MM-DD, local), `content` text notNull, `createdAt` integer notNull.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`server/test/db-chat.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 { briefs, chatMessages, chatThreads } from "../src/db/schema";
|
||
|
||
describe("chat schema", () => {
|
||
test("thread + message + brief round-trip", async () => {
|
||
const db = openDb(mkdtempSync(join(tmpdir(), "helios-")));
|
||
await db.insert(chatThreads).values({ id: "t1", title: "LDL question", createdAt: 1 });
|
||
await db.insert(chatMessages).values({ threadId: "t1", role: "user", content: "how is my LDL?", toolTrace: null, createdAt: 2 });
|
||
await db.insert(briefs).values({ date: "2026-08-18", content: "All good.", createdAt: 3 });
|
||
expect((await db.select().from(chatMessages))[0].content).toBe("how is my LDL?");
|
||
expect((await db.select().from(briefs))[0].date).toBe("2026-08-18");
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails** — `bun test server/test/db-chat.test.ts` → FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Append to `server/src/db/schema.ts`:
|
||
```ts
|
||
export const chatThreads = sqliteTable("chat_threads", {
|
||
id: text("id").primaryKey(),
|
||
title: text("title").notNull(),
|
||
createdAt: integer("created_at").notNull(),
|
||
});
|
||
|
||
export const chatMessages = sqliteTable("chat_messages", {
|
||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||
threadId: text("thread_id").notNull(),
|
||
role: text("role").notNull(), // user | assistant
|
||
content: text("content").notNull(),
|
||
toolTrace: text("tool_trace"),
|
||
createdAt: integer("created_at").notNull(),
|
||
});
|
||
|
||
export const briefs = sqliteTable("briefs", {
|
||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||
date: text("date").notNull(), // YYYY-MM-DD local
|
||
content: text("content").notNull(),
|
||
createdAt: integer("created_at").notNull(),
|
||
});
|
||
```
|
||
|
||
Run `cd server && bunx drizzle-kit generate --name chat_briefs`, commit generated files.
|
||
|
||
- [ ] **Step 4: Run to verify it passes** — PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(db): chat_threads, chat_messages, briefs tables"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: `chatWithTools` — OpenAI function-calling loop
|
||
|
||
**Files:**
|
||
- Modify: `server/src/lib/llm.ts`
|
||
- Test: `server/test/llm-tools.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces (consumed by Tasks 4–5):
|
||
```ts
|
||
export interface ToolDef { name: string; description: string; parameters: Record<string, unknown> }
|
||
export interface ToolTraceEntry { name: string; args: unknown; result: string }
|
||
export async function chatWithTools(deps: LlmDeps, opts: {
|
||
system: string;
|
||
messages: { role: "user" | "assistant"; content: string }[];
|
||
tools: ToolDef[];
|
||
executeTool: (name: string, args: unknown) => Promise<string>;
|
||
maxRounds?: number; // default 6
|
||
}): Promise<{ content: string; toolTrace: ToolTraceEntry[] }>
|
||
```
|
||
Behavior: POST `${base}/chat/completions` with `tools: [{type:"function", function: def}]` and NO `response_format`. While the reply contains `tool_calls`: append the assistant message verbatim, execute each call (`JSON.parse` its `function.arguments`, tolerating `{}` for empty), append one `{role:"tool", tool_call_id, content: result}` per call, record the trace, and loop. A reply without tool_calls returns its content. `maxRounds` exceeded → throw `llm_error: tool loop exceeded`. Executor throws → the error message string becomes the tool result (`"tool_error: <msg>"`), loop continues (the model sees the failure). Non-2xx / empty → `llm_error` as in `chatJSON`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`server/test/llm-tools.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 { chatWithTools } from "../src/lib/llm";
|
||
|
||
function deps(fetchImpl: typeof fetch) {
|
||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||
return { db: openDb(dir), key: loadOrCreateKey(dir), fetchImpl };
|
||
}
|
||
const TOOLS = [{ name: "get_data", description: "get data", parameters: { type: "object", properties: {} } }];
|
||
|
||
function reply(payload: unknown) {
|
||
return new Response(JSON.stringify({ choices: [{ message: payload }] }), { status: 200 });
|
||
}
|
||
|
||
describe("chatWithTools", () => {
|
||
test("executes tool calls then returns final content with trace", async () => {
|
||
const bodies: any[] = [];
|
||
let call = 0;
|
||
const mock = (async (_: any, init: any) => {
|
||
bodies.push(JSON.parse(String(init.body)));
|
||
call++;
|
||
if (call === 1) {
|
||
return reply({ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_data", arguments: '{"marker":"ldl"}' } }] });
|
||
}
|
||
return reply({ role: "assistant", content: "Your LDL is fine." });
|
||
}) as unknown as typeof fetch;
|
||
|
||
const out = await chatWithTools(deps(mock), {
|
||
system: "sys",
|
||
messages: [{ role: "user", content: "how is my LDL?" }],
|
||
tools: TOOLS,
|
||
executeTool: async (name, args) => `data for ${name}: ${JSON.stringify(args)}`,
|
||
});
|
||
|
||
expect(out.content).toBe("Your LDL is fine.");
|
||
expect(out.toolTrace).toEqual([{ name: "get_data", args: { marker: "ldl" }, result: 'data for get_data: {"marker":"ldl"}' }]);
|
||
// second request carries the assistant tool_calls message and the tool result
|
||
const msgs = bodies[1].messages;
|
||
expect(msgs.at(-1).role).toBe("tool");
|
||
expect(msgs.at(-1).tool_call_id).toBe("c1");
|
||
expect(bodies[0].tools[0].function.name).toBe("get_data");
|
||
expect(bodies[0].response_format).toBeUndefined();
|
||
});
|
||
|
||
test("executor errors are surfaced to the model, not thrown", async () => {
|
||
let call = 0;
|
||
const mock = (async () => {
|
||
call++;
|
||
if (call === 1) return reply({ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "get_data", arguments: "{}" } }] });
|
||
return reply({ role: "assistant", content: "Sorry, data unavailable." });
|
||
}) as unknown as typeof fetch;
|
||
const out = await chatWithTools(deps(mock), {
|
||
system: "s", messages: [{ role: "user", content: "u" }], tools: TOOLS,
|
||
executeTool: async () => { throw new Error("db exploded"); },
|
||
});
|
||
expect(out.content).toBe("Sorry, data unavailable.");
|
||
expect(out.toolTrace[0].result).toContain("tool_error: db exploded");
|
||
});
|
||
|
||
test("round cap throws llm_error", async () => {
|
||
const mock = (async () =>
|
||
reply({ role: "assistant", content: null, tool_calls: [{ id: "c", type: "function", function: { name: "get_data", arguments: "{}" } }] })
|
||
) as unknown as typeof fetch;
|
||
await expect(
|
||
chatWithTools(deps(mock), { system: "s", messages: [{ role: "user", content: "u" }], tools: TOOLS, executeTool: async () => "x", maxRounds: 3 }),
|
||
).rejects.toThrow(/tool loop exceeded/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails** — FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
Append to `server/src/lib/llm.ts`:
|
||
```ts
|
||
export interface ToolDef { name: string; description: string; parameters: Record<string, unknown> }
|
||
export interface ToolTraceEntry { name: string; args: unknown; result: string }
|
||
|
||
interface ToolCall { id: string; type: "function"; function: { name: string; arguments: string } }
|
||
interface ChatChoiceMessage { role: string; content: string | null; tool_calls?: ToolCall[] }
|
||
|
||
export async function chatWithTools(deps: LlmDeps, opts: {
|
||
system: string;
|
||
messages: { role: "user" | "assistant"; content: string }[];
|
||
tools: ToolDef[];
|
||
executeTool: (name: string, args: unknown) => Promise<string>;
|
||
maxRounds?: number;
|
||
}): Promise<{ content: string; toolTrace: ToolTraceEntry[] }> {
|
||
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 messages: unknown[] = [{ role: "system", content: opts.system }, ...opts.messages];
|
||
const toolTrace: ToolTraceEntry[] = [];
|
||
const maxRounds = opts.maxRounds ?? 6;
|
||
|
||
for (let round = 0; round < maxRounds; round++) {
|
||
const res = await f(`${base.replace(/\/$/, "")}/chat/completions`, {
|
||
method: "POST",
|
||
headers,
|
||
body: JSON.stringify({
|
||
model,
|
||
messages,
|
||
tools: opts.tools.map((t) => ({ type: "function", function: t })),
|
||
}),
|
||
});
|
||
if (!res.ok) throw new Error(`llm_error: provider returned ${res.status}`);
|
||
const data = (await res.json()) as { choices?: { message?: ChatChoiceMessage }[] };
|
||
const msg = data.choices?.[0]?.message;
|
||
if (!msg) throw new Error("llm_error: empty completion");
|
||
|
||
if (!msg.tool_calls?.length) {
|
||
return { content: msg.content ?? "", toolTrace };
|
||
}
|
||
|
||
messages.push(msg);
|
||
for (const call of msg.tool_calls) {
|
||
let args: unknown = {};
|
||
try { args = call.function.arguments ? JSON.parse(call.function.arguments) : {}; } catch { args = {}; }
|
||
let result: string;
|
||
try {
|
||
result = await opts.executeTool(call.function.name, args);
|
||
} catch (e) {
|
||
result = `tool_error: ${e instanceof Error ? e.message : "failed"}`;
|
||
}
|
||
toolTrace.push({ name: call.function.name, args, result });
|
||
messages.push({ role: "tool", tool_call_id: call.id, content: result });
|
||
}
|
||
}
|
||
throw new Error("llm_error: tool loop exceeded");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests** — `bun test server/test/llm-tools.test.ts` then full suite → PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(llm): chatWithTools function-calling loop with trace and round cap"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Health tools — bounded lab data access
|
||
|
||
**Files:**
|
||
- Create: `server/src/lib/health-tools.ts`
|
||
- Test: `server/test/health-tools.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces (consumed by Tasks 4–5):
|
||
```ts
|
||
export const HEALTH_TOOLS: ToolDef[]; // 5 tools below
|
||
export function makeToolExecutor(db: Db): (name: string, args: unknown) => Promise<string>;
|
||
```
|
||
Tools (all results are compact JSON strings; every list hard-capped):
|
||
- `list_lab_draws` (no args): up to 24 draws `{id, collectedAt, labName, markerCount, flaggedCount}` newest first.
|
||
- `get_lab_draw` (`{drawId: string}`): all markers of one draw `{panel, name, value, unit, referenceRange, flagged, valueCanonical, canonicalUnit}` — capped at 120 rows.
|
||
- `get_marker_history` (`{markerKey: string}`): registry display/canonicalUnit + up to 50 `{collectedAt, value}` points ascending; unknown key → `{"error":"unknown marker key"}` (a string result, not a throw).
|
||
- `list_flagged_biomarkers` (no args): flagged rows across the latest 5 draws, capped 60, `{collectedAt, panel, name, value, unit, referenceRange}`.
|
||
- `list_known_marker_keys` (no args): all registry `{key, display, panel}` (bounded by registry size).
|
||
Unknown tool name → throw `Error("unknown tool: <name>")` (chatWithTools turns it into tool_error).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`server/test/health-tools.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";
|
||
import { HEALTH_TOOLS, makeToolExecutor } from "../src/lib/health-tools";
|
||
|
||
async function seeded() {
|
||
const db = openDb(mkdtempSync(join(tmpdir(), "helios-")));
|
||
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" },
|
||
]);
|
||
return makeToolExecutor(db);
|
||
}
|
||
|
||
describe("health tools", () => {
|
||
test("tool defs are well-formed", () => {
|
||
expect(HEALTH_TOOLS.map((t) => t.name).sort()).toEqual([
|
||
"get_lab_draw", "get_marker_history", "list_flagged_biomarkers", "list_known_marker_keys", "list_lab_draws",
|
||
]);
|
||
for (const t of HEALTH_TOOLS) expect(t.parameters).toHaveProperty("type", "object");
|
||
});
|
||
|
||
test("list_lab_draws newest first with counts", async () => {
|
||
const exec = await seeded();
|
||
const out = JSON.parse(await exec("list_lab_draws", {}));
|
||
expect(out[0].id).toBe("d2");
|
||
expect(out[0].flaggedCount).toBe(1);
|
||
});
|
||
|
||
test("get_lab_draw and marker history", async () => {
|
||
const exec = await seeded();
|
||
const draw = JSON.parse(await exec("get_lab_draw", { drawId: "d2" }));
|
||
expect(draw.markers[0].name).toBe("Glucose");
|
||
const hist = JSON.parse(await exec("get_marker_history", { markerKey: "glucose" }));
|
||
expect(hist.display).toBe("Glucose");
|
||
expect(hist.points.map((p: any) => p.value)).toEqual([4.996, 5.551]);
|
||
const unknown = JSON.parse(await exec("get_marker_history", { markerKey: "nope" }));
|
||
expect(unknown.error).toContain("unknown");
|
||
});
|
||
|
||
test("flagged list and registry keys; unknown tool throws", async () => {
|
||
const exec = await seeded();
|
||
const flagged = JSON.parse(await exec("list_flagged_biomarkers", {}));
|
||
expect(flagged).toHaveLength(1);
|
||
expect(flagged[0].name).toBe("Glucose");
|
||
const keys = JSON.parse(await exec("list_known_marker_keys", {}));
|
||
expect(keys.find((k: any) => k.key === "glucose")).toBeTruthy();
|
||
await expect(exec("bogus_tool", {})).rejects.toThrow(/unknown tool/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails** — FAIL.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`server/src/lib/health-tools.ts`:
|
||
```ts
|
||
import { desc, eq } from "drizzle-orm";
|
||
import { z } from "zod";
|
||
import type { Db } from "../db";
|
||
import { biomarkers, labDraws } from "../db/schema";
|
||
import { ANALYTES, getAnalyte } from "./analytes";
|
||
import type { ToolDef } from "./llm";
|
||
|
||
// Bounded slices only: these caps are the spec's "never a full dump" guarantee.
|
||
const MAX_DRAWS = 24;
|
||
const MAX_MARKERS = 120;
|
||
const MAX_POINTS = 50;
|
||
const MAX_FLAGGED = 60;
|
||
const FLAGGED_DRAWS = 5;
|
||
|
||
export const HEALTH_TOOLS: ToolDef[] = [
|
||
{ name: "list_lab_draws", description: "List the user's blood-test draws, newest first (id, date, lab, marker/flag counts).", parameters: { type: "object", properties: {} } },
|
||
{ name: "get_lab_draw", description: "Get every marker of one draw by draw id.", parameters: { type: "object", properties: { drawId: { type: "string" } }, required: ["drawId"] } },
|
||
{ name: "get_marker_history", description: "History of one marker across all draws in its canonical unit. Use list_known_marker_keys for valid keys.", parameters: { type: "object", properties: { markerKey: { type: "string" } }, required: ["markerKey"] } },
|
||
{ name: "list_flagged_biomarkers", description: "Out-of-range results from the most recent draws.", parameters: { type: "object", properties: {} } },
|
||
{ name: "list_known_marker_keys", description: "All marker keys the system can chart, with display names and panels.", parameters: { type: "object", properties: {} } },
|
||
];
|
||
|
||
const DrawIdArgs = z.object({ drawId: z.string() });
|
||
const MarkerArgs = z.object({ markerKey: z.string() });
|
||
|
||
export function makeToolExecutor(db: Db) {
|
||
return async (name: string, args: unknown): Promise<string> => {
|
||
switch (name) {
|
||
case "list_lab_draws": {
|
||
const draws = await db.select().from(labDraws).orderBy(desc(labDraws.collectedAt)).limit(MAX_DRAWS);
|
||
const rows = await db.select().from(biomarkers);
|
||
return JSON.stringify(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,
|
||
})));
|
||
}
|
||
case "get_lab_draw": {
|
||
const { drawId } = DrawIdArgs.parse(args);
|
||
const d = (await db.select().from(labDraws).where(eq(labDraws.id, drawId))).at(0);
|
||
if (!d) return JSON.stringify({ error: "unknown draw id" });
|
||
const rows = (await db.select().from(biomarkers).where(eq(biomarkers.drawId, drawId))).slice(0, MAX_MARKERS);
|
||
return JSON.stringify({
|
||
collectedAt: d.collectedAt, labName: d.labName,
|
||
markers: rows.map((r) => ({
|
||
panel: r.panel, name: r.name, value: r.value, unit: r.unit,
|
||
referenceRange: r.referenceRange, flagged: r.flagged === 1,
|
||
valueCanonical: r.valueCanonical, canonicalUnit: r.canonicalUnit,
|
||
})),
|
||
});
|
||
}
|
||
case "get_marker_history": {
|
||
const { markerKey } = MarkerArgs.parse(args);
|
||
const analyte = getAnalyte(markerKey);
|
||
if (!analyte) return JSON.stringify({ error: "unknown marker key" });
|
||
const draws = await db.select().from(labDraws);
|
||
const dates = new Map(draws.map((d) => [d.id, d.collectedAt]));
|
||
const rows = await db.select().from(biomarkers).where(eq(biomarkers.analyteKey, analyte.key));
|
||
const points = rows
|
||
.filter((r) => r.valueCanonical !== null)
|
||
.map((r) => ({ collectedAt: dates.get(r.drawId) ?? "", value: r.valueCanonical! }))
|
||
.sort((a, b) => a.collectedAt.localeCompare(b.collectedAt))
|
||
.slice(-MAX_POINTS);
|
||
return JSON.stringify({ key: analyte.key, display: analyte.display, canonicalUnit: analyte.canonicalUnit, points });
|
||
}
|
||
case "list_flagged_biomarkers": {
|
||
const draws = await db.select().from(labDraws).orderBy(desc(labDraws.collectedAt)).limit(FLAGGED_DRAWS);
|
||
const dates = new Map(draws.map((d) => [d.id, d.collectedAt]));
|
||
const ids = new Set(draws.map((d) => d.id));
|
||
const rows = await db.select().from(biomarkers).where(eq(biomarkers.flagged, 1));
|
||
return JSON.stringify(rows
|
||
.filter((r) => ids.has(r.drawId))
|
||
.slice(0, MAX_FLAGGED)
|
||
.map((r) => ({ collectedAt: dates.get(r.drawId), panel: r.panel, name: r.name, value: r.value, unit: r.unit, referenceRange: r.referenceRange })));
|
||
}
|
||
case "list_known_marker_keys":
|
||
return JSON.stringify(ANALYTES.map((a) => ({ key: a.key, display: a.display, panel: a.panel })));
|
||
default:
|
||
throw new Error(`unknown tool: ${name}`);
|
||
}
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests** — PASS. (`zod` is already a server dependency.)
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(chat): bounded lab-data tools for the LLM"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Chat routes
|
||
|
||
**Files:**
|
||
- Create: `server/src/routes/chat.ts`
|
||
- Modify: `server/src/app.ts` (mount), `shared/src/types.ts`
|
||
- Test: `server/test/chat.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces (consumed by web):
|
||
- `GET /api/chat/threads` → `{ threads: { id, title, createdAt }[] }` newest first.
|
||
- `POST /api/chat/threads` (empty body) → 201 `{ id }` (title "New chat").
|
||
- `GET /api/chat/threads/:id` → `{ id, title, messages: { role, content, createdAt }[] }` ascending; 404 unknown.
|
||
- `POST /api/chat/threads/:id/messages` body `ChatMessageBody = { content: string (1..4000) }` → runs `chatWithTools` with `HEALTH_TOOLS`; persists the user message, then the assistant message with its toolTrace JSON; first user message retitles the thread to the first 60 chars; returns `{ reply: string }`. LLM failure → 502 `{ error }` with the user message still persisted.
|
||
- shared Zod: `ChatMessageBody`.
|
||
- System prompt (exact, in `chat.ts`):
|
||
```
|
||
You are Helios, a private health-data assistant running on the user's own server. Answer questions about the user's blood-test results using the provided tools — always fetch data before making claims about it, and cite dates and values from tool results. You are not a doctor and this is not medical advice; for concerning results, suggest discussing with a clinician. Be concise and concrete.
|
||
```
|
||
|
||
- [ ] **Step 1: Add shared schema**
|
||
|
||
```ts
|
||
export const ChatMessageBody = z.object({ content: z.string().min(1).max(4000) });
|
||
export type ChatMessageBody = z.infer<typeof ChatMessageBody>;
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing test**
|
||
|
||
`server/test/chat.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";
|
||
|
||
function reply(payload: unknown) {
|
||
return new Response(JSON.stringify({ choices: [{ message: payload }] }), { status: 200 });
|
||
}
|
||
|
||
async function authed(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 };
|
||
}
|
||
|
||
describe("chat", () => {
|
||
test("thread lifecycle: create, message with tool round, retitle, history", async () => {
|
||
let call = 0;
|
||
const mock = (async () => {
|
||
call++;
|
||
if (call === 1) return reply({ role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "list_lab_draws", arguments: "{}" } }] });
|
||
return reply({ role: "assistant", content: "You have no draws yet." });
|
||
}) as unknown as typeof fetch;
|
||
const { app, cookie } = await authed(mock);
|
||
const h = { cookie, "content-type": "application/json" };
|
||
|
||
const created = await app.request("/api/chat/threads", { method: "POST", headers: h, body: "{}" });
|
||
expect(created.status).toBe(201);
|
||
const { id } = await created.json();
|
||
|
||
const sent = await app.request(`/api/chat/threads/${id}/messages`, { method: "POST", headers: h, body: JSON.stringify({ content: "do I have any lab results?" }) });
|
||
expect(sent.status).toBe(200);
|
||
expect((await sent.json()).reply).toBe("You have no draws yet.");
|
||
|
||
const thread = await (await app.request(`/api/chat/threads/${id}`, { headers: { cookie } })).json();
|
||
expect(thread.title).toBe("do I have any lab results?");
|
||
expect(thread.messages.map((m: any) => m.role)).toEqual(["user", "assistant"]);
|
||
|
||
const list = await (await app.request("/api/chat/threads", { headers: { cookie } })).json();
|
||
expect(list.threads[0].id).toBe(id);
|
||
});
|
||
|
||
test("LLM failure → 502, user message persisted", async () => {
|
||
const mock = (async () => new Response("x", { status: 500 })) as unknown as typeof fetch;
|
||
const { app, cookie } = await authed(mock);
|
||
const h = { cookie, "content-type": "application/json" };
|
||
const { id } = await (await app.request("/api/chat/threads", { method: "POST", headers: h, body: "{}" })).json();
|
||
const sent = await app.request(`/api/chat/threads/${id}/messages`, { method: "POST", headers: h, body: JSON.stringify({ content: "hi" }) });
|
||
expect(sent.status).toBe(502);
|
||
const thread = await (await app.request(`/api/chat/threads/${id}`, { headers: { cookie } })).json();
|
||
expect(thread.messages).toHaveLength(1);
|
||
expect(thread.messages[0].role).toBe("user");
|
||
});
|
||
|
||
test("404 unknown thread; 400 bad body", async () => {
|
||
const { app, cookie } = await authed((async () => reply({ role: "assistant", content: "x" })) as unknown as typeof fetch);
|
||
const h = { cookie, "content-type": "application/json" };
|
||
expect((await app.request("/api/chat/threads/nope", { headers: { cookie } })).status).toBe(404);
|
||
const { id } = await (await app.request("/api/chat/threads", { method: "POST", headers: h, body: "{}" })).json();
|
||
expect((await app.request(`/api/chat/threads/${id}/messages`, { method: "POST", headers: h, body: JSON.stringify({ content: "" }) })).status).toBe(400);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run to verify it fails, then implement**
|
||
|
||
`server/src/routes/chat.ts`:
|
||
```ts
|
||
import { asc, desc, eq } from "drizzle-orm";
|
||
import { Hono } from "hono";
|
||
import { randomUUID } from "node:crypto";
|
||
import { ChatMessageBody } from "@helios/shared";
|
||
import type { Db } from "../db";
|
||
import { chatMessages, chatThreads } from "../db/schema";
|
||
import { HEALTH_TOOLS, makeToolExecutor } from "../lib/health-tools";
|
||
import { chatWithTools } from "../lib/llm";
|
||
|
||
const SYSTEM = `You are Helios, a private health-data assistant running on the user's own server. Answer questions about the user's blood-test results using the provided tools — always fetch data before making claims about it, and cite dates and values from tool results. You are not a doctor and this is not medical advice; for concerning results, suggest discussing with a clinician. Be concise and concrete.`;
|
||
|
||
const MAX_CONTEXT_MESSAGES = 30;
|
||
|
||
export type ChatDeps = { db: Db; key: Buffer; llmFetch?: typeof fetch };
|
||
|
||
export function chatRoutes(deps: ChatDeps) {
|
||
const app = new Hono();
|
||
|
||
app.get("/chat/threads", async (c) => {
|
||
const threads = await deps.db.select().from(chatThreads).orderBy(desc(chatThreads.createdAt));
|
||
return c.json({ threads });
|
||
});
|
||
|
||
app.post("/chat/threads", async (c) => {
|
||
const id = randomUUID();
|
||
await deps.db.insert(chatThreads).values({ id, title: "New chat", createdAt: Date.now() });
|
||
return c.json({ id }, 201);
|
||
});
|
||
|
||
app.get("/chat/threads/:id", async (c) => {
|
||
const t = (await deps.db.select().from(chatThreads).where(eq(chatThreads.id, c.req.param("id")))).at(0);
|
||
if (!t) return c.json({ error: "not found" }, 404);
|
||
const messages = await deps.db.select().from(chatMessages)
|
||
.where(eq(chatMessages.threadId, t.id)).orderBy(asc(chatMessages.createdAt));
|
||
return c.json({ id: t.id, title: t.title, messages: messages.map((m) => ({ role: m.role, content: m.content, createdAt: m.createdAt })) });
|
||
});
|
||
|
||
app.post("/chat/threads/:id/messages", async (c) => {
|
||
const t = (await deps.db.select().from(chatThreads).where(eq(chatThreads.id, c.req.param("id")))).at(0);
|
||
if (!t) return c.json({ error: "not found" }, 404);
|
||
const body = ChatMessageBody.safeParse(await c.req.json().catch(() => null));
|
||
if (!body.success) return c.json({ error: "content must be 1-4000 chars" }, 400);
|
||
|
||
const history = await deps.db.select().from(chatMessages)
|
||
.where(eq(chatMessages.threadId, t.id)).orderBy(asc(chatMessages.createdAt));
|
||
|
||
await deps.db.insert(chatMessages).values({ threadId: t.id, role: "user", content: body.data.content, toolTrace: null, createdAt: Date.now() });
|
||
if (history.length === 0) {
|
||
await deps.db.update(chatThreads).set({ title: body.data.content.slice(0, 60) }).where(eq(chatThreads.id, t.id));
|
||
}
|
||
|
||
try {
|
||
const out = await chatWithTools(
|
||
{ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch },
|
||
{
|
||
system: SYSTEM,
|
||
messages: [
|
||
...history.slice(-MAX_CONTEXT_MESSAGES).map((m) => ({ role: m.role as "user" | "assistant", content: m.content })),
|
||
{ role: "user", content: body.data.content },
|
||
],
|
||
tools: HEALTH_TOOLS,
|
||
executeTool: makeToolExecutor(deps.db),
|
||
},
|
||
);
|
||
await deps.db.insert(chatMessages).values({
|
||
threadId: t.id, role: "assistant", content: out.content,
|
||
toolTrace: out.toolTrace.length ? JSON.stringify(out.toolTrace) : null, createdAt: Date.now(),
|
||
});
|
||
return c.json({ reply: out.content });
|
||
} catch (e) {
|
||
return c.json({ error: e instanceof Error ? e.message : "chat failed" }, 502);
|
||
}
|
||
});
|
||
|
||
return app;
|
||
}
|
||
```
|
||
|
||
In `server/src/app.ts`: `import { chatRoutes } from "./routes/chat";` and mount `app.route("/api", chatRoutes(deps));` beside the other mounts.
|
||
|
||
- [ ] **Step 4: Run tests** — full suite PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(chat): threads, grounded messages with tool trace persistence"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: Daily brief — generator, schedule check, routes
|
||
|
||
**Files:**
|
||
- Create: `server/src/lib/brief.ts`, `server/src/routes/brief.ts`
|
||
- Modify: `server/src/app.ts` (mount), `server/src/index.ts` (interval), `server/src/routes/settings.ts` + `shared/src/types.ts` (briefHour setting)
|
||
- Test: `server/test/brief.test.ts`
|
||
|
||
**Interfaces:**
|
||
- Produces:
|
||
- `generateBrief(deps: LlmDeps & { db: Db }): Promise<string>` — assembles: draw list (via the Task 3 executor's `list_lab_draws`), flagged biomarkers, and marker history for each flagged analyte key (max 6 keys); prompts via `chatJSON` for `{ "brief": string }` (markdown); inserts into `briefs` with today's local date; returns the content. Prompt (exact):
|
||
```
|
||
You write a short daily health brief from the user's own lab data. Ground every statement in the data provided. Structure: 1-2 sentence status summary, then "Worth attention" bullets for out-of-range values with their dates and trends, then one practical, non-prescriptive suggestion. You are not a doctor; do not diagnose or prescribe. If there is no data, say so and suggest uploading lab results. Return JSON: {"brief": "<markdown>"}.
|
||
```
|
||
- `shouldGenerateBrief(now: Date, briefHour: number, lastBriefDate: string | null): boolean` — pure: true when `now.getHours() >= briefHour` and `lastBriefDate !== localDate(now)`. Exported `localDate(d: Date): string` (YYYY-MM-DD, local).
|
||
- Routes: `GET /api/brief/latest` → `{ brief: { date, content, createdAt } | null }`; `POST /api/brief/generate` → 201 `{ content }` or 502 on llm_error.
|
||
- Settings: `brief_hour` (default 7) joins the settings API — `SettingsResponse` gains `briefHour: number`, `SettingsUpdate` gains `briefHour: z.number().int().min(0).max(23).optional()`; stored as string in the settings table. Update the settings test's defaults expectation to include `briefHour: 7`.
|
||
- `index.ts`: `setInterval` every 5 min → if `llm_key` set and `shouldGenerateBrief(new Date(), briefHour, latestBriefDate)` → `generateBrief` (catch + `console.error` — scheduler must never crash the server).
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
`server/test/brief.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 { briefs, labDraws, biomarkers } from "../src/db/schema";
|
||
import { loadOrCreateKey } from "../src/lib/crypto";
|
||
import { localDate, shouldGenerateBrief } from "../src/lib/brief";
|
||
|
||
describe("shouldGenerateBrief", () => {
|
||
const at = (h: number) => new Date(2026, 7, 18, h, 30); // local Aug 18 2026
|
||
test("before the hour → false; after → true; already generated today → false", () => {
|
||
expect(shouldGenerateBrief(at(6), 7, null)).toBe(false);
|
||
expect(shouldGenerateBrief(at(8), 7, null)).toBe(true);
|
||
expect(shouldGenerateBrief(at(8), 7, localDate(at(8)))).toBe(false);
|
||
expect(shouldGenerateBrief(at(8), 7, "2026-08-17")).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("brief routes", () => {
|
||
function reply(content: string) {
|
||
return new Response(JSON.stringify({ choices: [{ message: { content } }] }), { status: 200 });
|
||
}
|
||
async function authed(fetchImpl: typeof fetch) {
|
||
const dir = mkdtempSync(join(tmpdir(), "helios-"));
|
||
const db = openDb(dir);
|
||
const app = createApp({ db, 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, db, cookie };
|
||
}
|
||
|
||
test("generate stores and latest returns it; prompt carries flagged data", async () => {
|
||
let userPrompt = "";
|
||
const mock = (async (_: any, init: any) => {
|
||
userPrompt = JSON.parse(String(init.body)).messages[1].content;
|
||
return reply(JSON.stringify({ brief: "## Status\nGlucose slightly high." }));
|
||
}) as unknown as typeof fetch;
|
||
const { app, db, cookie } = await authed(mock);
|
||
await db.insert(labDraws).values({ id: "d1", collectedAt: "2026-01-15", labName: "B", draftId: null, createdAt: 1 });
|
||
await db.insert(biomarkers).values({ drawId: "d1", 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" });
|
||
|
||
const gen = await app.request("/api/brief/generate", { method: "POST", headers: { cookie } });
|
||
expect(gen.status).toBe(201);
|
||
expect(userPrompt).toContain("Glucose");
|
||
|
||
const latest = await (await app.request("/api/brief/latest", { headers: { cookie } })).json();
|
||
expect(latest.brief.content).toContain("Glucose slightly high");
|
||
expect((await db.select().from(briefs))).toHaveLength(1);
|
||
});
|
||
|
||
test("latest is null when none; llm failure → 502", async () => {
|
||
const bad = (async () => new Response("x", { status: 500 })) as unknown as typeof fetch;
|
||
const { app, cookie } = await authed(bad);
|
||
expect((await (await app.request("/api/brief/latest", { headers: { cookie } })).json()).brief).toBeNull();
|
||
expect((await app.request("/api/brief/generate", { method: "POST", headers: { cookie } })).status).toBe(502);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run to verify it fails, then implement**
|
||
|
||
`server/src/lib/brief.ts`:
|
||
```ts
|
||
import { desc } from "drizzle-orm";
|
||
import type { Db } from "../db";
|
||
import { briefs } from "../db/schema";
|
||
import { makeToolExecutor } from "./health-tools";
|
||
import { chatJSON, type LlmDeps } from "./llm";
|
||
|
||
const PROMPT = `You write a short daily health brief from the user's own lab data. Ground every statement in the data provided. Structure: 1-2 sentence status summary, then "Worth attention" bullets for out-of-range values with their dates and trends, then one practical, non-prescriptive suggestion. You are not a doctor; do not diagnose or prescribe. If there is no data, say so and suggest uploading lab results. Return JSON: {"brief": "<markdown>"}.`;
|
||
|
||
export function localDate(d: Date): string {
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
}
|
||
|
||
export function shouldGenerateBrief(now: Date, briefHour: number, lastBriefDate: string | null): boolean {
|
||
return now.getHours() >= briefHour && lastBriefDate !== localDate(now);
|
||
}
|
||
|
||
export async function latestBrief(db: Db) {
|
||
return (await db.select().from(briefs).orderBy(desc(briefs.createdAt)).limit(1)).at(0) ?? null;
|
||
}
|
||
|
||
const MAX_HISTORY_KEYS = 6;
|
||
|
||
export async function generateBrief(deps: LlmDeps): Promise<string> {
|
||
const exec = makeToolExecutor(deps.db);
|
||
const draws = await exec("list_lab_draws", {});
|
||
const flagged = await exec("list_flagged_biomarkers", {});
|
||
|
||
// History for the flagged analytes so trends are grounded, capped.
|
||
const flaggedRows = JSON.parse(flagged) as { name: string }[];
|
||
const keysOut = JSON.parse(await exec("list_known_marker_keys", {})) as { key: string; display: string }[];
|
||
const byDisplay = new Map(keysOut.map((k) => [k.display.toLowerCase(), k.key]));
|
||
const historyKeys = [...new Set(flaggedRows.map((f) => byDisplay.get(f.name.toLowerCase())).filter((k): k is string => !!k))].slice(0, MAX_HISTORY_KEYS);
|
||
const histories: Record<string, unknown> = {};
|
||
for (const k of historyKeys) histories[k] = JSON.parse(await exec("get_marker_history", { markerKey: k }));
|
||
|
||
const user = JSON.stringify({ today: localDate(new Date()), draws: JSON.parse(draws), flagged: flaggedRows, histories });
|
||
const out = (await chatJSON(deps, { system: PROMPT, user })) as { brief?: unknown };
|
||
if (typeof out.brief !== "string" || !out.brief) throw new Error("llm_error: brief missing from completion");
|
||
|
||
await deps.db.insert(briefs).values({ date: localDate(new Date()), content: out.brief, createdAt: Date.now() });
|
||
return out.brief;
|
||
}
|
||
```
|
||
|
||
`server/src/routes/brief.ts`:
|
||
```ts
|
||
import { Hono } from "hono";
|
||
import type { Db } from "../db";
|
||
import { generateBrief, latestBrief } from "../lib/brief";
|
||
|
||
export function briefRoutes(deps: { db: Db; key: Buffer; llmFetch?: typeof fetch }) {
|
||
const app = new Hono();
|
||
|
||
app.get("/brief/latest", async (c) => {
|
||
const b = await latestBrief(deps.db);
|
||
return c.json({ brief: b ? { date: b.date, content: b.content, createdAt: b.createdAt } : null });
|
||
});
|
||
|
||
app.post("/brief/generate", async (c) => {
|
||
try {
|
||
const content = await generateBrief({ db: deps.db, key: deps.key, fetchImpl: deps.llmFetch });
|
||
return c.json({ content }, 201);
|
||
} catch (e) {
|
||
return c.json({ error: e instanceof Error ? e.message : "brief failed" }, 502);
|
||
}
|
||
});
|
||
|
||
return app;
|
||
}
|
||
```
|
||
|
||
Settings additions: in `shared/src/types.ts`, add `briefHour: z.number()` to `SettingsResponse` and `briefHour: z.number().int().min(0).max(23).optional()` to `SettingsUpdate`. In `server/src/routes/settings.ts`: DEFAULTS gains `brief_hour: "7"`; GET reads `brief_hour` and returns `briefHour: Number(...)`; PUT stores `String(briefHour)` when present. Update `server/test/settings.test.ts` defaults expectation to include `briefHour: 7`.
|
||
|
||
`server/src/app.ts`: mount `briefRoutes(deps)`.
|
||
|
||
`server/src/index.ts` — after `createApp`, add:
|
||
```ts
|
||
import { eq } from "drizzle-orm";
|
||
import { settings } from "./db/schema";
|
||
import { latestBrief, generateBrief, shouldGenerateBrief } from "./lib/brief";
|
||
|
||
// Daily brief scheduler: cheap check every 5 minutes; generateBrief runs at
|
||
// most once per local day. Never allowed to crash the server.
|
||
setInterval(async () => {
|
||
try {
|
||
const hasKey = (await db.select().from(settings).where(eq(settings.key, "llm_key"))).length > 0;
|
||
if (!hasKey) return;
|
||
const hourRow = (await db.select().from(settings).where(eq(settings.key, "brief_hour"))).at(0);
|
||
const hour = hourRow ? Number(hourRow.value) : 7;
|
||
const last = await latestBrief(db);
|
||
if (shouldGenerateBrief(new Date(), hour, last?.date ?? null)) {
|
||
await generateBrief({ db, key });
|
||
}
|
||
} catch (e) {
|
||
console.error("brief scheduler:", e instanceof Error ? e.message : e);
|
||
}
|
||
}, 5 * 60 * 1000);
|
||
```
|
||
(Requires `index.ts` to keep `db` and `key` in scope — refactor its two lines so `const db = openDb(dataDir); const key = loadOrCreateKey(dataDir);` are named before `createApp({ db, key, dataDir })`.)
|
||
|
||
- [ ] **Step 3: Run tests** — full suite PASS (settings test updated).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(brief): daily brief generator, scheduler check, routes, brief_hour setting"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Web UI — Chat page + Today brief card
|
||
|
||
**Files:**
|
||
- Create: `web/src/pages/Chat.tsx`
|
||
- Modify: `web/src/pages/Today.tsx`, `web/src/pages/Settings.tsx` (brief hour field), `web/src/App.tsx` (route + nav), `web/src/api.ts`, `web/src/styles.css`
|
||
|
||
**Interfaces:** consumes Tasks 4–5 endpoints.
|
||
|
||
- [ ] **Step 1: API client additions** (`web/src/api.ts`, inside `api`)
|
||
|
||
```ts
|
||
chatThreads: () => req<{ threads: { id: string; title: string; createdAt: number }[] }>("/api/chat/threads"),
|
||
createThread: () => req<{ id: string }>("/api/chat/threads", { method: "POST", body: "{}" }),
|
||
chatThread: (id: string) => req<{ id: string; title: string; messages: { role: string; content: string; createdAt: number }[] }>(`/api/chat/threads/${id}`),
|
||
sendMessage: (id: string, content: string) => req<{ reply: string }>(`/api/chat/threads/${id}/messages`, { method: "POST", body: JSON.stringify({ content }) }),
|
||
latestBrief: () => req<{ brief: { date: string; content: string; createdAt: number } | null }>("/api/brief/latest"),
|
||
generateBrief: () => req<{ content: string }>("/api/brief/generate", { method: "POST" }),
|
||
```
|
||
|
||
- [ ] **Step 2: Chat page**
|
||
|
||
`web/src/pages/Chat.tsx`:
|
||
```tsx
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { api } from "../api";
|
||
|
||
interface Msg { role: string; content: string }
|
||
|
||
export function Chat() {
|
||
const [threads, setThreads] = useState<{ id: string; title: string }[]>([]);
|
||
const [active, setActive] = useState<string | null>(null);
|
||
const [messages, setMessages] = useState<Msg[]>([]);
|
||
const [input, setInput] = useState("");
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const bottom = useRef<HTMLDivElement>(null);
|
||
|
||
const loadThreads = useCallback(() => {
|
||
api.chatThreads().then((r) => setThreads(r.threads)).catch(() => {});
|
||
}, []);
|
||
useEffect(loadThreads, [loadThreads]);
|
||
|
||
const open = async (id: string) => {
|
||
setActive(id);
|
||
setError(null);
|
||
const t = await api.chatThread(id).catch(() => null);
|
||
setMessages(t?.messages ?? []);
|
||
};
|
||
|
||
const newChat = async () => {
|
||
const { id } = await api.createThread();
|
||
loadThreads();
|
||
await open(id);
|
||
};
|
||
|
||
const send = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!input.trim() || busy) return;
|
||
let id = active;
|
||
if (!id) {
|
||
id = (await api.createThread()).id;
|
||
setActive(id);
|
||
}
|
||
const content = input;
|
||
setInput("");
|
||
setMessages((m) => [...m, { role: "user", content }]);
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const { reply } = await api.sendMessage(id, content);
|
||
setMessages((m) => [...m, { role: "assistant", content: reply }]);
|
||
loadThreads();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "send failed");
|
||
} finally {
|
||
setBusy(false);
|
||
bottom.current?.scrollIntoView({ behavior: "smooth" });
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section className="chat">
|
||
<aside className="chat-threads">
|
||
<button onClick={newChat}>New chat</button>
|
||
<ul className="list">
|
||
{threads.map((t) => (
|
||
<li key={t.id}>
|
||
<button className={`link${t.id === active ? " active" : ""}`} onClick={() => open(t.id)}>{t.title}</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</aside>
|
||
<div className="chat-main">
|
||
<div className="chat-messages">
|
||
{messages.length === 0 && (
|
||
<p className="hint">
|
||
Ask about your lab results — "how is my LDL trending?", "what was flagged in my last draw?".
|
||
Your questions and relevant data slices go to the AI endpoint configured in Settings; use a local
|
||
endpoint like Ollama if you want zero third parties. Not medical advice.
|
||
</p>
|
||
)}
|
||
{messages.map((m, i) => (
|
||
<div key={i} className={`bubble ${m.role}`}>{m.content}</div>
|
||
))}
|
||
{busy && <div className="bubble assistant muted">Thinking…</div>}
|
||
<div ref={bottom} />
|
||
</div>
|
||
{error && <p className="error">{error}</p>}
|
||
<form onSubmit={send} className="chat-input">
|
||
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="Ask about your health data" disabled={busy} />
|
||
<button type="submit" disabled={busy || !input.trim()}>Send</button>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Today brief card + Settings hour**
|
||
|
||
`web/src/pages/Today.tsx` (replace):
|
||
```tsx
|
||
import { useCallback, useEffect, useState } from "react";
|
||
import { api } from "../api";
|
||
|
||
export function Today() {
|
||
const [brief, setBrief] = useState<{ date: string; content: string } | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const refresh = useCallback(() => {
|
||
api.latestBrief().then((r) => setBrief(r.brief)).catch(() => {});
|
||
}, []);
|
||
useEffect(refresh, [refresh]);
|
||
|
||
const generate = async () => {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await api.generateBrief();
|
||
refresh();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "generation failed");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<section>
|
||
<div className="row-between">
|
||
<h2>Today</h2>
|
||
<button onClick={generate} disabled={busy}>{busy ? "Generating…" : "Generate brief"}</button>
|
||
</div>
|
||
{error && <p className="error">{error}</p>}
|
||
{brief ? (
|
||
<article className="brief">
|
||
<p className="muted">{brief.date}</p>
|
||
<div className="brief-content">{brief.content}</div>
|
||
</article>
|
||
) : (
|
||
<p className="muted">No brief yet. Upload lab results, set your AI key in Settings, then generate one — or wait for the daily schedule.</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
`web/src/pages/Settings.tsx`: add a `briefHour` number state initialized from `s.briefHour`, an input
|
||
`<label>Daily brief hour (0-23) <input type="number" min={0} max={23} value={briefHour} onChange={...} /></label>`
|
||
inside the form, and include `briefHour` in the `putSettings` body.
|
||
|
||
- [ ] **Step 4: Routes, nav, styles**
|
||
|
||
`App.tsx`: nav gains `<Link to="/chat">Chat</Link>`; route `<Route path="/chat" element={<Chat />} />`.
|
||
|
||
`styles.css` append:
|
||
```css
|
||
.chat { display: flex; gap: 16px; min-height: 60vh; }
|
||
.chat-threads { width: 180px; flex-shrink: 0; }
|
||
.chat-main { flex: 1; display: flex; flex-direction: column; }
|
||
.chat-messages { flex: 1; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; padding-bottom: 12px; }
|
||
.bubble { padding: 10px 12px; border-radius: 12px; max-width: 85%; white-space: pre-wrap; }
|
||
.bubble.user { align-self: flex-end; background: color-mix(in srgb, currentColor 12%, transparent); }
|
||
.bubble.assistant { align-self: flex-start; border: 1px solid color-mix(in srgb, currentColor 20%, transparent); }
|
||
.chat-input { display: flex; gap: 8px; }
|
||
.chat-input input { flex: 1; }
|
||
button.link.active { font-weight: 700; text-decoration: none; }
|
||
.brief-content { white-space: pre-wrap; line-height: 1.5; }
|
||
@media (max-width: 640px) { .chat { flex-direction: column; } .chat-threads { width: auto; } }
|
||
```
|
||
|
||
- [ ] **Step 5: Verify** — `bun run typecheck && bun run build:web && bun test server` all green.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A && git commit -m "feat(web): chat page, today brief card, brief hour setting"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (done at write time)
|
||
|
||
- **Spec coverage:** AI-layer chat with typed bounded tools ✔ (labs tools only — sleep/metrics/meals tools arrive with their data); daily brief at user hour, stored, on Today ✔; honest privacy wording in chat UI ✔; not-medical-advice framing in both prompts ✔.
|
||
- **Placeholders:** none.
|
||
- **Type consistency:** `LlmDeps` reused for tools loop; `ToolDef`/`ToolTraceEntry` exported from llm.ts and imported by health-tools; `createApp` deps unchanged (`llmFetch` already exists); brief scheduler uses named `db`/`key` from refactored index.ts; settings test update called out explicitly.
|
||
- **Known risk:** brief's flagged→analyteKey mapping goes through display-name lowercase matching (flagged tool output has no key) — acceptable M3 shortcut; noted for reviewers.
|