feat(coach): quote the real BP range, address by name, render action cards

Feed the user's own usual blood-pressure range — the median plus or minus
MAD band, computed from the readings already in the snapshot — into the Coach
context with a clause to quote those bounds verbatim, so a band is never
invented from window means. Thread the stored first name into the prompt and
surface it sparingly on roughly one turn in three, never as a rote greeting.
Add the context-layer rule that names the relevant context before a
suggestion, a single-chart-token allowance that works on every provider, and
a per-turn opener hint.

Wire the already-shipped suggested-action SSE frame to render: the streaming
hook collects it and the message thread shows the action card, mirroring the
reminder-suggestion path. Extend the eval golden set with band-verbatim,
chart-token, and no-overclaim cases, and pin a red-team case proving the
chart-token syntax cannot smuggle a dose past the outbound screen.
This commit is contained in:
Marc-André Bombeck committed 2026-06-27 14:40:07 +02:00
1 parent b393527cac
commit 5674f10b2f
10 files changed
+329 -13

No files matched your search

@@ -73,6 +73,7 @@ function streaming(
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -45,6 +45,7 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { MessageTokenFooter } from "./message-token-footer";
import type {
@@ -766,6 +767,7 @@ export function MessageThread({
content={streaming.content}
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
suggestedAction={streaming.suggestedAction}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
@@ -794,6 +796,14 @@ interface ChatBubbleProps {
* instead; the bubble falls back to that so the card survives reload.
*/
suggestion?: import("@/lib/ai/coach/types").CoachSuggestion | null;
/**
* v1.22 (W7/W6) — live generalised confirm-card action from the streaming
* hook. Persisted messages carry it on `metricSource.suggestedAction`; the
* bubble falls back to that so the card survives reload.
*/
suggestedAction?:
| import("@/lib/ai/coach/suggest-action").CoachSuggestedAction
| null;
providerType?: string | null;
inProgress?: boolean;
errorCode?: string | null;
@@ -834,6 +844,7 @@ function ChatBubble({
content,
metricSource,
suggestion,
suggestedAction,
providerType,
inProgress,
errorCode,
@@ -1161,6 +1172,16 @@ function ChatBubble({
const sug = suggestion ?? metricSource?.suggestion ?? null;
return sug ? <ReminderSuggestionCard suggestion={sug} /> : null;
})()}
{/* v1.22 (W7/W6) — generalised confirm-card action. Live from the
streaming hook, or restored from persisted message provenance on
reload. Mirrors the reminder-suggestion block above. */}
{!inProgress &&
!errorCode &&
(() => {
const action =
suggestedAction ?? metricSource?.suggestedAction ?? null;
return action ? <SuggestedActionCard action={action} /> : null;
})()}
{/* v1.18.9 — quiet per-message token footer. The just-finished
streaming turn reads the `done.usage` envelope; a persisted /
reloaded turn reads the message's own `tokensUsed` + `model`.
@@ -13,6 +13,7 @@ import type {
CoachSuggestion,
CoachUsage,
} from "@/lib/ai/coach/types";
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
@@ -186,6 +187,12 @@ export interface CoachStreamingMessage {
* `suggestion` frame, if the turn carried one. Null otherwise.
*/
suggestion: CoachSuggestion | null;
/**
* v1.22 (W7/W6) — generalised confirm-card action from the additive
* `suggestedAction` frame (mirrors `suggestion`). Persisted messages carry it
* on `metricSource.suggestedAction` instead. Null otherwise.
*/
suggestedAction: CoachSuggestedAction | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
@@ -236,6 +243,7 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -330,6 +338,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: true,
messageId: null,
errorCode: null,
@@ -344,6 +353,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
errorCode: "coach.network",
@@ -378,6 +388,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
errorCode: "coach.network",
@@ -391,6 +402,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
errorCode: `coach.http.${response.status}`,
@@ -429,6 +441,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: "",
metricSource: null,
suggestion: null,
suggestedAction: null,
inProgress: false,
messageId: null,
errorCode: structured ?? `coach.http.${response.status}`,
@@ -449,6 +462,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let collectedContent = "";
let collectedProvenance: CoachProvenance | null = null;
let collectedSuggestion: CoachSuggestion | null = null;
let collectedSuggestedAction: CoachSuggestedAction | null = null;
let lastError: string | null = null;
let messageId: string | null = null;
let collectedUsage: CoachUsage | null = null;
@@ -483,6 +497,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
suggestion: evt.suggestion,
}));
break;
case "suggestedAction":
collectedSuggestedAction = evt.suggestedAction;
setStreaming((prev) => ({
...prev,
suggestedAction: evt.suggestedAction,
}));
break;
case "reasoning":
// v1.19.1 — the thinking disclosure was removed, so the
// additive `reasoning` frame is accepted off the wire and
@@ -522,6 +543,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: collectedContent,
metricSource: collectedProvenance,
suggestion: collectedSuggestion,
suggestedAction: collectedSuggestedAction,
inProgress: false,
messageId,
errorCode: lastError,