mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(ai): wrapper overrides recommendation confidence with deterministic computation
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
0cb0373e59
commit
af21d4d4a8
3 files changed
+312
-4
No files matched your search
@@ -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<unknown[]>): Record<string, unknown> {
|
||||
for (const call of calls) {
|
||||
const arg = call[0] as { meta?: Record<string, unknown> } | 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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const meta = findCoverageCall(annotateMock.mock.calls);
|
||||
expect(meta).toMatchObject({
|
||||
ai_normative_recommendations: 1,
|
||||
ai_cited_normative_recommendations: 0,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<GenerateInsightOutcome> {
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user