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