Merge memory, plans and voice into release/v1.41.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-06 19:33:17 +02:00
commit 0ba698c54f
52 files changed
+6048 -850

No files matched your search

+149
View File
@@ -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(/<example>([\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(
/(<example>[\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/,
);
});
});
@@ -0,0 +1,158 @@
/**
* v1.41 — what the person told the Coach leaves the machine only when the
* turn may send to its providers.
*
* The memory block (`WHAT YOU KNOW ABOUT THIS PERSON`) carries facts, plans
* and reminders the person wrote; the briefing's plan lines carry plan
* targets. Both are built, not built-then-dropped: the egress or capability
* check runs first and nothing is read on a refusal. Behaviour is pinned in
* `memory/__tests__/context-block.test.ts` and `plan-progress.test.ts`; this
* guard pins the structure those tests rest on:
*
* 1. Inside each builder, the check comes before the first database read.
* 2. The builders are called only from the files that run after the turn's
* own egress check (the Coach turn) or behind the briefing capability (the
* briefing generation), and a Coach-turn caller hands over the chain so
* the wire check runs for exactly those providers.
* 3. No `annotate()` in the memory modules names a text-bearing value.
*
* Every check is also run against a source broken on purpose.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const SRC = join(process.cwd(), "src");
const MEMORY = "lib/ai/coach/memory";
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
function sourceFiles(): string[] {
return walkSourceFiles(SRC, { floor: 3000 })
.filter((p) => !p.startsWith("generated/"))
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"));
}
/** The body of `export async function <name>(`, up to the next top-level `}`. */
function functionBody(source: string, name: string): string {
const start = source.search(
new RegExp(`export\\s+async\\s+function\\s+${name}\\s*\\(`),
);
if (start === -1) return "";
const end = source.indexOf("\n}\n", start);
return source.slice(start, end === -1 ? undefined : end);
}
/** Whether `check` appears before the first database read in `body`. */
function checksBeforeRead(body: string, check: RegExp): boolean {
const checkAt = body.search(check);
const readAt = body.search(
/\bprisma\.|\bload(?:Facts|Plans|Reminders)\(|takePendingProposal\(/,
);
return checkAt !== -1 && readAt !== -1 && checkAt < readAt;
}
describe("memory block egress guard", () => {
const block = functionBody(
read(`${MEMORY}/context-block.ts`),
"buildMemoryContextBlock",
);
const lines = functionBody(
read(`${MEMORY}/plan-progress.ts`),
"buildPlanProgressLines",
);
it("the memory block checks egress before it reads anything", () => {
expect(block.length).toBeGreaterThan(200);
expect(checksBeforeRead(block, /egressAllowed\(/)).toBe(true);
const helper = read(`${MEMORY}/context-block.ts`);
expect(helper).toMatch(/aiEgressRefusal\(\s*"coach"/);
expect(helper).toMatch(/aiCapabilityForRecord\(args\.userId, "coach"\)/);
// Broken on purpose: the check moved below the reads.
const broken = block.replace(/if \(!\(await egressAllowed\(args\)\)\)/, "");
expect(checksBeforeRead(broken, /egressAllowed\(/)).toBe(false);
});
it("the briefing's plan lines check the briefing capability before any read", () => {
expect(lines.length).toBeGreaterThan(200);
expect(
checksBeforeRead(lines, /aiCapabilityForRecord\(userId, "briefing"\)/),
).toBe(true);
const broken = lines.replace(
/aiCapabilityForRecord\(userId, "briefing"\)/,
"",
);
expect(
checksBeforeRead(broken, /aiCapabilityForRecord\(userId, "briefing"\)/),
).toBe(false);
});
/**
* Files that may build the memory block: the Coach turn, after its own
* capability and consent checks (`turn/pipeline.ts` rechecks before it
* runs the model). The contract re-exports it; nothing else calls it.
*/
const BLOCK_CALLERS = new Set([
"lib/ai/coach/turn/model.ts",
"lib/ai/coach/turn/context.ts",
]);
/** Files that may build the briefing's plan lines. */
const LINE_CALLERS = new Set([
"lib/insights/comprehensive-generate.ts",
"lib/insights/briefing-provider.ts",
]);
function callers(name: string): string[] {
const call = new RegExp(`\\b${name}\\(`);
return sourceFiles()
.filter((rel) => !rel.startsWith(`${MEMORY}/`))
.filter((rel) => call.test(read(rel)));
}
it("only the Coach turn builds the memory block, and it names its providers", () => {
const found = callers("buildMemoryContextBlock");
for (const rel of found) {
expect(BLOCK_CALLERS.has(rel), rel).toBe(true);
expect(read(rel), `${rel} hands over the chain`).toMatch(
/buildMemoryContextBlock\(\{[\s\S]{0,400}providerTypes/,
);
}
// The matcher is live: it finds the definition it guards.
const definitions = sourceFiles().filter((rel) =>
/export async function buildMemoryContextBlock\(/.test(read(rel)),
);
expect(definitions).toEqual([`${MEMORY}/context-block.ts`]);
});
it("only the briefing builds the plan lines", () => {
for (const rel of callers("buildPlanProgressLines")) {
expect(LINE_CALLERS.has(rel), rel).toBe(true);
}
});
it("no memory annotation names a text-bearing value", () => {
const TEXT =
/\b(?:fact|text|ifCue|thenAction|target|recalled|note\.fact|userMessage|content)\s*[,:}]/;
const offending: string[] = [];
const files = walkSourceFiles(join(SRC, MEMORY), { floor: 5 }).filter(
(p) => !p.includes("__tests__"),
);
for (const rel of files) {
const source = read(`${MEMORY}/${rel}`);
for (const match of source.matchAll(/annotate\(\{[\s\S]*?\}\);/g)) {
const meta = match[0].split("meta:")[1] ?? "";
if (TEXT.test(meta)) offending.push(`${rel}: ${match[0]}`);
}
}
expect(offending).toEqual([]);
// Broken on purpose: a meta that carries the fact.
const planted = `annotate({ action: { name: "x" }, meta: { fact: call.fact } });`;
expect(TEXT.test(planted.split("meta:")[1])).toBe(true);
});
});
@@ -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": {