mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
cf331dbef0
commit
d45d60627f
4 files changed
+73
-1
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
export {
|
||||
buildCoachDataInventory,
|
||||
renderDataInventory,
|
||||
renderFocusHint,
|
||||
type CoachDataInventory,
|
||||
type InventoryEntry,
|
||||
} from "./inventory";
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user