Files
HealthLog/src/lib/ai/generate-insight.ts
T
Marc-André BombeckandClaude Opus 4.7 af21d4d4a8 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>
2026-05-10 01:40:29 +02:00

316 lines
11 KiB
TypeScript

import type { z } from "zod/v4";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import {
aiInsightResponseSchema,
findRecommendationsMissingRationale,
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";
/**
* Schema-enforced wrapper around `AIProvider.generateCompletion()`.
*
* Phase C1 (v1.4.15): Marc's mandate ("zero hallucinations, must
* ground on user data") means a free-form LLM response is no longer
* acceptable. This wrapper:
*
* 1. Calls the provider once.
* 2. Parses the response as JSON.
* 3. Validates it against `aiInsightResponseSchema` AND cross-checks
* that every recommendation's `metricSource` appears in
* `citations[]`.
* 4. On failure: retries ONCE, prepending a corrective system
* message that includes the violated zod issues.
* 5. On second failure: throws `InsightSchemaError` (httpStatus 422).
*
* The wrapper does not concern itself with provider-level retries
* (HTTP 5xx, 401 token-refresh, etc.) — each provider client handles
* those internally. This wrapper retries only on schema-violation.
*/
export interface GenerateInsightOutcome {
parsed: AIInsightResponse;
raw: CompletionResult;
/** Number of provider calls made (1 on success, 2 on retry). */
attempts: number;
/** True iff the retry attempt produced the parsed response. */
retried: boolean;
}
/**
* Build the corrective user-prompt suffix that explains to the model
* what went wrong on the first attempt. Includes the violated zod
* issues verbatim (truncated to 1KB) so the model can self-correct.
*/
function buildRetryCorrectionMessage(reason: string, details: string): string {
return `
Your previous response did not satisfy the required JSON schema.
Reason: ${reason}
Details: ${details.slice(0, 1024)}
You MUST return a JSON object matching this schema exactly. Do NOT
include prose, markdown fences, or commentary outside the JSON.
Required top-level fields:
- summary: string (2-3 sentences)
- recommendations: array of objects with
{ id: string,
text: string,
severity: "info" | "suggestion" | "important" | "urgent",
metricSource: { type: string, timeRange: string, summary: string, n?: number },
rationale: { dataWindow: "last7days" | "last30days" | "last90days" | "allTime",
comparedTo: non-empty string,
deviation: non-empty string } }
- citations: array of objects with { type: string, timeRange: string, summary: string }
- warnings: array of objects with { topic: string, message: string, severity?: same enum }
Every recommendation's metricSource MUST also appear in citations[]
(same type + timeRange). Every recommendation MUST carry a
rationale object — dataWindow, comparedTo, and deviation are all
required and rationale.dataWindow MUST equal metricSource.timeRange.
If you cannot ground a recommendation in user data from the
snapshot you were given, OMIT it. An empty recommendations[] is
acceptable.
`;
}
interface TryOutcome {
ok: boolean;
parsed?: AIInsightResponse;
raw: CompletionResult;
reason?: string;
details?: string;
zodIssues?: z.ZodIssue[];
}
async function tryOnce(
provider: AIProvider,
params: CompletionParams,
): Promise<TryOutcome> {
const raw = await provider.generateCompletion(params);
// Parse JSON.
let json: unknown;
try {
json = JSON.parse(raw.content);
} catch (e) {
return {
ok: false,
raw,
reason: "Response was not valid JSON",
details: (e as Error).message,
};
}
// Schema validate.
const result = aiInsightResponseSchema.safeParse(json);
if (!result.success) {
return {
ok: false,
raw,
reason: "Response did not match the required JSON schema",
details: result.error.issues
.slice(0, 8)
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; "),
zodIssues: result.error.issues,
};
}
// Cross-check citations.
const uncited = findUncitedRecommendations(result.data);
if (uncited.length > 0) {
const list = uncited
.slice(0, 4)
.map(
(u) =>
`${u.recommendationId} → ${u.missing.type}/${u.missing.timeRange}`,
)
.join(", ");
return {
ok: false,
raw,
reason: "Recommendations referenced metricSources not in citations[]",
details: `Missing citations: ${list}`,
};
}
return { ok: true, parsed: result.data, raw };
}
/**
* Forward the citation-coverage breakdown into the active Wide-Event
* via `annotate()` (no-op outside a request context). Keys are
* snake_case to match the rest of the AI Wide-Event vocabulary.
*
* v1.4.16 phase B5c — additionally emit `ai_rationale_*` keys so the
* admin AI quality dashboard can chart rationale-coverage per
* payload alongside citation-coverage. Rationale is required by the
* strict schema; the missing-ids list will only ever populate when
* a legacy payload sneaks through `.passthrough()` — useful as an
* observability tripwire for the migration window.
*/
function annotateCitationCoverage(parsed: AIInsightResponse): void {
const coverage = computeCitationCoverage(parsed);
const rationaleMissing = findRecommendationsMissingRationale(parsed);
annotate({
meta: {
ai_total_recommendations: coverage.totalRecommendations,
ai_normative_recommendations: coverage.normativeRecommendations,
ai_cited_normative_recommendations:
coverage.citedNormativeRecommendations,
ai_uncited_normative_recommendation_ids:
coverage.uncitedNormativeRecommendationIds,
ai_rationale_total_recommendations: coverage.totalRecommendations,
ai_rationale_with_rationale:
coverage.totalRecommendations - rationaleMissing.length,
ai_rationale_missing_recommendation_ids: rationaleMissing,
},
});
}
/**
* 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,
attempts: 1,
retried: false,
};
}
// Retry-once with corrective context appended to the user prompt.
const correction = buildRetryCorrectionMessage(
first.reason ?? "schema mismatch",
first.details ?? "",
);
const retryParams: CompletionParams = {
...params,
userPrompt: `${params.userPrompt}\n\n${correction}`,
};
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,
attempts: 2,
retried: true,
};
}
throw new InsightSchemaError(
second.reason ?? first.reason ?? "Schema validation failed twice",
{
issues: second.zodIssues ?? first.zodIssues,
attempts: 2,
},
);
}