From a6643950217d10385b74016e3cd5dba8f76f3efa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 21:52:51 +0200 Subject: [PATCH] fix(coach): make remembering reliable and survivable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../about-me/adopt/__tests__/route.test.ts | 35 +++++- src/app/api/coach/about-me/adopt/route.ts | 28 +++-- .../__tests__/message-thread.test.tsx | 43 ++++++++ .../insights/coach-panel/message-thread.tsx | 102 +++++++++++++++++- .../__tests__/facts-deterministic.test.ts | 45 ++++++++ .../coach/__tests__/snapshot-budget.test.ts | 41 +++++++ src/lib/ai/coach/facts.ts | 100 +++++++++++++---- src/lib/ai/coach/snapshot.ts | 21 +++- src/lib/validations/about-me.ts | 12 ++- 9 files changed, 387 insertions(+), 40 deletions(-) diff --git a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts index 09e0fea66..f25792089 100644 --- a/src/app/api/coach/about-me/adopt/__tests__/route.test.ts +++ b/src/app/api/coach/about-me/adopt/__tests__/route.test.ts @@ -63,7 +63,7 @@ import { getSelfContextForUser } from "@/lib/ai/coach/about-me"; const transaction = prisma.$transaction as ReturnType; const getCtx = getSelfContextForUser as ReturnType; -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, diff --git a/src/app/api/coach/about-me/adopt/route.ts b/src/app/api/coach/about-me/adopt/route.ts index ee5463153..5f8c1d8e5 100644 --- a/src/app/api/coach/about-me/adopt/route.ts +++ b/src/app/api/coach/about-me/adopt/route.ts @@ -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 }); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 53dd831fb..d298b3ed1 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -481,6 +481,49 @@ describe("", () => { expect(userIdx).toBeLessThan(assistantIdx); }); + // v1.16.8 — the remember control under persisted user bubbles. + it("renders the remember control under a persisted user message", () => { + const html = render(); + expect(html).toContain('data-slot="coach-remember-message"'); + expect(html).toContain("Remember"); + }); + + it("localises the remember control under 'de'", () => { + const html = render(, "de"); + expect(html).toContain('data-slot="coach-remember-message"'); + expect(html).toContain("Merken"); + }); + + it("omits the remember control on an optimistic user bubble (no id yet)", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="coach-bubble-user"'); + expect(html).not.toContain('data-slot="coach-remember-message"'); + }); + + it("omits the remember control when the message exceeds the field cap", () => { + const long = { + ...baseConversation, + messages: [ + { + ...baseConversation.messages[0], + content: "x".repeat(501), + }, + ], + }; + const html = render(); + expect(html).toContain('data-slot="coach-bubble-user"'); + expect(html).not.toContain('data-slot="coach-remember-message"'); + }); + // v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy. it("surfaces the daily-limit copy for coach.budget.exceeded", () => { const html = render( diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 21bc13e58..f88a40b66 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -4,8 +4,11 @@ import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; import { + BookmarkPlus, Bot, + Check, ChevronRight, + Loader2, Sparkles, ThumbsDown, ThumbsUp, @@ -18,6 +21,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { ApiError, apiPost } from "@/lib/api/api-fetch"; import { stripChartTokens } from "@/lib/insights/chart-tokens"; import { useAuth } from "@/hooks/use-auth"; +import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me"; import { SourceChips } from "./source-chips"; import type { @@ -469,15 +473,32 @@ function ChatBubble({ >
- {content} +
+ {content} +
+ {/* v1.16.8 — explicit remember control. Stating an allergy in + chat used to leave no durable trace unless a narrow + pattern pass happened to match the phrasing; this stores + the message into the editable self-context (Settings → AI) + on one tap, so it rides every future system prompt. Only + persisted messages get the control (an optimistic bubble + has no id yet), and only when the text fits the + self-context field cap. */} + {messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && ( + + )}
{avatarUrl ? ( // eslint-disable-next-line @next/next/no-img-element @@ -691,6 +712,79 @@ function ChatBubble({ ); } +/** + * v1.16.8 — per-message remember control under the user bubble. + * + * One tap stores the message text into the structured self-context via + * `POST /api/coach/about-me/adopt` (no `question` — the server matches + * the target field from the text itself: an allergy statement lands on + * the allergies field, a stated condition on conditions, everything + * else on the coach-focus slot). The stored text is visible and + * editable under Settings → AI and rides every future Coach system + * prompt. Settled states mirror `SelfContextAdoptOffer`: a short + * confirmation replaces the button after an adoption or a server-side + * dedupe; a failure surfaces a toast and the button stays tappable. + */ +function RememberUserMessage({ content }: { content: string }) { + const { t } = useTranslations(); + const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null); + + const remember = useMutation({ + mutationFn: async () => { + return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", { + answer: content, + }); + }, + onSuccess: (data) => { + setSettled(data.adopted ? "adopted" : "duplicate"); + }, + onError: () => { + toast.error(t("insights.coach.rememberMessage.failed")); + }, + }); + + if (settled) { + return ( +

+

+ ); + } + + return ( + + ); +} + /** * v1.16.1 — classic chat typing indicator: three dots pulsing in * sequence inside the assistant bubble, shown only between submit and diff --git a/src/lib/ai/coach/__tests__/facts-deterministic.test.ts b/src/lib/ai/coach/__tests__/facts-deterministic.test.ts index 05c7e6b6e..6600dd34c 100644 --- a/src/lib/ai/coach/__tests__/facts-deterministic.test.ts +++ b/src/lib/ai/coach/__tests__/facts-deterministic.test.ts @@ -63,6 +63,16 @@ describe("extractDeterministicFacts", () => { ["Ich hab eine Erdnuss-Allergie, seit Jahren.", "Erdnuss"], ["übrigens: ich bin allergisch gegen Penicillin.", "Penicillin"], ["ich habe eine Allergie gegen Hausstaubmilben", "Hausstaubmilben"], + // v1.16.8 — conversational fillers no longer defeat the pass. + ["Ich habe übrigens eine Erdnussallergie.", "Erdnuss"], + ["Ich habe seit Jahren eine Pollenallergie.", "Pollen"], + ["Ich habe auch eine Allergie gegen Nüsse.", "Nüsse"], + ["Übrigens, ich habe noch eine Allergie gegen Penicillin.", "Penicillin"], + ["Ich habe eine starke Erdnussallergie.", "Erdnuss"], + ["Ich bin leider allergisch auf Erdnüsse.", "Erdnüsse"], + // v1.16.8 — first-person possessive. + ["Meine Erdnussallergie macht mir zu schaffen.", "Erdnuss"], + ["Wegen meiner Pollenallergie schlafe ich schlecht.", "Pollen"], ])("matches the German allergy statement %j", (message, subject) => { const facts = extractDeterministicFacts(message, "de"); expect(facts).toHaveLength(1); @@ -81,6 +91,19 @@ describe("extractDeterministicFacts", () => { expect( extractDeterministicFacts("ich bin laktoseintolerant", "de")[0]?.fact, ).toContain("laktose"); + // v1.16.8 — fillers + possessive. + expect( + extractDeterministicFacts( + "ich habe übrigens eine Laktoseintoleranz", + "de", + )[0]?.fact, + ).toContain("Laktose"); + expect( + extractDeterministicFacts( + "wegen meiner Histaminunverträglichkeit", + "de", + )[0]?.fact, + ).toContain("Histamin"); }); it("matches a German self-reported diagnosis", () => { @@ -99,6 +122,11 @@ describe("extractDeterministicFacts", () => { ["I have a peanut allergy", "peanut"], ["I have a lactose intolerance", "lactose"], ["I was diagnosed with asthma", "asthma"], + // v1.16.8 — fillers + possessive no longer defeat the pass. + ["By the way, I also have a severe peanut allergy.", "peanut"], + ["I'm also allergic to penicillin.", "penicillin"], + ["My peanut allergy is acting up again.", "peanut"], + ["I was recently diagnosed with asthma.", "asthma"], ])("matches the English statement %j", (message, subject) => { const facts = extractDeterministicFacts(message, "en"); expect(facts).toHaveLength(1); @@ -116,6 +144,23 @@ describe("extractDeterministicFacts", () => { expect( extractDeterministicFacts("what is an allergy exactly?", "en"), ).toEqual([]); + // v1.16.8 — the filler tolerance is a closed list: open third-party + // clauses still never match. + expect( + extractDeterministicFacts( + "ich habe gehört dass seine Erdnussallergie schlimm ist", + "de", + ), + ).toEqual([]); + expect( + extractDeterministicFacts( + "ich habe eine Frage zu einer Erdnussallergie", + "de", + ), + ).toEqual([]); + expect( + extractDeterministicFacts("my friend has a peanut allergy", "en"), + ).toEqual([]); }); }); diff --git a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts index c0ba7dab0..063506be0 100644 --- a/src/lib/ai/coach/__tests__/snapshot-budget.test.ts +++ b/src/lib/ai/coach/__tests__/snapshot-budget.test.ts @@ -40,6 +40,26 @@ vi.mock("./glp1-snapshot", () => ({ buildGlp1SnapshotBlock: vi.fn().mockResolvedValue(null), })); +// v1.16.8 — the memory block carries durable personal facts (a stated +// allergy). The degrader may shed the bulky narrative recall but must +// keep the facts list; the mock pins both shapes in one block. +const MEMORY_FACT = { + category: "condition", + text: "Allergie: Erdnuss (eigene Angabe)", +}; +vi.mock("../memory-snapshot", () => ({ + buildCoachMemoryBlock: vi.fn().mockResolvedValue({ + trendMemory: {}, + priorNarrative: { headline: "n".repeat(600), drivers: [] }, + facts: [ + { + category: "condition", + text: "Allergie: Erdnuss (eigene Angabe)", + }, + ], + }), +})); + import { prisma } from "@/lib/db"; import { extractFeatures } from "@/lib/insights/features"; @@ -248,6 +268,27 @@ describe("buildCoachSnapshot — budgeting + progressive degradation", () => { expect(droppedBlocks).not.toContain("compliance"); }); + it("keeps the durable facts when the memory block is shed", async () => { + const out = await buildCoachSnapshot("user-1", { + sources: ALL_SOURCES, + window: "allTime", + }); + const truncated = annotateCalls.find( + (c) => c.name === "coach.snapshot.truncated", + ); + const droppedBlocks = (truncated?.meta?.droppedBlocks ?? []) as string[]; + // The memory block sits on the lowest-priority cluster and is shed + // under this dense load … + expect(droppedBlocks).toContain("memory"); + const snapshot = JSON.parse(out.snapshotJson) as { + memory?: { facts?: unknown[]; priorNarrative?: unknown }; + }; + // … but the durable facts survive the drop while the bulky + // narrative recall goes. + expect(snapshot.memory?.facts).toEqual([MEMORY_FACT]); + expect(snapshot.memory?.priorNarrative).toBeUndefined(); + }); + it("does not truncate a small default-scope snapshot", async () => { // A web-only account with no rows produces a tiny snapshot — the // degrader must be a no-op (no truncated annotation). diff --git a/src/lib/ai/coach/facts.ts b/src/lib/ai/coach/facts.ts index 4a73cf943..b12b0133d 100644 --- a/src/lib/ai/coach/facts.ts +++ b/src/lib/ai/coach/facts.ts @@ -405,63 +405,125 @@ interface DeterministicPattern { kind: "allergy" | "intolerance" | "diagnosis"; } +/** + * v1.16.8 — closed filler-word tolerance. The original patterns + * required the compound to follow "ich habe" immediately (modulo the + * article), so a single conversational adverb — "ich habe übrigens + * eine Erdnussallergie", "ich habe seit Jahren eine Pollenallergie" — + * defeated the always-remember pass entirely. The filler list is a + * CLOSED set of adverbs / temporal qualifiers / intensity adjectives: + * an open `\w+` gap would let third-party clauses through ("ich habe + * gehört dass seine …") and break the self-statement contract. + */ +const DE_FILLER = + "(?:übrigens|auch|noch|außerdem|zudem|leider|schon|immer|seit|jahren|jahrzehnten|kindheit|geburt|kurzem|langem|der|eine?|'?ne|starke|leichte|schwere|ausgeprägte|diagnostizierte)"; +const EN_FILLER = + "(?:also|still|actually|unfortunately|always|btw|a|an|severe|mild|bad|slight|known|diagnosed)"; + const DETERMINISTIC_PATTERNS: DeterministicPattern[] = [ // German — allergies. { - // "ich habe eine Erdnussallergie" / "ich hab 'ne Erdnuss-Allergie". - // The capture must be compounded onto "-allergie" (no space) so the - // article in "eine Allergie gegen X" can never be captured — that - // phrasing belongs to the next pattern. - re: /\bich\s+hab(?:e)?\s+(?:eine?\s+|'?ne\s+)?([a-zäöüß][\wäöüß-]*?)-?allergie\b/i, + // "ich habe (übrigens / seit Jahren / …) eine Erdnussallergie" / + // "ich hab 'ne Erdnuss-Allergie". The capture must be compounded + // onto "-allergie" (no space) so the article in "eine Allergie + // gegen X" can never be captured — that phrasing belongs to the + // next pattern. Intervening words come only from the closed filler + // list above. + re: new RegExp( + `\\bich\\s+hab(?:e)?\\s+(?:${DE_FILLER}\\s+){0,5}([a-zäöüß][\\wäöüß-]*?)-?allergie\\b`, + "i", + ), kind: "allergy", }, { - // "ich habe eine Allergie gegen Erdnüsse" / "ich bin allergisch gegen/auf Erdnüsse" - re: /\bich\s+(?:hab(?:e)?\s+(?:eine?\s+)?allergie\s+(?:gegen|auf)|bin\s+allergisch\s+(?:gegen|auf))\s+([a-zäöüß][\wäöüß -]{1,40}?)(?=[.,;!?]|$)/im, + // "ich habe (auch / noch / …) eine Allergie gegen Erdnüsse" / + // "ich bin (leider / auch / …) allergisch gegen/auf Erdnüsse" + re: new RegExp( + `\\bich\\s+(?:hab(?:e)?\\s+(?:${DE_FILLER}\\s+){0,5}allergie\\s+(?:gegen|auf)|bin\\s+(?:${DE_FILLER}\\s+){0,3}allergisch\\s+(?:gegen|auf))\\s+([a-zäöüß][\\wäöüß -]{1,40}?)(?=[.,;!?]|$)`, + "im", + ), + kind: "allergy", + }, + { + // First-person possessive: "meine Erdnussallergie", "wegen meiner + // Pollenallergie". A third-party owner ("die Allergie meiner + // Schwester") never matches — the captured word must itself end in + // "-allergie" right after the possessive. + re: /\bmein(?:e|er)?\s+([a-zäöüß][\wäöüß-]*?)-?allergie\b/i, kind: "allergy", }, // German — intolerances. { // "ich habe eine Laktoseunverträglichkeit / Laktose-Intoleranz". // Compound-only capture, same rationale as the allergy pattern. - re: /\bich\s+hab(?:e)?\s+(?:eine?\s+)?([a-zäöüß][\wäöüß-]*?)-?(?:unverträglichkeit|intoleranz)\b/i, + re: new RegExp( + `\\bich\\s+hab(?:e)?\\s+(?:${DE_FILLER}\\s+){0,5}([a-zäöüß][\\wäöüß-]*?)-?(?:unverträglichkeit|intoleranz)\\b`, + "i", + ), kind: "intolerance", }, { - // "ich bin laktoseintolerant" - re: /\bich\s+bin\s+([a-zäöüß][\wäöüß-]*?)[-\s]?intolerant\b/i, + // "ich bin (übrigens / leider) laktoseintolerant" + re: new RegExp( + `\\bich\\s+bin\\s+(?:${DE_FILLER}\\s+){0,3}([a-zäöüß][\\wäöüß-]*?)[-\\s]?intolerant\\b`, + "i", + ), + kind: "intolerance", + }, + { + // "meine Laktoseintoleranz" / "wegen meiner Histaminunverträglichkeit" + re: /\bmein(?:e|er)?\s+([a-zäöüß][\wäöüß-]*?)-?(?:unverträglichkeit|intoleranz)\b/i, kind: "intolerance", }, // German — explicit self-reported diagnosis. { - // "bei mir wurde Asthma diagnostiziert" - re: /\bbei\s+mir\s+wurde\s+([a-zäöüß][\wäöüß -]{1,40}?)\s+diagnostiziert\b/i, + // "bei mir wurde (kürzlich / übrigens) Asthma diagnostiziert" + re: /\bbei\s+mir\s+wurde\s+(?:übrigens\s+|kürzlich\s+|gerade\s+|neulich\s+|letztes\s+jahr\s+)?([a-zäöüß][\wäöüß -]{1,40}?)\s+diagnostiziert\b/i, kind: "diagnosis", }, // English — allergies. { - // "I'm allergic to peanuts" - re: /\bI(?:'m|\s+am)\s+allergic\s+to\s+([a-z][\w -]{1,40}?)(?=[.,;!?]|$)/im, + // "I'm (also / severely) allergic to peanuts" + re: new RegExp( + `\\bI(?:'m|\\s+am)\\s+(?:(?:also|really|severely|very|quite|unfortunately|actually|still)\\s+){0,2}allergic\\s+to\\s+([a-z][\\w -]{1,40}?)(?=[.,;!?]|$)`, + "im", + ), kind: "allergy", }, { - // "I have a peanut allergy" - re: /\bI\s+have\s+an?\s+([a-z][\w-]*)\s+allergy\b/i, + // "I (also) have a (severe) peanut allergy" + re: new RegExp( + `\\bI\\s+(?:also\\s+|still\\s+|actually\\s+)?have\\s+(?:${EN_FILLER}\\s+){0,3}([a-z][\\w-]*)\\s+allergy\\b`, + "i", + ), + kind: "allergy", + }, + { + // "my peanut allergy" + re: /\bmy\s+([a-z][\w-]*)\s+allergy\b/i, kind: "allergy", }, // English — intolerances. { // "I have a lactose intolerance" / "I'm lactose intolerant" - re: /\bI\s+have\s+an?\s+([a-z][\w-]*)\s+intolerance\b/i, + re: new RegExp( + `\\bI\\s+(?:also\\s+|still\\s+|actually\\s+)?have\\s+(?:${EN_FILLER}\\s+){0,3}([a-z][\\w-]*)\\s+intolerance\\b`, + "i", + ), kind: "intolerance", }, { - re: /\bI(?:'m|\s+am)\s+([a-z][\w-]*)[-\s]intolerant\b/i, + re: /\bI(?:'m|\s+am)\s+(?:(?:also|really|severely|very|quite|unfortunately|actually|still)\s+){0,2}([a-z][\w-]*)[-\s]intolerant\b/i, + kind: "intolerance", + }, + { + // "my lactose intolerance" + re: /\bmy\s+([a-z][\w-]*)\s+intolerance\b/i, kind: "intolerance", }, // English — explicit self-reported diagnosis. { - re: /\bI\s+(?:was|got)\s+diagnosed\s+with\s+([a-z][\w -]{1,40}?)(?=[.,;!?]|$)/im, + re: /\bI\s+(?:was|got)\s+(?:recently\s+|just\s+)?diagnosed\s+with\s+([a-z][\w -]{1,40}?)(?=[.,;!?]|$)/im, kind: "diagnosis", }, ]; diff --git a/src/lib/ai/coach/snapshot.ts b/src/lib/ai/coach/snapshot.ts index 7d30a5d59..78af5b4b4 100644 --- a/src/lib/ai/coach/snapshot.ts +++ b/src/lib/ai/coach/snapshot.ts @@ -1534,12 +1534,23 @@ function degradeToBudget( // Last resort: replace the whole block with a compact marker so the // model still knows the cluster exists without paying for its rows. + // v1.16.8 — the `memory` block's `facts` list survives the drop: it + // carries the durable personal facts (a stated allergy, a stated + // condition) and is tiny by construction (top-8, ≤160 chars each). + // Shedding it made the Coach forget a stated allergy exactly on the + // data-heavy accounts that hit the char cap; the bulky narrative + + // trend recall still goes. const dropBlock = (key: string): boolean => { - if (key in snapshot) { - snapshot[key] = { omitted: "trimmed for prompt budget" }; - return true; - } - return false; + if (!(key in snapshot)) return false; + const block = asRecord(snapshot[key]); + const facts = + block && Array.isArray(block.facts) && block.facts.length > 0 + ? block.facts + : null; + snapshot[key] = facts + ? { facts, omitted: "trimmed for prompt budget" } + : { omitted: "trimmed for prompt budget" }; + return true; }; // Degrade per-block, LOWEST priority first, collapsing each block as diff --git a/src/lib/validations/about-me.ts b/src/lib/validations/about-me.ts index 33d493ceb..ddc20a025 100644 --- a/src/lib/validations/about-me.ts +++ b/src/lib/validations/about-me.ts @@ -54,10 +54,18 @@ export type AboutMePutInput = z.infer; * stored self-context. The server picks the matching structured field * from the question wording, dedupes against the stored text, and * appends encrypted. + * + * v1.16.8 — `question` is optional: the remember action on a Coach + * chat message sends only the message text, and the server matches the + * target field from the text itself. */ export const aboutMeAdoptSchema = z.object({ - /** The clarifying question the answer belongs to (field matching). */ - question: z.string().trim().min(1).max(500), + /** + * The clarifying question the answer belongs to (field matching). + * Absent for the message-remember path — the field is then matched + * from the answer text. + */ + question: z.string().trim().min(1).max(500).optional(), /** The user's answer, as typed into the Coach composer. */ answer: z.string().trim().min(1).max(ABOUT_ME_FIELD_MAX_CHARS), });