feat(coach): a chat that behaves like one

The composer starts as a single line with send beside it and grows only
when the text does; the shift-enter hint and the inline disclaimer are
gone. A typing indicator bubbles before the first token. The context
rail hides by default behind a toggle and stops scrolling sideways,
conversations open in the full view instead of a broken side drawer,
and the coach preferences move into Settings where the rest of the
configuration lives — context toggles and the exclusion list now derive
from one source so they can no longer contradict each other. A floating
bubble appears only while a coach-initiated nudge is unread. Clearly
stated allergies and conditions are remembered immediately through a
deterministic extractor instead of waiting for the twenty-turn
summary pass.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 21:19:05 +02:00
1 parent b55fab5e94
commit 9f2c403f84
19 files changed
+1092 -879

No files matched your search

@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Hermetic codec: round-trip through a UTF-8 buffer, no real crypto keys
// needed. Mirrors the bytes-codec contract (string ↔ Uint8Array).
vi.mock("../bytes-codec", () => ({
encryptToBytes: (s: string) => new TextEncoder().encode(s),
decryptFromBytes: (b: Uint8Array) => new TextDecoder().decode(b),
}));
// Avoid importing the real Prisma client; opts injection supplies the fake.
vi.mock("@/lib/db", () => ({ prisma: {} }));
const annotateMock = vi.fn();
vi.mock("@/lib/logging/context", () => ({
annotate: (...args: unknown[]) => annotateMock(...args),
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
import {
DETERMINISTIC_FACT_CONFIDENCE,
extractDeterministicFacts,
storeDeterministicFacts,
} from "../facts";
function bytes(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
function makeFakePrisma(activeTexts: string[] = []) {
const created: Array<Record<string, unknown>> = [];
return {
created,
db: {
coachFact: {
findMany: vi.fn(async () =>
activeTexts.map((text, i) => ({
id: `fact-${i}`,
factEncrypted: bytes(text),
category: "condition",
confidence: 90,
})),
),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
created.push(data);
return { id: `new-${created.length}` };
}),
},
coachConversation: { findFirst: vi.fn() },
},
};
}
beforeEach(() => {
annotateMock.mockClear();
});
describe("extractDeterministicFacts", () => {
it.each([
["ich habe eine Erdnussallergie", "Erdnuss"],
["Ich hab eine Erdnuss-Allergie, seit Jahren.", "Erdnuss"],
["übrigens: ich bin allergisch gegen Penicillin.", "Penicillin"],
["ich habe eine Allergie gegen Hausstaubmilben", "Hausstaubmilben"],
])("matches the German allergy statement %j", (message, subject) => {
const facts = extractDeterministicFacts(message, "de");
expect(facts).toHaveLength(1);
expect(facts[0].category).toBe("condition");
expect(facts[0].confidence).toBe(DETERMINISTIC_FACT_CONFIDENCE);
expect(facts[0].fact).toContain(subject);
expect(facts[0].fact).toContain("Allergie");
expect(facts[0].fact).toContain("eigene Angabe");
});
it("matches German intolerance statements", () => {
expect(
extractDeterministicFacts("ich habe eine Laktoseunverträglichkeit", "de")[0]
?.fact,
).toContain("Laktose");
expect(
extractDeterministicFacts("ich bin laktoseintolerant", "de")[0]?.fact,
).toContain("laktose");
});
it("matches a German self-reported diagnosis", () => {
const facts = extractDeterministicFacts(
"bei mir wurde Asthma diagnostiziert",
"de",
);
expect(facts).toHaveLength(1);
expect(facts[0].fact).toContain("Asthma");
expect(facts[0].fact).toContain("eigener Angabe");
});
it.each([
["I'm allergic to peanuts", "peanuts"],
["I am allergic to penicillin, sadly.", "penicillin"],
["I have a peanut allergy", "peanut"],
["I have a lactose intolerance", "lactose"],
["I was diagnosed with asthma", "asthma"],
])("matches the English statement %j", (message, subject) => {
const facts = extractDeterministicFacts(message, "en");
expect(facts).toHaveLength(1);
expect(facts[0].fact.toLowerCase()).toContain(subject);
expect(facts[0].fact).toMatch(/self-reported/i);
});
it("returns nothing for unrelated prose or third-party statements", () => {
expect(extractDeterministicFacts("wie war mein Blutdruck?", "de")).toEqual(
[],
);
expect(
extractDeterministicFacts("meine Schwester hat eine Katzenhaarallergie", "de"),
).toEqual([]);
expect(
extractDeterministicFacts("what is an allergy exactly?", "en"),
).toEqual([]);
});
});
describe("storeDeterministicFacts", () => {
it("persists a fresh allergy fact with the always-remember confidence", async () => {
const fake = makeFakePrisma();
const stored = await storeDeterministicFacts({
conversationId: "conv-1",
userId: "user-1",
message: "ich habe eine Erdnussallergie",
locale: "de",
prisma: fake.db as never,
});
expect(stored).toBe(1);
expect(fake.created).toHaveLength(1);
expect(fake.created[0]).toMatchObject({
userId: "user-1",
category: "condition",
confidence: DETERMINISTIC_FACT_CONFIDENCE,
sourceConversationId: "conv-1",
});
expect(annotateMock).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "coach.facts.deterministic_stored" },
}),
);
});
it("dedupes against an existing near-identical fact", async () => {
const fake = makeFakePrisma(["Allergie: Erdnuss (eigene Angabe)"]);
const stored = await storeDeterministicFacts({
conversationId: "conv-2",
userId: "user-1",
message: "ich habe eine Erdnuss-Allergie",
locale: "de",
prisma: fake.db as never,
});
expect(stored).toBe(0);
expect(fake.created).toHaveLength(0);
});
it("is a no-op when the message carries no pattern", async () => {
const fake = makeFakePrisma();
const stored = await storeDeterministicFacts({
conversationId: "conv-3",
userId: "user-1",
message: "Wie sieht mein Schlaf diese Woche aus?",
locale: "de",
prisma: fake.db as never,
});
expect(stored).toBe(0);
expect(fake.db.coachFact.findMany).not.toHaveBeenCalled();
});
});
+186
View File
@@ -379,6 +379,192 @@ export async function extractAndStoreFacts(
return { status: "stored", count: toStore.length };
}
// ---------------------------------------------------------------------------
// Deterministic always-remember extraction
// ---------------------------------------------------------------------------
/**
* v1.16.1 — the LLM extraction above only runs once a conversation grows past
* the history cap (the chat route enqueues the memory refresh at >20 turns),
* so an allergy stated in the second message of a short chat never reached the
* fact store at all. Allergies, intolerances and explicitly self-reported
* diagnoses are the one category the Coach must NEVER drop, so they get a
* deterministic, provider-free path that runs on every user message: a small
* closed set of self-statement patterns ("ich habe eine X-Allergie", "I'm
* allergic to X", …). Matches store as `condition` facts at confidence 95 —
* high enough to displace at the cap and to rank into the injection top-N.
*
* The patterns stay deliberately narrow: they match explicit first-person
* self-statements only, never an inferred or third-party condition, keeping
* the "descriptive, never diagnostic" contract of the LLM prompt.
*/
export const DETERMINISTIC_FACT_CONFIDENCE = 95;
interface DeterministicPattern {
re: RegExp;
kind: "allergy" | "intolerance" | "diagnosis";
}
const DETERMINISTIC_PATTERNS: DeterministicPattern[] = [
// German — allergies.
{
// "ich habe eine Erdnussallergie" / "ich hab 'ne Erdnuss-Allergie".
// The capture must be compounded onto "-allergie" (no space) so the
// article in "eine Allergie gegen X" can never be captured — that
// phrasing belongs to the next pattern.
re: /\bich\s+hab(?:e)?\s+(?:eine?\s+|'?ne\s+)?([a-zäöüß][\wäöüß-]*?)-?allergie\b/i,
kind: "allergy",
},
{
// "ich habe eine Allergie gegen Erdnüsse" / "ich bin allergisch gegen/auf Erdnüsse"
re: /\bich\s+(?:hab(?:e)?\s+(?:eine?\s+)?allergie\s+(?:gegen|auf)|bin\s+allergisch\s+(?:gegen|auf))\s+([a-zäöüß][\wäöüß -]{1,40}?)(?=[.,;!?]|$)/im,
kind: "allergy",
},
// German — intolerances.
{
// "ich habe eine Laktoseunverträglichkeit / Laktose-Intoleranz".
// Compound-only capture, same rationale as the allergy pattern.
re: /\bich\s+hab(?:e)?\s+(?:eine?\s+)?([a-zäöüß][\wäöüß-]*?)-?(?:unverträglichkeit|intoleranz)\b/i,
kind: "intolerance",
},
{
// "ich bin laktoseintolerant"
re: /\bich\s+bin\s+([a-zäöüß][\wäöüß-]*?)[-\s]?intolerant\b/i,
kind: "intolerance",
},
// German — explicit self-reported diagnosis.
{
// "bei mir wurde Asthma diagnostiziert"
re: /\bbei\s+mir\s+wurde\s+([a-zäöüß][\wäöüß -]{1,40}?)\s+diagnostiziert\b/i,
kind: "diagnosis",
},
// English — allergies.
{
// "I'm allergic to peanuts"
re: /\bI(?:'m|\s+am)\s+allergic\s+to\s+([a-z][\w -]{1,40}?)(?=[.,;!?]|$)/im,
kind: "allergy",
},
{
// "I have a peanut allergy"
re: /\bI\s+have\s+an?\s+([a-z][\w-]*)\s+allergy\b/i,
kind: "allergy",
},
// English — intolerances.
{
// "I have a lactose intolerance" / "I'm lactose intolerant"
re: /\bI\s+have\s+an?\s+([a-z][\w-]*)\s+intolerance\b/i,
kind: "intolerance",
},
{
re: /\bI(?:'m|\s+am)\s+([a-z][\w-]*)[-\s]intolerant\b/i,
kind: "intolerance",
},
// English — explicit self-reported diagnosis.
{
re: /\bI\s+(?:was|got)\s+diagnosed\s+with\s+([a-z][\w -]{1,40}?)(?=[.,;!?]|$)/im,
kind: "diagnosis",
},
];
function cleanCapture(raw: string): string | null {
const cleaned = raw.replace(/\s+/g, " ").replace(/[.,;:!?]+$/u, "").trim();
if (cleaned.length < 2 || cleaned.length > 60) return null;
return cleaned;
}
function deterministicFactText(
kind: DeterministicPattern["kind"],
subject: string,
locale: string | undefined,
): string {
const de = locale?.toLowerCase().startsWith("de") ?? true;
switch (kind) {
case "allergy":
return de
? `Allergie: ${subject} (eigene Angabe)`
: `Allergy: ${subject} (self-reported)`;
case "intolerance":
return de
? `Unverträglichkeit: ${subject} (eigene Angabe)`
: `Intolerance: ${subject} (self-reported)`;
case "diagnosis":
return de
? `Diagnose laut eigener Angabe: ${subject}`
: `Self-reported diagnosis: ${subject}`;
}
}
/**
* Pure pattern pass over a single user message. Exported so the unit tests
* pin the patterns without a DB. Returns zero or more `condition` facts.
*/
export function extractDeterministicFacts(
message: string,
locale?: string,
): ParsedFact[] {
const out: ParsedFact[] = [];
for (const { re, kind } of DETERMINISTIC_PATTERNS) {
const match = re.exec(message);
if (!match) continue;
const subject = cleanCapture(match[1] ?? "");
if (!subject) continue;
const fact = deterministicFactText(kind, subject, locale);
if (fact.length > FACT_MAX_CHARS) continue;
out.push({
category: "condition",
fact,
confidence: DETERMINISTIC_FACT_CONFIDENCE,
});
}
return out;
}
/**
* Run the deterministic pass on one user message and persist the survivors
* (deduped against the active set). No provider call — safe to fire on every
* turn from the chat route. Returns the number of facts stored.
*/
export async function storeDeterministicFacts(args: {
conversationId: string;
userId: string;
message: string;
locale?: string;
prisma?: PrismaLike;
}): Promise<number> {
const db = args.prisma ?? prisma;
const candidates = extractDeterministicFacts(args.message, args.locale);
if (candidates.length === 0) return 0;
const active = await loadActiveFacts(db, args.userId);
const seen = active.map((f) => f.text);
let stored = 0;
for (const cand of candidates) {
if (isNearDuplicate(cand.fact, seen)) continue;
seen.push(cand.fact);
// Field-by-field, no spread (mass-assignment rule, CLAUDE.md).
await db.coachFact.create({
data: {
userId: args.userId,
factEncrypted: encryptToBytes(cand.fact),
category: cand.category,
confidence: cand.confidence,
sourceConversationId: args.conversationId,
},
});
stored += 1;
}
if (stored > 0) {
annotate({
action: { name: "coach.facts.deterministic_stored" },
meta: { count: stored, conversationId: args.conversationId },
});
}
return stored;
}
// ---------------------------------------------------------------------------
// Injection block
// ---------------------------------------------------------------------------
+2
View File
@@ -397,6 +397,8 @@ export const queryKeys = {
coachAboutMe: () => ["coach-about-me"] as const,
/** v1.16.0 — pending clarifying questions (Coach composer chips). */
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
/** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */
coachNudgeStatus: () => ["coach-nudge-status"] as const,
/**
* v1.4.41 — admin surfaces. Pre-fix every admin section declared its