mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
commit
234c43e4da
26 files changed
+1424
-48
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user