Files
HealthLog/src/lib/ai/citation-coverage.ts
T
Marc-André Bombeck 4183202ed3 Delete the citation-enforcement chain that never ran
generateInsight() parsed against the strict aiInsightResponseSchema,
cross-checked that every recommendation's metricSource appeared in
citations[], and threw InsightSchemaError on a second failure. Its only
caller was runWithFallback(), which had no production caller — every
live surface runs runRawCompletionWithFallback. So none of it executed.

Wiring it into the live path was the alternative and it is not viable:
the live insightResultSchema has no citations[] field at all and treats
metricSource as optional, so the cross-check would have rejected
essentially every generation rather than tightening anything.

Removed: runWithFallback and its param/result types, generateInsight
and its private helpers, findUncitedRecommendations,
findRecommendationsMissingRationale, the never-thrown InsightSchemaError
and the isHardProviderFailure branch that excluded it, and
computeConfidence — a deterministic override that only ran inside the
dead wrapper.

Kept: buildRetryCorrectionMessage, which comprehensive-generate sends on
its own retry leg; the file it lived in is renamed to match what it now
holds. aiInsightResponseSchema stays as the shape that message
describes. Note the retry prompt asks for a stricter payload than the
live validator requires — that mismatch predates this and is left alone.

computeCitationCoverage is untouched and still live: it is a metric, not
a gate, and v1.30.24 wired it into the comprehensive post-parse step.

Chain-runner coverage the deleted tests carried (last-working cache,
empty-input guard) is ported onto runRawCompletionWithFallback, which
shares the machinery.
2026-07-19 05:50:57 +02:00

140 lines
5.0 KiB
TypeScript

/**
* Reachability note. This module was written for `generateInsight()`, whose
* only caller was `runWithFallback()` — and that had no production caller,
* because every live surface routes through `runRawCompletionWithFallback`.
* The coverage annotation therefore never executed: an audit surface that
* implied enforcement it did not perform.
*
* v1.30.24 kept the logic and widened the input type to the structural shape
* both payloads share, so the comprehensive post-parse step could call it and
* the admin quality dashboard could see real data. The dead wrapper and chain
* runner around it have since been removed outright, so this module now has
* exactly one caller and it is a live one.
*
* Observational by design: an uncited normative recommendation is an
* annotation, never a parse failure. It is a METRIC, not a gate — the citation
* ENFORCEMENT that once sat beside it is gone rather than dormant, so nothing
* here should be read as withholding output.
*/
/**
* The minimum recommendation shape the coverage check needs. Both the strict
* `AIInsightResponse` and the live `InsightResult` satisfy it; the live union
* also admits a bare string, which carries neither an id nor a citation and is
* therefore counted as uncited when it makes a normative claim.
*/
export interface RecommendationForCoverage {
id?: string;
text: string;
referenceId?: string;
/**
* Callers pass their own richer recommendation objects (severity, rationale,
* metricSource, …). Only the three fields above are read; the index
* signature keeps a wider literal assignable without a cast at every site.
*/
[key: string]: unknown;
}
export interface PayloadForCoverage {
recommendations: ReadonlyArray<RecommendationForCoverage | string>;
/** Callers pass the whole payload; only `recommendations` is read. */
[key: string]: unknown;
}
/**
* v1.4.16 phase B5a — citation-coverage post-validation.
*
* After the schema parse + cross-citation check pass, the wrapper
* counts how many recommendations make a normative claim
* ("target", "should", "normal range", "above", "below") and how
* many of those carry a `referenceId` pointing into the curated
* medical-reference bundle. The result lands as a Wide-Event meta
* annotation so the admin AI quality dashboard can track coverage
* over time.
*
* The check is observational only — a normative rec without a
* referenceId is logged as a warning, never raised as a parse
* failure. v1.4.16 phase B5c flips it to required for severity
* >= "important".
*
* Heuristic policy: substring match against a small fixed keyword
* list. Locale-agnostic for v1.4.16; phase B5c may swap in a tagged
* model output ("rec.kind: normative | observational").
*/
const NORMATIVE_KEYWORDS_EN = [
"target",
"should",
"normal range",
"above",
"below",
];
const NORMATIVE_KEYWORDS_DE = [
"ziel",
"sollte",
"normalbereich",
"über",
"unter",
];
const NORMATIVE_KEYWORDS = [...NORMATIVE_KEYWORDS_EN, ...NORMATIVE_KEYWORDS_DE];
/**
* Returns true when the rec text contains a normative-claim keyword.
* Case-insensitive substring match; no word-boundary check because
* the keywords are common-English/-German fragments and the LLM
* sometimes inflects them ("targets", "should be" all hit).
*/
export function detectsNormativeClaim(text: string): boolean {
const lower = text.toLowerCase();
return NORMATIVE_KEYWORDS.some((kw) => lower.includes(kw));
}
export interface CitationCoverage {
/** Total `recommendations[]` length. */
totalRecommendations: number;
/** Subset of recs that make a normative claim. */
normativeRecommendations: number;
/** Subset of normative recs that carry a `referenceId`. */
citedNormativeRecommendations: number;
/** Recommendation ids that are normative but lack a referenceId. */
uncitedNormativeRecommendationIds: string[];
}
/**
* Compute the citation-coverage breakdown for a parsed insight
* response. Pure function — no side effects, safe to unit test in
* isolation. The wrapper at `generate-insight.ts` calls this once on
* the successful parse and forwards the result via `annotate()`.
*/
export function computeCitationCoverage(
parsed: PayloadForCoverage,
): CitationCoverage {
const total = parsed.recommendations.length;
let normative = 0;
let cited = 0;
const uncitedIds: string[] = [];
for (const [index, raw] of parsed.recommendations.entries()) {
const rec: RecommendationForCoverage =
typeof raw === "string" ? { text: raw } : raw;
if (!detectsNormativeClaim(rec.text)) continue;
normative += 1;
if (rec.referenceId) {
cited += 1;
} else {
// A legacy string rec has no id of its own; index it so the dashboard
// can still point at which entry was uncited.
uncitedIds.push(rec.id ?? `index:${index}`);
}
}
return {
totalRecommendations: total,
normativeRecommendations: normative,
citedNormativeRecommendations: cited,
uncitedNormativeRecommendationIds: uncitedIds,
};
}