Files
HealthLog/src/lib/ai/coach/clarify.ts
T
Marc-André Bombeck 2d9443b0b7 fix(coach): keep the person's question out of the system role, read the latest messages once
"Keep looking" no longer copies the unfinished question into the
server-written context lines. The question stays in the transcript as the
person's own turn; the line only points at it. Lifted into the system
role, whatever the question said would have carried the system's
authority.

A turn now reads its conversation's newest messages once and shares the
rows between the chip, clarification, "keep looking" and repeat-question
checks, instead of three identical reads.

Earlier tables keep their m<k> names once a conversation outgrows the
window a turn loads: the index counts the assistant messages before the
window too, so a name the model read on an earlier turn still points at
the same table.
2026-09-27 13:16:08 +02:00

405 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Clarifying questions. The model may end a reply with a `---CLARIFY---`
* block when the metric or window stays genuinely ambiguous:
*
* Which pulse do you mean?
* ---CLARIFY---
* kind: metric
* choices: pulse, resting_hr, walking_hr
* ---END---
*
* The block is always stripped from the prose. What survives is decided here,
* never by the model:
*
* - `metric` choices are kept only when the record holds that metric (an
* inventory row marked present). Fewer than two left, and there is nothing
* to choose between: the clarification is dropped and the reply stands.
* - `window` choices come from the window presets only.
* - `context` carries no choices; the person types the answer.
* - The question text is the reply itself. It is screened like any reply
* (the outbound screen and the refusal detector); a hit drops the choices.
*
* Labels are rendered on the server from the catalog, so no model text ever
* reaches a button. The person answers with
* `clarification: { messageId, choiceId? }`, which `resolveClarificationAnswer`
* turns into one server-written line for the next turn's context.
*/
import {
readLatestMessages,
type LatestMessage,
type LatestMessagesLoader,
} from "@/lib/ai/coach/latest-messages";
import { annotate } from "@/lib/logging/context";
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
coachScopeSourceSchema,
coachScopeWindowSchema,
type CoachClarification,
type CoachClarificationChoice,
type CoachScopeSource,
type CoachScopeWindow,
} from "@/lib/ai/coach/types";
import { coachClarificationSchema } from "@/lib/ai/coach/stream-events";
import {
clarifyWindowLabelKey,
coachDomainLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import { screenCoachReply } from "@/lib/ai/coach/outbound-guard";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import { COACH_SOURCE_DOMAIN_LABEL } from "@/lib/ai/coach/tools/source-keys";
import type { InventoryEntry } from "@/lib/ai/coach/tools/inventory";
const OPEN_SENTINEL = "---CLARIFY---";
const CLOSE_SENTINEL = "---END---";
/** Block body cap in bytes, after the opening marker. */
export const CLARIFY_BYTE_CAP = 512;
/** At most this many choices on a card. */
export const CLARIFY_MAX_CHOICES = 4;
/**
* A clarifying question is one short sentence. A reply longer than this
* answered something as well, so it is not a question and gets no card.
*/
const QUESTION_CHAR_CAP = 400;
type ClarifyKind = CoachClarification["kind"];
/** Why a block did not become a card. Ops-facing only. */
export type ClarifyDropReason =
| "malformed"
| "byte_overflow"
| "too_few_metrics"
| "no_window_choices"
| "no_question"
| "question_too_long"
| "screened"
| "no_inventory";
/**
* Dedicated-tool rows of the inventory carry no `metric` argument; these are
* the scope sources they stand for.
*/
const DEDICATED_TOOL_SOURCE: Readonly<Record<string, CoachScopeSource>> = {
get_sleep: "sleep",
get_glucose_panel: "glucose",
get_medication_compliance: "compliance",
};
const SCOPE_SOURCES: ReadonlySet<string> = new Set(
coachScopeSourceSchema.options,
);
/** Lower-cased preset → the preset, so "lastyear" still maps to "lastYear". */
const WINDOW_BY_TOKEN: ReadonlyMap<string, CoachScopeWindow> = new Map(
coachScopeWindowSchema.options.map((w) => [w.toLowerCase(), w]),
);
/** "resting heart rate" → "resting_hr", so either spelling maps. */
const SOURCE_BY_DOMAIN_LABEL: ReadonlyMap<string, CoachScopeSource> = new Map(
(
Object.entries(COACH_SOURCE_DOMAIN_LABEL) as Array<
[CoachScopeSource, string]
>
).map(([source, label]) => [label.toLowerCase(), source]),
);
function normaliseToken(raw: string): string {
return raw
.trim()
.replace(/^["'`]+|["'`]+$/g, "")
.trim()
.toLowerCase();
}
function toSource(token: string): CoachScopeSource | null {
const t = normaliseToken(token);
if (SCOPE_SOURCES.has(t)) return t as CoachScopeSource;
return SOURCE_BY_DOMAIN_LABEL.get(t) ?? null;
}
/** The scope sources the record holds, from the inventory's present rows. */
export function presentSources(
inventory: readonly InventoryEntry[],
): Set<CoachScopeSource> {
const out = new Set<CoachScopeSource>();
for (const entry of inventory) {
if (!entry.present) continue;
if (entry.metric && SCOPE_SOURCES.has(entry.metric)) {
out.add(entry.metric as CoachScopeSource);
continue;
}
const dedicated = DEDICATED_TOOL_SOURCE[entry.tool];
if (dedicated) out.add(dedicated);
}
return out;
}
interface RawBlock {
kind: string | null;
choices: string[];
}
/**
* Cut the block out of the prose. Returns the prose without it and the raw
* block, or a drop reason when a block was there but unusable. A reply with
* no marker comes back untouched with `block: null` and no reason.
*/
function extractBlock(prose: string): {
prose: string;
block: RawBlock | null;
dropped: ClarifyDropReason | null;
} {
const open = prose.indexOf(OPEN_SENTINEL);
if (open === -1) return { prose, block: null, dropped: null };
const before = prose.slice(0, open);
const afterOpen = prose.slice(open + OPEN_SENTINEL.length);
const close = afterOpen.indexOf(CLOSE_SENTINEL);
if (close === -1) {
// No closing marker: everything after the opening one is the block.
// Never show the raw marker; drop the whole tail.
return { prose: before.trimEnd(), block: null, dropped: "malformed" };
}
const body = afterOpen.slice(0, close);
const after = afterOpen.slice(close + CLOSE_SENTINEL.length);
const stripped = `${before.trimEnd()}${after.trim() ? `\n\n${after.trim()}` : ""}`;
if (Buffer.byteLength(body, "utf8") > CLARIFY_BYTE_CAP) {
return { prose: stripped, block: null, dropped: "byte_overflow" };
}
let kind: string | null = null;
let choices: string[] = [];
for (const line of body.split("\n")) {
const colon = line.indexOf(":");
if (colon < 1) continue;
const key = normaliseToken(line.slice(0, colon));
const value = line.slice(colon + 1);
if (key === "kind") kind = normaliseToken(value);
else if (key === "choices") {
choices = value
.split(/[,|;]/)
.map((c) => c.trim())
.filter((c) => c.length > 0);
}
}
return { prose: stripped, block: { kind, choices }, dropped: null };
}
function isKind(value: string | null): value is ClarifyKind {
return value === "metric" || value === "window" || value === "context";
}
function metricChoices(
tokens: readonly string[],
present: ReadonlySet<CoachScopeSource>,
locale: Locale,
): CoachClarificationChoice[] {
const { t } = getServerTranslator(locale);
const seen = new Set<CoachScopeSource>();
const choices: CoachClarificationChoice[] = [];
for (const token of tokens) {
const source = toSource(token);
if (!source || seen.has(source) || !present.has(source)) continue;
seen.add(source);
const labelKey = coachDomainLabelKey(source);
choices.push({
id: `c${choices.length + 1}`,
labelKey,
label: t(labelKey),
value: { metric: source },
});
if (choices.length === CLARIFY_MAX_CHOICES) break;
}
return choices;
}
function windowChoices(
tokens: readonly string[],
locale: Locale,
): CoachClarificationChoice[] {
const { t } = getServerTranslator(locale);
const seen = new Set<CoachScopeWindow>();
const choices: CoachClarificationChoice[] = [];
for (const token of tokens) {
const window = WINDOW_BY_TOKEN.get(normaliseToken(token));
if (!window || seen.has(window)) continue;
seen.add(window);
const labelKey = clarifyWindowLabelKey(window);
choices.push({
id: `c${choices.length + 1}`,
labelKey,
label: t(labelKey),
value: { window },
});
if (choices.length === CLARIFY_MAX_CHOICES) break;
}
return choices;
}
function hasQuestionMark(text: string): boolean {
return /[??]/.test(text);
}
export function parseClarifySentinel(args: {
prose: string;
/** What the record holds; null on the no-tools path. */
inventory: InventoryEntry[] | null;
locale: Locale;
}): { prose: string; clarification: CoachClarification | null } {
const { locale, inventory } = args;
const extracted = extractBlock(args.prose);
const prose = extracted.prose;
const drop = (reason: ClarifyDropReason, kind?: string | null) => {
annotate({
action: { name: "coach.clarification.dropped" },
meta: { reason, kind: isKind(kind ?? null) ? kind : "unknown" },
});
return { prose, clarification: null };
};
if (extracted.dropped) return drop(extracted.dropped);
const block = extracted.block;
if (!block) return { prose, clarification: null };
if (!isKind(block.kind)) return drop("malformed", block.kind);
const kind = block.kind;
const question = prose.trim();
if (!question || !hasQuestionMark(question)) return drop("no_question", kind);
if (question.length > QUESTION_CHAR_CAP) {
return drop("question_too_long", kind);
}
// The question is model text shown to the person: screen it like a reply,
// and like a message (an instruction smuggled into a question is refused).
if (
screenCoachReply(question, locale).block ||
detectRefusal({ message: question, locale }).refuse
) {
return drop("screened", kind);
}
let choices: CoachClarificationChoice[] = [];
if (kind === "metric") {
// Without an inventory nothing proves the record holds a metric, so
// there is nothing to offer.
if (!inventory) return drop("no_inventory", kind);
choices = metricChoices(block.choices, presentSources(inventory), locale);
if (choices.length < 2) return drop("too_few_metrics", kind);
} else if (kind === "window") {
choices = windowChoices(block.choices, locale);
if (choices.length < 2) return drop("no_window_choices", kind);
}
annotate({
action: { name: "coach.clarification.offered" },
meta: { kind, choices: choices.length },
});
return {
prose,
clarification: { kind, choices, freeText: true },
};
}
function storedClarification(
metricSourceJson: string | null,
): CoachClarification | null {
if (!metricSourceJson) return null;
try {
const raw = (JSON.parse(metricSourceJson) as { clarification?: unknown })
.clarification;
if (raw === undefined) return null;
const parsed = coachClarificationSchema.safeParse(raw);
return parsed.success ? parsed.data : null;
} catch {
return null;
}
}
/**
* Never two questions in a row: when the previous assistant reply already
* asked one, a new block is dropped and the reply stands as an answer. Runs
* after the person's message is persisted, so the latest assistant row is
* the previous reply. Reads nothing when there is no clarification to check.
*/
export async function dropRepeatClarification(args: {
userId: string;
conversationId: string;
clarification: CoachClarification | null;
/** The turn's shared read of the latest messages, when it has one. */
latest?: LatestMessagesLoader;
}): Promise<CoachClarification | null> {
const { clarification } = args;
if (!clarification) return null;
try {
const rows = await (args.latest?.() ??
readLatestMessages(args.userId, args.conversationId));
const previous = rows.find(
(m) => m.role === "assistant" && m.providerType !== "cancelled",
);
if (previous && storedClarification(previous.metricSourceJson)) {
annotate({
action: { name: "coach.clarification.dropped" },
meta: { reason: "repeat", kind: clarification.kind },
});
return null;
}
return clarification;
} catch {
// Unverifiable: offering no card is the safe side.
return null;
}
}
function clarifiedLine(choice: CoachClarificationChoice): string {
const parts: string[] = [];
if (choice.value.metric) parts.push(`metric=${choice.value.metric}`);
if (choice.value.window) parts.push(`window=${choice.value.window}`);
return `CLARIFIED: the person answered your clarifying question by choosing ${parts.join(" ")}. Answer the original question with exactly this; do not ask again.`;
}
const FREE_TEXT_LINE =
"CLARIFIED: the person answered your clarifying question in their own words (their latest message). Take it as the answer to the original question; do not ask again.";
/**
* The turn-context line for an answered clarification, or null when the
* request carries none or its question is no longer current.
*
* The question must be the conversation's latest message, asked by the
* assistant, with its choices on file. The choice value is read from what
* the server stored, never from the request.
*/
export async function resolveClarificationAnswer(args: {
userId: string;
conversationId: string | undefined;
clarification: { messageId: string; choiceId?: string } | undefined;
/** The turn's shared read of the latest messages, when it has one. */
latest?: LatestMessagesLoader;
}): Promise<string | null> {
const { userId, conversationId, clarification } = args;
if (!clarification || !conversationId) return null;
let rows: LatestMessage[];
try {
rows = await (args.latest?.() ??
readLatestMessages(userId, conversationId));
} catch {
return null;
}
const latest = rows.find((m) => m.providerType !== "cancelled");
const stored =
latest &&
latest.id === clarification.messageId &&
latest.role === "assistant"
? storedClarification(latest.metricSourceJson)
: null;
if (!stored) {
annotate({
action: { name: "coach.clarification.stale" },
meta: { conversationId },
});
return null;
}
const choice = clarification.choiceId
? stored.choices.find((c) => c.id === clarification.choiceId)
: undefined;
annotate({
action: { name: "coach.clarification.answered" },
meta: { kind: stored.kind, via: choice ? "choice" : "text" },
});
return choice ? clarifiedLine(choice) : FREE_TEXT_LINE;
}