Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 58 additions & 19 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2447,30 +2447,69 @@ a.ext {
font-size: 14px;
line-height: 1.5;
}
.admin-charts {
/* Light-themed calibration visuals (replaced the dark server-rendered SVGs). */
.calibration-grid-charts {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.calibration-gauge {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-top: 16px;
gap: 8px;
}
.admin-chart {
margin: 0;
border: 1px solid var(--hairline);
border-radius: 12px;
padding: 12px;
.calibration-gauge-track {
position: relative;
height: 10px;
border-radius: 999px;
background: var(--hairline);
/* visible so the baseline marker can extend a touch above/below the track */
overflow: visible;
}
.admin-chart img {
width: 100%;
height: auto;
display: block;
.calibration-gauge-fill {
position: absolute;
inset: 0 auto 0 0;
height: 100%;
border-radius: 999px;
min-width: 2px;
}
.admin-chart figcaption {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.02em;
.calibration-gauge-ok {
background: #1a7f37;
}
.calibration-gauge-warn {
background: #d29922;
}
.calibration-gauge-neutral {
background: var(--link);
}
.calibration-gauge-marker {
position: absolute;
top: -2px;
bottom: -2px;
width: 2px;
background: var(--ink);
z-index: 1;
}
.calibration-gauge small {
color: var(--muted);
margin-top: 8px;
font-size: 12px;
}
.calibration-split {
display: flex;
height: 12px;
border-radius: 999px;
overflow: hidden;
background: var(--hairline);
}
.calibration-split .seg {
display: block;
height: 100%;
}
.calibration-split .seg-ok {
background: #1a7f37;
}
.calibration-split .seg-partial {
background: #d29922;
}
.calibration-split .seg-bad {
background: #c0392b;
}
.admin-toggle {
display: flex;
Expand Down
98 changes: 84 additions & 14 deletions src/components/admin/panels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
agentOptions,
calibrationGeneratedAt,
calibrationIssues,
calibrationOutcomes,
decimal,
dollars,
formatParams,
Expand Down Expand Up @@ -358,12 +359,40 @@ export function JobsPanel() {
);
}

const CHARTS = [
{ type: "brier-trend", label: "Brier trend" },
{ type: "pattern-statements", label: "Pattern statements" },
{ type: "domain-bars", label: "Per-domain accuracy" },
{ type: "abandoned-threads", label: "Abandoned threads" },
];
// Light-themed horizontal gauge: a value on a 0..max track with an optional
// baseline marker. Replaces the dark server-rendered SVG charts so the panel
// stays on Lore's light theme (built from the profile JSON, no gbrain images).
function CalibrationGauge({
value,
max,
marker,
tone,
caption,
}: {
value: number | null | undefined;
max: number;
marker?: number;
tone: "ok" | "warn" | "neutral";
caption: string;
}) {
const pct =
value == null || !Number.isFinite(value) ? 0 : Math.max(0, Math.min(1, value / max)) * 100;
const markerPct = marker != null ? Math.max(0, Math.min(1, marker / max)) * 100 : null;
return (
<div className="calibration-gauge">
<div className="calibration-gauge-track">
<span
className={`calibration-gauge-fill calibration-gauge-${tone}`}
style={{ width: `${pct}%` }}
/>
{markerPct != null && (
<span className="calibration-gauge-marker" style={{ left: `${markerPct}%` }} />
)}
</div>
<small>{caption}</small>
</div>
);
}

function CalibrationMetric({
label,
Expand Down Expand Up @@ -410,6 +439,7 @@ export function CalibrationPanel() {
const issues = p ? calibrationIssues(p) : [];
const patterns = p?.pattern_statements ?? [];
const biasTags = p?.active_bias_tags ?? [];
const outcomes = p ? calibrationOutcomes(p) : null;
return (
<Panel
title="Calibration"
Expand Down Expand Up @@ -549,14 +579,54 @@ export function CalibrationPanel() {
<p className="admin-muted">No pattern statements returned.</p>
)}
</div>
<div className="admin-charts">
{CHARTS.map((c) => (
<figure key={c.type} className="admin-chart">
{/* server-proxied SVG, chart-type allowlisted */}
<img src={`/api/admin/charts/${c.type}`} alt={c.label} loading="lazy" />
<figcaption>{c.label}</figcaption>
</figure>
))}
<div className="calibration-grid calibration-grid-charts">
<div className="calibration-card">
<p className="admin-card-label">Brier vs baseline</p>
<CalibrationGauge
value={p.brier}
max={0.5}
marker={0.25}
tone={p.brier != null && p.brier <= 0.25 ? "ok" : "warn"}
caption={`${decimal(p.brier)} · 0.25 baseline · lower is better`}
/>
</div>
<div className="calibration-card">
<p className="admin-card-label">Accuracy</p>
<CalibrationGauge
value={p.accuracy}
max={1}
tone="neutral"
caption={`${percent(p.accuracy, 1)} of resolved calls`}
/>
</div>
<div className="calibration-card">
<p className="admin-card-label">Outcomes</p>
{outcomes ? (
<>
<div className="calibration-split" aria-hidden="true">
{outcomes.correct > 0 && (
<span className="seg seg-ok" style={{ flexGrow: outcomes.correct }} />
)}
{outcomes.partial > 0 && (
<span
className="seg seg-partial"
style={{ flexGrow: outcomes.partial }}
/>
)}
{outcomes.incorrect > 0 && (
<span className="seg seg-bad" style={{ flexGrow: outcomes.incorrect }} />
)}
</div>
<small>
{outcomes.correct} correct · {outcomes.incorrect} wrong
{outcomes.partial > 0 ? ` · ${outcomes.partial} partial` : ""} · n=
{outcomes.total}
</small>
</>
) : (
<p className="admin-muted">No resolved outcomes yet.</p>
)}
</div>
</div>
<details className="admin-details">
<summary>Raw profile</summary>
Expand Down
26 changes: 26 additions & 0 deletions src/lib/admin-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,32 @@ export interface CalibrationIssue {
detail: string;
}

export interface CalibrationOutcomes {
correct: number;
incorrect: number;
partial: number;
total: number;
}

// Reconstruct the correct/incorrect/partial counts from the scorecard's own
// aggregates — the inverse of how gbrain computes them:
// total = correct + incorrect + partial
// partial_rate = partial / total
// accuracy = correct / (correct + incorrect) (partial excluded)
// Exact for clean samples (integer rounding for the rest). Returns null when
// there's nothing to show or accuracy is unavailable.
export function calibrationOutcomes(profile: CalibrationProfile): CalibrationOutcomes | null {
const total = profile.total_resolved ?? 0;
if (total <= 0) return null;
const acc = profile.accuracy;
if (acc == null || !Number.isFinite(acc)) return null;
const partial = Math.min(total, Math.max(0, Math.round((profile.partial_rate ?? 0) * total)));
const nonPartial = total - partial;
const correct = Math.min(nonPartial, Math.max(0, Math.round(acc * nonPartial)));
const incorrect = nonPartial - correct;
return { correct, incorrect, partial, total };
}

// "just now" / "5m ago" / "3h ago" / "2d ago".
export function relativeTime(iso: string | null | undefined, now = Date.now()): string {
if (!iso) return "—";
Expand Down
17 changes: 17 additions & 0 deletions tests/admin-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
agentOptions,
calibrationGeneratedAt,
calibrationIssues,
calibrationOutcomes,
decimal,
dollars,
formatParams,
Expand All @@ -14,6 +15,22 @@ import {
} from "../src/lib/admin-format.js";
import { agents, calibrationProfile, jobsSnapshot, requestsPage } from "./fixtures/admin.js";

test("calibrationOutcomes reconstructs correct/incorrect/partial from scorecard aggregates", () => {
// 8 resolved, 50% accuracy, 0 partial → 4 correct / 4 wrong (exact)
expect(
calibrationOutcomes({ ...calibrationProfile, total_resolved: 8, accuracy: 0.5, partial_rate: 0 }),
).toEqual({ correct: 4, incorrect: 4, partial: 0, total: 8 });
// with partials: 10 resolved, 20% partial (=2), accuracy 75% over the 8 non-partial → 6/2
expect(
calibrationOutcomes({ ...calibrationProfile, total_resolved: 10, accuracy: 0.75, partial_rate: 0.2 }),
).toEqual({ correct: 6, incorrect: 2, partial: 2, total: 10 });
// guards: no data / no accuracy → null
expect(calibrationOutcomes({ ...calibrationProfile, total_resolved: 0 })).toBeNull();
expect(
calibrationOutcomes({ ...calibrationProfile, total_resolved: 8, accuracy: null }),
).toBeNull();
});

test("formatParams matches upstream summary (query / slug / ~partial / limit / +N)", () => {
expect(formatParams(null)).toBeNull();
expect(formatParams({})).toBeNull();
Expand Down
Loading