feat(coach): conversation-summary + durable-fact compute modules

Add the two background generators behind Coach long-term memory, both
reusing the budget-aware provider chain (runStatusCompletion) with a
local fallback and encrypting their output at rest:

- conversation-summary: folds the turns elided past the history window
  into a rolling natural-language summary, merging into any prior
  summary; short-circuits cheaply until a conversation grows long.
- facts: extracts durable, descriptive personal facts (preferences,
  stated conditions, goals, constraints, context), gated by a Zod
  schema + de-dup + per-user cap; buildCoachFactsBlock ranks the top
  facts for snapshot injection. Never diagnostic, never numeric.

Both are pure/injectable for unit testing and annotate counts only,
never content. Wiring into the queue, route, and snapshot follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 10:16:26 +02:00
1 parent 54539a801e
commit 5591858f16
5 files changed
+1261

No files matched your search

@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StatusProviderResult } from "@/lib/insights/status-provider";
// Mock the crypto codec so the unit test needs no encryption key in env.
// `decryptFromBytes` echoes the bytes back as a marker string so the merge
// path can be asserted; `encryptToBytes` is a spy that returns a sentinel.
vi.mock("../bytes-codec", () => ({
encryptToBytes: vi.fn((plaintext: string) => ({
__enc: plaintext,
})),
decryptFromBytes: vi.fn((buf: { __plain?: string }) => buf.__plain ?? ""),
}));
import { encryptToBytes } from "../bytes-codec";
import {
buildSummaryUserPrompt,
refreshConversationSummary,
SUMMARY_REFRESH_TURN_DELTA,
} from "../conversation-summary";
const TURN_CAP = 20;
const RECENT_HISTORY = 18;
/** A persisted message row as the module's select shape returns it. */
function msg(role: "user" | "assistant", content: string) {
return { role, encryptedContent: { __plain: content } };
}
/** Build a conversation row of `n` turns alternating user/assistant. */
function makeConversation(
n: number,
overrides: {
summaryEncrypted?: unknown;
summaryTurnCount?: number;
} = {},
) {
const messages = Array.from({ length: n }, (_, i) =>
msg(i % 2 === 0 ? "user" : "assistant", `turn-${i}`),
);
return {
id: "conv-1",
summaryEncrypted: overrides.summaryEncrypted ?? null,
summaryTurnCount: overrides.summaryTurnCount ?? 0,
messages,
};
}
function makePrisma(conversation: unknown) {
const update = vi.fn().mockResolvedValue({});
const findFirst = vi.fn().mockResolvedValue(conversation);
return {
update,
findFirst,
client: {
coachConversation: { findFirst, update },
} as never,
};
}
const okCompletion: StatusProviderResult = {
kind: "ok",
content: "The user is training for a 10k and prefers morning runs.",
providerType: "admin-openai",
model: "gpt-test",
tokensUsed: 42,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("refreshConversationSummary", () => {
it("(a) returns insufficient with no provider call at or below TURN_CAP", async () => {
const { client, update } = makePrisma(makeConversation(TURN_CAP));
const runCompletion = vi.fn();
const result = await refreshConversationSummary("conv-1", "user-1", {
prisma: client,
runCompletion,
});
expect(result.status).toBe("insufficient");
expect(runCompletion).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("returns insufficient when the conversation does not exist / is not owned", async () => {
const { client, update } = makePrisma(null);
const runCompletion = vi.fn();
const result = await refreshConversationSummary("conv-1", "user-1", {
prisma: client,
runCompletion,
});
expect(result.status).toBe("insufficient");
expect(runCompletion).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("(b) returns fresh with no provider call when fewer than DELTA new turns accumulated", async () => {
// 28 turns → foldHighWater = 28 - 18 = 10. summaryTurnCount = 10 - (DELTA-1)
// is > 0, and delta = DELTA-1 < DELTA ⇒ fresh.
const foldHighWater = 28 - RECENT_HISTORY;
const { client, update } = makePrisma(
makeConversation(28, {
summaryTurnCount: foldHighWater - (SUMMARY_REFRESH_TURN_DELTA - 1),
}),
);
const runCompletion = vi.fn();
const result = await refreshConversationSummary("conv-1", "user-1", {
prisma: client,
runCompletion,
});
expect(result.status).toBe("fresh");
expect(runCompletion).not.toHaveBeenCalled();
expect(update).not.toHaveBeenCalled();
});
it("(c) merge path passes the decrypted prior summary into the user prompt", async () => {
const prior = "Earlier: user wants better sleep and dislikes evening caffeine.";
// 30 turns, no prior summaryTurnCount → forces a generation.
const { client } = makePrisma(
makeConversation(30, {
summaryEncrypted: { __plain: prior },
summaryTurnCount: 0,
}),
);
const runCompletion = vi
.fn<(args: { userPrompt: string }) => Promise<StatusProviderResult>>()
.mockResolvedValue(okCompletion);
await refreshConversationSummary("conv-1", "user-1", {
prisma: client,
runCompletion: runCompletion as never,
});
expect(runCompletion).toHaveBeenCalledTimes(1);
const arg = runCompletion.mock.calls[0]![0];
expect(arg.userPrompt).toContain(prior);
expect(arg.userPrompt).toContain("PRIOR SUMMARY");
});
it("(d) timeout / none leaves the existing summary untouched (skipped, no update)", async () => {
for (const kind of ["timeout", "none", "error"] as const) {
vi.clearAllMocks();
const { client, update } = makePrisma(makeConversation(30));
const runCompletion = vi.fn().mockResolvedValue({ kind });
const result = await refreshConversationSummary("conv-1", "user-1", {
prisma: client,
runCompletion,
});
expect(result.status).toBe("skipped");
expect(update).not.toHaveBeenCalled();
}
});
it("(e) success path encrypts and persists summaryEncrypted + turn count + updatedAt", async () => {
const now = new Date("2026-06-04T08:00:00.000Z");
const { client, update } = makePrisma(makeConversation(30, { summaryTurnCount: 0 }));
const runCompletion = vi.fn().mockResolvedValue(okCompletion);
const result = await refreshConversationSummary("conv-1", "user-1", {
prisma: client,
runCompletion,
now,
});
expect(result.status).toBe("generated");
expect(encryptToBytes).toHaveBeenCalledWith(okCompletion.content);
expect(update).toHaveBeenCalledTimes(1);
const updateArg = update.mock.calls[0]![0];
expect(updateArg.where).toEqual({ id: "conv-1" });
expect(updateArg.data.summaryEncrypted).toBeDefined();
expect(updateArg.data.summaryUpdatedAt).toBe(now);
// foldHighWater = 30 - 18 = 12.
expect(updateArg.data.summaryTurnCount).toBe(12);
});
});
describe("buildSummaryUserPrompt", () => {
it("uses (none) when no prior summary is supplied", () => {
const out = buildSummaryUserPrompt(null, [
{ role: "user", content: "hi" },
]);
expect(out).toContain("PRIOR SUMMARY\n(none)");
expect(out).toContain("user: hi");
});
it("includes the prior summary and role-prefixes each folded turn", () => {
const out = buildSummaryUserPrompt("prior text", [
{ role: "user", content: "a" },
{ role: "assistant", content: "b" },
]);
expect(out).toContain("PRIOR SUMMARY\nprior text");
expect(out).toContain("user: a");
expect(out).toContain("assistant: b");
});
it("renders German labels when locale is de", () => {
const out = buildSummaryUserPrompt(null, [], "de");
expect(out).toContain("FRÜHERE ZUSAMMENFASSUNG");
expect(out).toContain("(keine)");
});
});
@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "vitest";
// Hermetic codec — same as facts.test.ts.
vi.mock("../bytes-codec", () => ({
encryptToBytes: (s: string) => new TextEncoder().encode(s),
decryptFromBytes: (b: Uint8Array) => new TextDecoder().decode(b),
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
import { buildCoachFactsBlock, FACTS_INJECT_TOP_N } from "../facts";
function bytes(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
interface SeedRow {
text: string;
category?: string;
confidence: number;
updatedAt: Date;
}
/**
* Fake prisma that honours the orderBy the helper passes (confidence DESC,
* updatedAt DESC) so the test exercises the real ranking contract.
*/
function makeFakePrisma(seed: SeedRow[]) {
const findMany = vi.fn(async () => {
const sorted = [...seed].sort((a, b) => {
if (b.confidence !== a.confidence) return b.confidence - a.confidence;
return b.updatedAt.getTime() - a.updatedAt.getTime();
});
return sorted.map((r) => ({
factEncrypted: bytes(r.text),
category: r.category ?? "preference",
confidence: r.confidence,
updatedAt: r.updatedAt,
}));
});
return { prisma: { coachFact: { findMany } }, findMany };
}
describe("buildCoachFactsBlock", () => {
it("ranks by confidence then recency", async () => {
const { prisma } = makeFakePrisma([
{ text: "older high", confidence: 90, updatedAt: new Date(2026, 0, 1) },
{ text: "newer high", confidence: 90, updatedAt: new Date(2026, 0, 5) },
{ text: "low", confidence: 40, updatedAt: new Date(2026, 0, 10) },
]);
const block = await buildCoachFactsBlock("user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
});
expect(block).not.toBeNull();
expect(block!.facts.map((f) => f.text)).toEqual(["newer high", "older high", "low"]);
});
it("takes only the top N", async () => {
const seed: SeedRow[] = Array.from({ length: FACTS_INJECT_TOP_N + 4 }, (_, i) => ({
text: `fact ${i}`,
confidence: 100 - i,
updatedAt: new Date(2026, 0, 1 + i),
}));
const { prisma } = makeFakePrisma(seed);
const block = await buildCoachFactsBlock("user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
});
expect(block).not.toBeNull();
expect(block!.facts).toHaveLength(FACTS_INJECT_TOP_N);
expect(block!.facts[0].text).toBe("fact 0");
});
it("returns null when there are no active facts", async () => {
const { prisma } = makeFakePrisma([]);
const block = await buildCoachFactsBlock("user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
});
expect(block).toBeNull();
});
it("skips undecryptable rows fault-isolated (never throws)", async () => {
// One row whose codec throws on decrypt; the helper must skip it.
const findMany = vi.fn(async () => [
{ factEncrypted: bytes("good fact"), category: "goal", confidence: 80, updatedAt: new Date() },
{ factEncrypted: bytes("__throw__"), category: "goal", confidence: 70, updatedAt: new Date() },
]);
// Re-point the codec mock to throw for the sentinel payload.
const codec = await import("../bytes-codec");
vi.spyOn(codec, "decryptFromBytes").mockImplementation((b: Uint8Array) => {
const s = new TextDecoder().decode(b);
if (s === "__throw__") throw new Error("bad key id");
return s;
});
const block = await buildCoachFactsBlock("user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: { coachFact: { findMany } } as any,
});
expect(block).not.toBeNull();
expect(block!.facts.map((f) => f.text)).toEqual(["good fact"]);
});
});
+294
View File
@@ -0,0 +1,294 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { StatusProviderResult } from "@/lib/insights/status-provider";
// 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),
}));
// status-provider is replaced by the injected runCompletion in every test,
// but the module imports it at load time.
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
import {
extractAndStoreFacts,
FACT_MAX_CHARS,
MAX_FACTS_PER_USER,
} from "../facts";
// ---------------------------------------------------------------------------
// Fake prisma builder
// ---------------------------------------------------------------------------
interface FakeFactRow {
id: string;
factEncrypted: Uint8Array;
category: string;
confidence: number;
updatedAt: Date;
deletedAt: Date | null;
}
function bytes(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
function makeFakePrisma(opts: {
activeFacts?: Array<{ id?: string; text: string; category?: string; confidence?: number }>;
turns?: Array<{ role: string; content: string }>;
conversationExists?: boolean;
}) {
const rows: FakeFactRow[] = (opts.activeFacts ?? []).map((f, i) => ({
id: f.id ?? `fact-${i}`,
factEncrypted: bytes(f.text),
category: f.category ?? "preference",
confidence: f.confidence ?? 50,
updatedAt: new Date(2026, 0, 1 + i),
deletedAt: null,
}));
const createCalls: Array<Record<string, unknown>> = [];
const messages = (opts.turns ?? []).map((t) => ({
role: t.role,
encryptedContent: bytes(t.content),
createdAt: new Date(),
}));
const prisma = {
coachFact: {
findMany: vi.fn(async () => rows),
create: vi.fn(async (arg: { data: Record<string, unknown> }) => {
createCalls.push(arg.data);
return { id: `new-${createCalls.length}`, ...arg.data };
}),
},
coachConversation: {
findFirst: vi.fn(async () =>
opts.conversationExists === false ? null : { messages },
),
},
};
return { prisma, createCalls };
}
function ok(content: string): StatusProviderResult {
return { kind: "ok", content, providerType: "mock", model: "m", tokensUsed: 1 };
}
beforeEach(() => {
annotateMock.mockClear();
});
describe("extractAndStoreFacts", () => {
it("(a) parses durable facts and persists them field-by-field", async () => {
const { prisma, createCalls } = makeFakePrisma({
turns: [{ role: "user", content: "I prefer morning workouts and I'm vegetarian." }],
});
const runCompletion = vi.fn(async () =>
ok(
JSON.stringify([
{ category: "preference", fact: "Prefers morning workouts", confidence: 90 },
{ category: "preference", fact: "Is vegetarian", confidence: 80 },
]),
),
);
const res = await extractAndStoreFacts("conv-1", "user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCompletion: runCompletion as any,
});
expect(res).toEqual({ status: "stored", count: 2 });
expect(createCalls).toHaveLength(2);
// Field-by-field — exact key set, no spread of the parsed object.
for (const data of createCalls) {
expect(Object.keys(data).sort()).toEqual(
["category", "confidence", "factEncrypted", "sourceConversationId", "userId"].sort(),
);
expect(data.userId).toBe("user-1");
expect(data.sourceConversationId).toBe("conv-1");
expect(data.factEncrypted).toBeInstanceOf(Uint8Array);
}
// Annotation carries counts/ids only.
const extracted = annotateMock.mock.calls.find(
(c) => (c[0] as { action?: { name?: string } }).action?.name === "coach.facts.extracted",
);
expect(extracted).toBeTruthy();
expect((extracted![0] as { meta: Record<string, unknown> }).meta).toEqual({
count: 2,
conversationId: "conv-1",
});
});
it("(b) drops items the Zod gate rejects (bad category, over-length)", async () => {
const { prisma, createCalls } = makeFakePrisma({
turns: [{ role: "user", content: "talk" }],
});
const tooLong = "x".repeat(FACT_MAX_CHARS + 5);
const runCompletion = vi.fn(async () =>
ok(
JSON.stringify([
{ category: "preference", fact: "Prefers tea", confidence: 70 }, // valid
{ category: "diagnosis", fact: "Has X", confidence: 90 }, // bad category
{ category: "goal", fact: tooLong, confidence: 60 }, // over length
{ category: "context", fact: "", confidence: 50 }, // empty
]),
),
);
const res = await extractAndStoreFacts("conv-1", "user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCompletion: runCompletion as any,
});
expect(res).toEqual({ status: "stored", count: 1 });
expect(createCalls).toHaveLength(1);
expect(createCalls[0].category).toBe("preference");
});
it("(c) malformed JSON → parse_failed annotation, 0 stored", async () => {
const { prisma, createCalls } = makeFakePrisma({
turns: [{ role: "user", content: "talk" }],
});
const runCompletion = vi.fn(async () => ok("not json at all {{{"));
const res = await extractAndStoreFacts("conv-9", "user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCompletion: runCompletion as any,
});
expect(res).toEqual({ status: "none", count: 0 });
expect(createCalls).toHaveLength(0);
const failed = annotateMock.mock.calls.find(
(c) => (c[0] as { action?: { name?: string } }).action?.name === "coach.facts.parse_failed",
);
expect(failed).toBeTruthy();
expect((failed![0] as { meta: Record<string, unknown> }).meta).toEqual({
conversationId: "conv-9",
});
});
it("(d) de-dup drops a near-duplicate of an existing fact", async () => {
const { prisma, createCalls } = makeFakePrisma({
activeFacts: [{ text: "Prefers morning workouts", confidence: 80 }],
turns: [{ role: "user", content: "talk" }],
});
const runCompletion = vi.fn(async () =>
ok(
JSON.stringify([
{ category: "preference", fact: "prefers morning workouts", confidence: 90 }, // dup
{ category: "goal", fact: "Wants to run a 10k in autumn", confidence: 85 }, // new
]),
),
);
const res = await extractAndStoreFacts("conv-1", "user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCompletion: runCompletion as any,
});
expect(res).toEqual({ status: "stored", count: 1 });
expect(createCalls).toHaveLength(1);
expect(createCalls[0].category).toBe("goal");
});
it("(e) cap enforcement: at MAX, only strictly-higher-confidence is stored", async () => {
// Fill to the cap; weakest active fact has confidence 30.
const activeFacts = Array.from({ length: MAX_FACTS_PER_USER }, (_, i) => ({
text: `active fact number ${i}`,
confidence: i === 0 ? 30 : 70,
}));
const { prisma, createCalls } = makeFakePrisma({
activeFacts,
turns: [{ role: "user", content: "talk" }],
});
const runCompletion = vi.fn(async () =>
ok(
JSON.stringify([
{ category: "goal", fact: "low confidence newcomer", confidence: 20 }, // ≤30 → skip
{ category: "goal", fact: "high confidence newcomer", confidence: 95 }, // >30 → store
]),
),
);
const res = await extractAndStoreFacts("conv-1", "user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCompletion: runCompletion as any,
});
expect(res).toEqual({ status: "stored", count: 1 });
expect(createCalls).toHaveLength(1);
expect(createCalls[0].confidence).toBe(95);
});
it("(f) none/timeout/error provider result → skipped", async () => {
for (const kind of ["none", "timeout", "error"] as const) {
const { prisma, createCalls } = makeFakePrisma({
turns: [{ role: "user", content: "talk" }],
});
const runCompletion = vi.fn(async () => ({ kind }) as StatusProviderResult);
const res = await extractAndStoreFacts("conv-1", "user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCompletion: runCompletion as any,
});
expect(res).toEqual({ status: "skipped", count: 0 });
expect(createCalls).toHaveLength(0);
}
});
it("returns [] handling: empty array → none, 0 stored", async () => {
const { prisma, createCalls } = makeFakePrisma({
turns: [{ role: "user", content: "talk" }],
});
const runCompletion = vi.fn(async () => ok("[]"));
const res = await extractAndStoreFacts("conv-1", "user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCompletion: runCompletion as any,
});
expect(res).toEqual({ status: "none", count: 0 });
expect(createCalls).toHaveLength(0);
});
it("skips when the conversation has no decryptable turns", async () => {
const { prisma, createCalls } = makeFakePrisma({ turns: [] });
const runCompletion = vi.fn(async () => ok("[]"));
const res = await extractAndStoreFacts("conv-1", "user-1", {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
prisma: prisma as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
runCompletion: runCompletion as any,
});
expect(res).toEqual({ status: "skipped", count: 0 });
expect(runCompletion).not.toHaveBeenCalled();
expect(createCalls).toHaveLength(0);
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* v1.11.1 (Epic B, B-W5b) — Coach conversation-summary COMPUTE module.
*
* A rolling, encrypted natural-language summary of the turns BEFORE the most
* recent `RECENT_HISTORY` window. When a conversation grows past `TURN_CAP`
* the chat route folds the older turns into a single synthetic line; today
* that line is a dead placeholder. This module produces a durable summary the
* route can substitute for the placeholder so the Coach keeps memory of the
* elided turns.
*
* Structural sibling of `period-narrative-generate.ts`:
* - freshness short-circuit before any provider call,
* - `runStatusCompletion` over the user's provider chain (NOT hand-rolled),
* - `encryptToBytes` → `update` of the single conversation row,
* - `annotate` carries counts + ids only, never the summary content.
*
* Background generation is intentionally UNBILLED — like the period-narrative
* warm, it does not call `recordSpend`. The operator's per-user daily token
* ceiling is the cost backstop; the interactive reply meter is not charged for
* background memory upkeep.
*
* Fail-closed crypto: an undecryptable prior summary is treated as absent
* (regenerate from scratch) and never allowed to throw into the caller.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { prisma as defaultPrisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import {
runStatusCompletion,
type StatusProviderResult,
} from "@/lib/insights/status-provider";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
/**
* Stable identifier for the summary prompt revision. Bumped whenever the
* prompt below changes so quality can be sliced per (provider × prompt).
*/
export const SUMMARY_PROMPT_VERSION = "1.11.1" as const;
/** Target prose length for the rolling summary (~150 tokens). */
export const SUMMARY_TARGET_CHARS = 600;
/** Re-summarise only once at least this many new turns accumulate. */
export const SUMMARY_REFRESH_TURN_DELTA = 6;
/**
* Mirror of the chat route's history-window constants (`route.ts:75-76`).
* Defined locally — the route is a 600+ LOC server module we must not import
* from here. Keep in sync with the route by hand if either changes.
*/
const TURN_CAP = 20;
const RECENT_HISTORY = 18;
/** Injected provider-completion shape (defaults to the real chain runner). */
type RunCompletion = typeof runStatusCompletion;
export interface RefreshConversationSummaryOptions {
now?: Date;
runCompletion?: RunCompletion;
prisma?: Pick<PrismaClient, "coachConversation">;
locale?: "de" | "en";
}
export type RefreshConversationSummaryResult = {
status: "fresh" | "generated" | "skipped" | "insufficient";
};
/** A conversation turn folded into the summary (role-prefixed in the prompt). */
export interface SummaryFoldTurn {
role: "user" | "assistant";
content: string;
}
const SYSTEM_PROMPT_EN = `You compress a coaching conversation into a durable rolling summary for the assistant's own future memory. Write 2-4 sentences (<= ~120 words) of plain prose. Capture: what the user is working on or worried about, any goals or preferences they stated, decisions or agreements reached, and open threads to follow up. Be descriptive, never diagnostic — record what was said, do not infer conditions. EXCLUDE: one-off pleasantries, exact numbers (those live in the live snapshot), anything the user asked you to forget, and any detail not useful to continuing the conversation. If a PRIOR SUMMARY is supplied, MERGE the new turns into it and return the merged summary — do not append; rewrite the whole thing concisely. Output only the summary prose, no preamble.`;
const SYSTEM_PROMPT_DE = `Du verdichtest ein Coaching-Gespräch zu einer dauerhaften, fortlaufenden Zusammenfassung für das eigene künftige Gedächtnis des Assistenten. Schreibe 2-4 Sätze (<= ~120 Wörter) in einfacher Prosa. Erfasse: woran die Person arbeitet oder was sie beschäftigt, genannte Ziele oder Vorlieben, getroffene Entscheidungen oder Vereinbarungen und offene Punkte zum Nachfassen. Sei beschreibend, nie diagnostisch — halte fest, was gesagt wurde, leite keine Erkrankungen ab. AUSSCHLIESSEN: einmalige Höflichkeiten, exakte Zahlen (die stehen im Live-Snapshot), alles, worum die Person dich zu vergessen gebeten hat, und jedes Detail, das für die Fortsetzung des Gesprächs nicht nützlich ist. Wenn eine FRÜHERE ZUSAMMENFASSUNG vorliegt, FÜGE die neuen Wortwechsel in sie EIN und gib die zusammengeführte Fassung zurück — nicht anhängen; schreibe das Ganze knapp neu. Gib nur die Zusammenfassungsprosa aus, ohne Vorrede.`;
/**
* Build the user prompt fed to the model from the prior summary (when any) and
* the turns being folded in. Exported so tests can assert the merge path
* carries the prior summary into the prompt.
*/
export function buildSummaryUserPrompt(
priorSummary: string | null,
foldedTurns: SummaryFoldTurn[],
locale: "de" | "en" = "en",
): string {
const priorLabel = locale === "de" ? "FRÜHERE ZUSAMMENFASSUNG" : "PRIOR SUMMARY";
const turnsLabel = locale === "de" ? "NEUE WORTWECHSEL" : "NEW TURNS";
const none = locale === "de" ? "(keine)" : "(none)";
const priorBlock = priorSummary && priorSummary.trim().length > 0
? priorSummary.trim()
: none;
const turnsBlock = foldedTurns
.map((t) => `${t.role}: ${t.content}`)
.join("\n");
return `${priorLabel}\n${priorBlock}\n\n${turnsLabel}\n${turnsBlock}`;
}
/**
* Decrypt the prior summary fail-closed: a missing or undecryptable row yields
* `null` so the caller regenerates from scratch rather than throwing into a
* background job (mirrors `readPeriodNarrative`'s undecryptable-as-absent rule).
*/
function decryptPriorSummary(buf: Uint8Array | null): string | null {
if (!buf || buf.byteLength === 0) return null;
try {
return decryptFromBytes(buf);
} catch {
return null;
}
}
/**
* (Re)compute the rolling summary for one conversation.
*
* Ownership-scoped: a conversation not owned by `userId` is treated as absent
* (`"insufficient"`). Best-effort: a no-provider / timeout / error completion
* leaves any existing summary untouched and returns `"skipped"`.
*/
export async function refreshConversationSummary(
conversationId: string,
userId: string,
opts?: RefreshConversationSummaryOptions,
): Promise<RefreshConversationSummaryResult> {
const now = opts?.now ?? new Date();
const prisma = opts?.prisma ?? defaultPrisma;
const runCompletion = opts?.runCompletion ?? runStatusCompletion;
const locale = opts?.locale ?? "en";
// 1. Ownership-scoped load. Decrypt every message body for the fold slice.
const conversation = await prisma.coachConversation.findFirst({
where: { id: conversationId, userId },
select: {
id: true,
summaryEncrypted: true,
summaryTurnCount: true,
messages: {
orderBy: { createdAt: "asc" },
select: { role: true, encryptedContent: true },
},
},
});
if (!conversation) {
return { status: "insufficient" };
}
const turns = conversation.messages;
const turnCount = turns.length;
// 2. Cheap short-circuit: nothing has been elided yet — the live window
// already carries the whole conversation.
if (turnCount <= TURN_CAP) {
return { status: "insufficient" };
}
// The high-water mark of turns this refresh would fold: everything before
// the most-recent `RECENT_HISTORY` window (same slice the route elides).
const foldHighWater = turnCount - RECENT_HISTORY;
// 3. Don't pay a provider call for a couple of new turns.
if (
conversation.summaryTurnCount > 0 &&
foldHighWater - conversation.summaryTurnCount < SUMMARY_REFRESH_TURN_DELTA
) {
return { status: "fresh" };
}
// 4. Decrypt prior summary (fail-closed) + the older turns to fold.
const priorSummary = decryptPriorSummary(conversation.summaryEncrypted);
const foldedTurns: SummaryFoldTurn[] = turns
.slice(0, foldHighWater)
.map((m) => ({
role: m.role === "assistant" ? "assistant" : "user",
content: decryptFromBytes(m.encryptedContent),
}));
// 5. Run the user's provider chain. Best-effort: non-ok leaves the old
// summary in place.
const completion: StatusProviderResult = await runCompletion({
userId,
cacheAction: "coach.summary",
systemPrompt: locale === "de" ? SYSTEM_PROMPT_DE : SYSTEM_PROMPT_EN,
userPrompt: buildSummaryUserPrompt(priorSummary, foldedTurns, locale),
temperature: 0.3,
maxTokens: 200,
});
if (completion.kind !== "ok") {
return { status: "skipped" };
}
const text = completion.content.trim();
if (text.length === 0) {
return { status: "skipped" };
}
// 6. Encrypt + persist. Field-by-field data object (no spread).
const summaryEncrypted = encryptToBytes(text);
await prisma.coachConversation.update({
where: { id: conversationId },
data: {
summaryEncrypted,
summaryUpdatedAt: now,
summaryTurnCount: foldHighWater,
},
});
// 7. Counts + ids only — never the summary text.
annotate({
action: { name: "coach.summary.generated" },
meta: { foldedTurns: foldHighWater, conversationId },
});
return { status: "generated" };
}
+423
View File
@@ -0,0 +1,423 @@
/**
* v1.11.1 (Epic B, B-W7) — durable personal-fact extraction + injection.
*
* The Coach learns STABLE facts about a user from a conversation — standing
* preferences, conditions the user STATES about themselves, durable goals,
* standing constraints, durable life context — and persists them encrypted
* at rest so future turns can personalise without re-asking. Facts are
* DESCRIPTIVE (the user's own framing), never diagnostic: the extractor is
* forbidden from inferring a medical condition or recording a measurement.
*
* Two halves:
* - `extractAndStoreFacts` — the background compute: load active facts +
* recent turns, run one bounded `runStatusCompletion`, defensively parse
* the JSON array, drop anything the Zod gate rejects, de-dup against the
* active set, enforce a per-user cap, and persist survivors field-by-field
* (no mass-assignment spread).
* - `buildCoachFactsBlock` — the injection read: top-N active facts ranked
* by confidence then recency, decrypted fault-isolated (an undecryptable
* row is skipped, never thrown), for the snapshot memory block.
*
* The fact TEXT is encrypted via `bytes-codec.ts` (the same AES-256-GCM codec
* as `CoachMessage`). category/confidence/sourceConversationId stay plain so
* the ranker can sort without paying a per-row decrypt. Annotations carry
* counts + ids only — fact text is NEVER logged.
*
* Server-only — reads `@/lib/db`.
*/
import { z } from "zod";
import { prisma } from "@/lib/db";
import { runStatusCompletion } from "@/lib/insights/status-provider";
import { annotate } from "@/lib/logging/context";
import { decryptFromBytes, encryptToBytes } from "./bytes-codec";
/** Closed category enum — app-side (no DB enum), matching the schema column. */
export const COACH_FACT_CATEGORIES = [
"preference",
"condition",
"goal",
"constraint",
"context",
] as const;
export type CoachFactCategory = (typeof COACH_FACT_CATEGORIES)[number];
/** Background-prompt attribution version (separate from interactive `PROMPT_VERSION`). */
export const FACTS_PROMPT_VERSION = "1.11.1";
/** Hard cap on active facts per user; at cap, only strictly-higher-confidence facts displace. */
export const MAX_FACTS_PER_USER = 50;
/** Per-fact text length cap (mirrors the Zod gate + the prompt instruction). */
export const FACT_MAX_CHARS = 160;
/** How many active facts the injection block carries into the snapshot. */
export const FACTS_INJECT_TOP_N = 8;
/** Cap on recent turns fed into the extraction prompt (bounds prompt size). */
const RECENT_TURNS_CAP = 10;
type RunCompletionFn = typeof runStatusCompletion;
type PrismaLike = Pick<typeof prisma, "coachFact" | "coachConversation">;
interface ExtractOpts {
now?: Date;
runCompletion?: RunCompletionFn;
prisma?: PrismaLike;
locale?: string;
}
interface BuildBlockOpts {
prisma?: PrismaLike;
}
// ---------------------------------------------------------------------------
// Extraction prompt (EN + DE mirror)
// ---------------------------------------------------------------------------
const EXTRACTION_PROMPT_EN = `You extract DURABLE personal facts about the user from a coaching conversation, for the assistant's long-term memory. Return a JSON array (no prose, no fences). Each item: { "category": one of preference|condition|goal|constraint|context, "fact": "<one short descriptive sentence>", "confidence": 0-100 }.
ONLY extract facts that are STABLE and will still be true next month: standing preferences, conditions the user STATES about themselves, durable goals, standing constraints, durable life context. Record descriptively in the user's own framing — NEVER diagnose, infer a medical condition the user did not state, or record a number/measurement (those live in the data). EXCLUDE: transient feelings ("tired today"), one-off events, anything time-bound, sensitive detail beyond what is needed to coach, and anything the user asked you to forget. If nothing durable was said, return []. Prefer FEW high-confidence facts over many speculative ones.`;
const EXTRACTION_PROMPT_DE = `Du extrahierst DAUERHAFTE persönliche Fakten über die Nutzerin oder den Nutzer aus einem Coaching-Gespräch für das Langzeitgedächtnis des Assistenten. Gib ein JSON-Array zurück (kein Fließtext, keine Code-Zäune). Jedes Element: { "category": eines von preference|condition|goal|constraint|context, "fact": "<ein kurzer beschreibender Satz>", "confidence": 0-100 }.
Extrahiere NUR Fakten, die STABIL sind und auch nächsten Monat noch zutreffen: dauerhafte Vorlieben, Bedingungen, die die Person SELBST über sich AUSSAGT, dauerhafte Ziele, dauerhafte Einschränkungen, dauerhafter Lebenskontext. Halte sie beschreibend in der eigenen Formulierung der Person fest — DIAGNOSTIZIERE NIEMALS, leite keine medizinische Bedingung ab, die die Person nicht selbst genannt hat, und erfasse keine Zahl oder Messung (die liegen in den Daten). SCHLIESSE AUS: vorübergehende Gefühle ("heute müde"), einmalige Ereignisse, alles Zeitgebundene, sensible Details über das fürs Coaching Nötige hinaus und alles, worum die Person gebeten hat, es zu vergessen. Wenn nichts Dauerhaftes gesagt wurde, gib [] zurück. Bevorzuge WENIGE Fakten mit hoher Sicherheit gegenüber vielen spekulativen.`;
function extractionSystemPrompt(locale: string | undefined): string {
return locale?.toLowerCase().startsWith("de")
? EXTRACTION_PROMPT_DE
: EXTRACTION_PROMPT_EN;
}
// ---------------------------------------------------------------------------
// Zod gate for the model's JSON array
// ---------------------------------------------------------------------------
const rawFactSchema = z.object({
category: z.enum(COACH_FACT_CATEGORIES),
fact: z
.string()
.trim()
.min(1)
.max(FACT_MAX_CHARS),
confidence: z.coerce.number().int().min(0).max(100),
});
const rawFactArraySchema = z.array(z.unknown());
interface ParsedFact {
category: CoachFactCategory;
fact: string;
confidence: number;
}
/**
* Parse the model output into validated facts. Returns `null` only when the
* top-level JSON is unparseable (the caller then annotates `parse_failed`);
* individual malformed items are dropped silently, not fatal.
*/
function parseFacts(content: string): { facts: ParsedFact[]; dropped: number } | null {
let json: unknown;
try {
json = JSON.parse(content.trim());
} catch {
return null;
}
const arr = rawFactArraySchema.safeParse(json);
if (!arr.success) return null;
const facts: ParsedFact[] = [];
let dropped = 0;
for (const item of arr.data) {
const parsed = rawFactSchema.safeParse(item);
if (parsed.success) {
facts.push({
category: parsed.data.category,
fact: parsed.data.fact,
confidence: Math.max(0, Math.min(100, Math.round(parsed.data.confidence))),
});
} else {
dropped += 1;
}
}
return { facts, dropped };
}
// ---------------------------------------------------------------------------
// De-dup — lowercase-normalise + token-overlap against the active set
// ---------------------------------------------------------------------------
const TOKEN_SPLIT = /[^a-z0-9äöüß]+/i;
function tokenSet(text: string): Set<string> {
return new Set(
text
.toLowerCase()
.split(TOKEN_SPLIT)
.filter((t) => t.length > 1),
);
}
/** Jaccard-style overlap of the two token sets, 0..1. */
function tokenOverlap(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) return 0;
let inter = 0;
for (const t of a) if (b.has(t)) inter += 1;
const union = a.size + b.size - inter;
return union === 0 ? 0 : inter / union;
}
const DEDUP_OVERLAP_THRESHOLD = 0.6;
function isNearDuplicate(candidate: string, existing: string[]): boolean {
const cTokens = tokenSet(candidate);
const cNorm = candidate.trim().toLowerCase();
for (const ex of existing) {
if (ex.trim().toLowerCase() === cNorm) return true;
if (tokenOverlap(cTokens, tokenSet(ex)) >= DEDUP_OVERLAP_THRESHOLD) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Conversation-turn loading
// ---------------------------------------------------------------------------
function decryptOrNull(buf: Uint8Array): string | null {
try {
return decryptFromBytes(buf);
} catch {
return null;
}
}
interface ActiveFactRow {
id: string;
factEncrypted: Uint8Array;
category: string;
confidence: number;
}
/** Load active facts and decrypt them fault-isolated (undecryptable rows skipped). */
async function loadActiveFacts(
db: PrismaLike,
userId: string,
): Promise<Array<{ id: string; text: string; category: string; confidence: number }>> {
const rows = (await db.coachFact.findMany({
where: { userId, deletedAt: null },
select: { id: true, factEncrypted: true, category: true, confidence: true },
})) as ActiveFactRow[];
const out: Array<{ id: string; text: string; category: string; confidence: number }> = [];
for (const r of rows) {
const text = decryptOrNull(r.factEncrypted);
if (text === null) continue;
out.push({ id: r.id, text, category: r.category, confidence: r.confidence });
}
return out;
}
interface ConversationTurnRow {
role: string;
encryptedContent: Uint8Array;
}
/** Load the last `RECENT_TURNS_CAP` turns of a conversation, decrypted. */
async function loadRecentTurns(
db: PrismaLike,
conversationId: string,
userId: string,
): Promise<Array<{ role: string; content: string }>> {
const row = (await db.coachConversation.findFirst({
where: { id: conversationId, userId },
select: {
messages: {
orderBy: { createdAt: "desc" },
take: RECENT_TURNS_CAP,
select: { role: true, encryptedContent: true },
},
},
})) as { messages: ConversationTurnRow[] } | null;
if (!row) return [];
// Loaded newest-first; reverse to chronological for the prompt.
const turns: Array<{ role: string; content: string }> = [];
for (const m of [...row.messages].reverse()) {
const content = decryptOrNull(m.encryptedContent);
if (content === null) continue;
turns.push({ role: m.role, content });
}
return turns;
}
function buildUserPrompt(
turns: Array<{ role: string; content: string }>,
activeFacts: string[],
): string {
const transcript = turns.map((t) => `${t.role}: ${t.content}`).join("\n");
const existing =
activeFacts.length > 0
? `\n\nEXISTING FACTS (do not re-emit these):\n${activeFacts.map((f) => `- ${f}`).join("\n")}`
: "\n\nEXISTING FACTS: none";
return `${transcript}${existing}`;
}
// ---------------------------------------------------------------------------
// Extraction entry point
// ---------------------------------------------------------------------------
/**
* Extract durable facts from a conversation and persist the survivors.
*
* Returns:
* - `{status:"stored", count}` — at least one new fact persisted.
* - `{status:"none", count:0}` — the model produced nothing durable (or the
* JSON was unparseable, in which case `coach.facts.parse_failed` is annotated).
* - `{status:"skipped", count:0}` — no usable provider / timeout / error, or
* every candidate was a dup / dropped / displaced by the cap.
*/
export async function extractAndStoreFacts(
conversationId: string,
userId: string,
opts?: ExtractOpts,
): Promise<{ status: "stored" | "none" | "skipped"; count: number }> {
const db = opts?.prisma ?? prisma;
const runCompletion = opts?.runCompletion ?? runStatusCompletion;
const [active, turns] = await Promise.all([
loadActiveFacts(db, userId),
loadRecentTurns(db, conversationId, userId),
]);
if (turns.length === 0) {
return { status: "skipped", count: 0 };
}
const activeTexts = active.map((f) => f.text);
const result = await runCompletion({
userId,
cacheAction: "coach.facts",
systemPrompt: extractionSystemPrompt(opts?.locale),
userPrompt: buildUserPrompt(turns, activeTexts),
temperature: 0.2,
maxTokens: 300,
});
if (result.kind !== "ok") {
return { status: "skipped", count: 0 };
}
const parsed = parseFacts(result.content);
if (parsed === null) {
annotate({
action: { name: "coach.facts.parse_failed" },
meta: { conversationId },
});
return { status: "none", count: 0 };
}
if (parsed.facts.length === 0) {
return { status: "none", count: 0 };
}
// De-dup against the active set; grow the seen-set as we accept candidates
// so two near-identical candidates in one batch can't both land.
const seen = [...activeTexts];
const accepted: ParsedFact[] = [];
for (const cand of parsed.facts) {
if (isNearDuplicate(cand.fact, seen)) continue;
accepted.push(cand);
seen.push(cand.fact);
}
if (accepted.length === 0) {
return { status: "skipped", count: 0 };
}
// Cap enforcement: at/over cap, a candidate may only be stored if it is
// strictly higher-confidence than the current lowest-confidence active fact
// (the new row simply outranks it for injection; soft-delete/eviction of the
// displaced row is the management surface's job, not the extractor's).
let lowestActiveConfidence =
active.length > 0 ? Math.min(...active.map((f) => f.confidence)) : -1;
let remainingCapacity = MAX_FACTS_PER_USER - active.length;
const toStore: ParsedFact[] = [];
// Highest-confidence candidates first so a tight cap admits the best.
for (const cand of [...accepted].sort((a, b) => b.confidence - a.confidence)) {
if (remainingCapacity > 0) {
toStore.push(cand);
remainingCapacity -= 1;
continue;
}
// At cap — only admit if strictly better than the weakest active fact.
if (cand.confidence > lowestActiveConfidence) {
toStore.push(cand);
lowestActiveConfidence = cand.confidence;
}
}
if (toStore.length === 0) {
return { status: "skipped", count: 0 };
}
for (const f of toStore) {
// Field-by-field, no spread (mass-assignment rule, CLAUDE.md).
await db.coachFact.create({
data: {
userId,
factEncrypted: encryptToBytes(f.fact),
category: f.category,
confidence: f.confidence,
sourceConversationId: conversationId,
},
});
}
annotate({
action: { name: "coach.facts.extracted" },
meta: { count: toStore.length, conversationId },
});
return { status: "stored", count: toStore.length };
}
// ---------------------------------------------------------------------------
// Injection block
// ---------------------------------------------------------------------------
interface RankFactRow {
factEncrypted: Uint8Array;
category: string;
confidence: number;
updatedAt: Date;
}
/**
* Build the top-N active facts for the snapshot memory block, ranked by
* `confidence DESC, updatedAt DESC`. Decrypt is fault-isolated — an
* undecryptable row is skipped, never thrown into the caller. Returns `null`
* when the user has no usable active facts.
*/
export async function buildCoachFactsBlock(
userId: string,
opts?: BuildBlockOpts,
): Promise<{ facts: Array<{ category: string; text: string }> } | null> {
const db = opts?.prisma ?? prisma;
const rows = (await db.coachFact.findMany({
where: { userId, deletedAt: null },
orderBy: [{ confidence: "desc" }, { updatedAt: "desc" }],
select: { factEncrypted: true, category: true, confidence: true, updatedAt: true },
})) as RankFactRow[];
const facts: Array<{ category: string; text: string }> = [];
for (const r of rows) {
if (facts.length >= FACTS_INJECT_TOP_N) break;
const text = decryptOrNull(r.factEncrypted);
if (text === null) continue;
facts.push({ category: r.category, text });
}
if (facts.length === 0) return null;
return { facts };
}