Merge w10/aigrounding into release/v1.18.10

Ground daily-briefing numbers against the signal features, guard Coach replies
against fabricated doses and risk scores, gate the period narrative, feed the six
status cards a precomputed signal block, add SSE cancellation, and cap the Coach
snapshot read.
This commit is contained in:
Marc-André Bombeck committed 2026-06-20 16:59:58 +02:00
commit 234c43e4da
26 files changed
+1424 -48

No files matched your search

+40 -3
View File
@@ -66,6 +66,10 @@ import {
reconcileSpend,
} from "@/lib/ai/coach/budget";
import { detectRefusal } from "@/lib/ai/coach/refusal";
import {
screenCoachReply,
coachOutboundFallback,
} from "@/lib/ai/coach/outbound-guard";
import { getCoachSystemPrompt } from "@/lib/ai/coach/system-prompt";
import { getSelfContextTextForUser } from "@/lib/ai/coach/about-me";
import { buildCoachSnapshot } from "@/lib/ai/coach/snapshot";
@@ -564,9 +568,33 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// + 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 replyText = suggestParse.prose.trim() || proseAfterStrip;
// v1.18.10 (HIGH-2) — OUTBOUND safety screen on the assembled assistant
// reply, before persistence and streaming. The inbound `detectRefusal`
// guards the user's message; this guards the model's reply for a
// dose-prescription or a fabricated clinical risk score that slipped past
// the system-prompt GLP-1/grounding contracts. On a trip the turn is
// replaced with a calm, grounded fallback and any reminder suggestion /
// key-value provenance is dropped — the user never sees the unsafe text.
const outbound = screenCoachReply(replyText);
if (outbound.block && outbound.reason) {
replyText = coachOutboundFallback(outbound.reason, locale);
annotate({
action: { name: "insights.coach.outbound_blocked" },
meta: { reason: outbound.reason, promptVersion: PROMPT_VERSION },
});
await auditLog("insights.coach.outbound_blocked", {
userId,
details: {
conversationId: workingConversationId,
reason: outbound.reason,
},
});
}
let surfacedSuggestion: CoachSuggestion | null = null;
if (suggestParse.cadence) {
if (!outbound.block && suggestParse.cadence) {
const decision = await gateSuggestion({
prisma,
userId,
@@ -614,7 +642,11 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
}
const enrichedProvenance: typeof snapshot.provenance = {
...snapshot.provenance,
...(sentinel.keyValues.length > 0 ? { keyValues: sentinel.keyValues } : {}),
// v1.18.10 (HIGH-2) — a blocked turn carries the fallback prose, so the
// key-values from the discarded reply must not ride along as provenance.
...(!outbound.block && sentinel.keyValues.length > 0
? { keyValues: sentinel.keyValues }
: {}),
...(surfacedSuggestion ? { suggestion: surfacedSuggestion } : {}),
};
if (sentinel.malformed) {
@@ -692,9 +724,14 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
// pace).
const stream = createSseStream(async (controller) => {
for (const tok of tokeniseForStreaming(replyText)) {
// v1.18.10 (A-2) — stop tokenising the moment the client disconnects;
// the cancel handler flips this signal, so we don't pace frames into a
// closed connection.
if (controller.signal.aborted) return;
controller.enqueue(encodeFrame({ type: "token", token: tok }));
await flushTick();
}
if (controller.signal.aborted) return;
controller.enqueue(
encodeFrame({
type: "provenance",
+64
View File
@@ -50,6 +50,11 @@ import {
runRawCompletionWithFallback,
} from "@/lib/ai/provider-runner";
import { assertConsentForChain } from "@/lib/ai/consent-guard";
import {
findUngroundedBriefingNumbers,
readBriefingBlock,
buildBriefingGroundingCorrection,
} from "@/lib/ai/briefing-grounding";
import { isLegacyInsightPayload } from "@/lib/ai/legacy-payload";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
@@ -729,6 +734,65 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("AI response was not valid JSON", 422);
}
// v1.18.10 (HIGH-1) — daily-briefing number-grounding gate, mirroring the
// recommendations citation cross-check next door. The schema only checks the
// briefing's SHAPE; this asserts every number the briefing prose restates
// traces to a `features.signalsOfDay` figure. On a miss: one corrective
// retry, then drop the briefing block rather than ship a fabricated number.
{
const signals = features.signalsOfDay ?? null;
let ungrounded = findUngroundedBriefingNumbers(
readBriefingBlock(insights),
signals,
);
if (ungrounded.length > 0) {
annotate({
action: { name: "insights.generate.briefing_grounding_retry" },
meta: { ungroundedCount: ungrounded.length },
});
try {
const retry = await runRawCompletionWithFallback({
userId,
providers: chain,
params: {
systemPrompt: buildSystemPromptWithReferences(
locale,
referenceMetrics,
),
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
temperature: 0.3,
maxTokens: 1500,
},
});
const parsedRetry = JSON.parse(retry.result.content);
const validatedRetry = insightResultSchema.safeParse(parsedRetry);
const retryInsights = validatedRetry.success
? validatedRetry.data
: parsedRetry;
ungrounded = findUngroundedBriefingNumbers(
readBriefingBlock(retryInsights),
signals,
);
if (ungrounded.length === 0) {
insights = retryInsights;
result = retry.result;
workingProviderType = retry.workingProvider.providerType;
}
} catch {
// Retry failed to produce parseable JSON — fall through to the strip.
}
}
// Still ungrounded after the retry: strip the briefing rather than ship a
// fabricated figure. The structured recommendations/citations stay.
if (ungrounded.length > 0 && insights && typeof insights === "object") {
(insights as Record<string, unknown>).dailyBriefing = null;
annotate({
action: { name: "insights.generate.briefing_grounding_stripped" },
meta: { ungroundedCount: ungrounded.length },
});
}
}
await prisma.user.update({
where: { id: userId },
data: {