mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
Merge memory, plans and voice into release/v1.41.0
This commit is contained in:
commit
0ba698c54f
52 files changed
+6048
-850
No files matched your search
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user