From 912558b96aa8ce6c256ec54463763437b7fcb540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 19:32:32 +0200 Subject: [PATCH] feat(coach): answer first, short and without filler Give the Coach one statement of how an answer is shaped, in the German and English bodies and on every other locale: the first sentence answers, one carrying number with its unit and window, the person's own baseline, an assumption in one line, at most one next step and no habitual closing question, 40 to 120 words, and a closed list of filler it never writes. - Brief is 30 to 70 words in one paragraph, detailed up to 220 in three. - Motivational-interviewing moves only when the person opens a change topic, at most one, never as a closing question; the confidence ruler only on an explicit plan request. - The few-shot examples are rewritten to model the voice. - Result tables: show the numbers whenever they carry the answer and span more than one value, a table for exact values or up to twelve like rows, one figure alone only for a moment. - Follow-up chips are the answer's only "what next". - A voice grader for the eval (length, filler, emoji, next steps, closing question, warm-up opening) plugs into the golden set as criteria; five voice cases in German and English. - A guard holds both prompt bodies to the rules and every example answer to the grader, each check broken once on purpose. --- src/__tests__/coach-voice-guard.test.ts | 149 ++++++++ .../unit-preference-display-guard.test.ts | 2 +- .../golden-transcripts.test.ts.snap | 54 +-- .../ai/coach/__tests__/system-prompt.test.ts | 12 +- .../coach/eval/__tests__/golden-set.test.ts | 1 + .../coach/eval/__tests__/grade-voice.test.ts | 125 +++++++ src/lib/ai/coach/eval/golden-cases.ts | 120 +++++- src/lib/ai/coach/eval/grade-voice.ts | 203 ++++++++++ src/lib/ai/coach/system-prompt.ts | 351 +++++++++++------- src/lib/ai/coach/turn/addenda/followups.ts | 8 +- src/lib/ai/coach/turn/addenda/method.ts | 3 +- src/lib/ai/coach/turn/addenda/results.ts | 14 +- 12 files changed, 870 insertions(+), 172 deletions(-) create mode 100644 src/__tests__/coach-voice-guard.test.ts create mode 100644 src/lib/ai/coach/eval/__tests__/grade-voice.test.ts create mode 100644 src/lib/ai/coach/eval/grade-voice.ts diff --git a/src/__tests__/coach-voice-guard.test.ts b/src/__tests__/coach-voice-guard.test.ts new file mode 100644 index 000000000..2e2475681 --- /dev/null +++ b/src/__tests__/coach-voice-guard.test.ts @@ -0,0 +1,149 @@ +/** + * v1.41 — the Coach's voice, held in the prompt. + * + * The voice section (`buildCoachVoiceBlock`) is the one statement of how a + * Coach answer is shaped: the answer first, one carrying number, the + * assumption in one line, at most one next step, 40-120 words, and a closed + * list of filler it never writes. A prompt rewrite that drops a rule, or an + * example that models the opposite (a closing question, two next steps, a + * filler), would quietly teach the model the old habits back. This guard + * pins both bodies to the rules, and every few-shot example to the grader + * that scores live answers. + * + * Each check is a function run once on the real prompt and once on a prompt + * broken on purpose, so the guard is shown to fail. + */ +import { describe, expect, it } from "vitest"; + +import { + COACH_ANSWER_WORDS, + COACH_FILLER_PHRASES, + buildCoachVoiceBlock, + getCoachSystemPrompt, +} from "@/lib/ai/coach/system-prompt"; +import { gradeVoice } from "@/lib/ai/coach/eval/grade-voice"; +import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs"; +import type { Locale } from "@/lib/i18n/config"; + +const LOCALES: Locale[] = ["de", "en", "fr", "es", "it", "pl", "ko"]; + +/** The rules a voice section must state; `[]` when it states them all. */ +function missingVoiceRules(prompt: string, lang: "de" | "en"): string[] { + const missing: string[] = []; + const band = COACH_ANSWER_WORDS.standard; + const header = + lang === "de" ? "TON UND ANTWORTAUFBAU" : "VOICE AND ANSWER SHAPE"; + if (!prompt.includes(header)) missing.push("header"); + if (!prompt.includes(`${band.min}-${band.max}`)) missing.push("length band"); + const rules = + lang === "de" + ? [ + /erste Satz beantwortet die Frage/, + /Eine tragende Zahl/, + /Angenommen: letzte/, + /höchstens EINEM konkreten nächsten Schritt/, + /Nie eine Frage am Ende/, + /keine Emojis/, + ] + : [ + /first sentence answers the question/, + /One carrying number/, + /Assumed: last 30 days/, + /at most ONE concrete next step/, + /Never a question at the end/, + /no emojis/, + ]; + for (const rule of rules) if (!rule.test(prompt)) missing.push(rule.source); + for (const phrase of COACH_FILLER_PHRASES[lang]) { + if (!prompt.includes(`"${phrase}"`)) missing.push(`filler ${phrase}`); + } + return missing; +} + +/** The COACH lines of the few-shot examples. */ +function exampleAnswers(prompt: string): string[] { + const answers: string[] = []; + for (const match of prompt.matchAll(/([\s\S]*?)<\/example>/g)) { + const coach = match[1].split(/^COACH:\s*/m)[1]; + if (coach) answers.push(coach.trim()); + } + return answers; +} + +/** Examples that break the voice; `[]` when every one keeps it. */ +function examplesOffVoice(prompt: string): string[] { + return exampleAnswers(prompt).filter((answer) => !gradeVoice(answer).passed); +} + +describe("coach voice guard", () => { + it.each(["en", "de"] as const)( + "the %s body states every voice rule and filler", + (lang) => { + const prompt = getCoachSystemPrompt(lang); + expect(missingVoiceRules(prompt, lang)).toEqual([]); + // Broken on purpose: one filler dropped from the list. + const broken = prompt.replaceAll( + `"${COACH_FILLER_PHRASES[lang][0]}"`, + "", + ); + expect(missingVoiceRules(broken, lang)).not.toEqual([]); + }, + ); + + it.each(LOCALES)("the %s prompt carries the voice section", (locale) => { + expect(getCoachSystemPrompt(locale)).toContain( + buildCoachVoiceBlock(locale), + ); + }); + + it.each(["en", "de"] as const)( + "every %s example answer keeps the voice", + (lang) => { + const prompt = getCoachSystemPrompt(lang); + expect(exampleAnswers(prompt).length).toBeGreaterThanOrEqual(5); + expect(examplesOffVoice(prompt)).toEqual([]); + // Broken on purpose: an example that ends on a habitual question. + const broken = prompt.replace( + /([\s\S]*?COACH:[\s\S]*?)(\n(?:---KEYVALUES---|<\/example>))/, + "$1 How does that sound to you?$2", + ); + expect(examplesOffVoice(broken)).not.toEqual([]); + }, + ); + + it("keeps no length rule from before the voice section", () => { + for (const lang of ["en", "de"] as const) { + const prompt = getCoachSystemPrompt(lang, { + ...DEFAULT_COACH_PREFS, + verbosity: "detailed", + }); + expect(prompt).not.toMatch(/60-180/); + expect(prompt).not.toMatch(/180-250/); + expect(prompt).not.toMatch(/~90/); + } + }); + + it("states the brief and detailed lengths from the same numbers", () => { + const brief = getCoachSystemPrompt("en", { + ...DEFAULT_COACH_PREFS, + verbosity: "brief", + }); + expect(brief).toContain( + `${COACH_ANSWER_WORDS.brief.min}-${COACH_ANSWER_WORDS.brief.max} words`, + ); + const detailed = getCoachSystemPrompt("de", { + ...DEFAULT_COACH_PREFS, + verbosity: "detailed", + }); + expect(detailed).toContain(`bis ${COACH_ANSWER_WORDS.detailed.max} Wörter`); + }); + + it("asks a confidence question only on an explicit plan request", () => { + expect(getCoachSystemPrompt("en")).toMatch( + /Confidence ruler, only on an explicit plan request/, + ); + expect(getCoachSystemPrompt("de")).toMatch( + /Konfidenz-Skala, nur bei ausdrücklicher Plan-Bitte/, + ); + }); +}); diff --git a/src/__tests__/unit-preference-display-guard.test.ts b/src/__tests__/unit-preference-display-guard.test.ts index 296434789..4f6362757 100644 --- a/src/__tests__/unit-preference-display-guard.test.ts +++ b/src/__tests__/unit-preference-display-guard.test.ts @@ -390,7 +390,7 @@ const UNIT_LITERAL_ALLOWLIST: Record< "the reply checker's list of unit tokens it recognises in model prose", }, "lib/ai/coach/eval/golden-cases.ts": { - lines: 3, + lines: 5, reason: "evaluation fixtures: scripted replies and ideal answers", }, "lib/ai/coach/eval/red-team.ts": { diff --git a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap index 66e4f6e98..7cc17d3ac 100644 --- a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap +++ b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap @@ -50,7 +50,7 @@ exports[`coach chat golden transcripts > 01a tool mode, two rounds, present data "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -251,7 +251,7 @@ exports[`coach chat golden transcripts > 01b tool mode, present data, one figure "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -464,7 +464,7 @@ exports[`coach chat golden transcripts > 02 tool mode, every tool missed, fabric "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -1162,7 +1162,7 @@ exports[`coach chat golden transcripts > 07a all providers failed (plain) 1`] = "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -1251,7 +1251,7 @@ exports[`coach chat golden transcripts > 07b all providers failed (every hop rat "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -1340,7 +1340,7 @@ exports[`coach chat golden transcripts > 07c all providers failed (primary crede "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -1429,7 +1429,7 @@ exports[`coach chat golden transcripts > 08 an unwrapped tagged provider error 1 "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -1519,7 +1519,7 @@ exports[`coach chat golden transcripts > 09 an aborted request leaves a cancelle "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -1615,7 +1615,7 @@ exports[`coach chat golden transcripts > 10 outbound block replaces the reply an "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -1946,7 +1946,7 @@ exports[`coach chat golden transcripts > 11a KEYVALUES valid 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -2151,7 +2151,7 @@ exports[`coach chat golden transcripts > 11b KEYVALUES partial 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -2354,7 +2354,7 @@ exports[`coach chat golden transcripts > 11c KEYVALUES malformed (no closing mar "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -2541,7 +2541,7 @@ exports[`coach chat golden transcripts > 11d sentinel-only reply is an empty rep "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -2630,7 +2630,7 @@ exports[`coach chat golden transcripts > 11e empty provider reply 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -2710,7 +2710,7 @@ exports[`coach chat golden transcripts > 12a SUGGEST-REMINDER surfaced 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -2935,7 +2935,7 @@ exports[`coach chat golden transcripts > 12b SUGGEST-REMINDER suppressed 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -3124,7 +3124,7 @@ exports[`coach chat golden transcripts > 13 SUGGEST-ACTION 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -3353,7 +3353,7 @@ exports[`coach chat golden transcripts > 14a REMEMBER valid 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -3562,7 +3562,7 @@ exports[`coach chat golden transcripts > 14b REMEMBER malformed 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -3766,7 +3766,7 @@ exports[`coach chat golden transcripts > 15 an unknown learn link is scrubbed, a "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -4298,7 +4298,7 @@ exports[`coach chat golden transcripts > 17a a workout-scoped first turn 1`] = ` "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -4508,7 +4508,7 @@ exports[`coach chat golden transcripts > 17b a workout-scoped first turn with th "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -4697,7 +4697,7 @@ exports[`coach chat golden transcripts > 18 a persistence throw after the provid "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -4790,7 +4790,7 @@ exports[`coach chat golden transcripts > 19 an existing conversation with prior "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -5003,7 +5003,7 @@ exports[`coach chat golden transcripts > 20 a guided-question turn with a scope "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 30000, "tools": 2, @@ -5172,7 +5172,7 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self "window": "last30days", }, "signal": true, - "system": "c61eecc94d49:3280", + "system": "d2d3a63acd33:4228", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, @@ -5377,7 +5377,7 @@ exports[`coach chat golden transcripts > 22 tool mode with live steps: found, em "window": "last30days", }, "signal": true, - "system": "4a4d00f5a344:3187", + "system": "9fdc684a515e:4135", "temperature": 0.4, "timeoutMs": 180000, "tools": 2, diff --git a/src/lib/ai/coach/__tests__/system-prompt.test.ts b/src/lib/ai/coach/__tests__/system-prompt.test.ts index d40440bc0..3194f0465 100644 --- a/src/lib/ai/coach/__tests__/system-prompt.test.ts +++ b/src/lib/ai/coach/__tests__/system-prompt.test.ts @@ -87,7 +87,7 @@ describe("getCoachSystemPrompt — EN", () => { expect(exampleOpens).toBe(exampleCloses); // At least one example demonstrates the missing-data invitation // pivot (rule 3) and one demonstrates the off-topic redirect (rule 6). - expect(prompt).toMatch(/I don't see exercise in what you're tracking/); + expect(prompt).toMatch(/exercise isn't something\s+you're tracking/); expect(prompt).toMatch(/outside what I can help with/i); }); @@ -209,7 +209,7 @@ describe("getCoachSystemPrompt — DE", () => { const exampleCloses = (prompt.match(/<\/example>/g) ?? []).length; expect(exampleOpens).toBeGreaterThanOrEqual(3); expect(exampleOpens).toBe(exampleCloses); - expect(prompt).toMatch(/Bewegung sehe ich in deinem Tracking gerade nicht/); + expect(prompt).toMatch(/Bewegung nicht erfasst/); expect(prompt).toMatch(/außerhalb dessen, womit ich helfen kann/); }); @@ -270,8 +270,9 @@ describe("getCoachSystemPrompt — H4 prefs prefix", () => { // Concise tone overrides verbosity — even if the user picked // "detailed", the prefix asks for the brief cap. expect(out).toMatch(/TONE OVERRIDE: be concise/); - expect(out).toMatch(/VERBOSITY OVERRIDE: cap replies at ~90 words/); - expect(out).not.toMatch(/180-250 words is acceptable/); + // v1.41 — brief is 30-70 words in one paragraph. + expect(out).toMatch(/VERBOSITY OVERRIDE: 30-70 words, one paragraph/); + expect(out).not.toMatch(/up to 220 words/); }); it("prepends a VERBOSITY OVERRIDE for verbosity=detailed at warm tone", () => { @@ -279,7 +280,8 @@ describe("getCoachSystemPrompt — H4 prefs prefix", () => { ...DEFAULT_COACH_PREFS, verbosity: "detailed", }); - expect(out).toMatch(/VERBOSITY OVERRIDE: 180-250 words is acceptable/); + // v1.41 — detailed is up to 220 words in up to three paragraphs. + expect(out).toMatch(/VERBOSITY OVERRIDE: up to 220 words/); }); it("prepends a German prefix in the de locale", () => { diff --git a/src/lib/ai/coach/eval/__tests__/golden-set.test.ts b/src/lib/ai/coach/eval/__tests__/golden-set.test.ts index 00268d7bb..3c4431410 100644 --- a/src/lib/ai/coach/eval/__tests__/golden-set.test.ts +++ b/src/lib/ai/coach/eval/__tests__/golden-set.test.ts @@ -29,6 +29,7 @@ describe("Coach golden set — deterministic floor", () => { "dataHonesty", "providerParity", "ownBaseline", + "voice", ]; for (const bucket of buckets) { expect(coverage[bucket], `taxonomy ${bucket}`).toBeGreaterThan(0); diff --git a/src/lib/ai/coach/eval/__tests__/grade-voice.test.ts b/src/lib/ai/coach/eval/__tests__/grade-voice.test.ts new file mode 100644 index 000000000..abeed714f --- /dev/null +++ b/src/lib/ai/coach/eval/__tests__/grade-voice.test.ts @@ -0,0 +1,125 @@ +/** + * v1.41 — the voice grader. Every rule is broken once on purpose: a grader + * that cannot fail is worse than none. + */ +import { describe, expect, it } from "vitest"; + +import { GOLDEN_CASES } from "@/lib/ai/coach/eval/golden-cases"; +import { gradeCase } from "@/lib/ai/coach/eval/grade-groundedness"; + +import { countWords, findFillers, gradeVoice } from "../grade-voice"; + +const GOOD = + "Your blood pressure has run a little above your usual for three weeks, 131/84 mmHg on average against 126/81 before.\nAssumed: last 30 days.\nOne thing to try this week: lights out 30 minutes earlier on three evenings."; + +describe("gradeVoice", () => { + it("passes the reference answer", () => { + const grade = gradeVoice(GOOD); + expect(grade.passed).toBe(true); + expect(grade.assumptionLines).toBe(1); + expect(grade.nextSteps).toBe(1); + }); + + it("ignores the evidence block", () => { + const withBlock = `${GOOD}\n---KEYVALUES---\navg30 systolic: 131 [mmHg] (last30days)\n---END---`; + expect(gradeVoice(withBlock).words).toBe(gradeVoice(GOOD).words); + }); + + it("fails a filler phrase, in either language", () => { + expect(gradeVoice(`${GOOD} I hope this helps.`).fillers).toEqual([ + "i hope this helps", + "hope this helps", + ]); + expect(findFillers("Insgesamt sieht es gut aus.")).toEqual(["insgesamt"]); + expect(gradeVoice("Gute Frage! Dein Puls liegt bei 61.").passed).toBe( + false, + ); + }); + + it("fails a warm-up opening", () => { + const grade = gradeVoice( + "Let's take a look at your sleep. You averaged 6 h 40 min this week.", + ); + expect(grade.warmUpOpening).toBe(true); + expect(grade.passed).toBe(false); + }); + + it("fails a habitual closing question", () => { + const grade = gradeVoice( + "You slept 6 h 40 min on average this week, a little under your usual. How does that match how you felt?", + ); + expect(grade.closingQuestion).toBe(true); + expect(grade.passed).toBe(false); + }); + + it("fails two next steps", () => { + const grade = gradeVoice( + "Your resting pulse is up 4 bpm this week. Try an earlier night. You could also consider a lighter workout tomorrow.", + ); + expect(grade.nextSteps).toBe(2); + expect(grade.passed).toBe(false); + }); + + it("fails an answer over the length band", () => { + const long = Array.from({ length: 130 }, () => "word").join(" ") + "."; + expect(gradeVoice(long).withinLength).toBe(false); + expect(gradeVoice(long, "detailed").withinLength).toBe(true); + const brief = Array.from({ length: 80 }, () => "word").join(" ") + "."; + expect(gradeVoice(brief, "brief").withinLength).toBe(false); + }); + + it("fails an emoji", () => { + expect(gradeVoice("Your weight is steady at 78 kg. 💪").emoji).toBe(true); + }); + + it("counts words, not numbers glued to units", () => { + expect(countWords("131/84 mmHg over 30 days")).toBe(5); + }); +}); + +describe("voice golden cases", () => { + const voiceCases = GOLDEN_CASES.filter((c) => c.taxonomy === "voice"); + + it("cover German and English, the brief length and memory", () => { + expect(voiceCases.map((c) => c.id)).toEqual( + expect.arrayContaining([ + "voice-de-bp-reference", + "voice-en-bp-reference", + "voice-en-brief", + "voice-de-one-step", + "voice-en-memory-once", + ]), + ); + }); + + it("fail a reply with filler, a closing question and a menu of steps", () => { + const testCase = voiceCases.find((c) => c.id === "voice-en-bp-reference")!; + const grade = gradeCase(testCase, { + id: testCase.id, + prose: + "Great question! Overall your blood pressure is fine. Try sleeping more. You could also walk daily. Let me know how it goes?", + toolPayloads: [testCase.snapshotSections], + }); + expect(grade.passed).toBe(false); + const failed = grade.criteria.filter((c) => !c.passed).map((c) => c.label); + expect(failed).toEqual( + expect.arrayContaining([ + "uses no filler phrase from the banned list", + "offers at most one next step", + "does not end on a habitual question", + "the first sentence answers the question, without a warm-up", + ]), + ); + }); + + it("fail a confidence ruler on a plain action question", () => { + const testCase = voiceCases.find((c) => c.id === "voice-de-one-step")!; + const grade = gradeCase(testCase, { + id: testCase.id, + prose: + "Diese Woche hast du im Mittel 6 h 12 min geschlafen. Leg das Handy um 22 Uhr weg. Wie sicher bist du dir auf einer Skala von 0 bis 10.", + toolPayloads: [testCase.snapshotSections], + }); + expect(grade.passed).toBe(false); + }); +}); diff --git a/src/lib/ai/coach/eval/golden-cases.ts b/src/lib/ai/coach/eval/golden-cases.ts index 7160f304a..2d2d3facc 100644 --- a/src/lib/ai/coach/eval/golden-cases.ts +++ b/src/lib/ai/coach/eval/golden-cases.ts @@ -20,6 +20,10 @@ * both surface the driver / honour the same floor. * - ownBaseline — the answer is framed against the USER's own range, * never a population norm. + * - voice — v1.41: the answer opens on the answer, stays inside + * the length band, carries no filler, at most one next + * step and no habitual closing question + * (`grade-voice.ts`). * * The criteria here are graded by the deterministic graders in this directory * against a SCRIPTED prose string (the case's `idealResponse` on the @@ -34,7 +38,8 @@ export type CoachEvalTaxonomy = | "crossMetric" | "dataHonesty" | "providerParity" - | "ownBaseline"; + | "ownBaseline" + | "voice"; /** * A single behaviour-anchored criterion. Binary + weighted: the grader returns @@ -88,6 +93,7 @@ export interface CoachEvalCase { criteria: CoachEvalCriterion[]; } +import { voiceCriteria } from "./grade-voice"; import { findUnverifiedCoachNumbers, hasOwnBaselineFraming, @@ -1637,6 +1643,117 @@ export const GOLDEN_CASES: readonly CoachEvalCase[] = [ }, ], }, + + /* ── voice (v1.41) ─────────────────────────────────────────────────────── */ + { + // The reference answer of the voice section, German, default length: + // the answer first, one carrying number with its window, the driver, the + // assumption in one line, one next step, no closing question. + id: "voice-de-bp-reference", + taxonomy: "voice", + snapshotSections: { + bloodPressure: { + avgSys30: 131, + avgDia30: 84, + avgSysPrior: 126, + avgDiaPrior: 81, + }, + }, + userMessage: "Wie sieht mein Blutdruck aus?", + idealResponse: + "Dein Blutdruck liegt seit drei Wochen etwas höher als üblich, im Mittel 131/84 mmHg gegenüber 126/81 davor. Der Anstieg fällt mit kürzeren Nächten zusammen; an Tagen unter sechs Stunden Schlaf lag er im Schnitt 4 mmHg höher.\nAngenommen: letzte 30 Tage.\nEin Versuch für diese Woche: an drei Abenden das Licht 30 Minuten früher aus.", + criteria: [ + ...voiceCriteria(), + { + kind: "mustInclude", + weight: 2, + matcher: /^angenommen:/im, + label: "states the assumed window in one line", + }, + { + kind: "mustInclude", + weight: 2, + matcher: /131\/84/, + label: "carries one number with its unit and window", + }, + ], + }, + { + id: "voice-en-bp-reference", + taxonomy: "voice", + snapshotSections: { + bloodPressure: { + avgSys30: 131, + avgDia30: 84, + avgSysPrior: 126, + avgDiaPrior: 81, + }, + }, + userMessage: "How is my blood pressure looking?", + idealResponse: + "Your blood pressure has run a little above your usual for three weeks, 131/84 mmHg on average against 126/81 before. The rise lines up with shorter nights; on days under six hours of sleep it averaged 4 mmHg higher.\nAssumed: last 30 days.\nOne thing to try this week: lights out 30 minutes earlier on three evenings.", + criteria: [ + ...voiceCriteria(), + { + kind: "mustInclude", + weight: 2, + matcher: /^assumed:/im, + label: "states the assumed window in one line", + }, + ], + }, + { + // Brief length: one paragraph, at most 70 words, still the answer first. + id: "voice-en-brief", + taxonomy: "voice", + snapshotSections: { weight: { avg7: 78.4, avg30: 79.1 } }, + userMessage: "Quick one: is my weight moving?", + idealResponse: + "Slowly down: 78.4 kg on average this week, against 79.1 kg over the last 30 days. That is the pace you have held since spring.", + criteria: voiceCriteria({ length: "brief" }), + }, + { + // A "what should I do" turn gets ONE concrete step, not a menu and not + // a confidence question back. + id: "voice-de-one-step", + taxonomy: "voice", + snapshotSections: { sleep: { avg7Minutes: 372, avg30Minutes: 401 } }, + userMessage: "Was soll ich gegen meinen kurzen Schlaf tun?", + idealResponse: + "Diese Woche hast du im Mittel 6 h 12 min geschlafen, rund eine halbe Stunde weniger als im Monat davor. Der kleinste Hebel ist der Abend: Leg das Handy um 22 Uhr außer Reichweite und schau in einer Woche, ob die Nächte länger werden.", + criteria: [ + ...voiceCriteria(), + { + kind: "mustAvoid", + weight: 2, + matcher: /0\s*(?:–|-|bis)\s*10/i, + label: "no confidence ruler on a plain action question", + }, + ], + }, + { + // Memory used visibly, once, and only because it changes the answer. + id: "voice-en-memory-once", + taxonomy: "voice", + snapshotSections: { weight: { avg7: 78.4 } }, + userMessage: "Am I on track?", + idealResponse: + "Yes, roughly: 78.4 kg this week, and as you told me, the goal is 75 kg by December, which the current pace reaches in late November.", + criteria: [ + ...voiceCriteria(), + { + kind: "mustAvoid", + weight: 2, + matcher: (prose) => + ( + prose.match( + /as you (?:told|said to) me|wie du mir gesagt hast/gi, + ) ?? [] + ).length > 1, + label: "recalls what the person said at most once", + }, + ], + }, ]; /** Count of cases per taxonomy bucket — handy for the report + a coverage test. */ @@ -1647,6 +1764,7 @@ export function taxonomyCoverage(): Record { dataHonesty: 0, providerParity: 0, ownBaseline: 0, + voice: 0, }; for (const c of GOLDEN_CASES) out[c.taxonomy] += 1; return out; diff --git a/src/lib/ai/coach/eval/grade-voice.ts b/src/lib/ai/coach/eval/grade-voice.ts new file mode 100644 index 000000000..6556cd9ef --- /dev/null +++ b/src/lib/ai/coach/eval/grade-voice.ts @@ -0,0 +1,203 @@ +/** + * v1.41 — the Coach voice grader. + * + * Grades an answer against the voice section of the Coach prompt + * (`buildCoachVoiceBlock` in `system-prompt.ts`): the length for the + * person's setting, no filler from `COACH_FILLER_PHRASES`, no emoji, at most + * one next step, no habitual closing question, an assumption in one line, + * and a first sentence that answers rather than warms up. + * + * Deterministic and offline, so the golden set runs it on every change + * against the reference answers, and the nightly live eval runs it against + * real generations (`voiceCriteria` plugs into a golden case). Whether the + * first sentence truly answers the question is a judgement; the + * deterministic check here only catches the forms that never do (a + * restated question, praise for the question, a preamble), and the live + * judge weighs the rest under the same criterion label. + * + * Pure. + */ +import { + COACH_ANSWER_WORDS, + COACH_FILLER_PHRASES, +} from "@/lib/ai/coach/system-prompt"; + +import type { CoachEvalCriterion } from "./golden-cases"; + +export type VoiceLength = keyof typeof COACH_ANSWER_WORDS; + +export interface VoiceGrade { + words: number; + withinLength: boolean; + paragraphs: number; + /** Filler phrases found, lower case. */ + fillers: string[]; + emoji: boolean; + /** Sentences that read as a suggested next step. */ + nextSteps: number; + /** The prose ends on a question. */ + closingQuestion: boolean; + /** Lines that state an assumption ("Assumed: …"). */ + assumptionLines: number; + /** The first sentence warms up instead of answering. */ + warmUpOpening: boolean; + passed: boolean; +} + +const EMOJI = /\p{Extended_Pictographic}/u; + +/** Evidence and sentinel blocks are not prose. */ +const SENTINEL_BLOCK = /---[A-Z-]+---[\s\S]*?---END---/g; + +/** Openings that never answer: praise, restating, preamble. */ +const WARM_UP_OPENING: readonly RegExp[] = [ + /^(?:great|good|excellent|interesting|nice)\s+question\b/i, + /^(?:gute|tolle|spannende|interessante)\s+frage\b/i, + /^(?:thanks|thank you)\s+for\s+asking\b/i, + /^danke\s+(?:für\s+die|dass\s+du)\b/i, + /^(?:let'?s|let me)\s+(?:take a look|look|dive|break)/i, + /^(?:lass|lass uns)\s+(?:mich\s+)?(?:mal\s+)?(?:schauen|ansehen|anschauen)/i, + /^you(?:'re| are)\s+asking\b/i, + /^du\s+fragst\b/i, + /^(?:sure|of course|absolutely|certainly)[,!.]/i, + /^(?:klar|natürlich|gerne|sicher)[,!.]/i, +]; + +/** Sentences that suggest something to do. */ +const NEXT_STEP: readonly RegExp[] = [ + /\b(?:one thing to try|try|consider|you could|you might|worth trying|a (?:small )?(?:step|experiment)|next step|how about)\b/i, + /\b(?:ein versuch|versuch(?:e|s)?|probier\w*|du könntest|erwäge|ein (?:kleiner )?schritt|nächster schritt|wie wäre es)\b/i, +]; + +const ASSUMPTION_LINE = /^(?:assumed|angenommen)\s*:/i; + +function prose(answer: string): string { + return answer.replace(SENTINEL_BLOCK, "").trim(); +} + +function sentences(text: string): string[] { + return text + .split(/(?<=[.!?])\s+|\n+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} + +export function countWords(text: string): number { + return text.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w)).length; +} + +/** Filler phrases from the banned list that appear in the prose. */ +export function findFillers(text: string): string[] { + const lower = text.toLowerCase(); + const all = [...COACH_FILLER_PHRASES.en, ...COACH_FILLER_PHRASES.de]; + return all.filter((phrase) => { + const escaped = phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(?:^|[^\\p{L}])${escaped}(?:$|[^\\p{L}])`, "u").test( + lower, + ); + }); +} + +/** Grade one answer for the person's length setting. */ +export function gradeVoice( + answer: string, + length: VoiceLength = "standard", +): VoiceGrade { + const text = prose(answer); + const words = countWords(text); + const band = COACH_ANSWER_WORDS[length]; + const paragraphs = text + .split(/\n\s*\n/) + .filter((p) => p.trim() && !ASSUMPTION_LINE.test(p.trim())).length; + const lines = text.split("\n").map((l) => l.trim()); + const assumptionLines = lines.filter((l) => ASSUMPTION_LINE.test(l)).length; + const body = lines.filter((l) => !ASSUMPTION_LINE.test(l)).join("\n"); + const all = sentences(body); + const first = all[0] ?? ""; + const last = all[all.length - 1] ?? ""; + const nextSteps = all.filter((s) => + NEXT_STEP.some((re) => re.test(s)), + ).length; + const fillers = findFillers(text); + const emoji = EMOJI.test(text); + const closingQuestion = /\?\s*$/.test(last); + const warmUpOpening = WARM_UP_OPENING.some((re) => re.test(first)); + const proseParagraphs = Math.max(1, paragraphs); + // The ceiling is the floor that matters: an answer may be shorter than the + // band (a redirect, a one-line state), never longer or more spread out. + const withinLength = words <= band.max && proseParagraphs <= band.paragraphs; + const passed = + withinLength && + fillers.length === 0 && + !emoji && + nextSteps <= 1 && + !closingQuestion && + assumptionLines <= 1 && + !warmUpOpening; + return { + words, + withinLength, + paragraphs, + fillers, + emoji, + nextSteps, + closingQuestion, + assumptionLines, + warmUpOpening, + passed, + }; +} + +/** + * The voice floor as golden-case criteria, one per rule, so a voice case is + * graded by the same dispatcher as every other case and its labels reach the + * live judge. `closingQuestionAllowed` is for a case whose right answer is a + * clarifying question. + */ +export function voiceCriteria( + opts: { length?: VoiceLength; closingQuestionAllowed?: boolean } = {}, +): CoachEvalCriterion[] { + const length = opts.length ?? "standard"; + const band = COACH_ANSWER_WORDS[length]; + const criteria: CoachEvalCriterion[] = [ + { + kind: "mustInclude", + weight: 2, + matcher: (p) => gradeVoice(p, length).withinLength, + label: `stays within ${band.max} words for the ${length} length`, + }, + { + kind: "mustAvoid", + weight: 2, + matcher: (p) => findFillers(prose(p)).length > 0, + label: "uses no filler phrase from the banned list", + }, + { + kind: "mustAvoid", + weight: 1, + matcher: (p) => EMOJI.test(p), + label: "uses no emoji", + }, + { + kind: "mustAvoid", + weight: 2, + matcher: (p) => gradeVoice(p, length).nextSteps > 1, + label: "offers at most one next step", + }, + { + kind: "mustAvoid", + weight: 2, + matcher: (p) => gradeVoice(p, length).warmUpOpening, + label: "the first sentence answers the question, without a warm-up", + }, + ]; + if (!opts.closingQuestionAllowed) { + criteria.push({ + kind: "mustAvoid", + weight: 2, + matcher: (p) => gradeVoice(p, length).closingQuestion, + label: "does not end on a habitual question", + }); + } + return criteria; +} diff --git a/src/lib/ai/coach/system-prompt.ts b/src/lib/ai/coach/system-prompt.ts index e1c64693c..264db9b17 100644 --- a/src/lib/ai/coach/system-prompt.ts +++ b/src/lib/ai/coach/system-prompt.ts @@ -53,8 +53,8 @@ GROUND RULES the data, in short paragraphs separated by blank lines. A "- " list is allowed when the user asks for a checklist or when you genuinely enumerate three or more parallel items — never as the default shape. - No JSON, no markdown fences, no inline number-dumps. Keep replies - focused — usually 60-180 words, sometimes shorter. + No JSON, no markdown fences, no inline number-dumps. Length and + shape follow VOICE AND ANSWER SHAPE below. 2. Values belong in the evidence block. If a specific number is load-bearing, cite it once in prose ("your last 30 days sit a few @@ -80,18 +80,16 @@ GROUND RULES sustained stretch — close with "this is one to walk through with your doctor" or equivalent. Never invent a threshold. -5. Motivational-interviewing micro-moves. Reach for one of these per - turn when the user opens a topic: an open question that surfaces - what they already think, a brief earned affirmation of something - they're genuinely doing well, a reflection of what you heard, a - one-line summary at the end of a longer turn, gently surfacing the - gap between where the user is and a goal they named (developing - discrepancy), or — when the user pushes back — rolling with the - resistance instead of arguing. Pick what fits — don't stack them, - and keep every affirmation anchored to a real figure or a real - change. Any change you suggest must serve the user's OWN stated - goal, never persuasion for its own sake; if you don't know their - goal, ask before nudging. +5. Motivational-interviewing micro-moves. Only when the person opens a + change topic themselves (a habit they want to change, a goal, a + setback), use at most ONE: a brief earned affirmation of something + they're genuinely doing well, a reflection of what you heard, gently + surfacing the gap between where they are and a goal they named + (developing discrepancy), or — when they push back — rolling with the + resistance instead of arguing. Never as a closing question, never on + a plain data question. Keep every affirmation anchored to a real + figure or a real change. Any change you suggest must serve the + person's OWN stated goal, never persuasion for its own sake. 6. Redirect off-topic input gracefully. If the user asks about politics, news, code, weather, or anything outside their health @@ -229,13 +227,12 @@ GROUND RULES a small experiment, never a cause, and only when the underlying figure is actually present. If you have no correlation to lean on, say what you observe across the series plainly and invite the user's read. -15. Confidence ruler on action turns. When the user asks "what should I - do" and there is a real, doable step, don't just prescribe it. Once - you have named one small step, ask how confident they feel about it - on a 0–10 scale; if that lands low, offer to shrink the step to - something they're more sure of rather than pushing the original. This - keeps the choice theirs. Use it only on genuine action turns — skip - it for a quick factual lookup or a reassurance reply. +15. Confidence ruler, only on an explicit plan request. When the person + explicitly asks you for a plan ("make me a plan", "how do I get + there?") and there is a real, doable step, you may ask once how + confident they feel about it on a 0–10 scale, and offer to shrink the + step if that lands low. Never on any other turn: a "what should I do" + gets one concrete step, not a question back. 16. Three-beat shape on data-review turns. When you're talking through a finding, a clean shape is: the finding, then the likely driver, then one small next step — don't stop at the finding. When you cite a @@ -399,19 +396,15 @@ ISO-week means. not continuous CGM — TIR/CV from spot checks are rough proxies, so caveat them as spot-based and never present them as a CGM-grade AGP. These are DESCRIPTIVE panel numbers, not a clinical diagnosis. -- The SNAPSHOT's "memory" block MAY carry a "facts" list — durable - things you have learned about this user across conversations (stable - preferences, conditions they have told you about, goals, constraints, - life context). Use them to personalise your reply and to avoid - re-asking what you already know. Treat each as the user's OWN stated - context, DESCRIPTIVE not diagnostic — never restate a "condition" fact - as a medical finding, and never invent a fact the block does not - carry. If a fact seems outdated, gently check it rather than assume. -- The "memory" block MAY also carry a "plans" list — the user's CONFIRMED - goal / if-then plans ({ metric, ifCue, thenAction, target? }). These are - commitments the user agreed to, not prescriptions you invented. Recall - them to check in on progress and reinforce the plan; never restate one as - a medical instruction, and never invent a plan the block does not carry. +- The user prompt MAY open with a "WHAT YOU KNOW ABOUT THIS PERSON" block: + durable facts the person told you (preferences, goals, life context, + and health facts they confirmed), their ACTIVE if-then plans each with a + server-computed progress line, and reminders they asked for. Treat each + entry as the person's OWN stated context, DESCRIPTIVE not diagnostic — + never restate a health fact as a medical finding, never restate a plan + as a medical instruction, and never invent a fact, a plan or a progress + figure the block does not carry. Quote progress only as the block gives + it. If a fact seems outdated, gently check it rather than assume. - The SNAPSHOT MAY carry an "illness" block: { restMode, active[], recentResolved[] }. When "restMode" is true the user has one or more ACTIVE conditions right now (each with a label, type, lifecycle, and @@ -517,19 +510,18 @@ EXAMPLES — TONE CALIBRATION USER: Do you think I should exercise more? -COACH: I don't see exercise in what you're tracking, so I can't read -that straight off your data. Can you tell me what a typical week -looks like for you — walks, gym, anything more structured? Then we -can think it through together with the bits I do see. +COACH: I can't tell from your data yet, because exercise isn't something +you're tracking. If you tell me what a typical week looks like, walks, +gym or anything more structured, I can weigh it against your resting +pulse and sleep, which I do see. USER: How's my blood pressure looking? -COACH: The last week sits a touch higher than your usual run — you've -been hovering near the upper end of where you've been the last three -months. Nothing to alarm. If it stays up another week, that's worth -a quick word with your doctor. Anything different about this week — -sleep, work, travel? +COACH: A touch higher than usual this week: 138 mmHg systolic on average +over the last 7 days, near the top of where you've sat for three months. +Nothing alarming on its own. If it stays up another week, it's one to +walk through with your doctor. ---KEYVALUES--- avg7 systolic: 138 [mmHg] (last7days) avg30 systolic: 134 [mmHg] (last30days) @@ -539,9 +531,8 @@ avg90 systolic: 132 [mmHg] (last90days) USER: How am I doing this month? -COACH: Compliance has been steady this month — that consistency is -doing a lot of quiet work in the background. Mood's leaned brighter -too. How does that match how the month has felt to you? +COACH: Steady, and the steadiness is doing quiet work: you took 96% of +your doses this month, and your mood leaned brighter alongside it. ---KEYVALUES--- 30-day adherence: 96 [%] (last30days) avg30 mood: 4.1 [/5] (last30days) @@ -550,11 +541,9 @@ avg30 mood: 4.1 [/5] (last30days) USER: Your last week is up a bit — should I worry? -COACH: A single uptick in one week isn't usually a signal on its own; -your three-month run still sits where it's been. If next week's -readings stay up too, that's the kind of pattern worth a brief -conversation with your doctor. Has anything shifted lately — sleep, -stress, a missed dose? +COACH: Not on the strength of one week: 138 mmHg systolic over the last 7 +days, while your three-month run still sits at 132. If next week stays +up as well, that pattern is worth a brief word with your doctor. ---KEYVALUES--- avg7 systolic: 138 [mmHg] (last7days) avg90 systolic: 132 [mmHg] (last90days) @@ -563,13 +552,11 @@ avg90 systolic: 132 [mmHg] (last90days) USER: Why has my recovery been low this week? -COACH: Your readiness has been sitting in the yellow band, and the -piece pulling it down most is your HRV balance. That lines up with -your sleep — on the nights you slept short this week, your next-morning -readings tended to run lower. It reads like an association worth a -small experiment rather than anything to worry about. If you wanted to -test it, an earlier night or two is the smallest lever — on a scale of -0 to 10, how doable does that feel this week? +COACH: Mostly your HRV balance: it is pulling your readiness down to 58, +in the yellow band. It lines up with your sleep, which averaged 6h 10m +this week; after the short nights your next-morning readings ran lower. +That reads like an association worth testing, not a worry. One thing to +try: two earlier nights this week. ---KEYVALUES--- readiness: 58 (last7days) avg7 sleep: 6h 10m (last7days) @@ -578,8 +565,8 @@ avg7 sleep: 6h 10m (last7days) USER: What's the weather going to be like tomorrow? -COACH: That's outside what I can help with — want to take a look at -how the last week's been going instead? +COACH: That's outside what I can help with. Your own health data is +where I'm useful. LANGUAGE @@ -611,8 +598,8 @@ GRUNDREGELN Leerzeilen. Eine "- "-Liste ist erlaubt, wenn der Nutzer nach einer Checkliste fragt oder du wirklich drei oder mehr parallele Punkte aufzählst — nie als Standardform. Kein JSON, keine Markdown-Fences, - keine Zahlen-Aufzählungen im Fließtext. Halte Antworten fokussiert — - meist 60-180 Wörter, manchmal kürzer. + keine Zahlen-Aufzählungen im Fließtext. Länge und Aufbau folgen + TON UND ANTWORTAUFBAU weiter unten. 2. Werte gehören in den Evidenz-Block. Wenn eine konkrete Zahl die Antwort trägt, nenne sie einmal im Fließtext ("die letzten 30 @@ -642,20 +629,18 @@ GRUNDREGELN deinem Arzt durchgehen solltest" oder einer Entsprechung. Erfinde keinen Schwellwert. -5. Mikro-Moves aus dem Motivational Interviewing. Greif pro Turn - nach einem davon, wenn der Nutzer ein Thema öffnet: eine offene - Frage, die zeigt, was er selbst schon denkt; eine kurze, verdiente - Anerkennung von etwas, das er wirklich gut macht; eine Reflexion - dessen, was du gehört hast; eine einsätzige Zusammenfassung am - Ende einer längeren Antwort; das behutsame Sichtbarmachen der Lücke - zwischen dem, wo der Nutzer steht, und einem von ihm genannten Ziel - (Diskrepanz entwickeln); oder — wenn der Nutzer Widerstand zeigt — - den Widerstand annehmen, statt zu argumentieren. Wähl was passt — - stapel sie nicht, und verankere jede Anerkennung an einer echten +5. Mikro-Moves aus dem Motivational Interviewing. Nur wenn die Person + selbst ein Veränderungsthema öffnet (eine Gewohnheit, die sie ändern + will, ein Ziel, ein Rückschlag), nutze höchstens EINEN: eine kurze, + verdiente Anerkennung von etwas, das sie wirklich gut macht; eine + Reflexion dessen, was du gehört hast; das behutsame Sichtbarmachen + der Lücke zwischen dem, wo sie steht, und einem von ihr genannten + Ziel (Diskrepanz entwickeln); oder — bei Widerstand — den Widerstand + annehmen, statt zu argumentieren. Nie als Abschlussfrage, nie bei + einer reinen Datenfrage. Verankere jede Anerkennung an einer echten Zahl oder Veränderung. Jede vorgeschlagene Veränderung muss dem - EIGENEN genannten Ziel des Nutzers dienen, nie der Überzeugung um - ihrer selbst willen; kennst du sein Ziel nicht, frag nach, bevor du - anstößt. + EIGENEN genannten Ziel der Person dienen, nie der Überzeugung um + ihrer selbst willen. 6. Off-topic-Eingaben elegant umlenken. Fragt der Nutzer nach Politik, Nachrichten, Code, Wetter oder etwas außerhalb seiner @@ -810,14 +795,13 @@ GRUNDREGELN keinen Zusammenhang, auf den du dich stützen kannst, benenne klar, was du über die Reihe hinweg beobachtest, und lade die Einschätzung des Nutzers ein. -15. Konfidenz-Skala bei Handlungs-Turns. Fragt der Nutzer "Was soll ich - tun?" und es gibt einen echten, machbaren Schritt, verschreib ihn - nicht einfach. Hast du einen kleinen Schritt benannt, frag, wie - sicher er sich auf einer Skala von 0–10 dabei fühlt; fällt das - niedrig aus, biete an, den Schritt auf etwas zu verkleinern, bei dem - er sich sicherer ist, statt am ursprünglichen festzuhalten. So bleibt - die Wahl bei ihm. Nutze das nur bei echten Handlungs-Turns — bei - einer kurzen Faktenfrage oder einer beruhigenden Antwort lass es weg. +15. Konfidenz-Skala, nur bei ausdrücklicher Plan-Bitte. Bittet die + Person ausdrücklich um einen Plan ("mach mir einen Plan", "wie komme + ich dahin?") und es gibt einen echten, machbaren Schritt, darfst du + einmal fragen, wie sicher sie sich auf einer Skala von 0–10 dabei + fühlt, und anbieten, den Schritt zu verkleinern, wenn das niedrig + ausfällt. Bei jedem anderen Turn nie: ein "Was soll ich tun?" bekommt + einen konkreten Schritt, keine Rückfrage. 16. Drei-Schritt-Form bei Daten-Turns. Wenn du einen Befund durchgehst, ist eine saubere Form: der Befund, dann der wahrscheinliche Treiber, dann ein kleiner nächster Schritt — bleib nicht beim Befund stehen. @@ -928,23 +912,17 @@ ISO-Wochenmittel zusammen. ist die Einheit, in der der Nutzer seine Werte liest; jede Zahl im Block steht schon in ihr. Nenne die Zahlen in dieser Einheit, rechne sie nie um und nenne nie eine andere Einheit für dieselbe Größe. -- Der "memory"-Block des SNAPSHOT kann eine "facts"-Liste tragen — - dauerhafte Dinge, die du über diesen Nutzer gelernt hast (stabile - Vorlieben, vom Nutzer selbst genannte gesundheitliche Umstände, - Ziele, Einschränkungen, Lebenskontext). Nutze sie, um deine Antwort - zu personalisieren und nicht erneut zu fragen, was du schon weißt. - Behandle jeden Eintrag als die EIGENE Aussage des Nutzers, - BESCHREIBEND, nicht diagnostisch — formuliere einen "condition"-Fakt - nie als medizinischen Befund um und erfinde nie einen Fakt, den der - Block nicht enthält. Wirkt ein Fakt veraltet, frage behutsam nach, - statt es anzunehmen. -- Der "memory"-Block KANN außerdem eine "plans"-Liste tragen — die vom - Nutzer BESTÄTIGTEN Ziel- / Wenn-dann-Pläne ({ metric, ifCue, thenAction, - target? }). Das sind Selbstverpflichtungen, denen der Nutzer zugestimmt - hat, keine von dir erdachten Verordnungen. Greife sie auf, um den - Fortschritt zu prüfen und den Plan zu bestärken; formuliere keinen davon - als medizinische Anweisung um und erfinde keinen Plan, den der Block - nicht enthält. +- Der User-Prompt KANN mit einem Block "WHAT YOU KNOW ABOUT THIS PERSON" + beginnen: dauerhafte Fakten, die die Person dir genannt hat (Vorlieben, + Ziele, Lebenskontext und von ihr bestätigte Gesundheitsangaben), ihre + AKTIVEN Wenn-dann-Pläne mit je einer vom Server berechneten + Fortschrittszeile, und Erinnerungen, um die sie gebeten hat. Behandle + jeden Eintrag als die EIGENE Aussage der Person, BESCHREIBEND, nicht + diagnostisch — formuliere eine Gesundheitsangabe nie als Befund und einen + Plan nie als medizinische Anweisung um, und erfinde keinen Fakt, keinen + Plan und keine Fortschrittszahl, die der Block nicht enthält. Nenne + Fortschritt nur so, wie der Block ihn angibt. Wirkt ein Fakt veraltet, + frage behutsam nach, statt es anzunehmen. - Der SNAPSHOT KANN einen "illness"-Block tragen: { restMode, active[], recentResolved[] }. Ist "restMode" true, hat der Nutzer gerade eine oder mehrere AKTIVE Erkrankungen (je mit Label, Typ, Lebenszyklus, Beginn, @@ -1054,19 +1032,19 @@ BEISPIELE — TONFALL USER: Meinst du, ich sollte mehr Sport machen? -COACH: Bewegung sehe ich in deinem Tracking gerade nicht — magst du -mir kurz erzählen, wie eine typische Woche bei dir aussieht? -Spaziergänge, Sport, irgendwas Strukturierteres? Dann schauen wir -gemeinsam mit dem, was ich sehe, ob mehr Sport gerade Sinn ergibt. +COACH: Aus deinen Daten kann ich das noch nicht ablesen, weil du +Bewegung nicht erfasst. Wenn du mir erzählst, wie eine typische Woche +aussieht, Spaziergänge, Fitnessstudio oder etwas Strukturierteres, lege +ich das neben deinen Ruhepuls und deinen Schlaf, die ich sehe. USER: Wie sieht mein Blutdruck aus? -COACH: Die letzte Woche liegt einen Tick über deinem üblichen -Schnitt — du bewegst dich am oberen Rand dessen, wo du die letzten -drei Monate warst. Nichts, das alarmiert. Wenn es noch eine Woche -oben bleibt, wäre das was, das du kurz mit deinem Arzt besprechen -kannst. War diese Woche irgendwas anders — Schlaf, Arbeit, Reise? +COACH: Diese Woche einen Tick höher als üblich: im Mittel 138 mmHg +systolisch über die letzten 7 Tage, am oberen Rand dessen, wo du die +letzten drei Monate lagst. Für sich genommen nichts, das alarmiert. +Bleibt er noch eine Woche oben, ist das etwas für ein kurzes Gespräch +mit deinem Arzt. ---KEYVALUES--- avg7 systolisch: 138 [mmHg] (last7days) avg30 systolisch: 134 [mmHg] (last30days) @@ -1076,9 +1054,8 @@ avg90 systolisch: 132 [mmHg] (last90days) USER: Wie war der Monat insgesamt? -COACH: Die Compliance war diesen Monat stabil — diese Konstanz -arbeitet leise im Hintergrund für dich. Auch deine Stimmung tendierte -heller. Wie deckt sich das mit deinem eigenen Eindruck vom Monat? +COACH: Stabil, und diese Konstanz arbeitet leise für dich: Du hast +diesen Monat 96 % deiner Dosen genommen, und deine Stimmung war heller. ---KEYVALUES--- 30-Tage-Adhärenz: 96 [%] (last30days) avg30 Stimmung: 4,1 [/5] (last30days) @@ -1087,11 +1064,10 @@ avg30 Stimmung: 4,1 [/5] (last30days) USER: Letzte Woche ist ein bisschen hoch — sollte ich mir Sorgen machen? -COACH: Eine einzelne erhöhte Woche ist für sich selten ein Signal; -dein Drei-Monats-Schnitt liegt weiterhin im üblichen Bereich. Wenn -die Werte nächste Woche oben bleiben, ist das die Art Muster, die -einen kurzen Austausch mit deinem Arzt wert ist. Hat sich etwas -verändert — Schlaf, Stress, eine ausgelassene Dosis? +COACH: Wegen einer Woche nicht: 138 mmHg systolisch über die letzten 7 +Tage, während dein Drei-Monats-Schnitt weiter bei 132 liegt. Bleibt auch +die nächste Woche oben, ist das Muster einen kurzen Austausch mit deinem +Arzt wert. ---KEYVALUES--- avg7 systolisch: 138 [mmHg] (last7days) avg90 systolisch: 132 [mmHg] (last90days) @@ -1100,14 +1076,11 @@ avg90 systolisch: 132 [mmHg] (last90days) USER: Warum war meine Erholung diese Woche niedrig? -COACH: Deine Readiness liegt im gelben Band, und was sie am stärksten -drückt, ist deine HRV-Balance. Das passt zu deinem Schlaf — an den -Nächten, in denen du diese Woche kurz geschlafen hast, lasen deine -Werte am nächsten Morgen tendenziell niedriger. Das wirkt eher wie ein -Zusammenhang, der einen kleinen Versuch wert ist, als etwas zum Sorgen. -Wenn du es testen wolltest, wären ein, zwei frühere Nächte der kleinste -Hebel — auf einer Skala von 0 bis 10, wie machbar fühlt sich das diese -Woche an? +COACH: Vor allem wegen deiner HRV-Balance: Sie drückt deine Readiness auf +58, ins gelbe Band. Das passt zu deinem Schlaf, im Mittel 6 h 10 min diese +Woche; nach den kurzen Nächten lagen deine Werte am nächsten Morgen +niedriger. Das ist eher ein Zusammenhang zum Ausprobieren als ein Grund +zur Sorge. Ein Versuch: zwei frühere Nächte diese Woche. ---KEYVALUES--- readiness: 58 (last7days) avg7 Schlaf: 6h 10m (last7days) @@ -1116,9 +1089,8 @@ avg7 Schlaf: 6h 10m (last7days) USER: Wie wird das Wetter morgen? -COACH: Das ist außerhalb dessen, womit ich helfen kann — magst du -stattdessen einen Blick darauf werfen, wie die letzte Woche bei dir -gelaufen ist? +COACH: Das ist außerhalb dessen, womit ich helfen kann. Bei deinen +eigenen Gesundheitsdaten bin ich dir nützlich. SPRACHE @@ -1126,6 +1098,115 @@ SPRACHE Antworte auf Deutsch, sofern der Nutzer auf Deutsch schreibt; bei englischen Nachrichten antworte auf Englisch.`; +/** + * v1.41 — how long a Coach answer is, by the person's length setting: + * `brief` 30-70 words in one paragraph, the default 40-120 in one or two, + * `detailed` up to 220 in up to three. The prompt and the voice grader + * (`eval/grade-voice.ts`) read the same numbers. + */ +export const COACH_ANSWER_WORDS = { + brief: { min: 30, max: 70, paragraphs: 1 }, + standard: { min: 40, max: 120, paragraphs: 2 }, + detailed: { min: 40, max: 220, paragraphs: 3 }, +} as const; + +/** + * v1.41 — phrases a Coach answer never contains: warm-up, sign-off and + * filler that carry nothing. The voice section names every one, the voice + * guard holds both bodies to the list, and the grader counts them in live + * answers. Lower case; matched case-insensitively. + */ +export const COACH_FILLER_PHRASES = { + en: [ + "great question", + "good question", + "glad you asked", + "i hope this helps", + "hope this helps", + "let me know", + "don't hesitate", + "feel free to", + "overall", + "it's important to note", + "it is important to note", + "keep up the great work", + ], + de: [ + "gute frage", + "tolle frage", + "toll, dass", + "ich hoffe, das hilft", + "lass mich wissen", + "zögere nicht", + "insgesamt", + "es ist wichtig zu beachten", + "mach weiter so", + ], +} as const; + +function quoted(phrases: readonly string[]): string { + return phrases.map((p) => `"${p}"`).join(", "); +} + +const VOICE_EN = `VOICE AND ANSWER SHAPE + +Calm, competent and warm, the way a good performance coach talks: every +sentence earns its place, nothing is there to sound friendly. +1. The first sentence answers the question. No warm-up, no restating the + question, no praise for asking it. +2. One carrying number, with its unit and window ("128/82 mmHg on average + over 30 days"). Further figures go into the evidence block or a table, + not into the prose. +3. Place it in one sentence against the person's own baseline, not + against population norms unless they ask for those. +4. When you assumed something (a window, a metric, what to compare + against), say so in one short line ("Assumed: last 30 days."). Only + when you did. +5. Close with at most ONE concrete next step that fits the person's own + goal, or with none. Never a question at the end out of habit, never an + offer to help further, never a second round of encouragement. +Length: ${COACH_ANSWER_WORDS.standard.min}-${COACH_ANSWER_WORDS.standard.max} words in one or two short paragraphs, unless a length +override above says otherwise. Numbers that carry the answer and span more +than one value deserve a chart or a table (see RESULT TABLES when offered). +Never write: ${quoted(COACH_FILLER_PHRASES.en)}, and no emojis. +What you know about the person: use an entry only when it changes the +answer, at most once ("as you told me").`; + +const VOICE_DE = `TON UND ANTWORTAUFBAU + +Ruhig, kompetent und warm, so wie ein guter Leistungscoach spricht: Jeder +Satz verdient seinen Platz, nichts steht da, nur um freundlich zu klingen. +1. Der erste Satz beantwortet die Frage. Kein Aufwärmen, keine + Wiederholung der Frage, kein Lob dafür, dass sie gestellt wurde. +2. Eine tragende Zahl, mit Einheit und Fenster ("im Mittel 128/82 mmHg + über 30 Tage"). Weitere Zahlen gehören in den Evidenz-Block oder eine + Tabelle, nicht in den Fließtext. +3. Ordne sie in einem Satz gegen die eigene Basis der Person ein, nicht + gegen Normtabellen, außer sie fragt danach. +4. Hast du etwas angenommen (ein Fenster, eine Metrik, einen + Vergleichszeitraum), sag es in einer kurzen Zeile ("Angenommen: letzte + 30 Tage."). Nur dann. +5. Schließe mit höchstens EINEM konkreten nächsten Schritt, der zum + eigenen Ziel der Person passt, oder mit keinem. Nie eine Frage am Ende + aus Gewohnheit, nie ein Angebot weiterer Hilfe, nie eine zweite Runde + Ermutigung. +Länge: ${COACH_ANSWER_WORDS.standard.min}-${COACH_ANSWER_WORDS.standard.max} Wörter in einem oder zwei kurzen Absätzen, sofern oben +keine Längenvorgabe etwas anderes sagt. Zahlen, die die Antwort tragen und +mehr als einen Wert umfassen, verdienen ein Diagramm oder eine Tabelle +(siehe RESULT TABLES, wenn angeboten). +Schreib nie: ${quoted(COACH_FILLER_PHRASES.de)}, ${quoted(COACH_FILLER_PHRASES.en)}, und keine Emojis. +Was du über die Person weißt: Nutze einen Eintrag nur, wenn er die Antwort +ändert, höchstens einmal ("wie du mir gesagt hast").`; + +/** + * v1.41 — the voice section every Coach prompt carries: German for a German + * reader, English (internal instructions) for every other locale, the native + * bodies included. + */ +export function buildCoachVoiceBlock(locale: Locale): string { + return instructionLocale(locale) === "de" ? VOICE_DE : VOICE_EN; +} + /** * v1.4.25 W14c — native locale-specific Coach system prompts. * @@ -1195,6 +1276,8 @@ export function getCoachSystemPrompt( base = COACH_PROMPT_EN + LOCALE_REPLY_FOOTER_FALLBACK[locale]; } } + // v1.41 — the voice and answer shape, on every locale's body. + base = `${base}\n\n${buildCoachVoiceBlock(locale)}`; // v1.18.7 (HIGH-2) — append the shared cross-surface contracts (canonical // wording, single source of truth in `shared-contracts.ts`) for the // hand-composed de/en bodies. The native FR/ES/IT/PL bodies carry their own @@ -1408,8 +1491,8 @@ function buildPrefsPrefix(locale: Locale, prefs: CoachPrefs): string { } else if (prefs.tone === "concise") { parts.push( isEn - ? "TONE OVERRIDE: be concise. Drop the motivational-interviewing micro-moves; one short observation + one short follow-up is enough." - : "TONFALL-OVERRIDE: Sei knapp. Lass die Motivational-Interviewing-Mikro-Moves weg; eine kurze Beobachtung plus eine kurze Anschlussfrage genügt.", + ? "TONE OVERRIDE: be concise. Drop the motivational-interviewing micro-moves; one short observation and, if it helps, one next step is enough." + : "TONFALL-OVERRIDE: Sei knapp. Lass die Motivational-Interviewing-Mikro-Moves weg; eine kurze Beobachtung und, wenn es hilft, ein nächster Schritt genügen.", ); } @@ -1419,14 +1502,14 @@ function buildPrefsPrefix(locale: Locale, prefs: CoachPrefs): string { if (effectiveVerbosity === "brief") { parts.push( isEn - ? "VERBOSITY OVERRIDE: cap replies at ~90 words. One paragraph; no opening recap of the user's question." - : "AUSFÜHRLICHKEITS-OVERRIDE: Antworten auf ~90 Wörter begrenzen. Ein Absatz; keine einleitende Wiederholung der Userfrage.", + ? `VERBOSITY OVERRIDE: ${COACH_ANSWER_WORDS.brief.min}-${COACH_ANSWER_WORDS.brief.max} words, one paragraph; no opening recap of the user's question.` + : `AUSFÜHRLICHKEITS-OVERRIDE: ${COACH_ANSWER_WORDS.brief.min}-${COACH_ANSWER_WORDS.brief.max} Wörter, ein Absatz; keine einleitende Wiederholung der Frage.`, ); } else if (effectiveVerbosity === "detailed") { parts.push( isEn - ? "VERBOSITY OVERRIDE: 180-250 words is acceptable. The user wants the long-form context; carry every cited number through to a thoughtful close." - : "AUSFÜHRLICHKEITS-OVERRIDE: 180-250 Wörter sind in Ordnung. Der Nutzer möchte den ausführlichen Kontext; trage jede genannte Zahl bis zu einem sorgfältigen Schluss.", + ? `VERBOSITY OVERRIDE: up to ${COACH_ANSWER_WORDS.detailed.max} words in up to three paragraphs. The person wants the fuller context; the first sentence still answers, and the close still holds at most one next step.` + : `AUSFÜHRLICHKEITS-OVERRIDE: bis ${COACH_ANSWER_WORDS.detailed.max} Wörter in bis zu drei Absätzen. Die Person möchte den ausführlicheren Kontext; der erste Satz antwortet trotzdem, und der Schluss hat höchstens einen nächsten Schritt.`, ); } diff --git a/src/lib/ai/coach/turn/addenda/followups.ts b/src/lib/ai/coach/turn/addenda/followups.ts index fd21c85e0..c709f003e 100644 --- a/src/lib/ai/coach/turn/addenda/followups.ts +++ b/src/lib/ai/coach/turn/addenda/followups.ts @@ -2,7 +2,10 @@ * Prompt rules for proposing follow-up chips from the catalog. * * The server derives the chips from what the turn read; a proposal only - * says which of them the model thinks matter most. The model names a kind + * says which of them the model thinks matter most. + * + * v1.41 — the chips are the answer's only "what next?" surface, so the + * prose does not end on a question of its own. The model names a kind * and a domain, never a label: the server renders every word the person * sees, and drops a proposal for a domain the turn did not read. * @@ -19,7 +22,8 @@ as_chart: bp ---END--- - One line each, at most three: a kind, a colon, a metric or domain you read with a tool on THIS turn and that returned data. - Kinds: as_chart, as_table, previous_period, year_ago, widen_window, related_metric. -- Never write a label or a question: the server words them. Leave the block out when nothing fits, and never mention it in your prose.`; +- Never write a label or a question: the server words them. Leave the block out when nothing fits, and never mention it in your prose. +- These suggestions are the answer's only "what next?": do not end your prose with a question that offers more, and do not list options to explore.`; export function followUpsAddendum(_locale: Locale): string { return FOLLOW_UPS_ADDENDUM; diff --git a/src/lib/ai/coach/turn/addenda/method.ts b/src/lib/ai/coach/turn/addenda/method.ts index 8ddac0848..78c500bff 100644 --- a/src/lib/ai/coach/turn/addenda/method.ts +++ b/src/lib/ai/coach/turn/addenda/method.ts @@ -11,7 +11,8 @@ import type { Locale } from "@/lib/i18n/config"; const METHOD_ADDENDUM = `RECHECKING A FIGURE -If the person questions a figure ("is that right?", "that seems high"): name the source and the window you used, fetch it again with the same tool and arguments (or call show_result for a table this conversation already holds), and compare. If the figures differ, correct your answer and say what changed; if they match, say so plainly. Never defend a figure you have not just re-read, and never adjust one to fit what the person expected.`; +If the person questions a figure ("is that right?", "that seems high"): name the source and the window you used, fetch it again with the same tool and arguments (or call show_result for a table this conversation already holds), and compare. If the figures differ, correct your answer and say what changed; if they match, say so plainly. Never defend a figure you have not just re-read, and never adjust one to fit what the person expected. +When the first answer rested on an assumption (a window, a metric, a comparison) and the person now names a different one, answer for theirs and drop the assumption line.`; export function methodAddendum(_locale: Locale): string { return METHOD_ADDENDUM; diff --git a/src/lib/ai/coach/turn/addenda/results.ts b/src/lib/ai/coach/turn/addenda/results.ts index ff7e0282a..4398dc620 100644 --- a/src/lib/ai/coach/turn/addenda/results.ts +++ b/src/lib/ai/coach/turn/addenda/results.ts @@ -3,13 +3,25 @@ * tables an answer relies on, and when a stored table answers without a * new fetch. * + * v1.41 — and when an answer deserves one at all. A chart or a table is how a + * Coach answer carries more than one number, so the rule is no longer "when + * the person asks for a table" but "whenever the numbers carry the answer": + * a trend, a spread, categories, two periods, two metrics. A single figure + * stands alone only when the question is about a moment. The server still + * picks the chart for a table's shape; the model decides that a table is + * read and shown. + * * English whatever the reply language, like the rest of the tool-mode * prompt: these are instructions to the model, never shown to the person. */ import type { Locale } from "@/lib/i18n/config"; const RESULTS_ADDENDUM = `RESULT TABLES -- For a table, a range of days, weeks or months, or a comparison with the period before or a year earlier, call get_metric_table (period "previous" or "yearAgo" for the comparison). The person sees the full table under your answer; you get its summary. +- Show the numbers whenever they carry the answer and more than one value is meant: a trend over time (shown as a line once there are about 8 points or more), how values spread (a distribution), counts by category, two periods side by side, or how two metrics move together. Fetch the table for it; the person sees it as a chart or a table under your answer. +- A table rather than a chart when the person wants exact values ("show me the values"), when there are up to 12 rows of like values, or when they ask for a table. Chart and table are two views of the same result; show_result's view picks one. +- One figure without a chart only when the question is about a moment ("how was my blood pressure this morning?"). +- With a chart or table under the answer, the prose names the direction and one number; never write "see the chart". +- For a range of days, weeks or months, or a comparison with the period before or a year earlier, call get_metric_table (period "previous" or "yearAgo" for the comparison). When compare_series is offered, use it to compare two periods or two metrics in one chart instead of fetching two tables. The person sees the full table under your answer; you get its summary. - A result that carries a resultRef (r1, r2, …) is a table the person can see. Mark the tables your answer relies on by writing result:r1 once, in the sentence that uses it. Do not restate every row; point at the table. - A result's rows cover what its summary lists. Cite only figures the summary shows; for anything else, refer the person to the table. - When the person wants a table from an earlier answer again, or as a chart or a table, call show_result with its EARLIER TABLES name (m.r) and do not fetch it again.