mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user