mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user