feat(coach): a chat that behaves like one

The composer starts as a single line with send beside it and grows only
when the text does; the shift-enter hint and the inline disclaimer are
gone. A typing indicator bubbles before the first token. The context
rail hides by default behind a toggle and stops scrolling sideways,
conversations open in the full view instead of a broken side drawer,
and the coach preferences move into Settings where the rest of the
configuration lives — context toggles and the exclusion list now derive
from one source so they can no longer contradict each other. A floating
bubble appears only while a coach-initiated nudge is unread. Clearly
stated allergies and conditions are remembered immediately through a
deterministic extractor instead of waiting for the twenty-turn
summary pass.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 21:19:05 +02:00
1 parent b55fab5e94
commit 9f2c403f84
19 files changed
+1092 -879

No files matched your search

+15
View File
@@ -57,6 +57,7 @@ import {
listConversations,
} from "@/lib/ai/coach/persistence";
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
import { storeDeterministicFacts } from "@/lib/ai/coach/facts";
import {
buildDateKey,
enforceBudget,
@@ -331,6 +332,20 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// rolling summary + extract durable facts off the request path. Fire-and-
// forget: this turn uses whatever summary is already on disk; the refresh
// makes the next long turn fresh. No-ops without an embedded worker.
// v1.16.1 — always-remember categories (allergies, intolerances, explicit
// self-reported diagnoses) must not wait for the >TURN_CAP memory refresh:
// a health-critical statement in the second message of a short chat used
// to never reach the fact store. The deterministic pattern pass is
// provider-free and deduped, so it fires on every user turn.
void storeDeterministicFacts({
conversationId: workingConversationId,
userId,
message,
locale,
}).catch(() => {
// Fact capture must never break the chat turn; the >TURN_CAP LLM
// extraction remains as the catch-all on long conversations.
});
if (allTurns.length > TURN_CAP) {
void enqueueCoachMemoryRefresh({
conversationId: workingConversationId,
@@ -0,0 +1,51 @@
/**
* GET /api/insights/coach/nudge-status — has the proactive Coach nudge
* something the caller has not engaged with yet?
*
* v1.16.1 — feeds the floating Coach bubble: the bubble renders only
* while an unseen Coach-initiated nudge exists, instead of being a
* permanent FAB. "Unseen" is derived, not stored: the latest successful
* `COACH_NUDGE` dispatch (the `push_attempts` ledger the nudge cron
* already anchors its frequency cap on) counts as read once the user
* has sent a Coach message after it. The client additionally remembers
* a local "seen" stamp so opening the chat without sending also clears
* the bubble on that device.
*
* No new table, no migration: both timestamps come from existing rows,
* scoped to the caller.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const [lastNudge, lastUserMessage] = await Promise.all([
prisma.pushAttempt.findFirst({
where: { userId: user.id, eventType: "COACH_NUDGE", result: "ok" },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
prisma.coachMessage.findFirst({
where: { role: "user", conversation: { userId: user.id } },
orderBy: { createdAt: "desc" },
select: { createdAt: true },
}),
]);
const nudgedAt = lastNudge?.createdAt ?? null;
const unread =
nudgedAt !== null &&
(lastUserMessage === null || lastUserMessage.createdAt < nudgedAt);
return apiSuccess({
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
unread,
});
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -5,17 +5,15 @@ import { I18nProvider } from "@/lib/i18n/context";
import { CoachDrawerBody } from "../coach-drawer-body";
/**
* v1.4.20 phase B4 — Coach drawer mobile rail trays.
* v1.4.20 phase B4 — Coach drawer rail-tray strip.
*
* Two chevron-button triggers along the thread sub-header open
* side-sheets surfacing the history + sources rails.
*
* v1.12.0 — the conversation history is no longer an inline column on
* any viewport. The "Conversations" trigger is the only entry to the
* history rail and is therefore visible at every breakpoint; the
* sources rail stays inline at xl+ so its trigger keeps `xl:hidden`.
* The clinical-decisions disclaimer renders once, directly above the
* composer.
* v1.16.1 — the body lost its inline xl+ sources column (the sources
* rail is tray-only on every viewport, hidden until toggled) and the
* clinical-decisions disclaimer slot. The history affordance forks by
* surface: the drawer hands off to the full-page route via
* `onHistoryClick`; the page passes an inline `historyRail` slot that
* renders as a left column on lg+ (the strip button collapses to
* `lg:hidden` there).
*/
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
@@ -25,33 +23,23 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
const baseProps = {
sourcesRail: <span>sources</span>,
thread: <span>thread</span>,
composer: <span>composer</span>,
disclaimer: <span>disclaimer</span>,
onOpenHistoryTray: () => undefined,
onHistoryClick: () => undefined,
onOpenSourcesTray: () => undefined,
};
describe("<CoachDrawerBody> — mobile rail trays", () => {
it("renders both tray triggers", () => {
describe("<CoachDrawerBody> — rail-tray strip", () => {
it("renders both strip triggers", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-history-tray-trigger"/);
expect(html).toMatch(/data-slot="coach-drawer-sources-tray-trigger"/);
});
it("keeps the history trigger on every breakpoint and hides the sources trigger on >=xl", () => {
// v1.12.0 — the history rail is tray-only now, so the
// "Conversations" toggle is always visible (no `lg:hidden`) and
// the strip wrapper no longer carries `xl:hidden`. The sources
// trigger keeps `xl:hidden` because the sources rail is inline at
// xl+.
it("keeps both triggers visible on every breakpoint without an inline historyRail", () => {
// v1.16.1 — the sources rail is tray-only now, so its trigger lost
// the `xl:hidden` cap; the history trigger stays visible too.
const html = render(<CoachDrawerBody {...baseProps} />);
const strip = html.match(
/<div[^>]*data-slot="coach-drawer-rail-tray-strip"[^>]*>/,
);
expect(strip?.[0]).not.toContain("xl:hidden");
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
@@ -61,37 +49,43 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
const sourcesTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-sources-tray-trigger"[^>]*>/,
);
expect(sourcesTrigger?.[0]).toContain("xl:hidden");
expect(sourcesTrigger).not.toBeNull();
expect(sourcesTrigger?.[0]).not.toContain("xl:hidden");
});
it("no longer mounts an inline history column; sources rail stays inline at xl+", () => {
it("no longer mounts an inline sources column", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).not.toContain('data-slot="coach-drawer-history"');
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-sources"[^>]*xl:flex/,
);
expect(html).not.toContain('data-slot="coach-drawer-sources"');
});
it("renders the message thread + composer slots and the disclaimer above the composer", () => {
it("mounts an inline history column on lg+ when historyRail is passed (page surface)", () => {
const html = render(
<CoachDrawerBody {...baseProps} historyRail={<span>history</span>} />,
);
expect(html).toMatch(
/<aside[^>]*data-slot="coach-drawer-history"[^>]*lg:flex/,
);
expect(html).toContain("history");
// The strip button collapses on lg+ where the inline column shows.
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>/,
);
expect(historyTrigger?.[0]).toContain("lg:hidden");
});
it("renders the message thread + composer slots without a disclaimer line", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-thread"/);
expect(html).toMatch(/data-slot="coach-drawer-composer"/);
// Slot content forwarded verbatim.
expect(html).toContain("thread");
expect(html).toContain("composer");
// The disclaimer slot renders inside the composer container, before
// the composer itself.
expect(html).toContain("disclaimer");
const composerBlock = html.match(
/data-slot="coach-drawer-composer"[^>]*>([\s\S]*?)<\/div>/,
);
expect(composerBlock?.[1]).toContain("disclaimer");
const disclaimerIdx = html.indexOf("disclaimer");
const composerIdx = html.lastIndexOf("composer");
expect(disclaimerIdx).toBeLessThan(composerIdx);
// v1.16.1 — the clinical-decisions disclaimer was removed from the
// chat UI entirely.
expect(html).not.toContain("coach-composer-disclaimer");
});
it("uses chevron-style trigger labels — History on the left, Sources on the right", () => {
it("uses the localized trigger labels — History on the left, Sources on the right", () => {
const html = render(<CoachDrawerBody {...baseProps} />);
const historyTrigger = html.match(
/<button[^>]*data-slot="coach-drawer-history-tray-trigger"[^>]*>[\s\S]*?<\/button>/,
@@ -114,9 +108,8 @@ describe("<CoachDrawerBody> — mobile rail trays", () => {
it("renders the rail triggers in a sub-header strip above the thread", () => {
// v1.4.27 R3d MB2 — the triggers were lifted out of the absolute
// overlay into a sub-header strip so the chevrons sit at a 44 px
// tap target and never overlay the first message bubble. The
// strip lives above the thread within the centre column.
// overlay into a sub-header strip so they sit at a 44 px tap
// target and never overlay the first message bubble.
const html = render(<CoachDrawerBody {...baseProps} />);
expect(html).toMatch(/data-slot="coach-drawer-rail-tray-strip"/);
const trigger = html.match(
@@ -26,22 +26,18 @@ describe("<CoachInput>", () => {
expect(html).not.toContain("Coach replies are generated");
});
it("renders the localised placeholder + the popover-trigger hint", () => {
// v1.4.27 F15 — the verbose prose hint footer was replaced by an
// Info icon button. The same `composerHint` translation surfaces
// as the trigger's `aria-label` (so screen-readers announce it on
// focus) and as the popover body for sighted users on tap.
//
// v1.4.27 MB3 / CF-31 — the disclosure swapped off Radix Tooltip
// (no reliable tap-toggle on touch surfaces) onto Radix Popover,
// so the contract is now "tap to open" instead of "hover".
it("renders the localised placeholder without the retired shortcut hint", () => {
// v1.16.1 — the Enter/Shift+Enter hint (prose footer, later an
// Info-popover) is gone entirely; the behaviour itself is
// unchanged. The composer is a single row: textarea + send button
// on the same baseline.
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toContain("Ask anything about your data");
expect(html).toMatch(
/aria-label="Press Enter to send[^"]*"\s+data-slot="coach-input-hint"/,
);
expect(html).not.toContain('data-slot="coach-input-hint"');
expect(html).not.toContain("Press Enter to send");
expect(html).toContain('data-slot="coach-input-send"');
});
it("renders the German placeholder under the 'de' locale", () => {
@@ -1,123 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { toast } from "sonner";
import { I18nProvider } from "@/lib/i18n/context";
import { DEFAULT_COACH_PREFS } from "@/lib/validations/coach-prefs";
// v1.4.23 W6 (Design-H3 + H4) — verify the settings sheet renders a
// skeleton placeholder while the persisted prefs are loading (no
// `DEFAULT_COACH_PREFS` ghost form) and emits a sonner toast on save
// success.
//
// The shadcn `<Sheet>` wraps Radix Dialog which uses Portals — those
// don't materialise under `renderToStaticMarkup`. We mock the sheet
// primitives down to plain wrappers so the form/skeleton body is
// reachable in the static markup.
const queryState: { data: unknown } = { data: undefined };
const mutationCallbacks: { onSuccess: ((env: unknown) => void) | null } = {
onSuccess: null,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: queryState.data }),
useMutation: (opts: { onSuccess?: (env: unknown) => void }) => {
mutationCallbacks.onSuccess = opts.onSuccess ?? null;
return {
mutate: (next: unknown) => {
mutationCallbacks.onSuccess?.({ data: next });
},
isPending: false,
};
},
useQueryClient: () => ({
setQueryData: vi.fn(),
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn() },
}));
vi.mock("@/components/ui/sheet", () => ({
Sheet: ({ children, open }: { children: React.ReactNode; open: boolean }) =>
open ? <div data-slot="mock-sheet">{children}</div> : null,
SheetContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="mock-sheet-content">{children}</div>
),
SheetHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SheetTitle: ({ children }: { children: React.ReactNode }) => (
<h2>{children}</h2>
),
SheetDescription: ({ children }: { children: React.ReactNode }) => (
<p>{children}</p>
),
// v1.4.27 R3d MB1 — the settings sheet now renders an inline
// `<SheetClose>` in the header instead of the primitive's
// absolutely-positioned close-X. Mock it as a passthrough so the
// existing skeleton + form snapshot assertions keep working.
SheetClose: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import { CoachSettingsSheet } from "../coach-settings-sheet";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
beforeEach(() => {
queryState.data = undefined;
mutationCallbacks.onSuccess = null;
vi.mocked(toast.success).mockClear();
});
describe("<CoachSettingsSheet>", () => {
it("renders a skeleton shell while persisted prefs are loading (Design-H3)", () => {
queryState.data = undefined;
const html = render(
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
);
expect(html).toContain('data-slot="coach-prefs-skeleton"');
// Form body markers must be absent during the skeleton state so
// the user never sees the DEFAULT_COACH_PREFS ghost form.
expect(html).not.toContain('data-slot="coach-prefs-tone"');
expect(html).not.toContain('data-slot="coach-prefs-evidence"');
});
it("renders the form once persisted prefs land", () => {
queryState.data = DEFAULT_COACH_PREFS;
const html = render(
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
);
expect(html).not.toContain('data-slot="coach-prefs-skeleton"');
expect(html).toContain('data-slot="coach-prefs-tone"');
// v1.4.27 F14 — the `showEvidenceByDefault` switch is retired.
expect(html).not.toContain('data-slot="coach-prefs-evidence"');
});
it("fires a sonner toast on save success (Design-H4)", () => {
queryState.data = DEFAULT_COACH_PREFS;
render(<CoachSettingsSheet open={true} onOpenChange={() => {}} />);
// Static markup doesn't dispatch click events; trigger the captured
// mutation success path directly. Verifies the wired-up onSuccess
// emits the toast with the localised "Saved." string.
mutationCallbacks.onSuccess?.({ data: DEFAULT_COACH_PREFS });
expect(vi.mocked(toast.success)).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Saved.");
});
// v1.4.25 W5 — defaultWindow picker added.
it("renders the default-window picker once persisted prefs land", () => {
queryState.data = DEFAULT_COACH_PREFS;
const html = render(
<CoachSettingsSheet open={true} onOpenChange={() => {}} />,
);
expect(html).toContain('data-slot="coach-prefs-default-window"');
expect(html).toContain("Default analysis window");
});
});
@@ -68,19 +68,19 @@ describe("<MobileRailTray>", () => {
expect(html).toContain("sources-content");
});
it("keeps the history tray on every breakpoint and hides the sources tray on xl+", () => {
// v1.12.0 — the history rail is tray-only now (no inline column on
// any viewport), so the history tray drops its `lg:hidden` cap and
// is available at every breakpoint. The sources rail still mounts
// inline at xl+, so its tray keeps `xl:hidden`.
it("keeps both trays available on every breakpoint", () => {
// v1.16.1 — the sources rail lost its inline xl+ column; the tray
// is the one sources surface everywhere, so the `xl:hidden` cap is
// gone. The history tray was breakpoint-free already.
const html = render(<MobileRailTray {...baseProps} />);
const historyTray = html.match(
/data-slot="coach-drawer-history-tray"[^>]*class="([^"]*)"/,
);
expect(historyTray?.[1]).not.toContain("lg:hidden");
expect(html).toMatch(
/data-slot="coach-drawer-sources-tray"[^>]*class="[^"]*xl:hidden/,
const sourcesTray = html.match(
/data-slot="coach-drawer-sources-tray"[^>]*class="([^"]*)"/,
);
expect(sourcesTray?.[1]).not.toContain("xl:hidden");
});
it("renders both header titles localised", () => {
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Plus, Settings, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -9,7 +10,6 @@ import { useTranslations } from "@/lib/i18n/context";
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachInput } from "./coach-input";
import { CoachSettingsSheet } from "./coach-settings-sheet";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { MobileRailTray } from "./mobile-rail-tray";
@@ -25,10 +25,10 @@ import { useCoachConversation, useSendCoachMessage } from "./use-coach";
*
* This component is the single source of truth for the Coach
* conversation: it owns the active-conversation id, the streaming send
* hook, the composer value, the settings sheet, and the mobile rail
* trays — every piece of Coach v2 behaviour (incremental streaming,
* collapsible history + provenance, the single composer disclaimer,
* the auto-grow composer, refusal / budget handling) lives here once.
* hook, the composer value, and the rail trays — every piece of Coach
* v2 behaviour (incremental streaming, collapsible history +
* provenance, the auto-grow composer, refusal / budget handling)
* lives here once.
* The drawer and the page differ ONLY in their chrome: the drawer wraps
* this surface in a `<Sheet>` and supplies a close button + a maximize
* control; the page renders it edge-to-edge and supplies a minimize
@@ -79,6 +79,14 @@ export interface CoachConversationProps {
* tears the stream down on its own.
*/
registerReset?: (reset: () => void) => void;
/**
* v1.16.1 — drawer-only: the "Conversations" affordance no longer
* opens an in-panel left tray (it kept breaking inside the sheet);
* it hands the user off to the full-page Coach route instead. The
* drawer wires this to its maximize handler (close sheet + route);
* the page omits it and renders the list inline / as a tray.
*/
onRequestFullView?: () => void;
/** className passthrough for the outer flex column. */
className?: string;
/** data-variant on the root, surfaced for e2e + styling hooks. */
@@ -93,6 +101,7 @@ export function CoachConversation({
trailingHeaderActions,
autoFocusComposer,
registerReset,
onRequestFullView,
className,
surface,
}: CoachConversationProps) {
@@ -103,7 +112,6 @@ export function CoachConversation({
>(null);
const [historyTrayOpen, setHistoryTrayOpen] = useState(false);
const [sourcesTrayOpen, setSourcesTrayOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [inputValue, setInputValue] = useResettableValue(prefill ?? "");
const { data: conversation } = useCoachConversation(currentConversationId);
@@ -188,23 +196,36 @@ export function CoachConversation({
>
<Plus className="size-4" aria-hidden="true" />
</Button>
{/* v1.16.1 — the Coach preferences moved into Settings → AI
(one place for model + behaviour). The header keeps a gear
that deep-links there instead of opening an in-chat sheet. */}
<Button
type="button"
asChild
variant="ghost"
size="icon"
onClick={() => setSettingsOpen(true)}
data-slot="coach-settings"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Settings className="size-4" aria-hidden="true" />
<Link
href="/settings/ai"
aria-label={t("insights.coach.settingsAriaLabel")}
title={t("insights.coach.settingsAriaLabel")}
>
<Settings className="size-4" aria-hidden="true" />
</Link>
</Button>
{trailingHeaderActions}
</header>
<CoachDrawerBody
sourcesRail={<SourcesRail />}
historyRail={
surface === "page" ? (
<HistoryRail
activeId={currentConversationId}
onSelect={(id) => setCurrentConversationId(id)}
/>
) : undefined
}
thread={
<MessageThread
conversation={conversation ?? null}
@@ -212,14 +233,6 @@ export function CoachConversation({
optimisticUser={send.optimisticUser}
/>
}
disclaimer={
<p
data-slot="coach-composer-disclaimer"
className="text-muted-foreground text-xs leading-relaxed"
>
{t("insights.coach.composerDisclaimer")}
</p>
}
composer={
<div>
{/* v1.16.0 — pending self-context questions as tappable
@@ -241,11 +254,14 @@ export function CoachConversation({
/>
</div>
}
onOpenHistoryTray={() => setHistoryTrayOpen(true)}
onHistoryClick={
surface === "drawer" && onRequestFullView
? onRequestFullView
: () => setHistoryTrayOpen(true)
}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
<CoachSettingsSheet open={settingsOpen} onOpenChange={setSettingsOpen} />
<MobileRailTray
historyOpen={historyTrayOpen}
onHistoryOpenChange={setHistoryTrayOpen}
@@ -15,66 +15,72 @@ import { useTranslations } from "@/lib/i18n/context";
* gives the test a node-renderable surface; the `<CoachDrawer>`
* itself stays the integrating shell that owns state + the trays.
*
* The component is stateless: the parent owns `historyTrayOpen` /
* `sourcesTrayOpen` and passes the open-toggles + slot content. This
* keeps the trigger logic + the desktop rail layout in one place
* while delegating the actual side-sheet to the parent.
* The component is stateless: the parent owns the tray state and
* passes the open-toggles + slot content.
*
* v1.12.0 — the history rail is no longer an inline column on any
* viewport; it lives behind the always-visible "Conversations" toggle
* and opens as the left-edge tray. The body therefore no longer takes
* a `historyRail` slot — the parent wires that rail straight into the
* `<MobileRailTray>`.
* v1.16.1 — two structural changes:
* - The sources rail lost its inline xl+ column. "What I draw on" is
* now hidden by default on every viewport and slides in as the
* right-edge tray when the user taps the toggle — the rail is a
* look-up surface, not something worth a permanent 280 px gutter.
* - The history affordance forked by surface: the drawer's
* "Conversations" button hands off to the full-page Coach route
* (the in-panel left tray kept breaking inside the sheet), while
* the page renders the conversation list inline as a left column
* on lg+ via the `historyRail` slot (tray below lg).
*/
export interface CoachDrawerBodyProps {
sourcesRail: React.ReactNode;
thread: React.ReactNode;
composer: React.ReactNode;
/** Clinical-decisions disclaimer line, pinned above the composer. */
disclaimer: React.ReactNode;
onOpenHistoryTray: () => void;
/**
* Inline conversation list (page surface only). When set, it renders
* as a left column on lg+ and the history button collapses to the
* sub-lg tray trigger. The drawer omits it.
*/
historyRail?: React.ReactNode;
onHistoryClick: () => void;
onOpenSourcesTray: () => void;
}
export function CoachDrawerBody({
sourcesRail,
thread,
composer,
disclaimer,
onOpenHistoryTray,
historyRail,
onHistoryClick,
onOpenSourcesTray,
}: CoachDrawerBodyProps) {
const { t } = useTranslations();
return (
<div
data-slot="coach-drawer-body"
// v1.12.0 — the conversation history is no longer a permanent
// left column. It used to mount inline on lg+ (a fixed 260px
// gutter) which ate the chat width on laptops and read as a
// takeover on the narrowed drawer cap. The rail now lives behind
// the "Conversations" toggle on EVERY viewport (opens as the
// left-edge tray), so the thread keeps the full width by default
// and the user summons history on demand. The sources rail stays
// inline at xl+ where there is room; below xl it stays on the
// chevron tray.
className="grid min-h-0 flex-1 grid-cols-1 xl:grid-cols-[1fr_280px]"
className={
historyRail
? "grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[260px_1fr]"
: "grid min-h-0 flex-1 grid-cols-1"
}
>
{/* Inline history column — page surface, lg+ only. */}
{historyRail && (
<aside
data-slot="coach-drawer-history"
className="border-border/70 hidden h-full min-h-0 border-r lg:flex lg:flex-col"
>
<h2 className="border-border/70 text-muted-foreground border-b p-3 text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</h2>
<div className="min-h-0 flex-1 overflow-y-auto">{historyRail}</div>
</aside>
)}
{/* Centre — message thread. */}
<main
data-slot="coach-drawer-thread"
className="flex h-full min-h-0 flex-col"
>
{/* v1.4.27 R3d MB2 — rail-tray triggers lifted out of the
absolute overlay into a sub-header strip so the chevrons
absolute overlay into a sub-header strip so the buttons
sit at a 44 px tap target and never overlay the first
message bubble.
v1.12.0 — the strip is now ALWAYS rendered because the
"Conversations" toggle is the only way to reach the history
rail on every viewport (the rail is no longer an inline
column). The history trigger stays visible at all
breakpoints; the sources trigger keeps `xl:hidden` since the
sources rail is inline at xl+. */}
message bubble. */}
<div
data-slot="coach-drawer-rail-tray-strip"
className="border-border/70 flex items-center justify-between gap-2 border-b px-3 py-2"
@@ -82,23 +88,29 @@ export function CoachDrawerBody({
<Button
type="button"
variant="ghost"
onClick={onOpenHistoryTray}
onClick={onHistoryClick}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="min-h-11 gap-1.5 text-xs"
// With an inline history column (page surface, lg+) the
// button is redundant above lg and hides there.
className={
historyRail
? "min-h-11 gap-1.5 text-xs lg:hidden"
: "min-h-11 gap-1.5 text-xs"
}
>
<MessagesSquare className="size-4" aria-hidden="true" />
{t("insights.coach.historyTitle")}
</Button>
{/* Sources tray trigger stays visible up to xl since the
inline sources rail is xl+ only. */}
{/* Sources toggle — the rail is hidden by default on every
viewport and opens as the right-edge tray. */}
<Button
type="button"
variant="ghost"
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="min-h-11 gap-1.5 text-xs xl:hidden"
className="text-muted-foreground hover:text-foreground ml-auto min-h-11 gap-1.5 text-xs"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-4" aria-hidden="true" />
@@ -106,28 +118,14 @@ export function CoachDrawerBody({
</div>
<div className="min-h-0 flex-1">{thread}</div>
{/* Composer pinned to the bottom. v1.12.0 — the clinical-
decisions disclaimer renders directly above the composer
(the single, always-visible home; the thread-bottom and
sources-rail copies were removed). */}
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 flex flex-col gap-2 border-t p-3 sm:p-4"
>
{disclaimer}
{composer}
</div>
</main>
{/* Sources rail — xl+ only. lg viewports surface it via the
mobile chevron tray to keep the underlying /insights column
readable behind the narrowed drawer cap. */}
<aside
data-slot="coach-drawer-sources"
className="border-border/70 hidden h-full min-h-0 border-l xl:flex xl:flex-col"
>
{sourcesRail}
</aside>
</div>
);
}
@@ -117,6 +117,10 @@ export function CoachDrawer({ open, onOpenChange, prefill }: CoachDrawerProps) {
prefill={prefill}
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to the
// full-page route (same path as maximize) instead of opening
// the broken in-panel left tray.
onRequestFullView={handleMaximize}
// Radix requires an accessible name + description on the
// dialog content. Mount them through the title/description
// render-props so the page can swap in a plain heading.
@@ -7,14 +7,9 @@ import {
useEffect,
useRef,
} from "react";
import { Info, Loader2, Send, Square } from "lucide-react";
import { Loader2, Send, Square } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -186,10 +181,17 @@ export function CoachInput({
onSubmit={handleFormSubmit}
className="flex flex-col"
>
{/* v1.16.1 — modern chat-app composer: a single row with the
textarea and the send control on the same baseline. The
textarea starts at one line and auto-grows; `items-end` keeps
the button pinned to the input's last line as it grows. The
old footer row (Info-popover hint + right-aligned button) is
gone — Enter still sends and Shift+Enter still inserts a
newline, the affordance just no longer needs explaining. */}
<div
className={cn(
"border-border/60 bg-muted/40 group rounded-md border",
"p-2.5 transition-colors",
"flex items-end gap-2 p-2 transition-colors",
"focus-within:border-dracula-purple/50 focus-within:ring-dracula-purple/15 focus-within:ring-2",
)}
>
@@ -221,86 +223,51 @@ export function CoachInput({
// 16 px floor and yanks the viewport on every Coach tap.
// Desktop shrinks back to `text-sm` for the compact
// composer.
"w-full resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
// Centre the single-line state against the 36 px send
// button so the placeholder and the icon share a baseline.
"py-1.5",
"max-h-[9.5rem] overflow-auto",
"placeholder:text-muted-foreground disabled:opacity-60",
)}
/>
{/* v1.4.27 F15 — the verbose "Enter to send, Shift+Enter for
new line" prose footer used to render under the textarea;
it ate ~140 px of vertical room for an Apple-Health-like
single-message exchange. The hint now sits behind a tiny
Info icon left of the send button; the existing
translation string surfaces as the popover body so
screen-reader users still hear it on focus.
v1.4.27 MB3 / CF-31 — swapped off Radix `<Tooltip>` (which
never tap-toggles reliably on touch) onto `<Popover>` so
mobile users can open the hint via a plain tap on the icon
button. The aria-label still labels the icon for screen
readers; the popover body carries the long-form copy. */}
<div className="mt-1.5 flex items-center justify-end gap-2">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t("insights.coach.composerHint")}
data-slot="coach-input-hint"
className={cn(
"text-muted-foreground hover:text-foreground",
"focus-visible:ring-ring/50 inline-flex h-11 w-11",
"items-center justify-center rounded",
"focus-visible:ring-2 focus-visible:outline-none",
)}
>
<Info className="size-3.5" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
side="top"
align="end"
data-slot="coach-input-hint-body"
>
{t("insights.coach.composerHint")}
</PopoverContent>
</Popover>
{isStreaming && onCancel ? (
// While a reply streams, swap the send button for a Stop
// control bound to the abort handler so the user can
// interrupt a long or off-track reply instead of waiting it
// out. The spinner stays inside the Stop affordance so the
// in-progress state is still legible.
<Button
type="button"
size="sm"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
className="gap-1.5"
>
<Square
className="size-3.5 fill-current"
{isStreaming && onCancel ? (
// While a reply streams, swap the send button for a Stop
// control bound to the abort handler so the user can
// interrupt a long or off-track reply instead of waiting it
// out.
<Button
type="button"
size="icon"
variant="outline"
onClick={onCancel}
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
className="size-9 shrink-0"
>
<Square className="size-3.5 fill-current" aria-hidden="true" />
</Button>
) : (
<Button
type="submit"
size="icon"
disabled={!canSubmit}
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
className="size-9 shrink-0"
>
{isStreaming ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
<span>{t("insights.coach.stop")}</span>
</Button>
) : (
<Button
type="submit"
size="sm"
disabled={!canSubmit}
data-slot="coach-input-send"
className="gap-1.5"
>
{isStreaming ? (
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden="true" />
) : (
<Send className="size-3.5" aria-hidden="true" />
)}
<span>{t("insights.coach.send")}</span>
</Button>
)}
</div>
) : (
<Send className="size-4" aria-hidden="true" />
)}
</Button>
)}
</div>
</form>
);
@@ -1,518 +0,0 @@
"use client";
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { X } from "lucide-react";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
type CoachDataCluster,
type CoachDefaultWindow,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
type CoachVerbosity,
} from "@/lib/validations/coach-prefs";
/**
* v1.4.23 H4 — per-user Coach prompt-tuning sheet.
*
* Surfaced from the Coach drawer header cog. Reads + writes
* `/api/auth/me/coach-prefs`. Mounted as a right-edge `<Sheet>` so it
* sits on top of the drawer without conflicting with the existing
* mobile rail trays (history = left, sources = right tray on `<xl`,
* settings = right sheet — fine because the user only opens one at a
* time).
*
* The four controls match the v1.4.23 H4 spec exactly: tone (3-state),
* verbosity (3-state), excludeMetrics (multi-toggle), and
* showEvidenceByDefault (single toggle).
*
* v1.4.23 W6 design-pass:
* - Skeleton placeholder while the prefs are loading (Design-H3) so
* the user never sees the `DEFAULT_COACH_PREFS` ghost form snap to
* their saved values mid-paint.
* - On save success the sheet emits a sonner toast (Design-H4) before
* closing so the action is visibly acknowledged.
*/
export interface CoachSettingsSheetProps {
open: boolean;
onOpenChange: (next: boolean) => void;
}
const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
"bp",
"weight",
"pulse",
"mood",
"compliance",
"hrv",
"sleep",
"resting_hr",
"steps",
];
// v1.4.36 W3 T2 — optional context blocks the user can opt out of.
// Surfaced as a separate group below the per-metric exclude list so
// the intent ("don't ship my profile / medication context to the
// model") reads distinctly from the per-metric data toggles above.
// Each row gates a single labelled block at the snapshot/feature layer
// so empty blocks never reach the prompt.
const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
"sleep",
"medications",
"anthropometrics",
];
// v1.4.25 W5 — picker options for the new `defaultWindow` preference.
// Mirrors `CoachScopeWindow` so the chat route can fold the selection
// straight into `scope.window` when the client didn't supply one.
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
"last7days",
"last30days",
"last90days",
"allTime",
];
// v1.7.0 — clustered, opt-in data sources. Order matches the design
// sketch (default-on clinical clusters first). Each row shows the
// cluster label + a member-preview hint + a toggle. Toggling writes
// `dataClusters`; the snapshot builder expands the enabled set into the
// source set, then subtracts the existing `excludeMetrics` post-filter.
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
export function CoachSettingsSheet({
open,
onOpenChange,
}: CoachSettingsSheetProps) {
const { t } = useTranslations();
const { data: persisted } = useCoachPrefs({ enabled: open });
// Local working copy edited by the form. Re-syncs to the persisted
// shape on every fetch via the same in-render setState pattern as
// the drawer's `useResettableValue` (no useEffect — the
// `react-hooks/set-state-in-effect` rule rejects setState inside an
// effect when the source is a query result).
const [draft, setDraft] = useState<CoachPrefs>(DEFAULT_COACH_PREFS);
const [lastSeenPersisted, setLastSeenPersisted] = useState<CoachPrefs | null>(
null,
);
if (persisted && persisted !== lastSeenPersisted) {
setLastSeenPersisted(persisted);
setDraft(persisted);
}
// v1.7.2 — write through the shared `useSaveCoachPrefs` mutation so
// the cog and the chat-side sources rail persist via one code path
// and seed the same `coachPrefs()` cache key on success.
const save = useSaveCoachPrefs({
onSuccess: () => {
toast.success(t("insights.coach.settingsSaved"));
onOpenChange(false);
},
});
function toggleExclude(metric: CoachExcludeMetric, next: boolean) {
setDraft((prev) => ({
...prev,
excludeMetrics: next
? Array.from(new Set([...prev.excludeMetrics, metric]))
: prev.excludeMetrics.filter((m) => m !== metric),
}));
}
// v1.7.0 — the persisted `dataClusters` may be `undefined` (the user
// never opened the picker). Materialise the legacy default set the
// first time the user toggles a cluster so the on-screen switches
// reflect what the snapshot builder actually resolves.
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
setDraft((prev) => {
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
const updated = next
? Array.from(new Set([...current, cluster]))
: current.filter((c) => c !== cluster);
return { ...prev, dataClusters: updated };
});
}
// The switch state mirrors the resolved set: undefined → legacy
// defaults, otherwise the explicit array (including the empty
// "everything off" state).
const enabledClusters = new Set<CoachDataCluster>(
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
// Render a skeleton shell while the persisted prefs are loading so
// we never render the form against `DEFAULT_COACH_PREFS` and snap
// the controls when the fetch resolves a moment later.
const isLoading = open && !persisted;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
// v1.4.27 R3d MB1 — the default `<SheetContent>` close-X is an
// absolutely positioned `opacity-70 rounded-xs` button that
// clashed with the in-header layout. Match the coach-drawer
// pattern instead: hide the primitive's close-X and render a
// matching ghost-icon `<SheetClose>` inline in the header so
// the close affordance sits on the same baseline as the rest
// of the sheet chrome and clears the 44 px tap target.
showCloseButton={false}
data-slot="coach-settings-sheet"
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-[420px]"
>
<SheetHeader className="border-border/70 flex-row items-start gap-2 border-b p-4">
<div className="min-w-0 flex-1">
<SheetTitle className="text-sm font-semibold">
{t("insights.coach.settingsTitle")}
</SheetTitle>
<SheetDescription className="text-muted-foreground text-xs">
{t("insights.coach.settingsDescription")}
</SheetDescription>
</div>
<SheetClose asChild>
<Button
type="button"
variant="ghost"
size="icon"
data-slot="coach-settings-sheet-close"
aria-label={t("common.close")}
title={t("common.close")}
className="text-muted-foreground hover:text-foreground -mt-1 shrink-0"
>
<X className="size-4" aria-hidden="true" />
</Button>
</SheetClose>
</SheetHeader>
{isLoading ? (
<div
data-slot="coach-prefs-skeleton"
className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-44 w-full" />
</div>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-3 w-2/3" />
</div>
<Skeleton className="h-5 w-9 rounded-full" />
</div>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto p-4">
{/* v1.13.1 — Tone + Verbosity side by side (2-col on ≥sm,
stacked on mobile) so the two short selects don't add a
full extra scroll length to the sheet. */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-tone"
className="text-xs font-medium"
>
{t("insights.coach.settingsToneLabel")}
</Label>
<Select
value={draft.tone}
onValueChange={(value) =>
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
}
>
<SelectTrigger
id="coach-prefs-tone"
data-slot="coach-prefs-tone"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warm">
{t("insights.coach.settingsToneWarm")}
</SelectItem>
<SelectItem value="neutral">
{t("insights.coach.settingsToneNeutral")}
</SelectItem>
<SelectItem value="concise">
{t("insights.coach.settingsToneConcise")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Verbosity */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-verbosity"
className="text-xs font-medium"
>
{t("insights.coach.settingsVerbosityLabel")}
</Label>
<Select
value={draft.verbosity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
verbosity: value as CoachVerbosity,
}))
}
>
<SelectTrigger
id="coach-prefs-verbosity"
data-slot="coach-prefs-verbosity"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brief">
{t("insights.coach.settingsVerbosityBrief")}
</SelectItem>
<SelectItem value="default">
{t("insights.coach.settingsVerbosityDefault")}
</SelectItem>
<SelectItem value="detailed">
{t("insights.coach.settingsVerbosityDetailed")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* v1.4.25 W5 — default analysis window. Sets the snapshot
scope window the Coach reads when the client doesn't
supply a per-conversation override. The drawer header
carries a small pill that overrides the picked default
for the current chat only. */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-default-window"
className="text-xs font-medium"
>
{t("insights.coach.settingsDefaultWindowLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsDefaultWindowHint")}
</p>
<Select
value={draft.defaultWindow}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultWindow: value as CoachDefaultWindow,
}))
}
>
<SelectTrigger
id="coach-prefs-default-window"
data-slot="coach-prefs-default-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w}>
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* v1.7.0 — clustered, opt-in data sources. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsClustersLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsClustersHint")}
</p>
<ul
data-slot="coach-prefs-cluster-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CLUSTER_OPTIONS.map((cluster) => {
const id = `coach-prefs-cluster-${cluster}`;
const checked = enabledClusters.has(cluster);
return (
<li
key={cluster}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<Label
htmlFor={id}
className="cursor-pointer text-xs font-medium"
>
{t(`insights.coach.cluster.${cluster}.label`)}
</Label>
<span className="text-muted-foreground text-[11px] leading-snug">
{t(`insights.coach.cluster.${cluster}.hint`)}
</span>
</div>
<Switch
id={id}
data-slot={`coach-prefs-cluster-${cluster}`}
checked={checked}
onCheckedChange={(next) => toggleCluster(cluster, next)}
/>
</li>
);
})}
</ul>
</div>
{/* Exclude metrics — advanced fine-tune. Subtracts a single
metric inside an otherwise-enabled cluster. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsExcludeLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsExcludeHint")}
</p>
<ul
data-slot="coach-prefs-exclude-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{EXCLUDE_OPTIONS.map((metric) => {
const id = `coach-prefs-exclude-${metric}`;
const checked = draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-exclude-${metric}`}
checked={checked}
onCheckedChange={(next) => toggleExclude(metric, next)}
/>
</li>
);
})}
</ul>
</div>
{/* v1.4.36 W3 T2 — optional context blocks. Mirrors the
EXCLUDE_OPTIONS list mechanically (same on/off switch
pattern, same toggleExclude handler) but groups the
profile / medication context toggles in their own
section so the intent reads distinctly. Empty blocks
are dropped at the snapshot/feature layer when the
toggle is on. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsContextLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsContextHint")}
</p>
<ul
data-slot="coach-prefs-context-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CONTEXT_OPTIONS.map((metric) => {
const id = `coach-prefs-context-${metric}`;
const checked = draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-context-${metric}`}
checked={checked}
onCheckedChange={(next) => toggleExclude(metric, next)}
/>
</li>
);
})}
</ul>
</div>
{/* v1.4.27 F14 — the `showEvidenceByDefault` switch was
retired. The evidence disclosure is now always closed
by default and the user expands by click; surfacing
raw values automatically created an UX trap. The
persisted `coachPrefs.showEvidenceByDefault` field
stays in the schema for backward compatibility, but no
UI reads or writes it. */}
</div>
)}
<div className="border-border/70 flex items-center justify-end gap-2 border-t p-4">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onOpenChange(false)}
data-slot="coach-prefs-cancel"
>
{t("insights.coach.settingsCancel")}
</Button>
<Button
type="button"
size="sm"
onClick={() => save.mutate(draft)}
disabled={save.isPending || isLoading}
data-slot="coach-prefs-save"
>
{save.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("insights.coach.settingsSave")}
</Button>
</div>
</SheetContent>
</Sheet>
);
}
@@ -78,7 +78,7 @@ export function HistoryRail({
(the rail still has to stand alone on desktop). */}
<h3
data-slot="coach-history-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"
>
<MessagesSquare
className="text-muted-foreground size-3.5"
@@ -444,7 +444,6 @@ function ChatBubble({
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
inProgress && "animate-pulse motion-reduce:animate-none",
)}
>
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from
@@ -452,12 +451,19 @@ function ChatBubble({
raw `content` is the streamed Coach reply (or the
persisted twin after `done` fires); both paths are AI-
authored so they share the same leak surface as the
insight prose elsewhere. */}
{content
? stripChartTokens(content)
: inProgress
? t("insights.coach.thinking")
: ""}
insight prose elsewhere.
v1.16.1 — before the first token lands, the bubble shows
a classic typing indicator (three pulsing dots) instead
of the old whole-bubble pulse + "Thinking…" prose. The
prose stays as the screen-reader announcement. */}
{content ? (
stripChartTokens(content)
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : (
""
)}
</div>
{safeError && (
<p className="text-warning/90 text-xs">{safeError}</p>
@@ -572,6 +578,33 @@ function ChatBubble({
);
}
/**
* v1.16.1 — classic chat typing indicator: three dots pulsing in
* sequence inside the assistant bubble, shown only between submit and
* the first streamed token. Uses the stock `animate-pulse` keyframe
* with staggered delays so no custom keyframe is introduced;
* `motion-reduce` freezes the dots and the `label` stays as the
* screen-reader text either way.
*/
function TypingDots({ label }: { label: string }) {
return (
<span
data-slot="coach-typing-indicator"
className="inline-flex items-center gap-1 py-1"
>
<span className="sr-only">{label}</span>
{[0, 1, 2].map((i) => (
<span
key={i}
aria-hidden="true"
className="bg-muted-foreground/70 size-1.5 animate-pulse rounded-full motion-reduce:animate-none"
style={{ animationDelay: `${i * 250}ms` }}
/>
))}
</span>
);
}
interface CoachMessageFeedbackProps {
messageId: string;
}
@@ -68,13 +68,15 @@ export function MobileRailTray({
<div className="h-full min-h-0 overflow-y-auto">{historyRail}</div>
</SheetContent>
</Sheet>
{/* Sources tray — slides in from the right edge. `xl:hidden`
because the sources rail is inline on xl+. */}
{/* Sources tray — slides in from the right edge. v1.16.1 — the
rail no longer has an inline xl+ column; the tray is the one
sources surface on every viewport (hidden by default, opened
via the toggle in the rail-tray strip). */}
<Sheet open={sourcesOpen} onOpenChange={onSourcesOpenChange}>
<SheetContent
side="right"
data-slot="coach-drawer-sources-tray"
className="w-[88vw] max-w-[320px] p-0 xl:hidden"
className="w-[88vw] max-w-[320px] p-0"
>
<SheetHeader className="border-border/70 border-b p-3">
<SheetTitle className="text-sm">
@@ -95,11 +95,20 @@ export function SourcesRail({ className }: SourcesRailProps) {
return (
<div
data-slot="coach-sources-rail"
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
// v1.16.1 — `min-w-0` + `overflow-x-hidden` pin the rail to its
// column width: the switch rows used to push past the 280/320 px
// container and paint a horizontal scrollbar under the rail.
className={cn(
"flex h-full min-h-0 min-w-0 flex-col gap-3 overflow-x-hidden p-3",
className,
)}
>
<h3
data-slot="coach-sources-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
// No horizontal inset — the heading sits flush with the row
// boxes below it (headings and their content share one left
// edge across the Coach surfaces).
className="text-muted-foreground flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase"
>
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
{t("insights.coach.sourcesTitle")}
@@ -111,7 +120,7 @@ export function SourcesRail({ className }: SourcesRailProps) {
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground px-1 text-[10px] font-medium tracking-wide uppercase"
className="text-muted-foreground text-[10px] font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
</label>
@@ -0,0 +1,410 @@
"use client";
/**
* v1.16.1 — Settings → AI "Coach preferences" card.
*
* The prompt-tuning form moved out of the in-chat sheet
* (`coach-settings-sheet.tsx`, retired) into the AI settings section so
* model + behaviour live in one place; the chat header keeps a gear
* that deep-links here. Reads + writes `/api/auth/me/coach-prefs`
* through the shared `useCoachPrefs` / `useSaveCoachPrefs` pair — the
* same persisted row the chat's "What I draw on" rail edits.
*
* One source of truth for data scope: `excludeMetrics`. The optional
* context group (sleep / medications / profile) used to render its own
* switches with the same checked-polarity as the exclude list, so
* "exclude sleep" and "send sleep context" could read as both ON at
* once. The context switches now DERIVE from the exclusion list
* (checked = not excluded = sent), so the two groups can never
* contradict each other.
*/
import { useState } from "react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
type CoachDataCluster,
type CoachDefaultWindow,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
type CoachVerbosity,
} from "@/lib/validations/coach-prefs";
const EXCLUDE_OPTIONS: CoachExcludeMetric[] = [
"bp",
"weight",
"pulse",
"mood",
"compliance",
"hrv",
"sleep",
"resting_hr",
"steps",
];
// Optional context blocks. Derived from `excludeMetrics` (see header
// comment): a switch here reads "send this block" and is simply the
// inverse of the exclusion entry.
const CONTEXT_OPTIONS: CoachExcludeMetric[] = [
"sleep",
"medications",
"anthropometrics",
];
const DEFAULT_WINDOW_OPTIONS: CoachDefaultWindow[] = [
"last7days",
"last30days",
"last90days",
"allTime",
];
const CLUSTER_OPTIONS: CoachDataCluster[] = coachDataClusterEnum.options;
export interface CoachPrefsSectionProps {
isAuthenticated: boolean;
}
export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
const { t } = useTranslations();
const { data: persisted } = useCoachPrefs({ enabled: isAuthenticated });
// Local working copy edited by the form; re-syncs to the persisted
// shape on every fetch via the in-render setState pattern (the
// `react-hooks/set-state-in-effect` rule rejects setState inside an
// effect when the source is a query result).
const [draft, setDraft] = useState<CoachPrefs>(DEFAULT_COACH_PREFS);
const [lastSeenPersisted, setLastSeenPersisted] = useState<CoachPrefs | null>(
null,
);
if (persisted && persisted !== lastSeenPersisted) {
setLastSeenPersisted(persisted);
setDraft(persisted);
}
const save = useSaveCoachPrefs({
onSuccess: () => {
toast.success(t("insights.coach.settingsSaved"));
},
});
function setExcluded(metric: CoachExcludeMetric, excluded: boolean) {
setDraft((prev) => ({
...prev,
excludeMetrics: excluded
? Array.from(new Set([...prev.excludeMetrics, metric]))
: prev.excludeMetrics.filter((m) => m !== metric),
}));
}
function toggleCluster(cluster: CoachDataCluster, next: boolean) {
setDraft((prev) => {
const current = prev.dataClusters ?? Array.from(DEFAULT_COACH_CLUSTERS);
const updated = next
? Array.from(new Set([...current, cluster]))
: current.filter((c) => c !== cluster);
return { ...prev, dataClusters: updated };
});
}
const enabledClusters = new Set<CoachDataCluster>(
draft.dataClusters ?? DEFAULT_COACH_CLUSTERS,
);
const isLoading = isAuthenticated && !persisted;
return (
<Card data-slot="coach-prefs-section">
<CardHeader>
<CardTitle className="text-lg font-semibold">
{t("insights.coach.settingsTitle")}
</CardTitle>
<CardDescription>
{t("insights.coach.settingsDescription")}
</CardDescription>
</CardHeader>
{isLoading ? (
<CardContent
data-slot="coach-prefs-skeleton"
className="flex flex-col gap-5"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-9 w-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-44 w-full" />
</div>
</CardContent>
) : (
<CardContent className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
<Label htmlFor="coach-prefs-tone" className="text-xs font-medium">
{t("insights.coach.settingsToneLabel")}
</Label>
<Select
value={draft.tone}
onValueChange={(value) =>
setDraft((prev) => ({ ...prev, tone: value as CoachTone }))
}
>
<SelectTrigger id="coach-prefs-tone" data-slot="coach-prefs-tone">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="warm">
{t("insights.coach.settingsToneWarm")}
</SelectItem>
<SelectItem value="neutral">
{t("insights.coach.settingsToneNeutral")}
</SelectItem>
<SelectItem value="concise">
{t("insights.coach.settingsToneConcise")}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Verbosity */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-verbosity"
className="text-xs font-medium"
>
{t("insights.coach.settingsVerbosityLabel")}
</Label>
<Select
value={draft.verbosity}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
verbosity: value as CoachVerbosity,
}))
}
>
<SelectTrigger
id="coach-prefs-verbosity"
data-slot="coach-prefs-verbosity"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="brief">
{t("insights.coach.settingsVerbosityBrief")}
</SelectItem>
<SelectItem value="default">
{t("insights.coach.settingsVerbosityDefault")}
</SelectItem>
<SelectItem value="detailed">
{t("insights.coach.settingsVerbosityDetailed")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Default analysis window */}
<div className="flex flex-col gap-2">
<Label
htmlFor="coach-prefs-default-window"
className="text-xs font-medium"
>
{t("insights.coach.settingsDefaultWindowLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsDefaultWindowHint")}
</p>
<Select
value={draft.defaultWindow}
onValueChange={(value) =>
setDraft((prev) => ({
...prev,
defaultWindow: value as CoachDefaultWindow,
}))
}
>
<SelectTrigger
id="coach-prefs-default-window"
data-slot="coach-prefs-default-window"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DEFAULT_WINDOW_OPTIONS.map((w) => (
<SelectItem key={w} value={w}>
{t(`insights.coach.window.${w}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Clustered, opt-in data sources */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsClustersLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsClustersHint")}
</p>
<ul
data-slot="coach-prefs-cluster-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CLUSTER_OPTIONS.map((cluster) => {
const id = `coach-prefs-cluster-${cluster}`;
const checked = enabledClusters.has(cluster);
return (
<li
key={cluster}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<Label
htmlFor={id}
className="cursor-pointer text-xs font-medium"
>
{t(`insights.coach.cluster.${cluster}.label`)}
</Label>
<span className="text-muted-foreground text-[11px] leading-snug">
{t(`insights.coach.cluster.${cluster}.hint`)}
</span>
</div>
<Switch
id={id}
data-slot={`coach-prefs-cluster-${cluster}`}
checked={checked}
onCheckedChange={(next) => toggleCluster(cluster, next)}
/>
</li>
);
})}
</ul>
</div>
{/* Exclude metrics — checked = excluded. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsExcludeLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsExcludeHint")}
</p>
<ul
data-slot="coach-prefs-exclude-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{EXCLUDE_OPTIONS.map((metric) => {
const id = `coach-prefs-exclude-${metric}`;
const checked = draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-exclude-${metric}`}
checked={checked}
onCheckedChange={(next) => setExcluded(metric, next)}
/>
</li>
);
})}
</ul>
</div>
{/* Optional context blocks — checked = sent, derived as the
inverse of the exclusion list so the two groups can never
contradict each other. */}
<div className="flex flex-col gap-2">
<Label className="text-xs font-medium">
{t("insights.coach.settingsContextLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
{t("insights.coach.settingsContextHint")}
</p>
<ul
data-slot="coach-prefs-context-list"
className="border-border/60 divide-border/50 mt-1 flex flex-col divide-y rounded-md border"
>
{CONTEXT_OPTIONS.map((metric) => {
const id = `coach-prefs-context-${metric}`;
const sent = !draft.excludeMetrics.includes(metric);
return (
<li
key={metric}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<Label htmlFor={id} className="cursor-pointer text-xs">
{t(`insights.coach.metric.${metric}`)}
</Label>
<Switch
id={id}
data-slot={`coach-prefs-context-${metric}`}
checked={sent}
onCheckedChange={(next) => setExcluded(metric, !next)}
/>
</li>
);
})}
</ul>
</div>
</CardContent>
)}
<CardFooter className="justify-end">
<Button
type="button"
size="sm"
onClick={() => save.mutate(draft)}
disabled={save.isPending || isLoading}
data-slot="coach-prefs-save"
>
{save.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : null}
{t("insights.coach.settingsSave")}
</Button>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Hermetic codec: round-trip through a UTF-8 buffer, no real crypto keys
// needed. Mirrors the bytes-codec contract (string ↔ Uint8Array).
vi.mock("../bytes-codec", () => ({
encryptToBytes: (s: string) => new TextEncoder().encode(s),
decryptFromBytes: (b: Uint8Array) => new TextDecoder().decode(b),
}));
// Avoid importing the real Prisma client; opts injection supplies the fake.
vi.mock("@/lib/db", () => ({ prisma: {} }));
const annotateMock = vi.fn();
vi.mock("@/lib/logging/context", () => ({
annotate: (...args: unknown[]) => annotateMock(...args),
}));
vi.mock("@/lib/insights/status-provider", () => ({
runStatusCompletion: vi.fn(),
}));
import {
DETERMINISTIC_FACT_CONFIDENCE,
extractDeterministicFacts,
storeDeterministicFacts,
} from "../facts";
function bytes(s: string): Uint8Array {
return new TextEncoder().encode(s);
}
function makeFakePrisma(activeTexts: string[] = []) {
const created: Array<Record<string, unknown>> = [];
return {
created,
db: {
coachFact: {
findMany: vi.fn(async () =>
activeTexts.map((text, i) => ({
id: `fact-${i}`,
factEncrypted: bytes(text),
category: "condition",
confidence: 90,
})),
),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
created.push(data);
return { id: `new-${created.length}` };
}),
},
coachConversation: { findFirst: vi.fn() },
},
};
}
beforeEach(() => {
annotateMock.mockClear();
});
describe("extractDeterministicFacts", () => {
it.each([
["ich habe eine Erdnussallergie", "Erdnuss"],
["Ich hab eine Erdnuss-Allergie, seit Jahren.", "Erdnuss"],
["übrigens: ich bin allergisch gegen Penicillin.", "Penicillin"],
["ich habe eine Allergie gegen Hausstaubmilben", "Hausstaubmilben"],
])("matches the German allergy statement %j", (message, subject) => {
const facts = extractDeterministicFacts(message, "de");
expect(facts).toHaveLength(1);
expect(facts[0].category).toBe("condition");
expect(facts[0].confidence).toBe(DETERMINISTIC_FACT_CONFIDENCE);
expect(facts[0].fact).toContain(subject);
expect(facts[0].fact).toContain("Allergie");
expect(facts[0].fact).toContain("eigene Angabe");
});
it("matches German intolerance statements", () => {
expect(
extractDeterministicFacts("ich habe eine Laktoseunverträglichkeit", "de")[0]
?.fact,
).toContain("Laktose");
expect(
extractDeterministicFacts("ich bin laktoseintolerant", "de")[0]?.fact,
).toContain("laktose");
});
it("matches a German self-reported diagnosis", () => {
const facts = extractDeterministicFacts(
"bei mir wurde Asthma diagnostiziert",
"de",
);
expect(facts).toHaveLength(1);
expect(facts[0].fact).toContain("Asthma");
expect(facts[0].fact).toContain("eigener Angabe");
});
it.each([
["I'm allergic to peanuts", "peanuts"],
["I am allergic to penicillin, sadly.", "penicillin"],
["I have a peanut allergy", "peanut"],
["I have a lactose intolerance", "lactose"],
["I was diagnosed with asthma", "asthma"],
])("matches the English statement %j", (message, subject) => {
const facts = extractDeterministicFacts(message, "en");
expect(facts).toHaveLength(1);
expect(facts[0].fact.toLowerCase()).toContain(subject);
expect(facts[0].fact).toMatch(/self-reported/i);
});
it("returns nothing for unrelated prose or third-party statements", () => {
expect(extractDeterministicFacts("wie war mein Blutdruck?", "de")).toEqual(
[],
);
expect(
extractDeterministicFacts("meine Schwester hat eine Katzenhaarallergie", "de"),
).toEqual([]);
expect(
extractDeterministicFacts("what is an allergy exactly?", "en"),
).toEqual([]);
});
});
describe("storeDeterministicFacts", () => {
it("persists a fresh allergy fact with the always-remember confidence", async () => {
const fake = makeFakePrisma();
const stored = await storeDeterministicFacts({
conversationId: "conv-1",
userId: "user-1",
message: "ich habe eine Erdnussallergie",
locale: "de",
prisma: fake.db as never,
});
expect(stored).toBe(1);
expect(fake.created).toHaveLength(1);
expect(fake.created[0]).toMatchObject({
userId: "user-1",
category: "condition",
confidence: DETERMINISTIC_FACT_CONFIDENCE,
sourceConversationId: "conv-1",
});
expect(annotateMock).toHaveBeenCalledWith(
expect.objectContaining({
action: { name: "coach.facts.deterministic_stored" },
}),
);
});
it("dedupes against an existing near-identical fact", async () => {
const fake = makeFakePrisma(["Allergie: Erdnuss (eigene Angabe)"]);
const stored = await storeDeterministicFacts({
conversationId: "conv-2",
userId: "user-1",
message: "ich habe eine Erdnuss-Allergie",
locale: "de",
prisma: fake.db as never,
});
expect(stored).toBe(0);
expect(fake.created).toHaveLength(0);
});
it("is a no-op when the message carries no pattern", async () => {
const fake = makeFakePrisma();
const stored = await storeDeterministicFacts({
conversationId: "conv-3",
userId: "user-1",
message: "Wie sieht mein Schlaf diese Woche aus?",
locale: "de",
prisma: fake.db as never,
});
expect(stored).toBe(0);
expect(fake.db.coachFact.findMany).not.toHaveBeenCalled();
});
});
+186
View File
@@ -379,6 +379,192 @@ export async function extractAndStoreFacts(
return { status: "stored", count: toStore.length };
}
// ---------------------------------------------------------------------------
// Deterministic always-remember extraction
// ---------------------------------------------------------------------------
/**
* v1.16.1 — the LLM extraction above only runs once a conversation grows past
* the history cap (the chat route enqueues the memory refresh at >20 turns),
* so an allergy stated in the second message of a short chat never reached the
* fact store at all. Allergies, intolerances and explicitly self-reported
* diagnoses are the one category the Coach must NEVER drop, so they get a
* deterministic, provider-free path that runs on every user message: a small
* closed set of self-statement patterns ("ich habe eine X-Allergie", "I'm
* allergic to X", …). Matches store as `condition` facts at confidence 95 —
* high enough to displace at the cap and to rank into the injection top-N.
*
* The patterns stay deliberately narrow: they match explicit first-person
* self-statements only, never an inferred or third-party condition, keeping
* the "descriptive, never diagnostic" contract of the LLM prompt.
*/
export const DETERMINISTIC_FACT_CONFIDENCE = 95;
interface DeterministicPattern {
re: RegExp;
kind: "allergy" | "intolerance" | "diagnosis";
}
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,
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,
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,
kind: "intolerance",
},
{
// "ich bin laktoseintolerant"
re: /\bich\s+bin\s+([a-zäöüß][\wäöüß-]*?)[-\s]?intolerant\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,
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,
kind: "allergy",
},
{
// "I have a peanut allergy"
re: /\bI\s+have\s+an?\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,
kind: "intolerance",
},
{
re: /\bI(?:'m|\s+am)\s+([a-z][\w-]*)[-\s]intolerant\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,
kind: "diagnosis",
},
];
function cleanCapture(raw: string): string | null {
const cleaned = raw.replace(/\s+/g, " ").replace(/[.,;:!?]+$/u, "").trim();
if (cleaned.length < 2 || cleaned.length > 60) return null;
return cleaned;
}
function deterministicFactText(
kind: DeterministicPattern["kind"],
subject: string,
locale: string | undefined,
): string {
const de = locale?.toLowerCase().startsWith("de") ?? true;
switch (kind) {
case "allergy":
return de
? `Allergie: ${subject} (eigene Angabe)`
: `Allergy: ${subject} (self-reported)`;
case "intolerance":
return de
? `Unverträglichkeit: ${subject} (eigene Angabe)`
: `Intolerance: ${subject} (self-reported)`;
case "diagnosis":
return de
? `Diagnose laut eigener Angabe: ${subject}`
: `Self-reported diagnosis: ${subject}`;
}
}
/**
* Pure pattern pass over a single user message. Exported so the unit tests
* pin the patterns without a DB. Returns zero or more `condition` facts.
*/
export function extractDeterministicFacts(
message: string,
locale?: string,
): ParsedFact[] {
const out: ParsedFact[] = [];
for (const { re, kind } of DETERMINISTIC_PATTERNS) {
const match = re.exec(message);
if (!match) continue;
const subject = cleanCapture(match[1] ?? "");
if (!subject) continue;
const fact = deterministicFactText(kind, subject, locale);
if (fact.length > FACT_MAX_CHARS) continue;
out.push({
category: "condition",
fact,
confidence: DETERMINISTIC_FACT_CONFIDENCE,
});
}
return out;
}
/**
* Run the deterministic pass on one user message and persist the survivors
* (deduped against the active set). No provider call — safe to fire on every
* turn from the chat route. Returns the number of facts stored.
*/
export async function storeDeterministicFacts(args: {
conversationId: string;
userId: string;
message: string;
locale?: string;
prisma?: PrismaLike;
}): Promise<number> {
const db = args.prisma ?? prisma;
const candidates = extractDeterministicFacts(args.message, args.locale);
if (candidates.length === 0) return 0;
const active = await loadActiveFacts(db, args.userId);
const seen = active.map((f) => f.text);
let stored = 0;
for (const cand of candidates) {
if (isNearDuplicate(cand.fact, seen)) continue;
seen.push(cand.fact);
// Field-by-field, no spread (mass-assignment rule, CLAUDE.md).
await db.coachFact.create({
data: {
userId: args.userId,
factEncrypted: encryptToBytes(cand.fact),
category: cand.category,
confidence: cand.confidence,
sourceConversationId: args.conversationId,
},
});
stored += 1;
}
if (stored > 0) {
annotate({
action: { name: "coach.facts.deterministic_stored" },
meta: { count: stored, conversationId: args.conversationId },
});
}
return stored;
}
// ---------------------------------------------------------------------------
// Injection block
// ---------------------------------------------------------------------------
+2
View File
@@ -397,6 +397,8 @@ export const queryKeys = {
coachAboutMe: () => ["coach-about-me"] as const,
/** v1.16.0 — pending clarifying questions (Coach composer chips). */
coachAboutMeQuestions: () => ["coach-about-me", "questions"] as const,
/** v1.16.1 — unseen proactive-nudge state for the floating Coach bubble. */
coachNudgeStatus: () => ["coach-nudge-status"] as const,
/**
* v1.4.41 — admin surfaces. Pre-fix every admin section declared its