mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
remember_fact and propose_plan wrote their rows during the tool round. An answer that was then blocked, failed or abandoned left the fact in memory without its note and Undo, and left the plan listed, activatable and fed into later turns and the briefing. Both are now staged under an id minted up front and written in the transaction that stores the assistant message, only when its provenance carries them. A background proposal counts as offered only once an answer carrying it is stored. Plan text is model text: ifCue, thenAction and target pass the outbound dose and risk screen in the person's locale, with their scheduled doses and medication names, and the injection screen; a trip declines as "unsafe". The trail route serves recalled facts and the proposal's fact to the owner whatever the AI capability (only the model-written entries are gated), and withholds medication facts while the medications module is off. Accepting a fact proposal is decided under a per-message advisory lock, so concurrent taps write one fact. Editing a fact re-reads its wording: text naming a medication moves to "medication", other health wording moves a non-health fact to "condition", and a health fact the person edited counts as confirmed.
537 lines
18 KiB
TypeScript
537 lines
18 KiB
TypeScript
/**
|
|
* v1.41 — the Coach's memory, end to end against Postgres and the real codec.
|
|
*
|
|
* The unit suites prove each memory function calls the right Prisma methods.
|
|
* They cannot prove that what one end writes is what the other end reads:
|
|
* that a fact the Coach saved in a turn comes back in the next turn's block,
|
|
* that a health proposal is stored nowhere until the person taps and is then
|
|
* read back from the encrypted trail, that a plan taken on carries its
|
|
* progress into both the block and the briefing, and that `last_used_at`
|
|
* moves without moving `updated_at`. And that a turn's fact or plan exists
|
|
* only once the answer carrying it is stored (`appendMessage`): a blocked,
|
|
* failed or abandoned answer leaves nothing behind. Those are the claims
|
|
* here, through the same functions the turn, the chat request and the
|
|
* briefing call.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
|
import {
|
|
buildMemoryContextBlock,
|
|
buildPlanProgressLines,
|
|
decideFactProposal,
|
|
decidePlanProposal,
|
|
proposePlanFromTool,
|
|
rememberFactFromTool,
|
|
} from "@/lib/ai/coach/memory/contract";
|
|
import { resetTurnMarksForTests } from "@/lib/ai/coach/memory/remember";
|
|
import { resetStagedTurnWritesForTests } from "@/lib/ai/coach/memory/turn-writes";
|
|
import { appendMessage } from "@/lib/ai/coach/persistence";
|
|
import type { CoachProvenance } from "@/lib/ai/coach/types";
|
|
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
|
|
import { runCoachReminderSweep } from "@/lib/jobs/coach-reminder-sweep";
|
|
|
|
const DAY = 86_400_000;
|
|
|
|
/** A user whose Coach and briefing may reach a model. */
|
|
async function seedUser(username: string) {
|
|
return getPrismaClient().user.create({
|
|
data: {
|
|
username,
|
|
email: `${username}@example.test`,
|
|
timezone: "UTC",
|
|
locale: "en",
|
|
aiProvider: "ANTHROPIC",
|
|
aiAnthropicKeyEncrypted: "v1:presence-only",
|
|
consentReceipts: {
|
|
create: { kind: "ai_full", artefact: "test", signedAt: new Date() },
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function seedTurn(userId: string, message: string) {
|
|
const prisma = getPrismaClient();
|
|
const conversation = await prisma.coachConversation.create({
|
|
data: { userId, titleEncrypted: encryptToBytes("t") },
|
|
});
|
|
const userMessage = await prisma.coachMessage.create({
|
|
data: {
|
|
conversationId: conversation.id,
|
|
role: "user",
|
|
encryptedContent: encryptToBytes(message),
|
|
},
|
|
});
|
|
return { conversationId: conversation.id, userMessageId: userMessage.id };
|
|
}
|
|
|
|
async function assistantMessage(
|
|
conversationId: string,
|
|
opts: { trail?: unknown; metricSource?: unknown },
|
|
) {
|
|
return getPrismaClient().coachMessage.create({
|
|
data: {
|
|
conversationId,
|
|
role: "assistant",
|
|
encryptedContent: encryptToBytes("answer"),
|
|
trailEncrypted: opts.trail
|
|
? encryptToBytes(JSON.stringify(opts.trail))
|
|
: null,
|
|
metricSourceJson: opts.metricSource
|
|
? JSON.stringify(opts.metricSource)
|
|
: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Stores the turn's answer the way the pipeline does. */
|
|
function storeAnswer(
|
|
conversationId: string,
|
|
provenance: Partial<CoachProvenance>,
|
|
providerType = "anthropic",
|
|
) {
|
|
return appendMessage({
|
|
conversationId,
|
|
role: "assistant",
|
|
content: "answer",
|
|
metricSource: { windows: [], metrics: [], ...provenance },
|
|
providerType,
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await truncateAllTables(getPrismaClient());
|
|
resetTurnMarksForTests();
|
|
resetStagedTurnWritesForTests();
|
|
});
|
|
|
|
describe("a fact the Coach keeps reaches the next turn", () => {
|
|
it("saves a goal from the message, then carries it in the block and stamps its use", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("memory-goal");
|
|
const turn = await seedTurn(
|
|
user.id,
|
|
"I want to get down to 75 kg by December.",
|
|
);
|
|
|
|
const saved = await rememberFactFromTool({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
userMessage: "I want to get down to 75 kg by December.",
|
|
call: {
|
|
category: "goal",
|
|
fact: "Wants to reach 75 kg by December",
|
|
why: "a goal",
|
|
},
|
|
});
|
|
expect(saved.kind).toBe("saved");
|
|
if (saved.kind !== "saved") return;
|
|
// Nothing until the answer that notes it is stored.
|
|
expect(await prisma.coachFact.count()).toBe(0);
|
|
await storeAnswer(turn.conversationId, {
|
|
memoryNote: {
|
|
proposal: false,
|
|
factId: saved.note.factId,
|
|
category: saved.note.category,
|
|
},
|
|
});
|
|
const row = await prisma.coachFact.findFirstOrThrow({
|
|
where: { userId: user.id },
|
|
});
|
|
// The id the note (and its Undo) carries is the row's.
|
|
expect(row.id).toBe(saved.note.factId);
|
|
expect(row.source).toBe("coach");
|
|
expect(row.sourceMessageId).toBe(turn.userMessageId);
|
|
expect(decryptFromBytes(row.factEncrypted)).toBe(
|
|
"Wants to reach 75 kg by December",
|
|
);
|
|
expect(row.lastUsedAt).toBeNull();
|
|
|
|
const block = await buildMemoryContextBlock({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
});
|
|
expect(block?.factIds).toEqual([row.id]);
|
|
expect(block?.text).toContain("- [goal] Wants to reach 75 kg by December");
|
|
|
|
const after = await prisma.coachFact.findUniqueOrThrow({
|
|
where: { id: row.id },
|
|
});
|
|
expect(after.lastUsedAt).not.toBeNull();
|
|
// A use is not an edit: the ranking's recency stays where it was.
|
|
expect(after.updatedAt.getTime()).toBe(row.updatedAt.getTime());
|
|
});
|
|
});
|
|
|
|
describe("a health fact waits for the person's tap", () => {
|
|
it("writes nothing on the proposal, and the tap saves the text read from the trail", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("memory-health");
|
|
const turn = await seedTurn(user.id, "ich nehme seit Mai Mounjaro");
|
|
|
|
const proposed = await rememberFactFromTool({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
userMessage: "ich nehme seit Mai Mounjaro",
|
|
call: {
|
|
category: "medication",
|
|
fact: "Nimmt seit Mai Mounjaro",
|
|
why: "",
|
|
},
|
|
});
|
|
expect(proposed.kind).toBe("proposed");
|
|
if (proposed.kind !== "proposed") return;
|
|
expect(await prisma.coachFact.count()).toBe(0);
|
|
|
|
const answer = await assistantMessage(turn.conversationId, {
|
|
trail: {
|
|
entries: [],
|
|
proposal: {
|
|
proposalId: proposed.note.proposalId,
|
|
category: proposed.note.category,
|
|
fact: proposed.note.fact,
|
|
},
|
|
},
|
|
});
|
|
|
|
const decided = await decideFactProposal({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
messageId: answer.id,
|
|
proposalId: proposed.note.proposalId!,
|
|
accept: true,
|
|
});
|
|
expect(decided.kind).toBe("saved");
|
|
const row = await prisma.coachFact.findFirstOrThrow({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(row).toMatchObject({
|
|
category: "medication",
|
|
source: "user",
|
|
sourceMessageId: answer.id,
|
|
});
|
|
expect(decryptFromBytes(row.factEncrypted)).toBe("Nimmt seit Mai Mounjaro");
|
|
|
|
// Another person cannot answer it.
|
|
const other = await seedUser("memory-other");
|
|
await expect(
|
|
decideFactProposal({
|
|
userId: other.id,
|
|
conversationId: turn.conversationId,
|
|
messageId: answer.id,
|
|
proposalId: proposed.note.proposalId!,
|
|
accept: true,
|
|
}),
|
|
).resolves.toEqual({ kind: "stale" });
|
|
});
|
|
|
|
it("offers a background proposal once, and lets it lapse after fourteen days", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("memory-pending");
|
|
const turn = await seedTurn(user.id, "I'm allergic to peanuts.");
|
|
|
|
await storeDeterministicFacts({
|
|
conversationId: turn.conversationId,
|
|
userId: user.id,
|
|
message: "I'm allergic to peanuts.",
|
|
locale: "en",
|
|
});
|
|
const pending = await prisma.coachFact.findFirstOrThrow({
|
|
where: { userId: user.id },
|
|
});
|
|
expect(pending.source).toBe("proposed");
|
|
|
|
const first = await buildMemoryContextBlock({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
});
|
|
expect(first?.pendingProposal).toMatchObject({
|
|
proposal: true,
|
|
proposalId: pending.id,
|
|
category: "condition",
|
|
});
|
|
// Not a known fact: never in the block itself.
|
|
expect(first?.text).toBe("");
|
|
// A turn that never stores its answer has not offered it.
|
|
const retried = await buildMemoryContextBlock({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
});
|
|
expect(retried?.pendingProposal?.proposalId).toBe(pending.id);
|
|
// A blocked answer (stored without its note) has not offered it either.
|
|
await storeAnswer(turn.conversationId, {});
|
|
expect(
|
|
(
|
|
await buildMemoryContextBlock({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
})
|
|
)?.pendingProposal?.proposalId,
|
|
).toBe(pending.id);
|
|
// An answer that carries it offers it, once.
|
|
await storeAnswer(turn.conversationId, {
|
|
memoryNote: {
|
|
proposal: true,
|
|
proposalId: pending.id,
|
|
category: "condition",
|
|
},
|
|
});
|
|
const offered = await prisma.coachFact.findUniqueOrThrow({
|
|
where: { id: pending.id },
|
|
});
|
|
expect(offered.lastUsedAt).not.toBeNull();
|
|
expect(offered.updatedAt.getTime()).toBe(pending.updatedAt.getTime());
|
|
const second = await buildMemoryContextBlock({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
});
|
|
expect(second).toBeNull();
|
|
|
|
await prisma.coachFact.update({
|
|
where: { id: pending.id },
|
|
data: { createdAt: new Date(Date.now() - 15 * DAY) },
|
|
});
|
|
const summary = await runCoachReminderSweep(prisma, new Date());
|
|
expect(summary.factProposalsLapsed).toBe(1);
|
|
const lapsed = await prisma.coachFact.findUniqueOrThrow({
|
|
where: { id: pending.id },
|
|
});
|
|
expect(lapsed.deletedAt).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("a plan the person takes on", () => {
|
|
it("is proposed, activated by the tap, and carries its progress into the block and the briefing", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("memory-plan");
|
|
const turn = await seedTurn(
|
|
user.id,
|
|
"I want to lose weight, I could walk after dinner.",
|
|
);
|
|
|
|
const outcome = await proposePlanFromTool({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
call: {
|
|
metric: "WEIGHT",
|
|
target: "75 kg by December",
|
|
ifCue: "after dinner",
|
|
thenAction: "a 20-minute walk",
|
|
reviewInDays: 21,
|
|
},
|
|
});
|
|
expect(outcome.kind).toBe("proposed");
|
|
if (outcome.kind !== "proposed") return;
|
|
// Nothing until the answer that proposes it is stored.
|
|
expect(await prisma.coachPlan.count()).toBe(0);
|
|
const answer = await storeAnswer(turn.conversationId, {
|
|
planProposal: {
|
|
planId: outcome.proposal.planId,
|
|
metric: "WEIGHT",
|
|
reviewInDays: 21,
|
|
},
|
|
});
|
|
const proposedRow = await prisma.coachPlan.findUniqueOrThrow({
|
|
where: { id: outcome.proposal.planId },
|
|
});
|
|
expect(proposedRow.status).toBe("proposed");
|
|
expect(proposedRow.sourceConversationId).toBe(turn.conversationId);
|
|
expect(decryptFromBytes(proposedRow.ifCueEncrypted)).toBe("after dinner");
|
|
const decided = await decidePlanProposal({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
messageId: answer.id,
|
|
planId: outcome.proposal.planId,
|
|
accept: true,
|
|
});
|
|
expect(decided).toEqual({ kind: "activated", reviewInDays: 21 });
|
|
const active = await prisma.coachPlan.findUniqueOrThrow({
|
|
where: { id: outcome.proposal.planId },
|
|
});
|
|
expect(active.status).toBe("active");
|
|
expect(active.reviewDate!.getTime() - Date.now()).toBeGreaterThan(20 * DAY);
|
|
|
|
// Readings before and after the start, so the line has numbers.
|
|
await prisma.coachPlan.update({
|
|
where: { id: active.id },
|
|
data: { createdAt: new Date(Date.now() - 12 * DAY) },
|
|
});
|
|
await prisma.measurement.createMany({
|
|
data: [20, 17, 14, 10, 7, 4, 2, 1].map((daysAgo, i) => ({
|
|
userId: user.id,
|
|
type: "WEIGHT" as const,
|
|
value: 80 - i * 0.2,
|
|
unit: "kg",
|
|
source: "MANUAL" as const,
|
|
measuredAt: new Date(Date.now() - daysAgo * DAY),
|
|
})),
|
|
});
|
|
|
|
const block = await buildMemoryContextBlock({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
});
|
|
expect(block?.planIds).toEqual([active.id]);
|
|
expect(block?.text).toContain(
|
|
"- WEIGHT: if after dinner, then a 20-minute walk (target: 75 kg by December). Progress: weight plan since",
|
|
);
|
|
expect(block?.text).toMatch(/latest 7-day mean \d+(\.\d)? kg/);
|
|
|
|
const lines = await buildPlanProgressLines(user.id);
|
|
expect(lines).toHaveLength(1);
|
|
expect(lines[0]).toMatch(
|
|
/^weight plan since \d{4}-\d{2}-\d{2}; target "75 kg by December"/,
|
|
);
|
|
|
|
// A second tap on the same answer changes nothing.
|
|
await expect(
|
|
decidePlanProposal({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
messageId: answer.id,
|
|
planId: outcome.proposal.planId,
|
|
accept: false,
|
|
}),
|
|
).resolves.toEqual({ kind: "stale" });
|
|
});
|
|
});
|
|
|
|
describe("a turn that does not store its answer keeps nothing", () => {
|
|
const PLAN = {
|
|
metric: "WEIGHT",
|
|
ifCue: "after dinner",
|
|
thenAction: "a 20-minute walk",
|
|
reviewInDays: 14,
|
|
};
|
|
|
|
it("never keeps a plan whose text the outbound screen blocks", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("memory-plan-unsafe");
|
|
const turn = await seedTurn(user.id, "Help me with my blood pressure.");
|
|
const outcome = await proposePlanFromTool({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
call: { ...PLAN, thenAction: "increase your ramipril to 10 mg" },
|
|
});
|
|
expect(outcome).toEqual({ kind: "declined", reason: "unsafe" });
|
|
await storeAnswer(turn.conversationId, {});
|
|
expect(await prisma.coachPlan.count()).toBe(0);
|
|
});
|
|
|
|
it.each([
|
|
["a blocked answer", () => ({ provenance: {}, providerType: "anthropic" })],
|
|
[
|
|
"an abandoned turn",
|
|
() => ({ provenance: undefined, providerType: "cancelled" }),
|
|
],
|
|
])("leaves no plan and no fact behind after %s", async (_label, shape) => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser(`memory-nothing-${_label.length}`);
|
|
const message = "I prefer to walk after dinner, help me lose weight.";
|
|
const turn = await seedTurn(user.id, message);
|
|
const plan = await proposePlanFromTool({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
call: PLAN,
|
|
});
|
|
const fact = await rememberFactFromTool({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
userMessage: message,
|
|
call: {
|
|
category: "preference",
|
|
fact: "Prefers to walk after dinner",
|
|
why: "",
|
|
},
|
|
});
|
|
expect(plan.kind).toBe("proposed");
|
|
expect(fact.kind).toBe("saved");
|
|
const { provenance, providerType } = shape();
|
|
if (provenance === undefined) {
|
|
await appendMessage({
|
|
conversationId: turn.conversationId,
|
|
role: "assistant",
|
|
content: "",
|
|
providerType,
|
|
});
|
|
} else {
|
|
await storeAnswer(turn.conversationId, provenance, providerType);
|
|
}
|
|
// What GET /api/coach/plans and the memory list read: nothing.
|
|
expect(await prisma.coachPlan.count()).toBe(0);
|
|
expect(await prisma.coachFact.count()).toBe(0);
|
|
// A later answer in the conversation cannot claim them either.
|
|
if (plan.kind !== "proposed" || fact.kind !== "saved") return;
|
|
await storeAnswer(turn.conversationId, {
|
|
planProposal: {
|
|
planId: plan.proposal.planId,
|
|
metric: "WEIGHT",
|
|
reviewInDays: 14,
|
|
},
|
|
memoryNote: {
|
|
proposal: false,
|
|
factId: fact.note.factId,
|
|
category: "preference",
|
|
},
|
|
});
|
|
expect(await prisma.coachPlan.count()).toBe(0);
|
|
expect(await prisma.coachFact.count()).toBe(0);
|
|
});
|
|
|
|
it("leaves nothing behind when the provider fails and no answer is stored", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("memory-provider-failed");
|
|
const turn = await seedTurn(user.id, "help me lose weight");
|
|
const plan = await proposePlanFromTool({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
locale: "en",
|
|
call: PLAN,
|
|
});
|
|
expect(plan.kind).toBe("proposed");
|
|
expect(await prisma.coachPlan.count()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("two taps on Yes, remember it", () => {
|
|
it("save the fact once", async () => {
|
|
const prisma = getPrismaClient();
|
|
const user = await seedUser("memory-double-tap");
|
|
const turn = await seedTurn(user.id, "I have asthma");
|
|
const answer = await assistantMessage(turn.conversationId, {
|
|
trail: {
|
|
entries: [],
|
|
proposal: {
|
|
proposalId: "mp_double",
|
|
category: "condition",
|
|
fact: "Has asthma",
|
|
},
|
|
},
|
|
});
|
|
const tap = () =>
|
|
decideFactProposal({
|
|
userId: user.id,
|
|
conversationId: turn.conversationId,
|
|
messageId: answer.id,
|
|
proposalId: "mp_double",
|
|
accept: true,
|
|
});
|
|
const outcomes = await Promise.all(Array.from({ length: 12 }, tap));
|
|
// One row, and one tap answered: the others find it already decided.
|
|
expect(await prisma.coachFact.count({ where: { userId: user.id } })).toBe(
|
|
1,
|
|
);
|
|
expect(outcomes.filter((o) => o.kind === "saved")).toHaveLength(1);
|
|
});
|
|
});
|