From af21d4d4a844bb4a710ae0ecd791bcac3ea09027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 01:30:02 +0200 Subject: [PATCH] feat(ai): wrapper overrides recommendation confidence with deterministic computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After zod parse, `generateInsight()` invokes `computeConfidence()` for every recommendation and overwrites `rec.confidence` — the model's self-reported number is discarded. Calibrated probabilities are not a small-LLM strength, so this is the deterministic substrate the v1.4.17 feedback ratchet will tune. The wrapper does not have direct access to the user's bucket-series data, so an optional `confidenceContext` resolver lets the caller (typically the route layer) supply per-rec `ConfidenceInputs` derived from `metricSource.n` + the freshest bucket + the deviation magnitude vs. the user's 90-day baseline. When the resolver is omitted, the wrapper falls back to inputs derivable from the parsed payload alone: - n: `metricSource.n` (or 0 — triggers the n<3 hard-cap) - recencyDays: 0 (defensive default; assumes window ends "now") - deviationStdRatio: null (neutral 15-point signal contribution) The override fires regardless of resolver presence so the contract is "the confidence value on the response NEVER comes from the LLM". A new structured Wide-Event annotation (`ai_confidence_override_event` = `confidence.override.applied`) carries per-rec `{id, model, computed}` triples so admin observability can chart drift between the model's claimed confidence and the deterministic value. `annotateCitationCoverage()` now runs before `applyConfidenceOverride()` so the existing citation/rationale-coverage tests can still match against `mock.calls[0]`. The citation-coverage test that asserted `toHaveBeenCalledTimes(1)` was relaxed to look up the right call by meta-key rather than by ordinal. 6 new wrapper tests cover: model value discarded, wrapper-fills when absent, resolver path overrides default, n<3 hard-cap survives strong signal, missing `metricSource.n` -> n=0 floor, resolver receives each rec in payload order. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../citation-coverage-logging.test.ts | 23 +- .../ai/__tests__/confidence-wrapper.test.ts | 200 ++++++++++++++++++ src/lib/ai/generate-insight.ts | 93 ++++++++ 3 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 src/lib/ai/__tests__/confidence-wrapper.test.ts diff --git a/src/lib/ai/__tests__/citation-coverage-logging.test.ts b/src/lib/ai/__tests__/citation-coverage-logging.test.ts index 65fdf9274..2e37958e9 100644 --- a/src/lib/ai/__tests__/citation-coverage-logging.test.ts +++ b/src/lib/ai/__tests__/citation-coverage-logging.test.ts @@ -241,6 +241,23 @@ describe("generateInsight() — citation-coverage annotation", () => { annotateMock.mockReset(); }); + /** + * v1.4.16 phase B5d — `applyConfidenceOverride()` also calls + * `annotate()` (action: "confidence.override.applied"), so the + * citation-coverage assertions filter by meta-key rather than by + * call count. + */ + function findCoverageCall(calls: Array): Record { + for (const call of calls) { + const arg = call[0] as { meta?: Record } | undefined; + const meta = arg?.meta; + if (meta && "ai_total_recommendations" in meta) { + return meta; + } + } + throw new Error("citation-coverage annotate() call not found"); + } + it("emits an annotate() call with the coverage breakdown on success", async () => { const provider = new MockAIProvider({ responses: JSON.stringify({ @@ -263,8 +280,7 @@ describe("generateInsight() — citation-coverage annotation", () => { systemPrompt: "system", userPrompt: "user", }); - expect(annotateMock).toHaveBeenCalledTimes(1); - const meta = annotateMock.mock.calls[0][0]?.meta as Record; + const meta = findCoverageCall(annotateMock.mock.calls); expect(meta).toMatchObject({ ai_total_recommendations: 1, ai_normative_recommendations: 1, @@ -294,8 +310,7 @@ describe("generateInsight() — citation-coverage annotation", () => { systemPrompt: "system", userPrompt: "user", }); - expect(annotateMock).toHaveBeenCalledTimes(1); - const meta = annotateMock.mock.calls[0][0]?.meta as Record; + const meta = findCoverageCall(annotateMock.mock.calls); expect(meta).toMatchObject({ ai_normative_recommendations: 1, ai_cited_normative_recommendations: 0, diff --git a/src/lib/ai/__tests__/confidence-wrapper.test.ts b/src/lib/ai/__tests__/confidence-wrapper.test.ts new file mode 100644 index 000000000..3cc65c59d --- /dev/null +++ b/src/lib/ai/__tests__/confidence-wrapper.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi } from "vitest"; +import { generateInsight } from "../generate-insight"; +import { MockAIProvider } from "../mock-client"; +import type { ConfidenceInputs } from "../confidence"; + +/** + * v1.4.16 phase B5d — wrapper post-validation override. + * + * The model's self-reported `confidence` is DISCARDED. After zod + * parse succeeds, `generateInsight()` derives `ConfidenceInputs` per + * recommendation and overwrites `rec.confidence` with the value + * `computeConfidence()` produces. + * + * Default-on per research §7 question 1 (Marc-acked). The override + * happens whether or not the caller supplies a bucket-series-aware + * resolver — when the resolver is missing we fall back to inputs + * derivable from the parsed payload alone (`metricSource.n`, + * recencyDays=0, deviationStdRatio=null) so the contract is "the + * confidence on the response NEVER comes from the LLM". + */ + +const validResponseWithModelConfidence = { + summary: "Your blood pressure is trending high.", + recommendations: [ + { + id: "rec-1", + text: "Discuss lifestyle and possible medication review with your physician.", + severity: "important", + // Model claims 99 — wrapper must overwrite this. + confidence: 99, + metricSource: { + type: "bloodPressure", + timeRange: "last7days", + summary: "avg 142/88 across 12 readings", + n: 12, + }, + rationale: { + dataWindow: "last7days", + comparedTo: "your 90-day median (128/82)", + deviation: "+14/+6 mmHg above baseline over 7 of 7 days", + }, + }, + ], + citations: [ + { + type: "bloodPressure", + timeRange: "last7days", + summary: "avg 142/88 across 12 readings", + }, + ], + warnings: [], +}; + +describe("generateInsight() — confidence override", () => { + it("overrides model-supplied confidence with deterministic computation (default fallback)", async () => { + const provider = new MockAIProvider({ + responses: JSON.stringify(validResponseWithModelConfidence), + }); + const outcome = await generateInsight(provider, { + systemPrompt: "sys", + userPrompt: "user", + }); + const rec = outcome.parsed.recommendations[0]; + // Model claimed 99; wrapper must have replaced it. + // n=12, recencyDays=0 (default), ratio=null (default) → + // nScore = 10 + 10*log10(12) ≈ 20.79 + // recency = 30 * (1 - 0/30) = 30 + // signal = null → 15 + // total ≈ 65.79 → 66 + expect(rec.confidence).toBe(66); + expect(rec.confidence).not.toBe(99); + }); + + it("fills in confidence even when the model omitted it", async () => { + const noConfidence = { + ...validResponseWithModelConfidence, + recommendations: [ + { + ...validResponseWithModelConfidence.recommendations[0], + confidence: undefined, + }, + ], + }; + // strip the undefined to emulate the model not emitting the key + const stringified = JSON.stringify(noConfidence); + const provider = new MockAIProvider({ responses: stringified }); + const outcome = await generateInsight(provider, { + systemPrompt: "sys", + userPrompt: "u", + }); + expect(outcome.parsed.recommendations[0].confidence).toBe(66); + }); + + it("uses the resolver-supplied ConfidenceInputs when provided", async () => { + const provider = new MockAIProvider({ + responses: JSON.stringify(validResponseWithModelConfidence), + }); + const resolver = vi.fn( + (): ConfidenceInputs => ({ + n: 14, + recencyDays: 1, + deviationStdRatio: 2, + }), + ); + const outcome = await generateInsight( + provider, + { systemPrompt: "s", userPrompt: "u" }, + { confidenceContext: resolver }, + ); + expect(resolver).toHaveBeenCalledTimes(1); + // n=14 fresh strong signal → ~80 + expect(outcome.parsed.recommendations[0].confidence).toBe(80); + }); + + it("hard-caps confidence to <=15 when n<3 (small-data shield)", async () => { + const tinyN = { + ...validResponseWithModelConfidence, + recommendations: [ + { + ...validResponseWithModelConfidence.recommendations[0], + metricSource: { + ...validResponseWithModelConfidence.recommendations[0] + .metricSource, + n: 1, + }, + }, + ], + }; + const provider = new MockAIProvider({ responses: JSON.stringify(tinyN) }); + const outcome = await generateInsight(provider, { + systemPrompt: "s", + userPrompt: "u", + }); + expect(outcome.parsed.recommendations[0].confidence).toBeLessThanOrEqual( + 15, + ); + }); + + it("treats missing metricSource.n as n=0 (hard-cap)", async () => { + const noN = { + ...validResponseWithModelConfidence, + recommendations: [ + { + ...validResponseWithModelConfidence.recommendations[0], + metricSource: { + type: "bloodPressure", + timeRange: "last7days", + summary: "avg 142/88 across 12 readings", + // n omitted + }, + }, + ], + }; + const provider = new MockAIProvider({ responses: JSON.stringify(noN) }); + const outcome = await generateInsight(provider, { + systemPrompt: "s", + userPrompt: "u", + }); + // n=0 → hard-cap floor 10 + expect(outcome.parsed.recommendations[0].confidence).toBe(10); + }); + + it("calls the resolver per-recommendation with the rec passed in", async () => { + const twoRecs = { + ...validResponseWithModelConfidence, + recommendations: [ + { + ...validResponseWithModelConfidence.recommendations[0], + id: "rec-a", + }, + { + ...validResponseWithModelConfidence.recommendations[0], + id: "rec-b", + metricSource: { + ...validResponseWithModelConfidence.recommendations[0] + .metricSource, + n: 5, + }, + }, + ], + }; + const provider = new MockAIProvider({ + responses: JSON.stringify(twoRecs), + }); + const seenIds: string[] = []; + const resolver = (rec: { id: string }) => { + seenIds.push(rec.id); + return { n: 14, recencyDays: 1, deviationStdRatio: 2 }; + }; + const outcome = await generateInsight( + provider, + { systemPrompt: "s", userPrompt: "u" }, + { confidenceContext: resolver }, + ); + expect(seenIds).toEqual(["rec-a", "rec-b"]); + expect(outcome.parsed.recommendations.every((r) => r.confidence === 80)).toBe( + true, + ); + }); +}); diff --git a/src/lib/ai/generate-insight.ts b/src/lib/ai/generate-insight.ts index c107399f4..8a24cf776 100644 --- a/src/lib/ai/generate-insight.ts +++ b/src/lib/ai/generate-insight.ts @@ -6,8 +6,10 @@ import { findUncitedRecommendations, InsightSchemaError, type AIInsightResponse, + type AIRecommendation, } from "./schema"; import { computeCitationCoverage } from "./citation-coverage"; +import { computeConfidence, type ConfidenceInputs } from "./confidence"; import { annotate } from "@/lib/logging/context"; /** @@ -172,18 +174,108 @@ function annotateCitationCoverage(parsed: AIInsightResponse): void { }); } +/** + * v1.4.16 phase B5d — confidence resolver. + * + * The wrapper does NOT have direct access to the user's bucket-series + * data, so the caller can pass an optional resolver that maps each + * recommendation to its `ConfidenceInputs`. When omitted, the wrapper + * falls back to inputs derivable from the parsed payload alone: + * + * - n: `metricSource.n` (or 0 when missing — triggers the n<3 hard + * cap so a recommendation cited from no quantified data is never + * dressed up as confident). + * - recencyDays: 0. Defensive default — assumes the cited window + * ends "now" because we have no fresher source. The route layer + * can supply a real value via the resolver. + * - deviationStdRatio: null. Yields the neutral 15-point signal + * contribution so a wrapper-only path never over-claims. + * + * The resolver fires once per recommendation in payload order so the + * caller can side-effect (e.g. log per-rec inputs) deterministically. + */ +export type ConfidenceContextResolver = ( + rec: AIRecommendation, +) => ConfidenceInputs; + +const DEFAULT_CONFIDENCE_RESOLVER: ConfidenceContextResolver = (rec) => ({ + n: rec.metricSource.n ?? 0, + recencyDays: 0, + deviationStdRatio: null, +}); + +/** + * Apply the deterministic confidence override to every recommendation. + * The model's value (if any) is REPLACED. Returns a Wide-Event + * payload with per-rec model-vs-computed pairs so admin observability + * can chart drift between what the LLM thinks and what the formula + * produces. + */ +function applyConfidenceOverride( + parsed: AIInsightResponse, + resolver: ConfidenceContextResolver, +): void { + const overrides: Array<{ + id: string; + model: number | null; + computed: number; + }> = []; + for (const rec of parsed.recommendations) { + const inputs = resolver(rec); + const computed = computeConfidence(inputs); + const modelValue = + typeof rec.confidence === "number" ? rec.confidence : null; + rec.confidence = computed; + overrides.push({ id: rec.id, model: modelValue, computed }); + } + if (overrides.length > 0) { + annotate({ + meta: { + ai_confidence_override_event: "confidence.override.applied", + ai_confidence_override_count: overrides.length, + ai_confidence_override_pairs: overrides, + }, + }); + } +} + +export interface GenerateInsightOptions { + /** + * v1.4.16 phase B5d — per-recommendation `ConfidenceInputs` + * resolver. When supplied, the wrapper uses these inputs to + * `computeConfidence()` and OVERWRITES `rec.confidence`. When + * omitted, the wrapper falls back to a parsed-payload-only default + * (n from `metricSource.n`, recencyDays=0, ratio=null). + * + * The override happens regardless — calibrated probabilities are + * not a small-LLM strength and the deterministic path keeps the + * v1.4.17 feedback ratchet reproducible. + */ + confidenceContext?: ConfidenceContextResolver; +} + /** * Run a schema-enforced insight generation. Up to 2 provider calls. * Throws `InsightSchemaError` (httpStatus 422) if both attempts fail * the schema; provider-level errors bubble untouched. + * + * v1.4.16 phase B5d — after a successful parse, every recommendation's + * `confidence` field is overwritten with the deterministic + * `computeConfidence()` value. The model's number (if any) is + * discarded; admin observability sees both via the + * `confidence.override.applied` Wide-Event annotation. */ export async function generateInsight( provider: AIProvider, params: CompletionParams, + options: GenerateInsightOptions = {}, ): Promise { + const resolver = + options.confidenceContext ?? DEFAULT_CONFIDENCE_RESOLVER; const first = await tryOnce(provider, params); if (first.ok && first.parsed) { annotateCitationCoverage(first.parsed); + applyConfidenceOverride(first.parsed, resolver); return { parsed: first.parsed, raw: first.raw, @@ -204,6 +296,7 @@ export async function generateInsight( const second = await tryOnce(provider, retryParams); if (second.ok && second.parsed) { annotateCitationCoverage(second.parsed); + applyConfidenceOverride(second.parsed, resolver); return { parsed: second.parsed, raw: second.raw,