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.