From d45d60627fc06ec4eb7f8dc85bed8904659b4e4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 26 Jun 2026 13:25:35 +0200 Subject: [PATCH] Narrow the tool-mode Coach to the metric it was launched from The tool-mode path dropped the launch scope's sources: the inventory probes the full source set, so a Coach opened from a metric page saw the whole inventory and could roam, while only the no-tools path honoured the narrowing. Inject a one-line FOCUS hint naming the launched domain(s) into the tool-mode user turn so the model prioritises that metric and fetches its figures first, with every other domain still reachable if the question leads there. Empty on a generic open, so the prompt prefix is unchanged on that path. --- src/app/api/insights/chat/route.ts | 10 +++++- .../coach/__tests__/tools-inventory.test.ts | 28 +++++++++++++++ src/lib/ai/coach/tools/index.ts | 1 + src/lib/ai/coach/tools/inventory.ts | 35 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index 13d02b2f4..59183ba6f 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -78,6 +78,7 @@ import { COACH_TOOL_DEFS, buildCoachDataInventory, renderDataInventory, + renderFocusHint, buildToolModeAddendum, runCoachToolLoop, MAX_ROUNDS, @@ -547,10 +548,17 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`; // that fire this turn share its reads. const inventory = await buildCoachDataInventory(userId, effectiveScope); const toolSystem = `${systemPrompt}\n\n${buildToolModeAddendum(locale)}`; + // v1.21.0 (D1) — when the Coach was opened from a metric page/card, thread + // the launch sources into a one-line FOCUS hint so tool mode honours the + // metric the user is looking at (the no-tools path already narrows the + // snapshot; the inventory probes the full set, so this is the tool-mode + // equivalent of that narrowing). Empty string on a generic open. + const focusHint = renderFocusHint(effectiveScope?.sources); + const focusBlock = focusHint ? `${focusHint}\n\n` : ""; const messages: AiMessage[] = [ { role: "user", - content: `${renderDataInventory(inventory)}${guidedBlock} + content: `${focusBlock}${renderDataInventory(inventory)}${guidedBlock} CONVERSATION ${transcript} diff --git a/src/lib/ai/coach/__tests__/tools-inventory.test.ts b/src/lib/ai/coach/__tests__/tools-inventory.test.ts index d2cfa9675..f4d9a3947 100644 --- a/src/lib/ai/coach/__tests__/tools-inventory.test.ts +++ b/src/lib/ai/coach/__tests__/tools-inventory.test.ts @@ -21,6 +21,7 @@ vi.mock("@/lib/cycle/gate", () => ({ import { buildCoachDataInventory, renderDataInventory, + renderFocusHint, } from "@/lib/ai/coach/tools/inventory"; function snapshot( @@ -142,3 +143,30 @@ describe("renderDataInventory", () => { expect(text.toLowerCase()).not.toContain("whoop"); }); }); + +// v1.21.0 (D1) — the launch FOCUS hint narrows tool mode to the metric the +// Coach was opened from. The no-tools path narrows the snapshot; this is the +// tool-mode equivalent so the metric-narrowing is not silently dropped. +describe("renderFocusHint", () => { + it("is empty on a generic open (no pinned sources)", () => { + expect(renderFocusHint(undefined)).toBe(""); + expect(renderFocusHint([])).toBe(""); + }); + + it("names the launched domain(s) by their natural-language label", () => { + const hint = renderFocusHint(["hrv", "resting_hr", "sleep"]); + expect(hint).toContain("FOCUS:"); + expect(hint).toContain("heart-rate variability"); + expect(hint).toContain("resting heart rate"); + expect(hint).toContain("sleep"); + // It instructs prioritisation, not exclusion (the user can still pivot). + expect(hint).toContain("prioritise"); + expect(hint).toContain("only branch to other domains"); + }); + + it("falls back to the raw source key for an unlabelled source", () => { + // `compliance` has no domain label in the map; the raw key is used. + const hint = renderFocusHint(["compliance"]); + expect(hint).toContain("compliance"); + }); +}); diff --git a/src/lib/ai/coach/tools/index.ts b/src/lib/ai/coach/tools/index.ts index 9f4858a2e..55d8b8a1f 100644 --- a/src/lib/ai/coach/tools/index.ts +++ b/src/lib/ai/coach/tools/index.ts @@ -20,6 +20,7 @@ export { export { buildCoachDataInventory, renderDataInventory, + renderFocusHint, type CoachDataInventory, type InventoryEntry, } from "./inventory"; diff --git a/src/lib/ai/coach/tools/inventory.ts b/src/lib/ai/coach/tools/inventory.ts index 10a5431e4..651dca57b 100644 --- a/src/lib/ai/coach/tools/inventory.ts +++ b/src/lib/ai/coach/tools/inventory.ts @@ -191,6 +191,41 @@ export async function buildCoachDataInventory( }; } +/** + * v1.21.0 (D1) — render the launch FOCUS hint for the tool-mode user turn. + * + * When the Coach is opened from a metric page or card, the client sends + * `scope.sources` (the metric the user is looking at). The no-tools path + * narrows the snapshot to those sources, but the tool-mode inventory probes the + * FULL source set, so without this hint the metric-narrowing was silently + * dropped server-side (D1 — the only surviving signals were the window + seed + * question). This injects a single `FOCUS:` line naming the launched domain(s) + * so the model prioritises the metric the user actually opened the Coach about, + * while every other domain stays fetchable (the user can still pivot). + * + * Returns `""` when no explicit sources were pinned (a generic open), so the + * prompt prefix is byte-identical to before on the non-scoped path. + */ +export function renderFocusHint( + sources: readonly CoachScopeSource[] | undefined, +): string { + if (!sources || sources.length === 0) return ""; + const labels = sources.map( + (s) => COACH_SOURCE_DOMAIN_LABEL[s] ?? (s as string), + ); + // De-dup while preserving order (two sources can share a label only via the + // fallback, but keep it stable regardless). + const seen = new Set(); + const unique = labels.filter((l) => { + if (seen.has(l)) return false; + seen.add(l); + return true; + }); + return `FOCUS: the user opened this conversation from ${unique.join( + ", ", + )} — prioritise that, fetch its figures first, and only branch to other domains if the question leads there.`; +} + /** * Render the inventory as the compact text block that rides the base context. * Brand-free, deterministic, and stable across turns so the prompt-cache prefix