mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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.
140 lines
5.0 KiB
TypeScript
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,
|
|
};
|
|
}
|