diff --git a/src/lib/confidence.test.ts b/src/lib/confidence.test.ts
new file mode 100644
index 00000000..19f13d8a
--- /dev/null
+++ b/src/lib/confidence.test.ts
@@ -0,0 +1,35 @@
+import { describe, it, expect } from "vitest";
+import { confidenceFraction, confidencePercent } from "./confidence.js";
+
+describe("confidenceFraction", () => {
+ it("passes 0–1 fractions through", () => {
+ expect(confidenceFraction(0.9)).toBeCloseTo(0.9);
+ expect(confidenceFraction(0.42)).toBeCloseTo(0.42);
+ expect(confidenceFraction(1)).toBe(1);
+ expect(confidenceFraction(0)).toBe(0);
+ });
+
+ it("rescales 0–100 values to a fraction", () => {
+ expect(confidenceFraction(90)).toBeCloseTo(0.9);
+ expect(confidenceFraction(95)).toBeCloseTo(0.95);
+ expect(confidenceFraction(95.0)).toBeCloseTo(0.95);
+ });
+
+ it("clamps to [0, 1] and handles bad input", () => {
+ expect(confidenceFraction(9000)).toBe(1); // pathological — clamp, don't emit 90x
+ expect(confidenceFraction(-5)).toBe(0);
+ expect(confidenceFraction(null)).toBe(0);
+ expect(confidenceFraction(undefined)).toBe(0);
+ expect(confidenceFraction(NaN)).toBe(0);
+ });
+});
+
+describe("confidencePercent", () => {
+ it("renders both scales as the same percentage (no more 9000%)", () => {
+ expect(confidencePercent(0.9)).toBe(90);
+ expect(confidencePercent(90)).toBe(90); // the bug: was 9000
+ expect(confidencePercent(0.95)).toBe(95);
+ expect(confidencePercent(95)).toBe(95);
+ expect(confidencePercent(null)).toBe(0);
+ });
+});
diff --git a/src/lib/confidence.ts b/src/lib/confidence.ts
new file mode 100644
index 00000000..80486c80
--- /dev/null
+++ b/src/lib/confidence.ts
@@ -0,0 +1,27 @@
+/**
+ * Confidence-score scale normalization.
+ *
+ * `confidenceScore` is meant to be a 0–1 fraction, but the synthesis LLM is
+ * inconsistent: some completions emit `0.9`, others `90` (or `95.00`) on a
+ * 0–100 scale. Stored unnormalized, the 0–100 values render as nonsense once a
+ * consumer multiplies by 100 (e.g. `90 * 100 = 9000%`).
+ *
+ * These helpers coerce either scale to a single canonical form. Normalize at
+ * the source so new reports store a 0–1 fraction, and use these defensively at
+ * display sites so reports already persisted on the wrong scale still render
+ * sensibly.
+ */
+
+/** Coerce a confidence score (0–1 fraction OR 0–100 percentage) to a 0–1
+ * fraction, clamped to [0, 1]. `null`/`undefined`/`NaN` → 0. A value `> 1` is
+ * assumed to be on the 0–100 scale and divided by 100. */
+export function confidenceFraction(raw: number | null | undefined): number {
+ if (raw == null || Number.isNaN(raw)) return 0;
+ const fraction = raw > 1 ? raw / 100 : raw;
+ return Math.max(0, Math.min(1, fraction));
+}
+
+/** Whole-number percentage (0–100) for display, from either input scale. */
+export function confidencePercent(raw: number | null | undefined): number {
+ return Math.round(confidenceFraction(raw) * 100);
+}
diff --git a/src/server/investigation-runner.ts b/src/server/investigation-runner.ts
index 9bb0bd11..18988005 100644
--- a/src/server/investigation-runner.ts
+++ b/src/server/investigation-runner.ts
@@ -14,6 +14,7 @@
import { ulid } from "ulid";
import { createLogger } from "../logger.js";
import type { Database } from "./db.js";
+import { confidencePercent } from "../lib/confidence.js";
import type { IInvestigationAgent } from "../types/agent-interfaces.js";
import type { RcaReport } from "../types/rca-types.js";
import type { ServiceConfig, InvestigationTemplate } from "../config/schema.js";
@@ -321,7 +322,7 @@ export class InvestigationRunner {
total_output_tokens: totalTokens.outputTokens,
total_duration_ms: totalDurationMs,
});
- const confidencePct = report.confidenceScore != null ? Math.round(report.confidenceScore * 100) : null;
+ const confidencePct = report.confidenceScore != null ? confidencePercent(report.confidenceScore) : null;
eventLog.append({
kind: "investigation_completed",
severity: "success",
diff --git a/src/server/slack-notifier.ts b/src/server/slack-notifier.ts
index 842b4a88..be3521a1 100644
--- a/src/server/slack-notifier.ts
+++ b/src/server/slack-notifier.ts
@@ -5,6 +5,7 @@
import { createLogger } from "../logger.js";
import type { RcaReport } from "../types/rca-types.js";
+import { confidencePercent } from "../lib/confidence.js";
const logger = createLogger();
@@ -79,7 +80,7 @@ export async function notifySlack(
const severity = report.severity ?? "unknown";
const confidence = report.confidenceScore != null
- ? `${Math.round(report.confidenceScore * 100)}%`
+ ? `${confidencePercent(report.confidenceScore)}%`
: "N/A";
const rootCause = report.rootCause ?? "Unable to determine";
const summary = report.summary ?? "";
diff --git a/src/web/components/ChatPane.tsx b/src/web/components/ChatPane.tsx
index 8b044289..384c42ba 100644
--- a/src/web/components/ChatPane.tsx
+++ b/src/web/components/ChatPane.tsx
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Search, SearchCode, MessageSquare, Plus, FileText, ChevronRight, ChevronDown, Send, Trash2, X, ArrowRight, Zap } from "lucide-react";
import { renderInline } from "../lib/renderInline";
import { renderMarkdown } from "../lib/renderMarkdown";
+import { confidenceFraction } from "../../lib/confidence.js";
import { formatTokens } from "../lib/formatTokens.js";
import { formatTimestamp } from "../lib/formatTimestamp";
import { MetricChart, type TimeSeriesData } from "./MetricChart";
@@ -729,7 +730,7 @@ export function ChatPane({ ws, onInvestigationStarted, onViewInvestigation, acti
{renderInline(report.rootCause)}
+{renderInline(report.rootCause)}