mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
b55fab5e94
commit
9f2c403f84
19 files changed
+1092
-879
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user