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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-26 13:25:35 +02:00
1 parent cf331dbef0
commit d45d60627f
4 files changed
+73 -1

No files matched your search

+9 -1
View File
@@ -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}
@@ -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");
});
});
+1
View File
@@ -20,6 +20,7 @@ export {
export {
buildCoachDataInventory,
renderDataInventory,
renderFocusHint,
type CoachDataInventory,
type InventoryEntry,
} from "./inventory";
+35
View File
@@ -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<string>();
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