mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
Close the launch-blocking gap where the ConsentReceipt infrastructure was built but never enforced as a precondition: a direct API caller with a valid token could forward a health snapshot to a server-managed external LLM with no receipt on file. Consent was enforced client-side only. Add a consent guard (src/lib/ai/consent-guard.ts) and call it before the first external-provider call on the server-managed path. The gate fires only when the resolved chain could egress via the operator's global key (admin-openai); a user's own BYOK key (openai/anthropic), their own ChatGPT OAuth account, and the self-hosted local provider stay ungated — that egress is the user's own act and the settings flow is its consent. The check fails closed: a BYOK-primary chain with an admin-openai fallback still requires a receipt, since the runner may cascade to the operator key. The surface maps to the consent kind (coach -> ai_coach, insights -> ai_insights_only; master ai_full satisfies either). - Interactive routes (insights/chat, insights/generate POST) throw ConsentRequiredError; api-handler renders a 403 with meta.errorCode = "consent.ai.required", mirroring assistant.disabled.* so the client can render an inline grant-consent notice. - Off-request pipelines fail closed quietly: comprehensive generation returns a typed skipped:no-consent outcome, and the per-metric status family (runStatusCompletion, gating every status card + the period-narrative warm + the off-budget Coach memory workers) surfaces the no-key fallback instead of egressing. Also rate-limit POST /api/fitbit/sync (the one Fitbit route without a limiter): a 5/60s baseline plus a tighter 1/hour bucket on the expensive fullSync walk, keyed by user id, matching the sibling Fitbit routes. Harden the Fitbit OAuth redirect_uri: assert it is an absolute https origin (or http on localhost), lands on /api/fitbit/callback, and stays same-origin with NEXT_PUBLIC_APP_URL — defence-in-depth behind Google's registered-URI check so a Host-coerced or misconfigured app URL cannot redirect the authorization code off-origin. (cherry picked from commit 46b5fc2cea1b6e80bee0445bfdacf5790442c67d)
664 lines
25 KiB
TypeScript
664 lines
25 KiB
TypeScript
/**
|
|
* v1.4.20 — POST /api/insights/chat
|
|
*
|
|
* Streaming chat endpoint for the AI Coach. Returns Server-Sent
|
|
* Events: one `token` frame per chunk of the assistant reply, then a
|
|
* single `provenance` frame describing what the assistant could see,
|
|
* and a closing `done` frame carrying the persisted message ids.
|
|
*
|
|
* Behaviour:
|
|
* 1. requireAuth() — cookie session OR bearer token (iOS app).
|
|
* 2. Validate body with `coachChatRequestSchema`.
|
|
* 3. enforceBudget() — 429 with `coach.budget.exceeded` when the
|
|
* user has already burned the day's token cap.
|
|
* 4. detectRefusal() — pattern-based prompt-injection +
|
|
* off-topic guard. Refusal emits a single `token` frame with
|
|
* the localised refusal copy and a `done` frame; never hits a
|
|
* provider.
|
|
* 5. Idempotency: only when `conversationId` is absent (= the user
|
|
* is creating a new conversation). withIdempotency() caches the
|
|
* streamed body so a retry under the same Idempotency-Key
|
|
* replays the original assistant message instead of double-
|
|
* creating the conversation.
|
|
* 6. Provider chain — runRawCompletionWithFallback() walks the
|
|
* user's configured providers; on AllProvidersFailedError emit
|
|
* an `error` frame and persist nothing.
|
|
* 7. Persist user message + assistant message (encrypted) and bump
|
|
* the day's CoachUsage token ledger.
|
|
*/
|
|
import { NextResponse, type NextRequest } from "next/server";
|
|
|
|
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
|
import { apiError, apiSuccess } from "@/lib/api-response";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { prisma } from "@/lib/db";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { requireAssistantSurface } from "@/lib/feature-flags";
|
|
|
|
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
|
import {
|
|
AllProvidersFailedError,
|
|
runRawCompletionWithFallback,
|
|
} from "@/lib/ai/provider-runner";
|
|
import { resolveProviderChain, resolveProvider } from "@/lib/ai/provider";
|
|
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
|
import type { CompletionResult } from "@/lib/ai/types";
|
|
import { PROMPT_VERSION } from "@/lib/ai/prompts/insight-generator";
|
|
|
|
import {
|
|
coachChatRequestSchema,
|
|
type CoachStreamEvent,
|
|
} from "@/lib/ai/coach/types";
|
|
import {
|
|
appendMessage,
|
|
createConversation,
|
|
fetchConversationWithMessages,
|
|
listConversations,
|
|
} from "@/lib/ai/coach/persistence";
|
|
import { enqueueCoachMemoryRefresh } from "@/lib/ai/coach/coach-memory-shared";
|
|
import {
|
|
buildDateKey,
|
|
enforceBudget,
|
|
recordSpend,
|
|
} from "@/lib/ai/coach/budget";
|
|
import { detectRefusal } from "@/lib/ai/coach/refusal";
|
|
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
|
|
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
|
|
import { parseKeyValuesSentinel } from "@/lib/ai/coach/keyvalues";
|
|
import { parseCoachPrefs } from "@/lib/validations/coach-prefs";
|
|
import { createSseStream } from "@/lib/sse/create-stream";
|
|
|
|
/**
|
|
* Hard cap on total turns kept inside the per-call prompt window.
|
|
* Older turns past this point are folded into a single synthetic
|
|
* summary so cost stays bounded.
|
|
*/
|
|
const TURN_CAP = 20;
|
|
const RECENT_HISTORY = 18; // Last N turns kept verbatim before the new user message.
|
|
|
|
const SSE_HEADERS: Record<string, string> = {
|
|
"Content-Type": "text/event-stream; charset=utf-8",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
};
|
|
|
|
function encodeFrame(event: CoachStreamEvent): Uint8Array {
|
|
return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`);
|
|
}
|
|
|
|
/**
|
|
* Split a full assistant reply into ~roughly-word-sized chunks so the
|
|
* UI gets a "streaming" feel even when the underlying provider client
|
|
* returned the body in one shot.
|
|
*/
|
|
function tokeniseForStreaming(content: string): string[] {
|
|
if (!content) return [];
|
|
// Split on whitespace boundaries while preserving the spaces — keeps
|
|
// word boundaries intact and avoids the UI having to glue tokens.
|
|
const matches = content.match(/\S+\s*/g);
|
|
return matches ?? [content];
|
|
}
|
|
|
|
/**
|
|
* v1.12.0 — yield control back to the event loop for one tick so the
|
|
* stream controller flushes the just-enqueued frame before the next one
|
|
* is produced. `setTimeout(0)` (rather than a bare `Promise.resolve()`
|
|
* microtask) hands the turn back to the platform's stream pump so each
|
|
* SSE frame lands in its own network chunk; a microtask would drain
|
|
* before the runtime gets a chance to flush. The delay is intentionally
|
|
* zero — we want incremental delivery, not an artificial typewriter
|
|
* pause.
|
|
*/
|
|
function flushTick(): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
|
}
|
|
|
|
interface CoachTurn {
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
}
|
|
|
|
/**
|
|
* Convert the persisted-message rows into the OpenAI-compatible
|
|
* `{ role, content }` chat shape the provider clients expect.
|
|
*
|
|
* Also enforces the 20-turn cap: when the conversation history exceeds
|
|
* `TURN_CAP`, the older half is folded out of the verbatim window. v1.11.1 —
|
|
* if a rolling summary of those elided turns is on file
|
|
* (`CoachConversation.summaryEncrypted`, refreshed off-budget by the
|
|
* coach-memory-refresh worker) it is prepended so the Coach keeps memory of
|
|
* the older conversation; otherwise we fall back to a placeholder that just
|
|
* names the elided count (the pre-v1.11.1 behaviour). The summary is read
|
|
* stale-while-revalidate — the current turn uses whatever is on disk, the
|
|
* enqueued refresh makes the next long turn fresh.
|
|
*/
|
|
function buildHistoryWindow(
|
|
turns: CoachTurn[],
|
|
summary: string | null,
|
|
): CoachTurn[] {
|
|
if (turns.length <= TURN_CAP) return turns;
|
|
const elided = turns.length - RECENT_HISTORY;
|
|
const recent = turns.slice(turns.length - RECENT_HISTORY);
|
|
const memo = summary
|
|
? `[earlier conversation summary] ${summary}`
|
|
: `[summary placeholder — ${elided} earlier turns elided to stay within the conversation budget]`;
|
|
return [{ role: "user", content: memo }, ...recent];
|
|
}
|
|
|
|
async function handleChatRequest(request: NextRequest): Promise<Response> {
|
|
const auth = await requireAuth();
|
|
// v1.4.31 — operator can disable the Coach surface app-wide.
|
|
// Throws AssistantDisabledError → apiHandler returns 403 +
|
|
// `errorCode: "assistant.disabled.coach"` per the iOS contract.
|
|
await requireAssistantSurface("coach");
|
|
const userId = auth.user.id;
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
throw new HttpError(400, "Invalid JSON body");
|
|
}
|
|
const parsed = coachChatRequestSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
annotate({
|
|
action: { name: "insights.coach.invalid" },
|
|
meta: { issues: parsed.error.issues.length },
|
|
});
|
|
return NextResponse.json(
|
|
{ data: null, error: "coach.request.invalid" },
|
|
{ status: 422 },
|
|
);
|
|
}
|
|
const { conversationId, message, locale: bodyLocale, scope } = parsed.data;
|
|
|
|
// Per-user request-rate ceiling layered in front of the daily budget
|
|
// gate. The budget catches the cost dimension; this catches the
|
|
// request-rate dimension (a tight loop or a stolen session can burn
|
|
// the budget in seconds while pinning Prisma + provider slots before
|
|
// the budget arithmetic catches up). 20 / minute is well outside any
|
|
// realistic interactive use — a human can't type that fast, the iOS
|
|
// client paces from user gestures.
|
|
const rl = await checkRateLimit(`coach-chat:${userId}`, 20, 60 * 1000);
|
|
if (!rl.allowed) {
|
|
annotate({
|
|
action: { name: "insights.coach.rate-limited" },
|
|
meta: { userId, resetAt: rl.resetAt },
|
|
});
|
|
return apiError("Too many Coach requests, please wait a moment", 429);
|
|
}
|
|
|
|
await enforceBudget(userId);
|
|
|
|
const locale = await resolveServerLocale({
|
|
request,
|
|
override: bodyLocale,
|
|
userLocale: auth.user.locale ?? null,
|
|
});
|
|
|
|
// ── Refusal short-circuit ────────────────────────────────────
|
|
const refusal = detectRefusal({ message, locale });
|
|
if (refusal.refuse && refusal.message) {
|
|
annotate({
|
|
action: { name: "insights.coach.refused" },
|
|
meta: { reason: refusal.reason },
|
|
});
|
|
return streamRefusal({
|
|
userId,
|
|
conversationId,
|
|
message,
|
|
refusalText: refusal.message,
|
|
});
|
|
}
|
|
|
|
// ── Conversation resolution ──────────────────────────────────
|
|
let workingConversationId: string;
|
|
let priorTurns: CoachTurn[] = [];
|
|
// v1.11.1 — rolling summary of the elided older turns, read stale-while-
|
|
// revalidate; null for a fresh conversation or when none is on file.
|
|
let priorSummary: string | null = null;
|
|
|
|
if (conversationId) {
|
|
const existing = await fetchConversationWithMessages(
|
|
userId,
|
|
conversationId,
|
|
);
|
|
if (!existing) {
|
|
// 404, not 403 — never reveal cross-user existence
|
|
throw new HttpError(404, "coach.conversation.notFound");
|
|
}
|
|
workingConversationId = existing.id;
|
|
priorSummary = existing.summary ?? null;
|
|
priorTurns = existing.messages.map((m) => ({
|
|
role: m.role,
|
|
content: m.content,
|
|
}));
|
|
|
|
// v1.4.43 W13 M-3 — replay-injection guard. `detectRefusal` runs
|
|
// only on the inbound `message` per turn, so an injection that
|
|
// slipped past the regex bank on a previous turn would re-enter
|
|
// the prompt every reply. Re-run the detector against every
|
|
// user-turn re-loaded from DB; on a hit, short-circuit the SSE
|
|
// with a refusal AND drop an `insights.coach.replay_injection`
|
|
// row so the failure case is observable. The audit row carries
|
|
// the conversation id (server-owned), the turn index (no PII)
|
|
// and the matched reason — never the message content. v1.4.43
|
|
// W10 simplifier-L-2 — action name follows the `<surface>.<verb>`
|
|
// convention (no `audit.` prefix; no `.replay-injection` dash).
|
|
for (let i = 0; i < priorTurns.length; i++) {
|
|
const turn = priorTurns[i];
|
|
if (turn.role !== "user") continue;
|
|
const replayed = detectRefusal({ message: turn.content, locale });
|
|
if (!replayed.refuse) continue;
|
|
annotate({
|
|
action: { name: "insights.coach.replay_injection" },
|
|
meta: { reason: replayed.reason, turnIndex: i },
|
|
});
|
|
await auditLog("insights.coach.replay_injection", {
|
|
userId,
|
|
details: {
|
|
conversationId: existing.id,
|
|
turnIndex: i,
|
|
reason: replayed.reason,
|
|
},
|
|
});
|
|
return streamRefusal({
|
|
userId,
|
|
conversationId: existing.id,
|
|
message,
|
|
refusalText:
|
|
replayed.message ??
|
|
(locale === "de"
|
|
? "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
|
|
: "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."),
|
|
});
|
|
}
|
|
} else {
|
|
const created = await createConversation({ userId, title: message });
|
|
workingConversationId = created.id;
|
|
}
|
|
|
|
// Persist the user's turn first so it's safely on disk regardless
|
|
// of whether the provider call succeeds.
|
|
await appendMessage({
|
|
conversationId: workingConversationId,
|
|
role: "user",
|
|
content: message,
|
|
});
|
|
|
|
// Build the prompt: system + (optional) snapshot + recent history +
|
|
// the new user message. v1.4.23 H4 — fold per-user prefs into the
|
|
// system-prompt prefix; the snapshot builder reads the same prefs
|
|
// separately so excluded metrics never even leave the DB.
|
|
//
|
|
// v1.4.25 W5 — `coachPrefs.defaultWindow` is the user's saved
|
|
// analysis-window preference. Merge it into the snapshot scope when
|
|
// the client didn't supply a per-conversation override; the override
|
|
// (header pill / sources rail) always wins. Keep the merge cheap so
|
|
// we don't accidentally widen narrow per-call scopes.
|
|
const prefsRow = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: { coachPrefsJson: true },
|
|
});
|
|
const coachPrefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
|
const effectiveScope =
|
|
scope?.window === undefined && coachPrefs.defaultWindow
|
|
? { ...(scope ?? {}), window: coachPrefs.defaultWindow }
|
|
: scope;
|
|
const snapshot = await buildCoachSnapshot(userId, effectiveScope);
|
|
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs);
|
|
const allTurns: CoachTurn[] = [
|
|
...priorTurns,
|
|
{ role: "user", content: message },
|
|
];
|
|
const window = buildHistoryWindow(allTurns, priorSummary);
|
|
// v1.11.1 — once a conversation grows past the history cap, refresh the
|
|
// 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.
|
|
if (allTurns.length > TURN_CAP) {
|
|
void enqueueCoachMemoryRefresh({
|
|
conversationId: workingConversationId,
|
|
userId,
|
|
// Coach memory prose is composed in de/en only (the snapshot's
|
|
// coachLocale); collapse the wider UI locale union here.
|
|
locale: locale === "en" ? "en" : "de",
|
|
});
|
|
}
|
|
const transcript = window
|
|
.map((t) => `${t.role.toUpperCase()}: ${t.content}`)
|
|
.join("\n\n");
|
|
const userPrompt = `SNAPSHOT
|
|
${snapshot.snapshotJson || "(no metric data in this user's log yet)"}
|
|
|
|
CONVERSATION
|
|
${transcript}
|
|
|
|
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
|
|
|
// ── Provider chain ──────────────────────────────────────────
|
|
const chain = await resolveProviderChain(userId);
|
|
if (chain.length === 0) {
|
|
const legacy = await resolveProvider(userId);
|
|
if (legacy.type === "none") {
|
|
annotate({
|
|
action: { name: "insights.coach.noProvider" },
|
|
});
|
|
return streamProviderError({ code: "coach.provider.none" });
|
|
}
|
|
chain.push({ providerType: "admin-openai", instance: legacy });
|
|
}
|
|
|
|
// v1.12.1 — consent gate before any server-managed external egress. When
|
|
// the chain could egress via the operator's global key, require an active
|
|
// `ai_coach` (or master `ai_full`) receipt. BYOK / local / ChatGPT-OAuth
|
|
// chains are the user's own egress and stay ungated. Throws
|
|
// ConsentRequiredError → apiHandler returns 403 + `consent.ai.required`.
|
|
await assertConsentForChain({ userId, chain, surface: "coach" });
|
|
|
|
let result: CompletionResult;
|
|
let workingProviderType: string;
|
|
try {
|
|
const fallback = await runRawCompletionWithFallback({
|
|
userId,
|
|
providers: chain,
|
|
params: {
|
|
systemPrompt,
|
|
userPrompt,
|
|
temperature: 0.4,
|
|
maxTokens: 600,
|
|
},
|
|
});
|
|
result = fallback.result;
|
|
workingProviderType = fallback.workingProvider.providerType;
|
|
} catch (err) {
|
|
if (err instanceof AllProvidersFailedError) {
|
|
annotate({
|
|
action: { name: "insights.coach.providerFailed" },
|
|
meta: {
|
|
attempts: err.attempts.length,
|
|
firstStatus: err.attempts[0]?.httpStatus ?? null,
|
|
credentialExpired: err.primaryCredentialExpired,
|
|
},
|
|
});
|
|
// v1.11.0 W1 — when the user's PRIMARY provider failed with an
|
|
// auth-class status (401/403), the credential is dead, not the
|
|
// service. Surface a distinct `credential_expired` frame so the
|
|
// drawer can deep-link the user to reconnect rather than telling
|
|
// them to "try again later" — the gap that let an expired codex
|
|
// token silently kill all generation.
|
|
if (err.primaryCredentialExpired) {
|
|
return streamProviderError({
|
|
code: "coach.provider.credential_expired",
|
|
});
|
|
}
|
|
// v1.4.25 W5 — distinguish provider rate-limit (every attempt
|
|
// landed on 429) from generic unavailability. The drawer's
|
|
// error-decoder surfaces the rate-limit copy with a warning
|
|
// toast instead of the generic provider-down message, so the
|
|
// user understands the limit is transient.
|
|
const allRateLimited =
|
|
err.attempts.length > 0 &&
|
|
err.attempts.every((a) => a.httpStatus === 429);
|
|
return streamProviderError({
|
|
code: allRateLimited
|
|
? "coach.provider.rate_limited"
|
|
: "coach.provider.unavailable",
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const rawReply = (result.content ?? "").trim();
|
|
if (!rawReply) {
|
|
return streamProviderError({ code: "coach.provider.empty" });
|
|
}
|
|
|
|
// v1.4.22 — strip the optional `---KEYVALUES---` … `---END---`
|
|
// sentinel out of the prose. The stripped prose is what we stream
|
|
// to the client and persist; the parsed entries enrich the
|
|
// provenance envelope so the UI can render the collapsible
|
|
// "Worauf bezieht sich das?" disclosure.
|
|
const sentinel = parseKeyValuesSentinel(rawReply);
|
|
const proseAfterStrip = sentinel.prose.trim();
|
|
// v1.4.22 W5 reconcile (Code-H1) — when the model emits a
|
|
// sentinel-only / malformed reply, `sentinel.prose` is empty after
|
|
// stripping. The previous fallback `sentinel.prose.trim() || rawReply`
|
|
// surfaced raw `---KEYVALUES---` markers to the user. The empty-prose
|
|
// condition signals an unusable provider response: short-circuit to
|
|
// the structured `coach.provider.empty` error frame instead of
|
|
// streaming the raw sentinel body.
|
|
if (!proseAfterStrip) {
|
|
annotate({
|
|
action: { name: "coach.keyvalues.parse_failed" },
|
|
meta: {
|
|
kept: sentinel.keyValues.length,
|
|
reason: "empty_prose_after_strip",
|
|
promptVersion: PROMPT_VERSION,
|
|
},
|
|
});
|
|
return streamProviderError({ code: "coach.provider.empty" });
|
|
}
|
|
const replyText = proseAfterStrip;
|
|
const enrichedProvenance =
|
|
sentinel.keyValues.length > 0
|
|
? { ...snapshot.provenance, keyValues: sentinel.keyValues }
|
|
: snapshot.provenance;
|
|
if (sentinel.malformed) {
|
|
// Graceful degrade: log so ops can spot a provider whose
|
|
// sentinel format has drifted, but pass the prose through
|
|
// unchanged. v1.4.23 H1 — split the annotation:
|
|
// - parse_partial: at least one row parsed AND at least one
|
|
// row failed (mixed-format drift on a single reply)
|
|
// - parse_failed: the whole block was unusable
|
|
// Both annotations carry the per-line `reasons` array so an ops
|
|
// dashboard can attribute the failure cause without re-running
|
|
// the parser.
|
|
const reasons = sentinel.malformedEntries.map((entry) => entry.reason);
|
|
const annotationName =
|
|
sentinel.keyValues.length > 0 && sentinel.malformedEntries.length > 0
|
|
? "coach.keyvalues.parse_partial"
|
|
: "coach.keyvalues.parse_failed";
|
|
annotate({
|
|
action: { name: annotationName },
|
|
meta: {
|
|
kept: sentinel.keyValues.length,
|
|
malformedCount: sentinel.malformedEntries.length,
|
|
reasons,
|
|
promptVersion: PROMPT_VERSION,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Persist the assistant message BEFORE we begin streaming; if the
|
|
// client disconnects we still have the canonical row.
|
|
const assistantMessage = await appendMessage({
|
|
conversationId: workingConversationId,
|
|
role: "assistant",
|
|
content: replyText,
|
|
metricSource: enrichedProvenance,
|
|
providerType: workingProviderType,
|
|
promptVersion: PROMPT_VERSION,
|
|
});
|
|
|
|
// Bump the day's spend ledger AFTER persistence so a retried request
|
|
// doesn't double-count when the persistence layer rolled back.
|
|
await recordSpend({
|
|
userId,
|
|
tokens: result.tokensUsed ?? 0,
|
|
dateKey: buildDateKey(),
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "insights.coach.replied" },
|
|
meta: {
|
|
provider: workingProviderType,
|
|
tokens: result.tokensUsed ?? null,
|
|
promptVersion: PROMPT_VERSION,
|
|
conversationId: workingConversationId,
|
|
historyTurns: window.length,
|
|
// v1.7.0 — count of provenance metrics the snapshot surfaced
|
|
// this turn (a proxy for cluster breadth) so the dashboards can
|
|
// correlate reply shape with cluster activation.
|
|
clusterCount: snapshot.provenance.metrics.length,
|
|
},
|
|
});
|
|
|
|
// ── Stream the body to the client ────────────────────────────
|
|
// v1.12.0 — yield to the event loop between token frames so each one
|
|
// flushes as its own network chunk. The provider clients return the
|
|
// full reply in one shot; without the yield the whole tokenised body
|
|
// was enqueued synchronously inside `start()` and the runtime
|
|
// coalesced every frame into a single read, so the client painted the
|
|
// answer all at once despite the per-token render path in `use-coach`.
|
|
// A zero-delay yield is enough to land each frame on its own tick —
|
|
// the visible cadence reads ChatGPT/Claude-style without a contrived
|
|
// sleep. The refusal + error paths stay single-frame (nothing to
|
|
// pace).
|
|
const stream = createSseStream(async (controller) => {
|
|
for (const tok of tokeniseForStreaming(replyText)) {
|
|
controller.enqueue(encodeFrame({ type: "token", token: tok }));
|
|
await flushTick();
|
|
}
|
|
controller.enqueue(
|
|
encodeFrame({
|
|
type: "provenance",
|
|
metricSource: enrichedProvenance,
|
|
}),
|
|
);
|
|
controller.enqueue(
|
|
encodeFrame({
|
|
type: "done",
|
|
conversationId: workingConversationId,
|
|
messageId: assistantMessage.id,
|
|
}),
|
|
);
|
|
});
|
|
|
|
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
|
}
|
|
|
|
/**
|
|
* Emit a refusal as a single `token` frame followed by `done`. No
|
|
* provider call, no persisted assistant message — the user message is
|
|
* still kept on disk so the rail shows the conversation history
|
|
* accurately. The user message landing on disk is a deliberate choice;
|
|
* the rail otherwise wouldn't show the user's attempt at all.
|
|
*/
|
|
async function streamRefusal(args: {
|
|
userId: string;
|
|
conversationId: string | undefined;
|
|
message: string;
|
|
refusalText: string;
|
|
}): Promise<Response> {
|
|
let conversationId = args.conversationId;
|
|
if (!conversationId) {
|
|
const created = await createConversation({
|
|
userId: args.userId,
|
|
title: args.message,
|
|
});
|
|
conversationId = created.id;
|
|
} else {
|
|
const owned = await prisma.coachConversation.findFirst({
|
|
where: { id: conversationId, userId: args.userId },
|
|
select: { id: true },
|
|
});
|
|
if (!owned) {
|
|
throw new HttpError(404, "coach.conversation.notFound");
|
|
}
|
|
}
|
|
|
|
await appendMessage({
|
|
conversationId,
|
|
role: "user",
|
|
content: args.message,
|
|
});
|
|
const refusalMessage = await appendMessage({
|
|
conversationId,
|
|
role: "assistant",
|
|
content: args.refusalText,
|
|
metricSource: { windows: [], metrics: ["general"] },
|
|
providerType: "refusal",
|
|
promptVersion: PROMPT_VERSION,
|
|
});
|
|
|
|
const stream = createSseStream((controller) => {
|
|
controller.enqueue(encodeFrame({ type: "token", token: args.refusalText }));
|
|
controller.enqueue(
|
|
encodeFrame({
|
|
type: "done",
|
|
conversationId,
|
|
messageId: refusalMessage.id,
|
|
}),
|
|
);
|
|
});
|
|
|
|
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
|
}
|
|
|
|
function streamProviderError(args: { code: string }): Response {
|
|
const stream = createSseStream((controller) => {
|
|
controller.enqueue(
|
|
encodeFrame({
|
|
type: "error",
|
|
code: args.code,
|
|
message: args.code,
|
|
}),
|
|
);
|
|
});
|
|
// Status 200 so the streaming client reads the SSE body and parses
|
|
// the structured `error` frame (HTTP-status branches drop the
|
|
// structured code on the floor).
|
|
return new Response(stream, { status: 200, headers: SSE_HEADERS });
|
|
}
|
|
|
|
// Idempotency is intentionally NOT applied to this SSE-streaming route.
|
|
// `withIdempotency()` caches the response body via `cloned.text()` and
|
|
// replays it through `NextResponse.json(JSON.parse(...))` — that path
|
|
// turns an SSE wire format (`data: …\n\n` frames) into a `null` body
|
|
// because the cached text isn't JSON. The PWA never sets
|
|
// `Idempotency-Key` here so the bug is invisible today, but the iOS
|
|
// client does. Dedup still holds: a duplicate first-turn POST creates
|
|
// a second conversation row (cheap), and follow-up turns are gated by
|
|
// the conversationId existence check + 20-turn cap.
|
|
export const POST = apiHandler(handleChatRequest);
|
|
|
|
/**
|
|
* GET /api/insights/chat?cursor=<id>&limit=<n>
|
|
*
|
|
* Cursor-paginated list of the caller's conversations for the rail.
|
|
* Default limit 20, hard cap 50. Cursor is the id of the last item
|
|
* on the previous page; callers receive `{ nextCursor: null }` when
|
|
* they have reached the end.
|
|
*/
|
|
export const GET = apiHandler(async (request: NextRequest) => {
|
|
const auth = await requireAuth();
|
|
// v1.4.31 — same gate as the SSE POST. Hiding the rail when
|
|
// the operator has disabled Coach matches the FAB suppression
|
|
// on the client.
|
|
await requireAssistantSurface("coach");
|
|
const url = new URL(request.url);
|
|
const cursor = url.searchParams.get("cursor");
|
|
const limitRaw = url.searchParams.get("limit");
|
|
const limit = limitRaw ? Number.parseInt(limitRaw, 10) : undefined;
|
|
|
|
const page = await listConversations({
|
|
userId: auth.user.id,
|
|
cursor,
|
|
limit: Number.isFinite(limit) ? (limit as number) : undefined,
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "insights.coach.list" },
|
|
meta: { count: page.conversations.length },
|
|
});
|
|
|
|
return apiSuccess(page);
|
|
});
|
|
|
|
// Disable the static-page optimisation; we are always streaming.
|
|
export const dynamic = "force-dynamic";
|
|
export const runtime = "nodejs";
|