mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(coach): evidence-based cadence suggestions with one-tap in-chat reminder
The Coach can append a ---SUGGEST-REMINDER--- sentinel to propose a measurement cadence (weight daily, the ESH/AHA 7-2-2 home-BP protocol, structured glucose). The server resolves the cadence from a closed catalog and emits an additive 'suggestion' SSE frame the chat renders as a one-tap action card; accepting it creates a MeasurementReminder with origin COACH through the same engine the Vorsorge surface uses (server-authoritative schedule, finite course window self-expires via endsOn). Non-naggy by construction: a frequency-cap cooldown, dismissal memory, a master opt-out under Coach settings, and an explicit 'I measure enough' stop path, all in the coach-prefs blob. Triple dedup at suggest, render, and create so a cadence is never double-suggested or double-created, and suggestions for a disabled module never surface. RHR/HRV stay passive -- the Coach surfaces the baseline, it never nudges to 'go measure' them. The reminder DTO now echoes origin and endsOn for the iOS client.
This commit is contained in:
1 parent
b6796eee96
commit
12f4bc24e8
29 files changed
+1538
-9
No files matched your search
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* POST /api/coach/reminder-suggestions — act on a Coach cadence suggestion.
|
||||
*
|
||||
* v1.18.1 (Workstream C). The Coach proposes an evidence-based measurement
|
||||
* cadence (the `---SUGGEST-REMINDER---` sentinel → `suggestion` SSE frame →
|
||||
* action card). This endpoint is what the card's buttons call:
|
||||
*
|
||||
* - `accept` → create a `MeasurementReminder` with `origin: COACH`,
|
||||
* routed through the SAME engine + endpoint semantics as the
|
||||
* Vorsorge surface. The server resolves the cadence from the
|
||||
* closed catalog (metric + interval/rrule + course window) —
|
||||
* the client sends only the cadence id, never a schedule.
|
||||
* - `dismiss` → dismissal memory (never re-suggest this cadence).
|
||||
* - `stop` → the explicit "you measure enough — stop" path.
|
||||
*
|
||||
* Triple-dedup completion: the CREATE path here refuses a second live
|
||||
* COACH reminder for the same metric, so a re-tap or a stale card cannot
|
||||
* double-create even though the SUGGEST gate already deduped at emit time.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import {
|
||||
parseCoachPrefs,
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
|
||||
import {
|
||||
CADENCE_CATALOG,
|
||||
isCadenceId,
|
||||
} from "@/lib/ai/coach/suggest-reminder";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
async function postAction(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// The Coach surface gates the suggestion card; mirror the gate here so a
|
||||
// disabled-coach account cannot drive the endpoint directly.
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachReminderSuggestionActionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const { cadenceId, action } = parsed.data;
|
||||
|
||||
if (!isCadenceId(cadenceId)) {
|
||||
return apiError("coach.suggestion.unknownCadence", 422);
|
||||
}
|
||||
const cadence = CADENCE_CATALOG[cadenceId];
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { coachPrefsJson: true, timezone: true },
|
||||
});
|
||||
const coachPrefs = parseCoachPrefs(row?.coachPrefsJson);
|
||||
const suggestionPrefs =
|
||||
coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS;
|
||||
|
||||
// ── dismiss / stop: prefs-only writes ────────────────────────
|
||||
if (action === "dismiss" || action === "stop") {
|
||||
const next = {
|
||||
...suggestionPrefs,
|
||||
...(action === "stop" ? { stopped: true } : {}),
|
||||
dismissedCadences:
|
||||
action === "dismiss" &&
|
||||
!suggestionPrefs.dismissedCadences.includes(cadenceId)
|
||||
? [...suggestionPrefs.dismissedCadences, cadenceId].slice(0, 32)
|
||||
: suggestionPrefs.dismissedCadences,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
coachPrefsJson: { ...coachPrefs, reminderSuggestions: next },
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: `coach.reminder.${action}` },
|
||||
meta: { cadenceId },
|
||||
});
|
||||
return apiSuccess({ ok: true, action });
|
||||
}
|
||||
|
||||
// ── accept: create the reminder (origin: COACH) ──────────────
|
||||
// Module-toggle re-check at create time (defence in depth — the gate
|
||||
// already checked at emit, but a stale card could outlive a module flip).
|
||||
if (cadence.module) {
|
||||
const moduleGate = await requireModuleEnabled(user.id, cadence.module);
|
||||
if (!moduleGate.enabled) return moduleGate.response;
|
||||
}
|
||||
|
||||
// Third dedup: refuse a second live COACH reminder for the same metric.
|
||||
const existing = await prisma.measurementReminder.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
origin: "COACH",
|
||||
measurementType: cadence.measurementType,
|
||||
deletedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
annotate({
|
||||
action: { name: "coach.reminder.accept.duplicate" },
|
||||
meta: { cadenceId, reminderId: existing.id },
|
||||
});
|
||||
return apiSuccess(
|
||||
{ ok: true, action: "accept", reminder: null, duplicate: true },
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
const timezone = row?.timezone || DEFAULT_TIMEZONE;
|
||||
const now = new Date();
|
||||
const endsOn =
|
||||
cadence.courseDays != null
|
||||
? new Date(now.getTime() + cadence.courseDays * 24 * 60 * 60 * 1000)
|
||||
: null;
|
||||
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: cadence.intervalDays,
|
||||
rrule: cadence.rrule,
|
||||
anchorDate: null,
|
||||
notifyHour: cadence.notifyHour,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: now,
|
||||
endsOn,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
|
||||
// Field-by-field — no mass assignment. `origin: COACH` is the
|
||||
// provenance the UI labels and the SUGGEST gate dedupes against.
|
||||
const created = await prisma.measurementReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
label: cadence.labelKey,
|
||||
measurementType: cadence.measurementType,
|
||||
intervalDays: cadence.intervalDays,
|
||||
rrule: cadence.rrule,
|
||||
anchorDate: null,
|
||||
endsOn,
|
||||
origin: "COACH",
|
||||
notifyHour: cadence.notifyHour,
|
||||
location: null,
|
||||
enabled: true,
|
||||
nextDueAt,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.create.coach", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: created.id, cadenceId },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "coach.reminder.accept" },
|
||||
meta: { cadenceId, reminderId: created.id },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
ok: true,
|
||||
action: "accept",
|
||||
reminder: toMeasurementReminderDto(created),
|
||||
duplicate: false,
|
||||
},
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(postAction);
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -69,7 +69,13 @@ import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
|
||||
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
|
||||
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
||||
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
|
||||
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
||||
import { parseSuggestReminder } from "@/lib/ai/coach/suggest-reminder";
|
||||
import { gateSuggestion } from "@/lib/ai/coach/suggest-gate";
|
||||
import {
|
||||
parseCoachPrefs,
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import type { CoachSuggestion } from "@/lib/ai/coach/types";
|
||||
import { createSseStream } from "@/lib/sse/create-stream";
|
||||
|
||||
/**
|
||||
@@ -498,11 +504,66 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
});
|
||||
return streamProviderError({ code: "coach.provider.empty" });
|
||||
}
|
||||
const replyText = proseAfterStrip;
|
||||
const enrichedProvenance =
|
||||
sentinel.keyValues.length > 0
|
||||
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
|
||||
: snapshot.provenance;
|
||||
// v1.18.1 (Workstream C) — strip the optional `---SUGGEST-REMINDER---`
|
||||
// block out of the prose-after-keyvalues. The model proposes a cadence;
|
||||
// the gate decides whether it actually surfaces (module-toggle + opt-out
|
||||
// + dismissal memory + cooldown + dedup against a live COACH reminder).
|
||||
// A suppressed proposal leaves the prose unchanged and emits no card.
|
||||
const suggestParse = parseSuggestReminder(proseAfterStrip);
|
||||
const replyText = suggestParse.prose.trim() || proseAfterStrip;
|
||||
let surfacedSuggestion: CoachSuggestion | null = null;
|
||||
if (suggestParse.cadence) {
|
||||
const decision = await gateSuggestion({
|
||||
prisma,
|
||||
userId,
|
||||
cadence: suggestParse.cadence,
|
||||
prefs: coachPrefs.reminderSuggestions ?? DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
});
|
||||
if (decision.surface) {
|
||||
const cadence = suggestParse.cadence;
|
||||
surfacedSuggestion = {
|
||||
cadenceId: cadence.id,
|
||||
measurementType: cadence.measurementType,
|
||||
label: cadence.labelKey,
|
||||
};
|
||||
// Stamp the cooldown anchor (frequency cap) onto the prefs blob.
|
||||
const nextSuggestionPrefs = {
|
||||
...(coachPrefs.reminderSuggestions ??
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS),
|
||||
lastSuggestedAt: new Date().toISOString(),
|
||||
};
|
||||
void prisma.user
|
||||
.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
coachPrefsJson: {
|
||||
...coachPrefs,
|
||||
reminderSuggestions: nextSuggestionPrefs,
|
||||
},
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Cooldown stamp is best-effort: a write failure at worst lets a
|
||||
// second suggestion through sooner, never breaks the chat turn.
|
||||
});
|
||||
annotate({
|
||||
action: { name: "coach.reminder.suggested" },
|
||||
meta: { cadenceId: cadence.id, metric: cadence.measurementType },
|
||||
});
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "coach.reminder.suppressed" },
|
||||
meta: { cadenceId: suggestParse.cadence.id, reason: decision.reason },
|
||||
});
|
||||
}
|
||||
}
|
||||
const enrichedProvenance: typeof snapshot.provenance = {
|
||||
...snapshot.provenance,
|
||||
...(sentinel.keyValues.length > 0
|
||||
? { keyValues: sentinel.keyValues }
|
||||
: {}),
|
||||
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
|
||||
};
|
||||
if (sentinel.malformed) {
|
||||
// Graceful degrade: log so ops can spot a provider whose
|
||||
// sentinel format has drifted, but pass the prose through
|
||||
@@ -585,6 +646,13 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
metricSource: enrichedProvenance,
|
||||
}),
|
||||
);
|
||||
// v1.18.1 (Workstream C) — additive `suggestion` frame. Older clients
|
||||
// ignore it; newer ones render the one-tap action card.
|
||||
if (surfacedSuggestion) {
|
||||
controller.enqueue(
|
||||
encodeFrame({ type: "suggestion", suggestion: surfacedSuggestion }),
|
||||
);
|
||||
}
|
||||
controller.enqueue(
|
||||
encodeFrame({
|
||||
type: "done",
|
||||
|
||||
@@ -147,6 +147,9 @@ export const PATCH = apiHandler(
|
||||
(updateData.notifyHour as number | undefined) ?? existing.notifyHour,
|
||||
lastSatisfiedAt: existing.lastSatisfiedAt,
|
||||
createdAt: existing.createdAt,
|
||||
// v1.18.1 — preserve the course window so an edit of a finite
|
||||
// (Coach-suggested) cadence keeps self-expiring.
|
||||
endsOn: existing.endsOn,
|
||||
};
|
||||
const after =
|
||||
existing.lastSatisfiedAt && existing.lastSatisfiedAt > now
|
||||
|
||||
Reference in new issue
Block a user