fix(coach): keep a turn's fact and plan only with the answer that carries them

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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-06 21:49:27 +02:00
1 parent fac55720a4
commit 5361239ae5
21 files changed
+1176 -157

No files matched your search

+4 -1
View File
@@ -5,7 +5,10 @@
* Coach proposes. A plan is an "if-then" implementation intention tied to one
* metric, with an optional target. The Coach extractor writes a plan as
* `status: "proposed"`; only the user-facing PATCH (`/api/coach/plans/[id]`)
* activates it. There is no silent self-edit of the user's plan set.
* activates it. There is no silent self-edit of the user's plan set. A plan
* the Coach proposes in a turn (`propose_plan`, v1.41) exists only once the
* answer carrying it is stored (`memory/turn-writes.ts`), so a turn that was
* blocked, failed or abandoned has nothing here to list.
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
* only ever see their own plans. The free-text fields (if-cue, then-action,
@@ -1,18 +1,26 @@
/**
* v1.41 — the trail route: owner-narrowed, 404 for anything else, and the
* model-written text only while the Coach's text may be shown.
* v1.41 — the trail route: owner-narrowed, 404 for anything else, the
* model-written text only while the Coach's text may be shown, and the
* person's own facts (recalled, proposed) always, minus medication facts
* while the medications module is off.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import type * as ApiHandlerModule from "@/lib/api-handler";
const { requireAuth, readMessageTrail, aiCapabilityToServe } = vi.hoisted(
() => ({
requireAuth: vi.fn(),
readMessageTrail: vi.fn(),
aiCapabilityToServe: vi.fn(),
}),
);
const {
requireAuth,
readMessageTrail,
aiCapabilityToServe,
isModuleEnabled,
findMany,
} = vi.hoisted(() => ({
requireAuth: vi.fn(),
readMessageTrail: vi.fn(),
aiCapabilityToServe: vi.fn(),
isModuleEnabled: vi.fn(),
findMany: vi.fn(),
}));
vi.mock("@/lib/api-handler", async (importOriginal) => {
const actual = await importOriginal<typeof ApiHandlerModule>();
@@ -26,6 +34,11 @@ vi.mock("@/lib/api-handler", async (importOriginal) => {
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/ai/coach/persistence", () => ({ readMessageTrail }));
vi.mock("@/lib/ai/capabilities/gate", () => ({ aiCapabilityToServe }));
vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled }));
vi.mock("@/lib/db", () => ({ prisma: { coachFact: { findMany } } }));
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
decryptFromBytes: (b: Uint8Array) => new TextDecoder().decode(b),
}));
import { GET } from "../route";
@@ -57,8 +70,26 @@ beforeEach(() => {
reason: null,
onDeviceAllowed: true,
});
isModuleEnabled.mockResolvedValue(true);
findMany.mockResolvedValue([]);
});
const UNAVAILABLE = {
available: false,
reason: "disabled_by_user",
onDeviceAllowed: false,
};
const WITH_FACTS = {
...TRAIL,
recalled: ["Prefers walks after dinner", "Takes Mounjaro since May"],
proposal: {
proposalId: "mp_1",
category: "condition",
fact: "Has asthma",
},
};
describe("GET /api/insights/chat/[id]/messages/[messageId]/trail", () => {
it("reads under the caller's own id and the path ids only", async () => {
readMessageTrail.mockResolvedValue({ trail: TRAIL });
@@ -94,6 +125,48 @@ describe("GET /api/insights/chat/[id]/messages/[messageId]/trail", () => {
expect(aiCapabilityToServe).toHaveBeenCalledWith(USER_ID, "coach");
});
it("serves the person's own facts while the model text is withheld", async () => {
readMessageTrail.mockResolvedValue({ trail: WITH_FACTS });
aiCapabilityToServe.mockResolvedValue(UNAVAILABLE);
const body = await (await call()).json();
expect(body.data.trail).toEqual({
entries: [],
recalled: WITH_FACTS.recalled,
proposal: WITH_FACTS.proposal,
});
expect(body.data.ai.available).toBe(false);
});
it("withholds medication facts while the medications module is off", async () => {
readMessageTrail.mockResolvedValue({
trail: {
...WITH_FACTS,
recalled: [...WITH_FACTS.recalled, "Morning pill with coffee"],
proposal: {
proposalId: "mp_1",
category: "medication",
fact: "Takes ramipril",
},
},
});
isModuleEnabled.mockResolvedValue(false);
// A stored fact filed under medication whose words the lexicon misses.
findMany.mockResolvedValue([
{ factEncrypted: new TextEncoder().encode("Morning pill with coffee") },
]);
const body = await (await call()).json();
expect(isModuleEnabled).toHaveBeenCalledWith(USER_ID, "medications");
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { userId: USER_ID, category: "medication" },
}),
);
expect(body.data.trail).toEqual({
entries: TRAIL.entries,
recalled: ["Prefers walks after dinner"],
});
});
it("answers a message without a trail with null", async () => {
readMessageTrail.mockResolvedValue({ trail: null });
const body = await (await call()).json();
@@ -1,20 +1,26 @@
/**
* GET /api/insights/chat/[id]/messages/[messageId]/trail — the model-written
* text of one assistant message's trail (v1.41): the screened reasoning
* titles and summaries and checkpoint sentences by trail entry, decrypted for
* their owner. The structure of the trail (phases, statuses, counts) is
* plaintext on the message's `metricSource.activity`; only the text is here,
* read lazily so a long thread does not carry every trail.
* GET /api/insights/chat/[id]/messages/[messageId]/trail — the text of one
* assistant message's trail (v1.41), decrypted for its owner: the screened
* reasoning titles and summaries and checkpoint sentences by trail entry,
* the facts the turn recalled, and a fact proposal it offered. The structure
* of the trail (phases, statuses, counts) is plaintext on the message's
* `metricSource.activity`; only the text is here, read lazily so a long
* thread does not carry every trail.
*
* Narrowed exactly like `…/results`: the message must sit in a conversation
* the caller owns, and a foreign or unknown id is 404, never 403. A session
* or a full-access token reads it; a narrow token is refused by
* `requireAuth()` like on every Coach read.
*
* A mixed read: the text is model-written, so it is served only while the
* Coach's text may be shown for the record (`aiCapabilityToServe`). When it
* may not, the answer is 200 with `trail: null` and the `ai` state beside it,
* and the client keeps the trail's catalog labels.
* A mixed read, split by who wrote what. The entries' text is model-written,
* so it is served only while the Coach's text may be shown for the record
* (`aiCapabilityToServe`); when it may not, `entries` is empty, the `ai`
* state rides beside it, and the client keeps the trail's catalog labels.
* The recalled facts and the proposal's fact are the person's own data and
* are served to the owner whatever the capability, like the memory list:
* only the medications module gates them, at read time, the way `…/results`
* withholds a table whose module is off. `trail` is null when nothing is
* left to serve.
*/
import type { NextRequest } from "next/server";
@@ -23,6 +29,9 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { aiCapabilityToServe } from "@/lib/ai/capabilities/gate";
import { readMessageTrail } from "@/lib/ai/coach/persistence";
import { withholdMedicationFacts } from "@/lib/ai/coach/memory/trail-read";
import { isModuleEnabled } from "@/lib/modules/gate";
import type { CoachTrail } from "@/lib/ai/coach/types";
interface RouteCtx {
params: Promise<{ id: string; messageId: string }>;
@@ -38,13 +47,34 @@ export const GET = apiHandler(async (_request: NextRequest, ctx: RouteCtx) => {
if (!read) return apiError("coach.message.notFound", 404);
const ai = await aiCapabilityToServe(userId, "coach");
const trail = ai.available ? read.trail : null;
const stored = read.trail;
let trail: CoachTrail | null = null;
let withheld = 0;
if (stored) {
const medicationsOn = await isModuleEnabled(userId, "medications");
const facts = medicationsOn
? { recalled: stored.recalled, proposal: stored.proposal, withheld: 0 }
: await withholdMedicationFacts(userId, stored);
withheld = facts.withheld;
const entries = ai.available ? stored.entries : [];
const recalled =
facts.recalled && facts.recalled.length > 0 ? facts.recalled : undefined;
if (entries.length > 0 || recalled || facts.proposal) {
trail = {
entries,
...(recalled ? { recalled } : {}),
...(facts.proposal ? { proposal: facts.proposal } : {}),
};
}
}
annotate({
action: { name: "insights.coach.trail.read" },
meta: {
conversationId: id,
entries: trail?.entries.length ?? 0,
recalled: trail?.recalled?.length ?? 0,
withheld,
served: ai.available,
},
});
+36 -11
View File
@@ -6,10 +6,15 @@
* Coach facts surface. It is also the "Undo" under a note the Coach saved in
* a turn (v1.41).
*
* v1.41 — PATCH rewrites the fact's text (re-encrypted; the category and the
* source stay). Only a listed fact can be edited: a proposal still waiting
* for the person's answer is not one, and answers 404 like an unknown id.
* Editing is the person's own data, so no Coach or AI gate.
* v1.41 — PATCH rewrites the fact's text (re-encrypted) and reads the new
* wording again (`settleCategory`, the lexicon the Coach's own saves pass):
* text that now names a medication moves to `medication`, so the medications
* module still filters it; other health wording moves a non-health category
* to `condition`; otherwise the category stays. A health fact the person
* wrote themselves is confirmed by that edit (`source: "user"`). Only a
* listed fact can be edited: a proposal still waiting for the person's
* answer is not one, and answers 404 like an unknown id. Editing is the
* person's own data, so no Coach or AI gate.
*
* Ownership + existence privacy: the soft-delete uses `updateMany` scoped
* `where: { id, userId, deletedAt: null }` rather than `update`. A
@@ -34,7 +39,13 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { isHealthFact } from "@/lib/ai/coach/facts";
import { settleCategory } from "@/lib/ai/coach/memory/remember";
import { PROPOSED_FACT_SOURCE } from "@/lib/ai/coach/memory/shared";
import {
COACH_MEMORY_CATEGORIES,
type CoachMemoryCategory,
} from "@/lib/ai/coach/types";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { coachFactPatchSchema } from "@/lib/validations/coach-fact";
@@ -70,16 +81,30 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const text = parsed.data.fact.replace(/\s+/g, " ");
const live = {
id,
userId: user.id,
deletedAt: null,
source: { not: PROPOSED_FACT_SOURCE },
};
const current = await prisma.coachFact.findFirst({
where: live,
select: { category: true, source: true },
});
if (!current) return apiError("Fact not found", 404);
const known = (COACH_MEMORY_CATEGORIES as readonly string[]).includes(
current.category,
);
const category = known
? settleCategory(current.category as CoachMemoryCategory, text)
: current.category;
// A health fact the person wrote is one they confirmed.
const source = isHealthFact(category, text) ? "user" : current.source;
// `updateMany` scoped by id, owner and liveness: an unknown, cross-user,
// deleted or still-proposed id is a 0-count no-op, never a P2025 throw.
const { count } = await prisma.coachFact.updateMany({
where: {
id,
userId: user.id,
deletedAt: null,
source: { not: PROPOSED_FACT_SOURCE },
},
data: { factEncrypted: encryptToBytes(text) },
where: live,
data: { factEncrypted: encryptToBytes(text), category, source },
});
if (count === 0) {
// Indistinguishable from "owned by someone else" — never reveal which.
@@ -16,7 +16,8 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/lib/ai/coach/memory/remember", () => ({
vi.mock("@/lib/ai/coach/memory/remember", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/ai/coach/memory/remember")>()),
rememberMessageAsFact: vi.fn(),
}));
@@ -237,10 +238,47 @@ describe("PATCH /api/insights/coach/facts/[id]", () => {
expect(Buffer.from(arg.data.factEncrypted).toString()).toBe(
"Wants 74 kg by December",
);
expect(Object.keys(arg.data)).toEqual(["factEncrypted"]);
// Not health: the category and the source stay.
expect(arg.data).toMatchObject({ category: "goal", source: "coach" });
expect(Object.keys(arg.data).sort()).toEqual(
["category", "factEncrypted", "source"].sort(),
);
});
it.each([
// Health text under a non-health category: moved, and confirmed.
["preference", "coach", "Takes metformin with breakfast", "medication"],
["context", "extracted", "Has asthma since childhood", "condition"],
// A medication named under another health category still moves, so the
// medications module keeps filtering it.
["condition", "user", "Asthma, uses a salbutamol inhaler", "medication"],
// A health category the new wording no longer reads as: kept.
["constraint", "coach", "No running before 7", "constraint"],
])(
"re-reads a %s fact (%s) edited to %j as %s",
async (category, source, fact, expected) => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(prisma.coachFact.findFirst).mockResolvedValue({
id: "f1",
category,
source,
updatedAt: new Date("2026-10-06T10:00:00Z"),
} as never);
const res = await callPatch(req("PATCH", { fact }), ctx);
expect(res.status).toBe(200);
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
data: { category: string; source: string };
};
expect(arg.data.category).toBe(expected);
// A health fact the person wrote themselves is one they confirmed.
expect(arg.data.source).toBe("user");
},
);
it("is a 404 for an unknown, foreign or proposed id", async () => {
vi.mocked(prisma.coachFact.findFirst).mockResolvedValue(null);
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);