fix(coach): make remembering reliable and survivable

A remember action under every persisted chat message adopts its content
into the matching self-context field — allergy statements land in
allergies, diagnosis wording in conditions, everything else in the
coach focus — reusing the adopt endpoint's dedupe and encryption, with
the result visible and editable where the self-context lives. The
deterministic fact patterns accept closed filler-word and possessive
variants that previously defeated them, and stored facts survive the
snapshot budget squeeze instead of being the first block shed on
data-rich accounts.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 21:52:51 +02:00
1 parent 242d2514ee
commit a664395021
9 files changed
+387 -40

No files matched your search

@@ -63,7 +63,7 @@ import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
const transaction = prisma.$transaction as ReturnType<typeof vi.fn>;
const getCtx = getSelfContextForUser as ReturnType<typeof vi.fn>;
function adoptRequest(body: { question: string; answer: string }): Request {
function adoptRequest(body: { question?: string; answer: string }): Request {
return new Request("http://localhost/api/coach/about-me/adopt", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -143,6 +143,39 @@ describe("POST /api/coach/about-me/adopt", () => {
);
});
// v1.16.8 — the remember action on a chat message sends only the
// message text; the target field is matched from the text itself.
it("matches the field from the answer text when no question is sent", async () => {
getCtx.mockResolvedValue(emptyCtx);
const res = await post(
adoptRequest({ answer: "Ich habe eine Erdnussallergie" }),
);
expect(res.status).toBe(200);
expect(res.data).toEqual({ adopted: true, field: "allergies" });
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
expect(Object.keys(data)).toEqual(["allergiesEncrypted"]);
});
it("lands a question-less answer without keywords on coachFocus", async () => {
getCtx.mockResolvedValue(emptyCtx);
const res = await post(
adoptRequest({ answer: "Ich gehe abends gern spazieren" }),
);
expect(res.data).toEqual({ adopted: true, field: "coachFocus" });
const data = tx.userHealthProfile.update.mock.calls[0][0].data;
expect(Object.keys(data)).toEqual(["coachFocusEncrypted"]);
});
it("still 422s on an empty answer regardless of the question", async () => {
const res = await post(adoptRequest({ answer: " " }));
expect(res.status).toBe(422);
expect(tx.userHealthProfile.update).not.toHaveBeenCalled();
});
it("422s when the overflow target aboutMe is full, still without a write", async () => {
getCtx.mockResolvedValue({
...emptyCtx,
+19 -9
View File
@@ -13,6 +13,10 @@
* - The target field is picked from the QUESTION wording (allergy /
* condition keywords across the six UI locales; everything else
* lands on `coachFocus`, the "what the Coach should know" slot).
* v1.16.8 — the question is optional: the remember action on a
* chat message sends only the message text, and the field is then
* matched from that text ("ich habe eine Erdnussallergie" lands on
* `allergies` directly).
* - Dedupe: an answer already contained in the target field (or in
* the free-text `aboutMe`) is a no-op (`adopted: false`) — tapping
* the offer twice or re-answering the same chip cannot stack
@@ -49,11 +53,12 @@ const POST_WINDOW_MS = 60_000;
type StructuredField = "conditions" | "allergies" | "coachFocus";
/**
* Keyword match against the question wording, covering the six UI
* locales the question generator writes in (de / en / es / fr / it /
* pl). Stems, not words, so inflections match. Anything that is not
* clearly an allergy or condition question lands on `coachFocus` — the
* generic "worth knowing for the Coach" slot.
* Keyword match against the question wording — or, for the
* message-remember path, against the message text itself — covering
* the six UI locales the question generator writes in (de / en / es /
* fr / it / pl). Stems, not words, so inflections match. Anything that
* is not clearly an allergy or condition statement lands on
* `coachFocus` — the generic "worth knowing for the Coach" slot.
*/
function matchSelfContextField(question: string): StructuredField {
const q = question.toLowerCase();
@@ -114,6 +119,11 @@ export const POST = apiHandler(async (req: Request) => {
const { question, answer } = parsed.data;
const normAnswer = normalise(answer);
// Question-led adoptions (guided dialog) match the field from the
// question wording; the message-remember path matches from the
// message text itself.
const matchSource = question ?? answer;
const adoptionSource = question ? "question" : "message";
// Read-modify-write under a row lock: two concurrent adoptions used
// to read the same base value and one append silently lost (or the
@@ -134,7 +144,7 @@ export const POST = apiHandler(async (req: Request) => {
`;
const ctx = await getSelfContextForUser(user.id, tx);
let field: StructuredField | "aboutMe" = matchSelfContextField(question);
let field: StructuredField | "aboutMe" = matchSelfContextField(matchSource);
// Dedupe BEFORE any write: an answer already present in the target
// field or in the free-text aboutMe never stacks a second copy.
@@ -181,7 +191,7 @@ export const POST = apiHandler(async (req: Request) => {
if (outcome.kind === "duplicate") {
annotate({
action: { name: "coach.about_me.adopt_deduped" },
meta: { field },
meta: { field, source: adoptionSource },
});
return apiSuccess({ adopted: false, field, reason: "duplicate" });
}
@@ -192,12 +202,12 @@ export const POST = apiHandler(async (req: Request) => {
await auditLog("coach.about_me.adopted", {
userId: user.id,
ipAddress: getClientIp(req),
details: { field, answerLength: answer.length },
details: { field, answerLength: answer.length, source: adoptionSource },
});
annotate({
action: { name: "coach.about_me.adopted" },
meta: { field, answer_length: answer.length },
meta: { field, answer_length: answer.length, source: adoptionSource },
});
return apiSuccess({ adopted: true, field });