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 });
@@ -481,6 +481,49 @@ describe("<MessageThread>", () => {
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(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-remember-message"');
expect(html).toContain("Remember");
});
it("localises the remember control under 'de'", () => {
const html = render(<MessageThread conversation={baseConversation} />, "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(
<MessageThread
conversation={null}
optimisticUser={{
localId: "local-1",
content: "Ich habe eine Erdnussallergie",
conversationId: null,
}}
/>,
);
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(<MessageThread conversation={long} />);
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(
@@ -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({
>
<div
className={cn(
"border-dose-accent/30 bg-dose-accent/12 text-foreground",
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
// of the 80% cap so the bubble + avatar together never
// overflow a comfortable width on a narrow phone.
"max-w-[calc(80%-2.625rem)] rounded-xl rounded-tr-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
"flex max-w-[calc(80%-2.625rem)] flex-col items-end gap-1",
)}
>
{content}
<div
className={cn(
"border-dose-accent/30 bg-dose-accent/12 text-foreground",
"rounded-xl rounded-tr-sm border px-3.5 py-2.5",
"text-sm leading-relaxed",
)}
>
{content}
</div>
{/* 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 && (
<RememberUserMessage content={content} />
)}
</div>
{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 (
<p
role="status"
data-slot="coach-remember-message-done"
className="text-muted-foreground flex items-center gap-1 text-xs"
>
<Check className="text-dracula-green size-3" aria-hidden="true" />
{t(
settled === "adopted"
? "insights.coach.rememberMessage.done"
: "insights.coach.rememberMessage.duplicate",
)}
</p>
);
}
return (
<button
type="button"
data-slot="coach-remember-message"
onClick={() => remember.mutate()}
disabled={remember.isPending}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-8 items-center gap-1 rounded px-1.5 py-1 text-xs",
"outline-none focus-visible:ring-2 disabled:opacity-50",
)}
>
{remember.isPending ? (
<Loader2
className="size-3 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<BookmarkPlus className="size-3" aria-hidden="true" />
)}
{t("insights.coach.rememberMessage.action")}
</button>
);
}
/**
* v1.16.1 — classic chat typing indicator: three dots pulsing in
* sequence inside the assistant bubble, shown only between submit and
@@ -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([]);
});
});
@@ -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).
+81 -19
View File
@@ -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",
},
];
+16 -5
View File
@@ -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
+10 -2
View File
@@ -54,10 +54,18 @@ export type AboutMePutInput = z.infer<typeof aboutMePutSchema>;
* 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),
});