mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
fix(insights): ground daily-briefing numbers against precomputed signals
The daily-briefing block (paragraph / signalsOfDay / keyFindings) was schema-shaped but never number-checked the way recommendations[] are: a restated figure could drift from the snapshot with no server rejection. Add a grounding cross-check that asserts every number the briefing prose states traces to a features.signalsOfDay figure (latest / avg / delta) within a rounding tolerance. Wire it into both generation sites with one corrective retry, then strip the briefing rather than persist a fabricated number. Also gate the higher-temperature paragraph reroll on the same check so a phrasing reroll cannot drift a figure.
This commit is contained in:
1 parent
fa37941add
commit
8b5235c25b
4 files changed
+492
No files matched your search
@@ -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: {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
findUngroundedBriefingNumbers,
|
||||
readBriefingBlock,
|
||||
buildBriefingGroundingCorrection,
|
||||
extractNumbers,
|
||||
} from "@/lib/ai/briefing-grounding";
|
||||
import type { SignalOfDay } from "@/lib/insights/features";
|
||||
|
||||
function signal(partial: Partial<SignalOfDay>): SignalOfDay {
|
||||
return {
|
||||
metric: "weight",
|
||||
label: "Weight",
|
||||
latest: 82,
|
||||
latestDaysAgo: 0,
|
||||
avg7: 83,
|
||||
avg30: 84,
|
||||
deltaVs7: -1,
|
||||
deltaVs30: -2,
|
||||
spread30: 0.5,
|
||||
outsideNormalSwing: true,
|
||||
emergingTrend: "falling",
|
||||
recentAnomaly: null,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("extractNumbers", () => {
|
||||
it("parses signed, decimal, and comma-decimal numbers", () => {
|
||||
expect(
|
||||
extractNumbers("dropped 2.5 kg, +1,2 and -3").map((n) => n.value),
|
||||
).toEqual([2.5, 1.2, -3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUngroundedBriefingNumbers", () => {
|
||||
const signals = [signal({})];
|
||||
|
||||
it("returns nothing when there are no signals to grade against", () => {
|
||||
const out = findUngroundedBriefingNumbers(
|
||||
{ paragraph: "your weight dropped 9.9 kg" },
|
||||
null,
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes a paragraph whose numbers match the signal figures", () => {
|
||||
const out = findUngroundedBriefingNumbers(
|
||||
{
|
||||
paragraph: "Your weight is 82 kg, down 2 kg from your monthly average.",
|
||||
},
|
||||
signals,
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a fabricated delta that no signal supports", () => {
|
||||
const out = findUngroundedBriefingNumbers(
|
||||
{
|
||||
paragraph: "Your weight dropped 6.4 kg this week.",
|
||||
signalsOfDay: [
|
||||
{ headline: "down 6.4 kg", nudge: "keep going", delta: "-6.4 kg" },
|
||||
],
|
||||
},
|
||||
signals,
|
||||
);
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
expect(out.some((f) => f.value === 6.4)).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates rounding within the band", () => {
|
||||
const out = findUngroundedBriefingNumbers(
|
||||
{ paragraph: "down 2.1 kg vs your 84.0 average" },
|
||||
[signal({ avg30: 84, deltaVs30: -2 })],
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag structural integers like a 7-day window or 3 signals", () => {
|
||||
const out = findUngroundedBriefingNumbers(
|
||||
{ paragraph: "Over the last 7 days, your 3 readings held steady." },
|
||||
signals,
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns nothing when the briefing is absent", () => {
|
||||
expect(findUngroundedBriefingNumbers(null, signals)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readBriefingBlock", () => {
|
||||
it("reads the dailyBriefing block off a parsed payload", () => {
|
||||
const block = readBriefingBlock({ dailyBriefing: { paragraph: "hi" } });
|
||||
expect(block?.paragraph).toBe("hi");
|
||||
});
|
||||
it("returns null when no briefing is present", () => {
|
||||
expect(readBriefingBlock({ summary: "x" })).toBeNull();
|
||||
expect(readBriefingBlock(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBriefingGroundingCorrection", () => {
|
||||
it("names the offending numbers and asks for a re-write", () => {
|
||||
const msg = buildBriefingGroundingCorrection([
|
||||
{ field: "paragraph", value: 7, source: "7 kg" },
|
||||
]);
|
||||
expect(msg).toContain("7 kg");
|
||||
expect(msg.toLowerCase()).toContain("signalsofday");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* v1.18.10 (HIGH-1 / MEDIUM-3) — Daily-Briefing number-grounding cross-check.
|
||||
*
|
||||
* The comprehensive-insight schema validates that `dailyBriefing.paragraph`,
|
||||
* `signalsOfDay[]` and `keyFindings[]` are well-shaped (non-empty strings,
|
||||
* known enums, length caps) — but it does NOT verify that any NUMBER the model
|
||||
* restates in those fields traces back to a figure the server pre-computed.
|
||||
* `recommendations[]` carry that guarantee (the citation cross-check + retry +
|
||||
* 422 in `generate-insight.ts`); the briefing block did not. A model could
|
||||
* write "your weight dropped 3 kg this week" while the snapshot's
|
||||
* `features.signalsOfDay` says 2 kg, and the payload still passed.
|
||||
*
|
||||
* This module closes that gap the same way the recommendations gate does:
|
||||
* extract every number the briefing prose asserts, and reject the payload when
|
||||
* one of them cannot be matched (within a rounding tolerance) to a value the
|
||||
* deterministic `features.signalsOfDay` block already holds — `latest`,
|
||||
* `avg7`, `avg30`, `deltaVs7`, `deltaVs30`, `spread30`, or a recent-anomaly
|
||||
* value. The caller turns a non-empty finding list into the existing
|
||||
* corrective-retry → 422 machinery.
|
||||
*
|
||||
* Scope + posture:
|
||||
* - The check only fires when `features.signalsOfDay` is present. When the
|
||||
* server pre-computed no signals there is no authoritative number set to
|
||||
* grade against, so the prose is left to the prompt-level grounding rule
|
||||
* (same as before) — we never reject for a number we have nothing to
|
||||
* compare to.
|
||||
* - It grades NUMBERS only. Tone, framing and word choice stay the prompt's
|
||||
* job; this is purely "did the model restate a figure we did not give it".
|
||||
* - Pure + side-effect-free, so it unit-tests in isolation.
|
||||
*/
|
||||
import type { SignalOfDay } from "@/lib/insights/features";
|
||||
|
||||
/**
|
||||
* Absolute rounding tolerance when matching a restated number to a
|
||||
* pre-computed signal figure. The model legitimately rounds ("+1.24 kg" →
|
||||
* "+1.2 kg"); anything inside this band is treated as the same number. A
|
||||
* relative slack widens it for large values (heart rate, steps) where a
|
||||
* one-decimal restatement of a server mean drifts by more than 0.15 in
|
||||
* absolute terms.
|
||||
*/
|
||||
const ABS_TOLERANCE = 0.15;
|
||||
const REL_TOLERANCE = 0.02;
|
||||
|
||||
/** The briefing sub-payload we grade. Mirrors `dailyBriefingSchema` loosely. */
|
||||
export interface BriefingForGrounding {
|
||||
paragraph?: unknown;
|
||||
signalsOfDay?: Array<{
|
||||
headline?: unknown;
|
||||
nudge?: unknown;
|
||||
delta?: unknown;
|
||||
}> | null;
|
||||
keyFindings?: Array<{
|
||||
headline?: unknown;
|
||||
detail?: unknown;
|
||||
delta?: unknown;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
/** One ungrounded number the briefing asserted. */
|
||||
export interface UngroundedBriefingNumber {
|
||||
/** Which field carried it. */
|
||||
field: "paragraph" | "signalsOfDay" | "keyFindings";
|
||||
/** The numeric value the model wrote, as parsed. */
|
||||
value: number;
|
||||
/** The raw token the value was read from (truncated). */
|
||||
source: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse every standalone number out of a free-text string. Captures an
|
||||
* optional leading sign and both `.`/`,` decimal separators (the DE briefing
|
||||
* writes "1,2 kg"). Percent / unit suffixes are ignored — only the magnitude
|
||||
* matters for grounding. Years and obvious dates (YYYY-MM-DD, "6 May") are not
|
||||
* special-cased: a 4-digit run is allowed through and simply won't match any
|
||||
* signal figure, which is fine because real briefings do not quote bare years
|
||||
* as health numbers, and the tolerance band keeps small ordinals from
|
||||
* colliding with real values.
|
||||
*/
|
||||
export function extractNumbers(
|
||||
text: string,
|
||||
): Array<{ value: number; raw: string }> {
|
||||
const out: Array<{ value: number; raw: string }> = [];
|
||||
// Sign, integer part, optional decimal (.,) — bounded so we don't span words.
|
||||
const re = /[+-]?\d+(?:[.,]\d+)?/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
const raw = match[0];
|
||||
const normalised = raw.replace(",", ".");
|
||||
const value = Number.parseFloat(normalised);
|
||||
if (Number.isFinite(value)) out.push({ value, raw });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of authoritative magnitudes one signal exposes. We grade against
|
||||
* absolute magnitudes so a "+1.2" delta matches a `deltaVs7` of `-1.2` only
|
||||
* when the model also flipped the sign in prose — but because briefings phrase
|
||||
* direction in words ("dropped", "up"), grading on |value| avoids penalising
|
||||
* the model for choosing a sign convention. We keep both the signed and the
|
||||
* absolute form so an exact signed restatement also matches.
|
||||
*/
|
||||
function authoritativeValues(signals: readonly SignalOfDay[]): number[] {
|
||||
const values: number[] = [];
|
||||
const push = (n: number | null | undefined) => {
|
||||
if (typeof n === "number" && Number.isFinite(n)) {
|
||||
values.push(n);
|
||||
values.push(Math.abs(n));
|
||||
}
|
||||
};
|
||||
for (const s of signals) {
|
||||
push(s.latest);
|
||||
push(s.avg7);
|
||||
push(s.avg30);
|
||||
push(s.deltaVs7);
|
||||
push(s.deltaVs30);
|
||||
push(s.spread30);
|
||||
if (s.recentAnomaly) push(s.recentAnomaly.value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** True when `value` matches any authoritative figure within tolerance. */
|
||||
function isGrounded(value: number, authoritative: readonly number[]): boolean {
|
||||
for (const a of authoritative) {
|
||||
const tol = Math.max(ABS_TOLERANCE, Math.abs(a) * REL_TOLERANCE);
|
||||
if (Math.abs(value - a) <= tol) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Numbers a briefing may always state without a signal match: small integers
|
||||
* the prose uses structurally (a day count, "3 signals", "last 7 days"), which
|
||||
* would otherwise false-positive against honest window/ordinal phrasing. We
|
||||
* exempt only bare integers (no decimal) that are either a small ordinal (≤ 3)
|
||||
* or one of the literal window lengths the briefing references (7/14/30/90/365).
|
||||
*
|
||||
* Trade-off: a fabricated number that happens to be exactly one of those bare
|
||||
* window integers slips through. That is the deliberate conservative choice —
|
||||
* the typical fabrication shapes (a restated delta/mean) carry a decimal or a
|
||||
* non-window magnitude (a 6.4 kg drop, a pulse of 72, "down 5 kg") and are
|
||||
* still graded; exempting the window vocabulary avoids reflexively flagging
|
||||
* every honest "over the last 7 days". A real restated health figure that is a
|
||||
* round window integer is rare and the grounding rule in the prompt remains the
|
||||
* backstop for it.
|
||||
*/
|
||||
const STRUCTURAL_INTEGERS = new Set([7, 14, 30, 31, 90, 365]);
|
||||
|
||||
function isStructural(value: number, raw: string): boolean {
|
||||
if (raw.includes(".") || raw.includes(",")) return false;
|
||||
if (!Number.isInteger(value)) return false;
|
||||
const abs = Math.abs(value);
|
||||
if (abs <= 3) return true; // "3 signals", "2 days", ordinals
|
||||
return STRUCTURAL_INTEGERS.has(abs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find every number the briefing block asserts that does not trace to a
|
||||
* pre-computed `features.signalsOfDay` figure. Returns an empty array when the
|
||||
* briefing is absent, when no signals were pre-computed (nothing to grade
|
||||
* against), or when every restated number is grounded.
|
||||
*/
|
||||
export function findUngroundedBriefingNumbers(
|
||||
briefing: BriefingForGrounding | null | undefined,
|
||||
signals: readonly SignalOfDay[] | null | undefined,
|
||||
): UngroundedBriefingNumber[] {
|
||||
if (!briefing) return [];
|
||||
if (!signals || signals.length === 0) return [];
|
||||
|
||||
const authoritative = authoritativeValues(signals);
|
||||
// No usable authoritative figures (every field null) — nothing to grade.
|
||||
if (authoritative.length === 0) return [];
|
||||
|
||||
const findings: UngroundedBriefingNumber[] = [];
|
||||
|
||||
const grade = (
|
||||
field: UngroundedBriefingNumber["field"],
|
||||
text: unknown,
|
||||
): void => {
|
||||
if (typeof text !== "string" || text.length === 0) return;
|
||||
for (const { value, raw } of extractNumbers(text)) {
|
||||
if (isStructural(value, raw)) continue;
|
||||
if (isGrounded(value, authoritative)) continue;
|
||||
findings.push({ field, value, source: raw.slice(0, 32) });
|
||||
}
|
||||
};
|
||||
|
||||
grade("paragraph", briefing.paragraph);
|
||||
for (const s of briefing.signalsOfDay ?? []) {
|
||||
grade("signalsOfDay", s?.headline);
|
||||
grade("signalsOfDay", s?.nudge);
|
||||
grade("signalsOfDay", s?.delta);
|
||||
}
|
||||
for (const f of briefing.keyFindings ?? []) {
|
||||
grade("keyFindings", f?.headline);
|
||||
grade("keyFindings", f?.detail);
|
||||
grade("keyFindings", f?.delta);
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `dailyBriefing` block off a parsed comprehensive payload, whatever
|
||||
* its concrete type (the strict `InsightResult` or the loose passthrough
|
||||
* object). Returns null when no briefing is present.
|
||||
*/
|
||||
export function readBriefingBlock(
|
||||
parsed: unknown,
|
||||
): BriefingForGrounding | null {
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const briefing = (parsed as { dailyBriefing?: unknown }).dailyBriefing;
|
||||
if (!briefing || typeof briefing !== "object") return null;
|
||||
return briefing as BriefingForGrounding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the corrective user-prompt suffix appended on a grounding miss, naming
|
||||
* the offending numbers so the model can self-correct on the single retry —
|
||||
* the same cite-or-omit posture the recommendations gate uses.
|
||||
*/
|
||||
export function buildBriefingGroundingCorrection(
|
||||
findings: readonly UngroundedBriefingNumber[],
|
||||
): string {
|
||||
const list = findings
|
||||
.slice(0, 6)
|
||||
.map((f) => `${f.source} (in ${f.field})`)
|
||||
.join(", ");
|
||||
return `
|
||||
Your daily-briefing block stated numbers that do NOT appear in the snapshot's pre-computed "signalsOfDay" figures: ${list}.
|
||||
|
||||
Re-write the dailyBriefing (paragraph, signalsOfDay, keyFindings) so EVERY number you state matches a value the snapshot already gives you — the latest reading, the 7-day or 30-day average, or the signed delta against one of those. Do NOT invent or re-derive a figure. If you cannot ground a number, drop the number and keep only the qualitative framing. Return the full JSON object again.
|
||||
`;
|
||||
}
|
||||
@@ -22,7 +22,13 @@ import { prisma } from "@/lib/db";
|
||||
import {
|
||||
extractFeatures,
|
||||
FeaturesPayloadTooLargeError,
|
||||
type SignalOfDay,
|
||||
} from "@/lib/insights/features";
|
||||
import {
|
||||
findUngroundedBriefingNumbers,
|
||||
readBriefingBlock,
|
||||
buildBriefingGroundingCorrection,
|
||||
} from "@/lib/ai/briefing-grounding";
|
||||
import { applyInsightsExcludeFilter } from "@/lib/insights/exclude-filter";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import {
|
||||
@@ -169,6 +175,14 @@ async function rerollBriefingParagraph(args: {
|
||||
userPrompt: string;
|
||||
cachedText: string;
|
||||
locale: "de" | "en";
|
||||
/**
|
||||
* v1.18.10 (MEDIUM-3) — the pre-computed signals the briefing must match.
|
||||
* The reroll runs at a higher, seedless temperature for phrasing variety,
|
||||
* which is exactly the path most prone to drifting a restated number; the
|
||||
* fresh paragraph is rejected (caller keeps the cached one) when a number in
|
||||
* it does not trace to one of these figures.
|
||||
*/
|
||||
signals: readonly SignalOfDay[] | null;
|
||||
}): Promise<{ text: string; providerType: string } | null> {
|
||||
let cached: Record<string, unknown>;
|
||||
try {
|
||||
@@ -229,6 +243,18 @@ async function rerollBriefingParagraph(args: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// v1.18.10 (MEDIUM-3) — grounding gate on the rerolled paragraph. The higher
|
||||
// seedless temperature can drift a restated figure ("+1.2 kg" → "+1.3 kg");
|
||||
// reject the reroll on any ungrounded number so the caller keeps the cached
|
||||
// (already-grounded) paragraph rather than swapping in a fabricated one.
|
||||
const rerollUngrounded = findUngroundedBriefingNumbers(
|
||||
{ paragraph: freshParagraph },
|
||||
args.signals,
|
||||
);
|
||||
if (rerollUngrounded.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep the cached payload verbatim; swap in only the fresh paragraph.
|
||||
const mergedBriefing = {
|
||||
...(cachedBriefing as Record<string, unknown>),
|
||||
@@ -816,6 +842,7 @@ export async function generateComprehensiveInsight(
|
||||
userPrompt,
|
||||
cachedText: dbUser.insightsCachedText,
|
||||
locale,
|
||||
signals: features.signalsOfDay ?? null,
|
||||
});
|
||||
if (rerolled) {
|
||||
await prisma.user.update({
|
||||
@@ -926,6 +953,60 @@ export async function generateComprehensiveInsight(
|
||||
}
|
||||
}
|
||||
|
||||
// v1.18.10 (HIGH-1) — daily-briefing number-grounding gate. The cron +
|
||||
// force-warm path produces the same briefing the POST route does, so it
|
||||
// enforces the same cross-check: every number the briefing restates must
|
||||
// trace to a `features.signalsOfDay` figure. One corrective retry, then strip
|
||||
// the briefing rather than persist 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: { locale, ungroundedCount: ungrounded.length },
|
||||
});
|
||||
try {
|
||||
const retry = await runRawCompletionWithFallback({
|
||||
userId,
|
||||
providers: chain,
|
||||
params: {
|
||||
systemPrompt,
|
||||
userPrompt: `${userPrompt}\n\n${buildBriefingGroundingCorrection(ungrounded)}`,
|
||||
temperature: AI_BUDGETS.comprehensive.temperature,
|
||||
maxTokens: AI_BUDGETS.comprehensive.maxTokens,
|
||||
responseFormat: "json",
|
||||
},
|
||||
});
|
||||
const retryInsights = parseComprehensiveResult(retry.result.content);
|
||||
const retryUngrounded = findUngroundedBriefingNumbers(
|
||||
readBriefingBlock(retryInsights),
|
||||
signals,
|
||||
);
|
||||
if (retryInsights !== null && retryUngrounded.length === 0) {
|
||||
insights = retryInsights;
|
||||
result = retry.result;
|
||||
workingProviderType = retry.workingProvider.providerType;
|
||||
ungrounded = [];
|
||||
} else {
|
||||
ungrounded = retryUngrounded;
|
||||
}
|
||||
} catch {
|
||||
// Retry failed — fall through to the strip below.
|
||||
}
|
||||
}
|
||||
if (ungrounded.length > 0 && insights && typeof insights === "object") {
|
||||
(insights as Record<string, unknown>).dailyBriefing = null;
|
||||
annotate({
|
||||
action: { name: "insights.generate.briefing_grounding_stripped" },
|
||||
meta: { locale, ungroundedCount: ungrounded.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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