@oh-my-pi/omp-stats 18.3.4 → 18.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +123 -40
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- package/tailwind.config.js +0 -40
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { Sparkles } from "lucide-react";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import {
|
|
4
|
+
cancelFrustrationRun,
|
|
5
|
+
getFrustrationDashboardStats,
|
|
6
|
+
getFrustrationEstimate,
|
|
7
|
+
startFrustrationRun,
|
|
8
|
+
} from "../api";
|
|
9
|
+
import { Chart, type ChartSeries, Legend, useHiddenSeries } from "../charts";
|
|
10
|
+
import { SERIES_COLORS } from "../data/colors";
|
|
11
|
+
import { formatCost, formatInteger, formatPercent } from "../data/formatters";
|
|
12
|
+
import { useQuery } from "../data/query";
|
|
13
|
+
import { rangeMeta } from "../data/range";
|
|
14
|
+
import type {
|
|
15
|
+
FrustrationCounts,
|
|
16
|
+
FrustrationEstimate,
|
|
17
|
+
FrustrationJobStatus,
|
|
18
|
+
FrustrationModelStats,
|
|
19
|
+
TimeRange,
|
|
20
|
+
} from "../types";
|
|
21
|
+
import {
|
|
22
|
+
Badge,
|
|
23
|
+
Card,
|
|
24
|
+
ChartSkeleton,
|
|
25
|
+
type Column,
|
|
26
|
+
Dot,
|
|
27
|
+
EmptyState,
|
|
28
|
+
KeyValues,
|
|
29
|
+
Modal,
|
|
30
|
+
PageHeader,
|
|
31
|
+
QueryView,
|
|
32
|
+
Segmented,
|
|
33
|
+
Stat,
|
|
34
|
+
StatGrid,
|
|
35
|
+
Table,
|
|
36
|
+
TableSkeleton,
|
|
37
|
+
} from "../ui";
|
|
38
|
+
import "./frustration.css";
|
|
39
|
+
|
|
40
|
+
export interface FrustrationRouteProps {
|
|
41
|
+
active: boolean;
|
|
42
|
+
range: TimeRange;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** While a judge run is active every poll re-renders with the verdicts landed so far. */
|
|
46
|
+
const RUNNING_POLL_MS = 1_000;
|
|
47
|
+
/** Rows under this many messages are too noisy to compare; hidden unless asked for. */
|
|
48
|
+
const MIN_MESSAGES = 50;
|
|
49
|
+
/** Below this judged share a row is mostly classified by the regex fallback. */
|
|
50
|
+
const MIN_JUDGED_SHARE = 0.5;
|
|
51
|
+
/** Segmented-control value for "every model class". */
|
|
52
|
+
const ALL_CLASSES = "*";
|
|
53
|
+
|
|
54
|
+
export function FrustrationRoute({ active, range }: FrustrationRouteProps) {
|
|
55
|
+
const [running, setRunning] = useState(false);
|
|
56
|
+
const stats = useQuery(["frustration", range], () => getFrustrationDashboardStats(range), {
|
|
57
|
+
enabled: active,
|
|
58
|
+
pollMs: running ? RUNNING_POLL_MS : undefined,
|
|
59
|
+
});
|
|
60
|
+
const meta = rangeMeta(range);
|
|
61
|
+
|
|
62
|
+
const jobState = stats.data?.job.state;
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (jobState !== undefined) setRunning(jobState === "running");
|
|
65
|
+
}, [jobState]);
|
|
66
|
+
|
|
67
|
+
const { refetch } = stats;
|
|
68
|
+
const handleRunStarted = useCallback(() => {
|
|
69
|
+
setRunning(true);
|
|
70
|
+
refetch();
|
|
71
|
+
}, [refetch]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="page">
|
|
75
|
+
<PageHeader
|
|
76
|
+
title="Frustration"
|
|
77
|
+
description={`How often your messages sound annoyed, per model version, in ${meta.windowLabel}.`}
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
<QueryView query={stats} skeleton={<ChartSkeleton height={96} />}>
|
|
81
|
+
{data => (
|
|
82
|
+
<div data-stale={stats.stale}>
|
|
83
|
+
<FrustrationSummary overall={data.overall} />
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</QueryView>
|
|
87
|
+
|
|
88
|
+
<QueryView query={stats} skeleton={<ChartSkeleton height={120} />}>
|
|
89
|
+
{data => (
|
|
90
|
+
<JudgePanel
|
|
91
|
+
active={active}
|
|
92
|
+
range={range}
|
|
93
|
+
judgeAvailable={data.judgeAvailable}
|
|
94
|
+
job={data.job}
|
|
95
|
+
onRunStarted={handleRunStarted}
|
|
96
|
+
onRunChanged={refetch}
|
|
97
|
+
/>
|
|
98
|
+
)}
|
|
99
|
+
</QueryView>
|
|
100
|
+
|
|
101
|
+
<QueryView
|
|
102
|
+
query={stats}
|
|
103
|
+
skeleton={
|
|
104
|
+
<Card index={2} title="Frustration by model version">
|
|
105
|
+
<ChartSkeleton height={340} />
|
|
106
|
+
<TableSkeleton rows={6} />
|
|
107
|
+
</Card>
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
{data => <FrustrationModelsSection models={data.byModel} stale={stats.stale} />}
|
|
111
|
+
</QueryView>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `part / whole` as a percentage string; `–` when there is nothing to divide. */
|
|
117
|
+
function rate(part: number, whole: number): string {
|
|
118
|
+
return whole > 0 ? formatPercent(part / whole) : "–";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function errorMessage(err: unknown): string {
|
|
122
|
+
return err instanceof Error ? err.message : String(err);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function formatElapsed(ms: number): string {
|
|
126
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
127
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
128
|
+
const seconds = totalSeconds % 60;
|
|
129
|
+
return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Summary
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function FrustrationSummary({ overall }: { overall: FrustrationCounts }) {
|
|
137
|
+
const regex = overall.messages - overall.judged;
|
|
138
|
+
return (
|
|
139
|
+
<StatGrid min={170}>
|
|
140
|
+
<Stat
|
|
141
|
+
label="User messages"
|
|
142
|
+
title="User messages with prose in range"
|
|
143
|
+
value={formatInteger(overall.messages)}
|
|
144
|
+
/>
|
|
145
|
+
<Stat
|
|
146
|
+
label="Judge coverage"
|
|
147
|
+
title="Messages with a cached judge verdict; the rest use regex signals"
|
|
148
|
+
value={rate(overall.judged, overall.messages)}
|
|
149
|
+
hint={`${formatInteger(overall.judged)} judged · ${formatInteger(regex)} regex`}
|
|
150
|
+
/>
|
|
151
|
+
<Stat
|
|
152
|
+
label="Annoyed"
|
|
153
|
+
title="Messages annoyed at any target"
|
|
154
|
+
value={rate(overall.annoyed, overall.messages)}
|
|
155
|
+
hint={`${formatInteger(overall.annoyed)} messages`}
|
|
156
|
+
/>
|
|
157
|
+
<Stat
|
|
158
|
+
label="At assistant"
|
|
159
|
+
title="Messages annoyed at the assistant"
|
|
160
|
+
value={rate(overall.atAssistant, overall.messages)}
|
|
161
|
+
hint={`${formatInteger(overall.atAssistant)} messages`}
|
|
162
|
+
/>
|
|
163
|
+
<Stat
|
|
164
|
+
label="Angry"
|
|
165
|
+
title="Messages angry or hostile toward the assistant"
|
|
166
|
+
value={rate(overall.angry, overall.messages)}
|
|
167
|
+
hint={`${formatInteger(overall.angry)} messages`}
|
|
168
|
+
/>
|
|
169
|
+
</StatGrid>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Judge control
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
type EstimateState =
|
|
178
|
+
| { status: "loading" }
|
|
179
|
+
| { status: "error"; message: string }
|
|
180
|
+
| { status: "ready"; estimate: FrustrationEstimate; range: TimeRange };
|
|
181
|
+
|
|
182
|
+
interface JudgePanelProps {
|
|
183
|
+
active: boolean;
|
|
184
|
+
range: TimeRange;
|
|
185
|
+
judgeAvailable: boolean;
|
|
186
|
+
job: FrustrationJobStatus;
|
|
187
|
+
onRunStarted: () => void;
|
|
188
|
+
onRunChanged: () => void;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const UNAVAILABLE_HINT =
|
|
192
|
+
"Judging needs the omp host process: open the dashboard with omp stats and configure a judge model. Until then messages are classified by regex signals.";
|
|
193
|
+
|
|
194
|
+
function JudgePanel({ active, range, judgeAvailable, job, onRunStarted, onRunChanged }: JudgePanelProps) {
|
|
195
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
196
|
+
const [estimate, setEstimate] = useState<EstimateState>({ status: "loading" });
|
|
197
|
+
const [starting, setStarting] = useState(false);
|
|
198
|
+
const [startError, setStartError] = useState<string | null>(null);
|
|
199
|
+
const [cancelling, setCancelling] = useState(false);
|
|
200
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
201
|
+
const estimateAbortRef = useRef<AbortController | null>(null);
|
|
202
|
+
|
|
203
|
+
const running = job.state === "running";
|
|
204
|
+
|
|
205
|
+
const closeModal = useCallback(() => {
|
|
206
|
+
estimateAbortRef.current?.abort();
|
|
207
|
+
estimateAbortRef.current = null;
|
|
208
|
+
setModalOpen(false);
|
|
209
|
+
}, []);
|
|
210
|
+
|
|
211
|
+
// A hidden route must not leave a dialog (portalled to <body>) on screen.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!active) closeModal();
|
|
214
|
+
}, [active, closeModal]);
|
|
215
|
+
useEffect(() => () => estimateAbortRef.current?.abort(), []);
|
|
216
|
+
|
|
217
|
+
const openEstimate = () => {
|
|
218
|
+
estimateAbortRef.current?.abort();
|
|
219
|
+
const controller = new AbortController();
|
|
220
|
+
estimateAbortRef.current = controller;
|
|
221
|
+
const quotedRange = range;
|
|
222
|
+
setEstimate({ status: "loading" });
|
|
223
|
+
setStartError(null);
|
|
224
|
+
setActionError(null);
|
|
225
|
+
setModalOpen(true);
|
|
226
|
+
getFrustrationEstimate(quotedRange, controller.signal).then(
|
|
227
|
+
result => {
|
|
228
|
+
if (!controller.signal.aborted) setEstimate({ status: "ready", estimate: result, range: quotedRange });
|
|
229
|
+
},
|
|
230
|
+
(err: unknown) => {
|
|
231
|
+
if (!controller.signal.aborted) setEstimate({ status: "error", message: errorMessage(err) });
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const proceed = async () => {
|
|
237
|
+
if (estimate.status !== "ready") return;
|
|
238
|
+
setStarting(true);
|
|
239
|
+
setStartError(null);
|
|
240
|
+
try {
|
|
241
|
+
// Judge exactly the range that was quoted, even if the picker moved meanwhile.
|
|
242
|
+
await startFrustrationRun(estimate.range);
|
|
243
|
+
closeModal();
|
|
244
|
+
onRunStarted();
|
|
245
|
+
} catch (err) {
|
|
246
|
+
setStartError(errorMessage(err));
|
|
247
|
+
} finally {
|
|
248
|
+
setStarting(false);
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const cancelRun = async () => {
|
|
253
|
+
setCancelling(true);
|
|
254
|
+
setActionError(null);
|
|
255
|
+
try {
|
|
256
|
+
await cancelFrustrationRun();
|
|
257
|
+
onRunChanged();
|
|
258
|
+
} catch (err) {
|
|
259
|
+
setActionError(errorMessage(err));
|
|
260
|
+
} finally {
|
|
261
|
+
setCancelling(false);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const quote = estimate.status === "ready" ? estimate.estimate : null;
|
|
266
|
+
const primaryAction =
|
|
267
|
+
estimate.status === "loading"
|
|
268
|
+
? { label: "Proceed", onClick: proceed, disabled: true }
|
|
269
|
+
: quote?.available
|
|
270
|
+
? {
|
|
271
|
+
label: starting ? "Starting…" : "Proceed",
|
|
272
|
+
onClick: proceed,
|
|
273
|
+
disabled: starting || quote.messages === 0,
|
|
274
|
+
}
|
|
275
|
+
: undefined;
|
|
276
|
+
|
|
277
|
+
return (
|
|
278
|
+
<Card
|
|
279
|
+
index={1}
|
|
280
|
+
title={
|
|
281
|
+
<>
|
|
282
|
+
Judge classification <JobBadge job={job} judgeAvailable={judgeAvailable} />
|
|
283
|
+
</>
|
|
284
|
+
}
|
|
285
|
+
description="Cached LLM verdicts replace the regex heuristics; unjudged messages fall back to regex signals."
|
|
286
|
+
actions={
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
className="btn"
|
|
290
|
+
data-variant="accent"
|
|
291
|
+
data-size="sm"
|
|
292
|
+
onClick={openEstimate}
|
|
293
|
+
disabled={!judgeAvailable || running}
|
|
294
|
+
title={
|
|
295
|
+
!judgeAvailable
|
|
296
|
+
? UNAVAILABLE_HINT
|
|
297
|
+
: running
|
|
298
|
+
? "A judge run is in progress"
|
|
299
|
+
: "Quote the cost of judging every unjudged message in range"
|
|
300
|
+
}
|
|
301
|
+
>
|
|
302
|
+
<Sparkles size={13} /> Classify with judge
|
|
303
|
+
</button>
|
|
304
|
+
}
|
|
305
|
+
>
|
|
306
|
+
<div className="stack frustration-judge">
|
|
307
|
+
{running ? (
|
|
308
|
+
<JudgeProgress job={job} cancelling={cancelling} onCancel={cancelRun} />
|
|
309
|
+
) : (
|
|
310
|
+
<JudgeResultLine job={job} />
|
|
311
|
+
)}
|
|
312
|
+
{!judgeAvailable && <p className="micro dim">{UNAVAILABLE_HINT}</p>}
|
|
313
|
+
{actionError && (
|
|
314
|
+
<p className="micro tone-bad" role="alert">
|
|
315
|
+
{actionError}
|
|
316
|
+
</p>
|
|
317
|
+
)}
|
|
318
|
+
</div>
|
|
319
|
+
|
|
320
|
+
<Modal open={modalOpen} title="Classify with judge" onClose={closeModal} primaryAction={primaryAction}>
|
|
321
|
+
<EstimateBody estimate={estimate} />
|
|
322
|
+
{startError && (
|
|
323
|
+
<p className="micro tone-bad" role="alert">
|
|
324
|
+
{startError}
|
|
325
|
+
</p>
|
|
326
|
+
)}
|
|
327
|
+
</Modal>
|
|
328
|
+
</Card>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function JobBadge({ job, judgeAvailable }: { job: FrustrationJobStatus; judgeAvailable: boolean }) {
|
|
333
|
+
switch (job.state) {
|
|
334
|
+
case "running":
|
|
335
|
+
return (
|
|
336
|
+
<Badge tone="ok">
|
|
337
|
+
<Dot tone="live" pulse /> Running
|
|
338
|
+
</Badge>
|
|
339
|
+
);
|
|
340
|
+
case "done":
|
|
341
|
+
return <Badge tone="ok">Last run done</Badge>;
|
|
342
|
+
case "cancelled":
|
|
343
|
+
return <Badge tone="warn">Last run cancelled</Badge>;
|
|
344
|
+
case "failed":
|
|
345
|
+
return <Badge tone="bad">Last run failed</Badge>;
|
|
346
|
+
case "idle":
|
|
347
|
+
return <Badge>{judgeAvailable ? "Not run yet" : "Regex only"}</Badge>;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function EstimateBody({ estimate }: { estimate: EstimateState }) {
|
|
352
|
+
if (estimate.status === "loading") return <p className="muted">Estimating cost…</p>;
|
|
353
|
+
if (estimate.status === "error") return <p className="tone-bad">{estimate.message}</p>;
|
|
354
|
+
const quote = estimate.estimate;
|
|
355
|
+
if (!quote.available) return <p>{quote.reason}</p>;
|
|
356
|
+
if (quote.messages === 0) {
|
|
357
|
+
return <p>Everything in this range is already judged. There is nothing left to classify.</p>;
|
|
358
|
+
}
|
|
359
|
+
return (
|
|
360
|
+
<>
|
|
361
|
+
<div className="frustration-quote">
|
|
362
|
+
<span className="dim micro">Estimated cost</span>
|
|
363
|
+
<span className="num frustration-quote-value">≈ {formatCost(quote.cost)}</span>
|
|
364
|
+
</div>
|
|
365
|
+
<KeyValues
|
|
366
|
+
items={[
|
|
367
|
+
{ key: "messages", label: "Unjudged unique messages", value: formatInteger(quote.messages) },
|
|
368
|
+
{ key: "chars", label: "Prose characters", value: formatInteger(quote.chars) },
|
|
369
|
+
{ key: "tokens", label: "Estimated input tokens", value: formatInteger(quote.inputTokens) },
|
|
370
|
+
{ key: "judge", label: "Judge", value: quote.judge },
|
|
371
|
+
]}
|
|
372
|
+
/>
|
|
373
|
+
<p className="micro dim">
|
|
374
|
+
Identical messages share one verdict, and verdicts are cached, so later runs only pay for new messages.
|
|
375
|
+
Nothing is spent until you proceed.
|
|
376
|
+
</p>
|
|
377
|
+
</>
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function JudgeProgress({
|
|
382
|
+
job,
|
|
383
|
+
cancelling,
|
|
384
|
+
onCancel,
|
|
385
|
+
}: {
|
|
386
|
+
job: FrustrationJobStatus;
|
|
387
|
+
cancelling: boolean;
|
|
388
|
+
onCancel: () => void;
|
|
389
|
+
}) {
|
|
390
|
+
const finished = job.done + job.failed;
|
|
391
|
+
const share = job.total > 0 ? Math.min(1, finished / job.total) : 0;
|
|
392
|
+
return (
|
|
393
|
+
<div className="stack frustration-progress">
|
|
394
|
+
<div className="row frustration-progress-head">
|
|
395
|
+
<span className="num">
|
|
396
|
+
{formatInteger(job.done)}/{formatInteger(job.total)} judged
|
|
397
|
+
</span>
|
|
398
|
+
<span className="num dim">{formatPercent(share)}</span>
|
|
399
|
+
<span className="frustration-spacer" />
|
|
400
|
+
<button
|
|
401
|
+
type="button"
|
|
402
|
+
className="btn"
|
|
403
|
+
data-variant="danger"
|
|
404
|
+
data-size="sm"
|
|
405
|
+
onClick={onCancel}
|
|
406
|
+
disabled={cancelling}
|
|
407
|
+
>
|
|
408
|
+
{cancelling ? "Cancelling…" : "Cancel run"}
|
|
409
|
+
</button>
|
|
410
|
+
</div>
|
|
411
|
+
<div
|
|
412
|
+
className="meter frustration-progress-track"
|
|
413
|
+
role="progressbar"
|
|
414
|
+
aria-valuemin={0}
|
|
415
|
+
aria-valuemax={job.total}
|
|
416
|
+
aria-valuenow={finished}
|
|
417
|
+
>
|
|
418
|
+
<div className="meter-fill frustration-progress-fill" style={{ width: `${share * 100}%` }} />
|
|
419
|
+
</div>
|
|
420
|
+
<div className="row micro muted frustration-progress-meta">
|
|
421
|
+
<span className={job.failed > 0 ? "tone-bad" : undefined}>{formatInteger(job.failed)} failed</span>
|
|
422
|
+
<span>
|
|
423
|
+
cost so far <span className="num">{formatCost(job.cost)}</span>
|
|
424
|
+
</span>
|
|
425
|
+
{job.judge && <span className="mono">{job.judge}</span>}
|
|
426
|
+
{job.startedAt !== null && <span>elapsed {formatElapsed(Date.now() - job.startedAt)}</span>}
|
|
427
|
+
{job.startedAt !== null && job.done > 0 && (
|
|
428
|
+
<span className="num">{(job.done / Math.max(1, (Date.now() - job.startedAt) / 1000)).toFixed(0)}/s</span>
|
|
429
|
+
)}
|
|
430
|
+
{job.concurrency > 0 && <span className="num">{formatInteger(job.concurrency)} in flight</span>}
|
|
431
|
+
</div>
|
|
432
|
+
</div>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function JudgeResultLine({ job }: { job: FrustrationJobStatus }) {
|
|
437
|
+
const progress = `${formatInteger(job.done)}/${formatInteger(job.total)} messages`;
|
|
438
|
+
const failed = job.failed > 0 ? `, ${formatInteger(job.failed)} failed` : "";
|
|
439
|
+
const judge = job.judge ? ` with ${job.judge}` : "";
|
|
440
|
+
const elapsed =
|
|
441
|
+
job.startedAt !== null && job.finishedAt !== null ? ` in ${formatElapsed(job.finishedAt - job.startedAt)}` : "";
|
|
442
|
+
switch (job.state) {
|
|
443
|
+
case "idle":
|
|
444
|
+
return (
|
|
445
|
+
<p className="muted">
|
|
446
|
+
No judge run yet. Classify the unjudged messages to replace regex estimates with verdicts.
|
|
447
|
+
</p>
|
|
448
|
+
);
|
|
449
|
+
case "running":
|
|
450
|
+
return null;
|
|
451
|
+
case "done":
|
|
452
|
+
return (
|
|
453
|
+
<p className="muted">
|
|
454
|
+
Last run judged {progress}
|
|
455
|
+
{failed}
|
|
456
|
+
{judge}
|
|
457
|
+
{elapsed} for <span className="num">{formatCost(job.cost)}</span>.
|
|
458
|
+
</p>
|
|
459
|
+
);
|
|
460
|
+
case "cancelled":
|
|
461
|
+
return (
|
|
462
|
+
<p className="muted">
|
|
463
|
+
Last run cancelled after {progress}
|
|
464
|
+
{failed}; spent <span className="num">{formatCost(job.cost)}</span>.
|
|
465
|
+
</p>
|
|
466
|
+
);
|
|
467
|
+
case "failed":
|
|
468
|
+
return (
|
|
469
|
+
<p className="muted">
|
|
470
|
+
Last run failed after {progress}
|
|
471
|
+
{failed}: <span className="tone-bad">{job.error ?? "unknown error"}</span>. Spent{" "}
|
|
472
|
+
<span className="num">{formatCost(job.cost)}</span>.
|
|
473
|
+
</p>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
// Per-model chart + table
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
/** Chart/filter identity of a model family: `class/family`. */
|
|
483
|
+
function familyKey(row: FrustrationModelStats): string {
|
|
484
|
+
return `${row.modelClass}/${row.family ?? "unclassified"}`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isMostlyRegex(row: FrustrationModelStats): boolean {
|
|
488
|
+
return row.messages > 0 && row.judged / row.messages < MIN_JUDGED_SHARE;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** FNV-1a: a stable preferred palette slot per family, independent of range. */
|
|
492
|
+
function hashString(value: string): number {
|
|
493
|
+
let hash = 0x811c9dc5;
|
|
494
|
+
for (let i = 0; i < value.length; i++) {
|
|
495
|
+
hash ^= value.charCodeAt(i);
|
|
496
|
+
hash = Math.imul(hash, 0x01000193);
|
|
497
|
+
}
|
|
498
|
+
return hash >>> 0;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Deterministic family → palette color. Each family starts at its hashed slot
|
|
503
|
+
* and probes forward past taken slots, so families present together stay
|
|
504
|
+
* distinct (until the palette runs out) and keep their hue across ranges.
|
|
505
|
+
*/
|
|
506
|
+
function assignFamilyColors(models: readonly FrustrationModelStats[]): Map<string, string> {
|
|
507
|
+
const keys = [...new Set(models.map(familyKey))].sort();
|
|
508
|
+
const taken = new Set<number>();
|
|
509
|
+
const colors = new Map<string, string>();
|
|
510
|
+
for (const key of keys) {
|
|
511
|
+
let slot = hashString(key) % SERIES_COLORS.length;
|
|
512
|
+
for (let probe = 0; probe < SERIES_COLORS.length && taken.has(slot); probe++) {
|
|
513
|
+
slot = (slot + 1) % SERIES_COLORS.length;
|
|
514
|
+
}
|
|
515
|
+
taken.add(slot);
|
|
516
|
+
colors.set(key, SERIES_COLORS[slot]);
|
|
517
|
+
}
|
|
518
|
+
return colors;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Shades of one family hue for the three stacked layers. `color-mix` keeps
|
|
523
|
+
* the faint layer see-through on both themes.
|
|
524
|
+
*/
|
|
525
|
+
function layerColor(hue: string, layer: Layer): string {
|
|
526
|
+
if (layer === "angry") return `color-mix(in srgb, ${hue} 55%, black)`;
|
|
527
|
+
if (layer === "other") return `color-mix(in srgb, ${hue} 32%, transparent)`;
|
|
528
|
+
return hue;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Stack layers, bottom first. `annoyed ⊇ atAssistant ⊇ angry`, so stacking the
|
|
533
|
+
* differences draws each bar to the `annoyed` height with the subsets inside.
|
|
534
|
+
*/
|
|
535
|
+
const LAYERS = [
|
|
536
|
+
{ key: "angry", label: "Angry at assistant" },
|
|
537
|
+
{ key: "assistant", label: "At assistant, not angry" },
|
|
538
|
+
{ key: "other", label: "Annoyed at other targets" },
|
|
539
|
+
] as const;
|
|
540
|
+
type Layer = (typeof LAYERS)[number]["key"];
|
|
541
|
+
|
|
542
|
+
function layerCount(row: FrustrationModelStats, layer: Layer): number {
|
|
543
|
+
if (layer === "angry") return row.angry;
|
|
544
|
+
if (layer === "assistant") return row.atAssistant - row.angry;
|
|
545
|
+
return row.annoyed - row.atAssistant;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function percent(part: number, whole: number): number {
|
|
549
|
+
return whole > 0 ? (part / whole) * 100 : 0;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
interface FamilyOption {
|
|
553
|
+
key: string;
|
|
554
|
+
label: string;
|
|
555
|
+
messages: number;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function FrustrationModelsSection({ models, stale }: { models: FrustrationModelStats[]; stale: boolean }) {
|
|
559
|
+
const [selectedClass, setSelectedClass] = useState<string | null>(null);
|
|
560
|
+
const [hiddenFamilies, toggleFamily] = useHiddenSeries();
|
|
561
|
+
const [showSmall, setShowSmall] = useState(false);
|
|
562
|
+
const [hideRegex, setHideRegex] = useState(false);
|
|
563
|
+
|
|
564
|
+
const familyColors = useMemo(() => assignFamilyColors(models), [models]);
|
|
565
|
+
|
|
566
|
+
const classes = useMemo(() => {
|
|
567
|
+
const totals = new Map<string, number>();
|
|
568
|
+
for (const row of models) totals.set(row.modelClass, (totals.get(row.modelClass) ?? 0) + row.messages);
|
|
569
|
+
return totals;
|
|
570
|
+
}, [models]);
|
|
571
|
+
|
|
572
|
+
const defaultClass = useMemo(() => {
|
|
573
|
+
let best: string | null = null;
|
|
574
|
+
let bestMessages = -1;
|
|
575
|
+
for (const [modelClass, messages] of classes) {
|
|
576
|
+
if (messages > bestMessages) {
|
|
577
|
+
best = modelClass;
|
|
578
|
+
bestMessages = messages;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return best ?? ALL_CLASSES;
|
|
582
|
+
}, [classes]);
|
|
583
|
+
|
|
584
|
+
const activeClass =
|
|
585
|
+
selectedClass !== null && (selectedClass === ALL_CLASSES || classes.has(selectedClass))
|
|
586
|
+
? selectedClass
|
|
587
|
+
: defaultClass;
|
|
588
|
+
|
|
589
|
+
const classOptions = useMemo(
|
|
590
|
+
() => [
|
|
591
|
+
{ value: ALL_CLASSES, label: "All", title: "Every model class" },
|
|
592
|
+
...[...classes].map(([modelClass, messages]) => ({
|
|
593
|
+
value: modelClass,
|
|
594
|
+
label: modelClass,
|
|
595
|
+
title: `${formatInteger(messages)} messages`,
|
|
596
|
+
})),
|
|
597
|
+
],
|
|
598
|
+
[classes],
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
const inClass = useMemo(
|
|
602
|
+
() => (activeClass === ALL_CLASSES ? models : models.filter(row => row.modelClass === activeClass)),
|
|
603
|
+
[models, activeClass],
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
const families = useMemo(() => {
|
|
607
|
+
const byKey = new Map<string, FamilyOption>();
|
|
608
|
+
for (const row of inClass) {
|
|
609
|
+
const key = familyKey(row);
|
|
610
|
+
const existing = byKey.get(key);
|
|
611
|
+
if (existing) {
|
|
612
|
+
existing.messages += row.messages;
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const family = row.family ?? "unclassified";
|
|
616
|
+
byKey.set(key, {
|
|
617
|
+
key,
|
|
618
|
+
label: activeClass === ALL_CLASSES ? `${row.modelClass} · ${family}` : family,
|
|
619
|
+
messages: row.messages,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
return [...byKey.values()];
|
|
623
|
+
}, [inClass, activeClass]);
|
|
624
|
+
|
|
625
|
+
const visibleFamilies = useMemo(
|
|
626
|
+
() => inClass.filter(row => !hiddenFamilies.has(familyKey(row))),
|
|
627
|
+
[inClass, hiddenFamilies],
|
|
628
|
+
);
|
|
629
|
+
const smallCount = visibleFamilies.filter(row => row.messages < MIN_MESSAGES).length;
|
|
630
|
+
const regexCount = visibleFamilies.filter(isMostlyRegex).length;
|
|
631
|
+
const rows = useMemo(
|
|
632
|
+
() =>
|
|
633
|
+
visibleFamilies.filter(
|
|
634
|
+
row => (showSmall || row.messages >= MIN_MESSAGES) && !(hideRegex && isMostlyRegex(row)),
|
|
635
|
+
),
|
|
636
|
+
[visibleFamilies, showSmall, hideRegex],
|
|
637
|
+
);
|
|
638
|
+
|
|
639
|
+
if (models.length === 0) {
|
|
640
|
+
return (
|
|
641
|
+
<Card index={2} title="Frustration by model version" stale={stale}>
|
|
642
|
+
<EmptyState title="No user messages in this range" hint="Try a longer range." />
|
|
643
|
+
</Card>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const hasRegexRows = rows.some(isMostlyRegex);
|
|
648
|
+
|
|
649
|
+
return (
|
|
650
|
+
<>
|
|
651
|
+
<Card
|
|
652
|
+
index={2}
|
|
653
|
+
title="Frustration by model version"
|
|
654
|
+
description="Share of user messages per model version. Each bar reaches the annoyed rate; the subsets aimed at the assistant stack inside it."
|
|
655
|
+
stale={stale}
|
|
656
|
+
>
|
|
657
|
+
<div className="stack frustration-chart-body">
|
|
658
|
+
<div className="frustration-classes">
|
|
659
|
+
<Segmented
|
|
660
|
+
size="sm"
|
|
661
|
+
aria-label="Model class"
|
|
662
|
+
options={classOptions}
|
|
663
|
+
value={activeClass}
|
|
664
|
+
onChange={setSelectedClass}
|
|
665
|
+
/>
|
|
666
|
+
</div>
|
|
667
|
+
<div className="row frustration-filters">
|
|
668
|
+
<Legend
|
|
669
|
+
items={families.map(family => ({
|
|
670
|
+
key: family.key,
|
|
671
|
+
label: family.label,
|
|
672
|
+
color: familyColors.get(family.key) ?? SERIES_COLORS[0],
|
|
673
|
+
value: formatInteger(family.messages),
|
|
674
|
+
}))}
|
|
675
|
+
hidden={hiddenFamilies}
|
|
676
|
+
onToggle={toggleFamily}
|
|
677
|
+
/>
|
|
678
|
+
<span className="frustration-spacer" />
|
|
679
|
+
<label className="check" title={`Models with fewer than ${MIN_MESSAGES} messages have noisy rates`}>
|
|
680
|
+
<input type="checkbox" checked={showSmall} onChange={e => setShowSmall(e.target.checked)} />
|
|
681
|
+
Include < {MIN_MESSAGES} messages{smallCount > 0 ? ` (${smallCount})` : ""}
|
|
682
|
+
</label>
|
|
683
|
+
<label
|
|
684
|
+
className="check"
|
|
685
|
+
title={`Hide models with under ${MIN_JUDGED_SHARE * 100}% judged messages (rates from regex signals)`}
|
|
686
|
+
>
|
|
687
|
+
<input type="checkbox" checked={hideRegex} onChange={e => setHideRegex(e.target.checked)} />
|
|
688
|
+
Hide mostly regex{regexCount > 0 ? ` (${regexCount})` : ""}
|
|
689
|
+
</label>
|
|
690
|
+
</div>
|
|
691
|
+
<EncodingKey hasRegexRows={hasRegexRows} />
|
|
692
|
+
<FrustrationChart rows={rows} familyColors={familyColors} />
|
|
693
|
+
</div>
|
|
694
|
+
</Card>
|
|
695
|
+
<FrustrationTable rows={rows} familyColors={familyColors} stale={stale} />
|
|
696
|
+
</>
|
|
697
|
+
);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** What the marks mean: layer shades, hatching and the trend line (hue = family). */
|
|
701
|
+
function EncodingKey({ hasRegexRows }: { hasRegexRows: boolean }) {
|
|
702
|
+
return (
|
|
703
|
+
<div className="legend frustration-key">
|
|
704
|
+
<span className="legend-item">
|
|
705
|
+
<span className="swatch frustration-key-swatch" data-variant="angry" />
|
|
706
|
+
Angry at assistant
|
|
707
|
+
</span>
|
|
708
|
+
<span className="legend-item">
|
|
709
|
+
<span className="swatch frustration-key-swatch" data-variant="assistant" />
|
|
710
|
+
At assistant
|
|
711
|
+
</span>
|
|
712
|
+
<span className="legend-item">
|
|
713
|
+
<span className="swatch frustration-key-swatch" data-variant="other" />
|
|
714
|
+
Annoyed at other targets
|
|
715
|
+
</span>
|
|
716
|
+
<span className="legend-item">
|
|
717
|
+
<span className="frustration-key-line" />
|
|
718
|
+
At assistant trend
|
|
719
|
+
</span>
|
|
720
|
+
<span
|
|
721
|
+
className="legend-item frustration-key-wide"
|
|
722
|
+
title={`Under ${MIN_JUDGED_SHARE * 100}% of these messages are judged; their rates come from regex signals`}
|
|
723
|
+
>
|
|
724
|
+
<span className="swatch frustration-key-swatch" data-variant="hatch" />
|
|
725
|
+
Hatched: mostly regex fallback{hasRegexRows ? " (run the judge)" : ""}
|
|
726
|
+
</span>
|
|
727
|
+
<span className="legend-item dim">Hue = model family</span>
|
|
728
|
+
</div>
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function FrustrationChart({
|
|
733
|
+
rows,
|
|
734
|
+
familyColors,
|
|
735
|
+
}: {
|
|
736
|
+
rows: FrustrationModelStats[];
|
|
737
|
+
familyColors: Map<string, string>;
|
|
738
|
+
}) {
|
|
739
|
+
const series = useMemo((): ChartSeries[] => {
|
|
740
|
+
// Bars take their family's hue, so every (layer, family, judged|regex)
|
|
741
|
+
// combination is its own series with values only in that family's slots.
|
|
742
|
+
const groups = new Map<string, { family: string; regex: boolean; slots: number[] }>();
|
|
743
|
+
rows.forEach((row, i) => {
|
|
744
|
+
const family = familyKey(row);
|
|
745
|
+
const regex = isMostlyRegex(row);
|
|
746
|
+
const id = `${family}|${regex ? "regex" : "judged"}`;
|
|
747
|
+
const group = groups.get(id);
|
|
748
|
+
if (group) group.slots.push(i);
|
|
749
|
+
else groups.set(id, { family, regex, slots: [i] });
|
|
750
|
+
});
|
|
751
|
+
const out: ChartSeries[] = [];
|
|
752
|
+
for (const layer of LAYERS) {
|
|
753
|
+
for (const [id, group] of groups) {
|
|
754
|
+
const values: (number | null)[] = rows.map(() => null);
|
|
755
|
+
for (const i of group.slots) values[i] = percent(layerCount(rows[i], layer.key), rows[i].messages);
|
|
756
|
+
out.push({
|
|
757
|
+
key: `${layer.key}|${id}`,
|
|
758
|
+
label: layer.label,
|
|
759
|
+
color: layerColor(familyColors.get(group.family) ?? SERIES_COLORS[0], layer.key),
|
|
760
|
+
values,
|
|
761
|
+
pattern: group.regex ? "hatch" : undefined,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
out.push({
|
|
766
|
+
key: "trend",
|
|
767
|
+
label: "At assistant (total)",
|
|
768
|
+
color: "var(--ink-1)",
|
|
769
|
+
values: rows.map(row => percent(row.atAssistant, row.messages)),
|
|
770
|
+
kind: "line",
|
|
771
|
+
// Repeats the two at-assistant bar layers; the tooltip already lists them.
|
|
772
|
+
tooltip: false,
|
|
773
|
+
});
|
|
774
|
+
return out;
|
|
775
|
+
}, [rows, familyColors]);
|
|
776
|
+
|
|
777
|
+
if (rows.length === 0) {
|
|
778
|
+
return (
|
|
779
|
+
<div className="frustration-chart-empty">
|
|
780
|
+
<EmptyState title="No model versions match the filters" />
|
|
781
|
+
</div>
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return (
|
|
786
|
+
<Chart
|
|
787
|
+
slots={rows.length}
|
|
788
|
+
tickLabel={i => rows[i].label}
|
|
789
|
+
series={series}
|
|
790
|
+
height={340}
|
|
791
|
+
format={v => `${v}%`}
|
|
792
|
+
formatTooltip={v => `${v.toFixed(1)}%`}
|
|
793
|
+
showTotal={false}
|
|
794
|
+
emptyLabel="No frustrated messages for these models"
|
|
795
|
+
tooltipExtra={i => {
|
|
796
|
+
const row = rows[i];
|
|
797
|
+
return (
|
|
798
|
+
<div className="frustration-tooltip-extra">
|
|
799
|
+
<div className="chart-tooltip-row chart-tooltip-total">
|
|
800
|
+
<span className="chart-tooltip-label">Annoyed, any target</span>
|
|
801
|
+
<span className="chart-tooltip-value">{rate(row.annoyed, row.messages)}</span>
|
|
802
|
+
</div>
|
|
803
|
+
<div className="micro dim">
|
|
804
|
+
{formatInteger(row.messages)} messages · judged {formatInteger(row.judged)} (
|
|
805
|
+
{rate(row.judged, row.messages)}){isMostlyRegex(row) ? " · mostly regex fallback" : ""}
|
|
806
|
+
</div>
|
|
807
|
+
</div>
|
|
808
|
+
);
|
|
809
|
+
}}
|
|
810
|
+
/>
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function FrustrationTable({
|
|
815
|
+
rows,
|
|
816
|
+
familyColors,
|
|
817
|
+
stale,
|
|
818
|
+
}: {
|
|
819
|
+
rows: FrustrationModelStats[];
|
|
820
|
+
familyColors: Map<string, string>;
|
|
821
|
+
stale: boolean;
|
|
822
|
+
}) {
|
|
823
|
+
const columns = useMemo(
|
|
824
|
+
(): Column<FrustrationModelStats>[] => [
|
|
825
|
+
{
|
|
826
|
+
key: "model",
|
|
827
|
+
header: "Model",
|
|
828
|
+
sort: row => row.label,
|
|
829
|
+
render: row => (
|
|
830
|
+
<span className="row" title={row.key}>
|
|
831
|
+
<span
|
|
832
|
+
className="swatch"
|
|
833
|
+
style={{ background: familyColors.get(familyKey(row)) ?? SERIES_COLORS[0] }}
|
|
834
|
+
/>
|
|
835
|
+
<span className="cell-primary">{row.label}</span>
|
|
836
|
+
</span>
|
|
837
|
+
),
|
|
838
|
+
},
|
|
839
|
+
{
|
|
840
|
+
key: "ids",
|
|
841
|
+
header: "Model IDs",
|
|
842
|
+
wrap: true,
|
|
843
|
+
render: row => <span className="mono micro dim">{row.models.join(", ")}</span>,
|
|
844
|
+
},
|
|
845
|
+
{
|
|
846
|
+
key: "messages",
|
|
847
|
+
header: "Messages",
|
|
848
|
+
align: "right",
|
|
849
|
+
sort: row => row.messages,
|
|
850
|
+
render: row => <span className="num">{formatInteger(row.messages)}</span>,
|
|
851
|
+
},
|
|
852
|
+
{
|
|
853
|
+
key: "judged",
|
|
854
|
+
header: "Judged",
|
|
855
|
+
align: "right",
|
|
856
|
+
sort: row => (row.messages > 0 ? row.judged / row.messages : 0),
|
|
857
|
+
render: row =>
|
|
858
|
+
isMostlyRegex(row) ? (
|
|
859
|
+
<span
|
|
860
|
+
className="row frustration-judged"
|
|
861
|
+
title="Mostly regex fallback: run the judge for accurate rates"
|
|
862
|
+
>
|
|
863
|
+
<Badge tone="warn">regex</Badge>
|
|
864
|
+
<span className="num dim">{rate(row.judged, row.messages)}</span>
|
|
865
|
+
</span>
|
|
866
|
+
) : (
|
|
867
|
+
<span className="num">{rate(row.judged, row.messages)}</span>
|
|
868
|
+
),
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
key: "annoyed",
|
|
872
|
+
header: "Annoyed",
|
|
873
|
+
align: "right",
|
|
874
|
+
sort: row => percent(row.annoyed, row.messages),
|
|
875
|
+
render: row => (
|
|
876
|
+
<span className="num" title={`${formatInteger(row.annoyed)} messages`}>
|
|
877
|
+
{rate(row.annoyed, row.messages)}
|
|
878
|
+
</span>
|
|
879
|
+
),
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
key: "atAssistant",
|
|
883
|
+
header: "At assistant",
|
|
884
|
+
align: "right",
|
|
885
|
+
sort: row => percent(row.atAssistant, row.messages),
|
|
886
|
+
render: row => (
|
|
887
|
+
<span className="num" title={`${formatInteger(row.atAssistant)} messages`}>
|
|
888
|
+
{rate(row.atAssistant, row.messages)}
|
|
889
|
+
</span>
|
|
890
|
+
),
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
key: "angry",
|
|
894
|
+
header: "Angry",
|
|
895
|
+
align: "right",
|
|
896
|
+
sort: row => percent(row.angry, row.messages),
|
|
897
|
+
render: row => (
|
|
898
|
+
<span className={row.angry > 0 ? "num tone-bad" : "num"} title={`${formatInteger(row.angry)} messages`}>
|
|
899
|
+
{rate(row.angry, row.messages)}
|
|
900
|
+
</span>
|
|
901
|
+
),
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
[familyColors],
|
|
905
|
+
);
|
|
906
|
+
|
|
907
|
+
return (
|
|
908
|
+
<Card
|
|
909
|
+
index={3}
|
|
910
|
+
title="Model versions"
|
|
911
|
+
description="Rates are shares of user messages; rows follow the chart filters."
|
|
912
|
+
flush
|
|
913
|
+
stale={stale}
|
|
914
|
+
>
|
|
915
|
+
<Table
|
|
916
|
+
rows={rows}
|
|
917
|
+
rowKey={row => row.key}
|
|
918
|
+
columns={columns}
|
|
919
|
+
dense
|
|
920
|
+
empty={<EmptyState title="No model versions match the filters" />}
|
|
921
|
+
/>
|
|
922
|
+
</Card>
|
|
923
|
+
);
|
|
924
|
+
}
|