Files
HealthLog/src/components/insights/confidence-meter.tsx
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

203 lines
5.2 KiB
TypeScript

import { useTranslations } from "@/lib/i18n/context";
/**
* Renders a 0..100 confidence score as a 5-bar meter (default) or SVG
* ring. Sub-25 replaces the meter with a "draft" pill ("quiet-when-
* unsure"). aria-label always carries the numeric value.
*/
export type ConfidenceBand = "high" | "medium" | "low" | "draft";
export type ConfidenceMeterVariant = "bars" | "ring";
export interface ConfidenceMeterProps {
/** Score carried on the insight payload — 0..100, integer expected. */
value: number;
/** Visual variant. Defaults to `bars`. */
variant?: ConfidenceMeterVariant;
/** Optional className applied to the outer wrapper. */
className?: string;
}
const DRAFT_THRESHOLD = 25;
function clamp(value: number): number {
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
if (value > 100) return 100;
return Math.round(value);
}
function bandFor(value: number): ConfidenceBand {
if (value < DRAFT_THRESHOLD) return "draft";
if (value < 50) return "low";
if (value < 80) return "medium";
return "high";
}
/**
* Lit-bar count = ceil(value / 20). 0..20 is 1, 21..40 is 2, etc.
* Note: when band is "draft" we don't render bars at all, so this
* helper is only consulted for non-draft bands. We still clamp to
* [1, 5] to defend against off-by-one drift.
*/
function litBarsFor(value: number): number {
const lit = Math.ceil(value / 20);
if (lit < 1) return 1;
if (lit > 5) return 5;
return lit;
}
// Semantic severities only (the coverage-meter precedent): medium and
// low both ride the caution `--warning` — the lit-bar count and the
// numeric aria-label carry the medium↔low distinction, never colour
// alone.
const BAR_LIT_COLOUR_BY_BAND: Record<
Exclude<ConfidenceBand, "draft">,
string
> = {
high: "bg-success",
medium: "bg-warning",
low: "bg-warning",
};
const RING_STROKE_COLOUR_BY_BAND: Record<
Exclude<ConfidenceBand, "draft">,
string
> = {
high: "stroke-success",
medium: "stroke-warning",
low: "stroke-warning",
};
function DraftPill({ ariaLabel }: { ariaLabel: string }) {
const { t } = useTranslations();
return (
<span
data-confidence-band="draft"
role="img"
aria-label={ariaLabel}
className="bg-destructive/10 text-destructive border-destructive/25 inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium tracking-wide uppercase"
>
{t("insights.recommendation.confidenceDraft")}
</span>
);
}
function BarsMeter({
value,
band,
ariaLabel,
}: {
value: number;
band: Exclude<ConfidenceBand, "draft">;
ariaLabel: string;
}) {
const lit = litBarsFor(value);
const litColour = BAR_LIT_COLOUR_BY_BAND[band];
return (
<span
data-confidence-band={band}
role="img"
aria-label={ariaLabel}
className="inline-flex items-end gap-[2px]"
>
{[1, 2, 3, 4, 5].map((i) => {
const isLit = i <= lit;
// Bars rise in height to give a visual "more is more" cue
// independent of colour (accessibility): 4 / 6 / 8 / 10 / 12 px.
const heightClass = ["h-1", "h-1.5", "h-2", "h-2.5", "h-3"][i - 1];
return (
<span
key={i}
data-bar-index={i}
data-bar-state={isLit ? "lit" : "unlit"}
className={`w-[3px] rounded-sm ${heightClass} ${
isLit ? litColour : "bg-muted/40"
}`}
/>
);
})}
</span>
);
}
function RingMeter({
value,
band,
ariaLabel,
}: {
value: number;
band: Exclude<ConfidenceBand, "draft">;
ariaLabel: string;
}) {
const stroke = RING_STROKE_COLOUR_BY_BAND[band];
// Ring geometry: r=10, circumference=2π·10≈62.83. Fill ratio = value/100.
const radius = 10;
const circumference = 2 * Math.PI * radius;
const dashArray = circumference;
const dashOffset = circumference * (1 - value / 100);
return (
<svg
data-confidence-band={band}
role="img"
aria-label={ariaLabel}
width="28"
height="28"
viewBox="0 0 28 28"
fill="none"
>
<circle
cx="14"
cy="14"
r={radius}
className="stroke-muted/30"
strokeWidth="3"
/>
<circle
cx="14"
cy="14"
r={radius}
className={stroke}
strokeWidth="3"
strokeLinecap="round"
strokeDasharray={dashArray}
strokeDashoffset={dashOffset}
transform="rotate(-90 14 14)"
/>
</svg>
);
}
export function ConfidenceMeter({
value,
variant = "bars",
className,
}: ConfidenceMeterProps) {
const { t } = useTranslations();
const clamped = clamp(value);
const band = bandFor(clamped);
const ariaLabel = t("insights.recommendation.confidenceAria", {
value: clamped,
});
const wrapperClass = `inline-flex items-center ${className ?? ""}`.trim();
if (band === "draft") {
return (
<span data-slot="confidence-meter" className={wrapperClass}>
<DraftPill ariaLabel={ariaLabel} />
</span>
);
}
return (
<span data-slot="confidence-meter" className={wrapperClass}>
{variant === "ring" ? (
<RingMeter value={clamped} band={band} ariaLabel={ariaLabel} />
) : (
<BarsMeter value={clamped} band={band} ariaLabel={ariaLabel} />
)}
</span>
);
}