mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Screen the whole comprehensive-insight prose surface before it persists
The briefing gate grades numbers only, and only the paragraph, signals and key-findings blocks -- so a digit-free risk assertion with a dose suggestion attached passed it untouched, and recommendations and summary were not graded at all. Screen every readable prose field against the insights contracts. A safety violation cannot be surgically stripped the way an ungrounded number can: the offending sentence may sit in any field, and deleting one would leave the rest referring to it. So the background generator fails the whole run and persists nothing, leaving the previous cached payload in place, while the user-initiated route returns an explicit error for the same reason.
This commit is contained in:
1 parent
70e6e5e374
commit
0becc2a56d
3 files changed
+115
No files matched your search
@@ -58,6 +58,7 @@ import {
|
||||
runRawCompletionWithFallback,
|
||||
} from "@/lib/ai/provider-runner";
|
||||
import { assertConsentForChain } from "@/lib/ai/consent-guard";
|
||||
import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen";
|
||||
import {
|
||||
findUngroundedBriefingNumbers,
|
||||
readBriefingBlock,
|
||||
@@ -895,6 +896,26 @@ export const POST = apiHandler((request: NextRequest) =>
|
||||
}
|
||||
}
|
||||
|
||||
// Outbound safety screen over the whole prose surface, before the persist.
|
||||
//
|
||||
// This route is user-initiated but writes the SAME cached payload the
|
||||
// background generator writes, so a violation must not be persisted here
|
||||
// either. The user is waiting, so unlike the background path they get an
|
||||
// explicit error rather than silence -- and because nothing was written,
|
||||
// the previous cached insight is still what the app renders.
|
||||
const screened = screenInsightPayloadProse(insights, locale);
|
||||
if (screened) {
|
||||
annotate({
|
||||
action: { name: "insights.generate.outbound_blocked" },
|
||||
meta: { locale, reason: screened },
|
||||
});
|
||||
return apiError(
|
||||
"The generated insight did not pass the safety check and was discarded. Try regenerating.",
|
||||
502,
|
||||
{ errorCode: "insights.generate.outboundScreened" },
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Outbound safety screen over a whole comprehensive-insight payload.
|
||||
*
|
||||
* Lives apart from the generator on purpose: it is a pure function of the
|
||||
* payload shape, both the background generator and the user-initiated POST
|
||||
* route run it before their persist, and neither should have to import the
|
||||
* other to get at it.
|
||||
*
|
||||
* What it closes. The number-grounding gate covers `dailyBriefing.paragraph`,
|
||||
* `signalsOfDay[]` and `keyFindings[]`, and it grades NUMBERS only — so a
|
||||
* digit-free "your 10-year cardiovascular risk is elevated, consider stepping
|
||||
* your dose up" passed it untouched, and `recommendations[]` / `summary` were
|
||||
* not graded at all. This screens every prose field the user can read against
|
||||
* the insights safety contracts.
|
||||
*/
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { readBriefingBlock } from "@/lib/ai/briefing-grounding";
|
||||
import {
|
||||
screenModelOutput,
|
||||
INSIGHTS_CONTRACTS,
|
||||
type OutboundReason,
|
||||
} from "@/lib/ai/safety/outbound-screen";
|
||||
|
||||
/** Every free-text field of a comprehensive payload the user can read. */
|
||||
export function collectInsightProse(parsed: unknown): string[] {
|
||||
const out: string[] = [];
|
||||
const push = (v: unknown) => {
|
||||
if (typeof v === "string" && v.trim().length > 0) out.push(v);
|
||||
};
|
||||
if (!parsed || typeof parsed !== "object") return out;
|
||||
const root = parsed as Record<string, unknown>;
|
||||
|
||||
push(root.summary);
|
||||
for (const rec of Array.isArray(root.recommendations)
|
||||
? root.recommendations
|
||||
: []) {
|
||||
if (rec && typeof rec === "object") {
|
||||
push((rec as Record<string, unknown>).text);
|
||||
}
|
||||
}
|
||||
|
||||
const briefing = readBriefingBlock(parsed);
|
||||
if (briefing) {
|
||||
push(briefing.paragraph);
|
||||
for (const s of briefing.signalsOfDay ?? []) {
|
||||
push(s?.headline);
|
||||
push(s?.nudge);
|
||||
push(s?.delta);
|
||||
}
|
||||
for (const f of briefing.keyFindings ?? []) {
|
||||
push(f?.headline);
|
||||
push(f?.detail);
|
||||
push(f?.delta);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen every prose field of a payload. Returns the first tripped contract,
|
||||
* or null when the whole payload is clean.
|
||||
*/
|
||||
export function screenInsightPayloadProse(
|
||||
parsed: unknown,
|
||||
locale: Locale,
|
||||
): OutboundReason | null {
|
||||
for (const text of collectInsightProse(parsed)) {
|
||||
const decision = screenModelOutput(text, locale, INSIGHTS_CONTRACTS);
|
||||
if (decision.block && decision.reason) return decision.reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
readBriefingBlock,
|
||||
buildBriefingGroundingCorrection,
|
||||
} from "@/lib/ai/briefing-grounding";
|
||||
import { screenInsightPayloadProse } from "@/lib/ai/safety/insight-payload-screen";
|
||||
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
|
||||
import { getCachedFeatures } from "@/lib/insights/feature-cache";
|
||||
import {
|
||||
@@ -1038,6 +1039,27 @@ export async function generateComprehensiveInsight(
|
||||
}
|
||||
}
|
||||
|
||||
// Outbound safety screen over the WHOLE prose surface.
|
||||
//
|
||||
// SURFACE POLICY — WITHHOLD. This payload is generated in the background and
|
||||
// persisted as the cached insight; nobody is waiting on the turn. Unlike the
|
||||
// number-grounding gate above, a safety violation cannot be surgically
|
||||
// stripped: a dose imperative may sit in `recommendations[]`, in `summary`,
|
||||
// or in the briefing, and deleting an arbitrary field would leave a payload
|
||||
// whose remaining prose still refers to it. So the whole generation fails and
|
||||
// persists NOTHING -- the previous cached payload stays, exactly as a
|
||||
// provider outage or an unparseable response already behaves. The next run
|
||||
// regenerates from scratch.
|
||||
const screened = screenInsightPayloadProse(insights, locale);
|
||||
if (screened) {
|
||||
annotate({
|
||||
action: { name: "insights.generate.outbound_blocked" },
|
||||
meta: { locale, reason: screened },
|
||||
});
|
||||
void recordBriefingFailure({ userId, reason: "outbound-screened", locale });
|
||||
return { status: "failed", reason: "outbound-screened" };
|
||||
}
|
||||
|
||||
// v1.9.0 — the caller abandoned this generation (its bounded timeout
|
||||
// fired and it has moved on to warm the per-status caches itself). Bail
|
||||
// out before the cache write so a late-resolving comprehensive cannot
|
||||
|
||||
Reference in new issue
Block a user