mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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.
203 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|