@linxiraos/pi-stats 1.3.2 → 1.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.
@@ -91,14 +91,34 @@ export interface UserMessageMetrics {
91
91
  */
92
92
  unsupportedScript: number;
93
93
  }
94
+ export declare const WORD_RE: RegExp;
95
+ /** 1 when the prose leans on a script the lexicons do not cover. */
96
+ export declare function detectUnsupportedScript(prose: string): number;
97
+ /**
98
+ * 1 when the message says essentially the same thing as the one before it -
99
+ * the one frustration signal that needs no lexicon and therefore works for
100
+ * any language and survives every transcription error.
101
+ */
102
+ export declare function detectRepeatedMessage(prose: string, prevProse: string): number;
103
+ /** Count regex hits without materializing the match array. */
104
+ export declare function countMatches(text: string, re: RegExp): number;
105
+ /**
106
+ * Strip structured content so that pasted code, harness wrappers, file
107
+ * mentions and quoted blocks don't dilute or fake behavior signals.
108
+ * Each strip is replaced with a newline so subsequent line counting
109
+ * reflects what was removed instead of merging neighbors.
110
+ */
111
+ export declare function stripStructuredContent(text: string): string;
94
112
  /** Cap on the judge-facing prose stored per user message; longer prose is truncated. */
95
113
  export declare const PROSE_MAX_CHARS = 4000;
96
114
  /**
97
- * The prose a user message contributes to frustration judging: the same
115
+ * The prose a message contributes to behavioral analysis: the same
98
116
  * structured-content strip the regex signals score on, trimmed and capped at
99
117
  * {@link PROSE_MAX_CHARS}. Empty when the message was nothing but markup.
118
+ * Shared by the user-message judge and the model-side analyzer so both store
119
+ * exactly the same body.
100
120
  */
101
- export declare function judgeProse(text: string): string;
121
+ export declare function analysisProse(text: string): string;
102
122
  /**
103
123
  * Compute behavioral metrics for a user message.
104
124
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-stats",
4
- "version": "1.3.2",
4
+ "version": "1.4.0",
5
5
  "description": "Local observability dashboard for omp AI usage statistics",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Linxira-OS",
@@ -39,9 +39,9 @@
39
39
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
40
40
  },
41
41
  "dependencies": {
42
- "@linxiraos/pi-ai": "1.3.2",
43
- "@linxiraos/pi-catalog": "1.3.2",
44
- "@linxiraos/pi-utils": "1.3.2",
42
+ "@linxiraos/pi-ai": "1.4.0",
43
+ "@linxiraos/pi-catalog": "1.4.0",
44
+ "@linxiraos/pi-utils": "1.4.0",
45
45
  "lucide-react": "^1.24.0",
46
46
  "react": "19.2.7",
47
47
  "react-dom": "19.2.7"
package/src/aggregator.ts CHANGED
@@ -415,6 +415,7 @@ async function syncAllSessionsLocked(
415
415
  pendingRows +=
416
416
  result.stats.length +
417
417
  result.userStats.length +
418
+ result.assistantTexts.length +
418
419
  result.userLinks.length +
419
420
  result.toolCalls.length +
420
421
  result.toolResults.length;
@@ -101,6 +101,46 @@ export const frustration = {
101
101
  "frustration.cancelled": "Cancelled",
102
102
  "frustration.failed": "Failed",
103
103
  "frustration.done": "Done",
104
+ "frustration.model-side-title": "AI breakdown",
105
+ "frustration.model-side-description": "How the model's own replies read, per model version, in {window}.",
106
+ "frustration.model-turns": "Assistant turns",
107
+ "frustration.model-turns-title":
108
+ "Assistant replies with text in range; tool-call-only replies and thinking blocks are not analyzed",
109
+ "frustration.model-breakdown": "Breakdown rate",
110
+ "frustration.model-breakdown-title": "Share of analyzed turns with at least one breakdown signal",
111
+ "frustration.model-mean": "Signals / turn",
112
+ "frustration.model-mean-title": "Average per-turn aggregate: text markers plus capped failed tool results",
113
+ "frustration.model-self-blame": "Self-blame",
114
+ "frustration.model-self-blame-title": "Turns containing an apology or admission",
115
+ "frustration.model-restatement": "Restatement",
116
+ "frustration.model-restatement-title":
117
+ "Turns repeating earlier content, by explicit marker or near-duplicate reply",
118
+ "frustration.model-hedge": "Hedged",
119
+ "frustration.model-hedge-title": "Turns with hedged fallbacks: uncertainty or suggested alternatives",
120
+ "frustration.model-spiral": "Tool spirals",
121
+ "frustration.model-spiral-title": "Turns following three or more failed tool results",
122
+ "frustration.model-rambling": "Rambling",
123
+ "frustration.model-rambling-title": "Turns past the verbosity threshold",
124
+ "frustration.model-unscored-line": "Turns in a language without a lexicon: {count} — unscored, not calm.",
125
+ "frustration.model-by-model": "AI breakdown by model version",
126
+ "frustration.model-table-title": "Model breakdown detail",
127
+ "frustration.model-by-model-description":
128
+ "Share of each model's analyzed turns carrying a breakdown signal. Hue = model family.",
129
+ "frustration.model-over-time": "AI breakdown over time",
130
+ "frustration.model-over-time-description":
131
+ "Breakdown rate per bucket: turns with a signal over analyzed turns. Gaps are buckets with no turns.",
132
+ "frustration.model-sessions": "Sessions by breakdown",
133
+ "frustration.model-sessions-description": "Sessions with the most analyzed assistant turns.",
134
+ "frustration.model-table-description": "Rates are shares of analyzed assistant turns.",
135
+ "frustration.column-session": "Session",
136
+ "frustration.column-turns": "Turns",
137
+ "frustration.column-mean": "Signals/turn",
138
+ "frustration.column-tool-errors": "Tool errors",
139
+ "frustration.model-no-turns": "No assistant turns with text in this range",
140
+ "frustration.model-try-longer-range": "Try a longer range.",
141
+ "frustration.model-legend-breakdown": "Breakdown rate",
142
+ "frustration.turns-n": "{count} turns",
143
+ "frustration.model-hint-mean": "{value} signals per turn on average",
104
144
  },
105
145
  "zh-CN": {
106
146
  "frustration.estimate-note":
@@ -194,5 +234,42 @@ export const frustration = {
194
234
  "frustration.cancelled": "已取消",
195
235
  "frustration.failed": "失败",
196
236
  "frustration.done": "完成",
237
+ "frustration.model-side-title": "AI 崩溃度",
238
+ "frustration.model-side-description": "各模型版本自身回复的读感,统计窗口:{window}。",
239
+ "frustration.model-turns": "助手回合",
240
+ "frustration.model-turns-title": "时间范围内带文本的助手回复;仅含工具调用的回复与思考块不参与分析",
241
+ "frustration.model-breakdown": "崩溃率",
242
+ "frustration.model-breakdown-title": "至少带一个崩溃信号的已分析回合占比",
243
+ "frustration.model-mean": "信号 / 回合",
244
+ "frustration.model-mean-title": "每回合聚合均值:文本标记加上封顶的工具失败数",
245
+ "frustration.model-self-blame": "自责",
246
+ "frustration.model-self-blame-title": "包含道歉或认错的回合",
247
+ "frustration.model-restatement": "重复陈述",
248
+ "frustration.model-restatement-title": "以显式标记或与上一条回复高度相似的方式重复先前内容的回合",
249
+ "frustration.model-hedge": "含糊兜底",
250
+ "frustration.model-hedge-title": "带有含糊兜底的回合:不确定表述或建议替代方案",
251
+ "frustration.model-spiral": "工具失败螺旋",
252
+ "frustration.model-spiral-title": "紧随三个及以上工具失败之后的回合",
253
+ "frustration.model-rambling": "冗长",
254
+ "frustration.model-rambling-title": "超过冗长阈值的回合",
255
+ "frustration.model-unscored-line": "使用无词典语言的回合:{count} — 未评分,而非平静。",
256
+ "frustration.model-by-model": "各模型版本的 AI 崩溃度",
257
+ "frustration.model-table-title": "模型崩溃明细",
258
+ "frustration.model-by-model-description": "每个模型已分析回合中带崩溃信号的占比。色相 = 模型家族。",
259
+ "frustration.model-over-time": "AI 崩溃度随时间变化",
260
+ "frustration.model-over-time-description":
261
+ "每个时间桶的崩溃率:带信号的回合除以已分析回合。空白表示该桶没有回合。",
262
+ "frustration.model-sessions": "按崩溃度查看会话",
263
+ "frustration.model-sessions-description": "已分析助手回合最多的会话。",
264
+ "frustration.model-table-description": "比例为已分析助手回合的占比。",
265
+ "frustration.column-session": "会话",
266
+ "frustration.column-turns": "回合数",
267
+ "frustration.column-mean": "信号/回合",
268
+ "frustration.column-tool-errors": "工具错误",
269
+ "frustration.model-no-turns": "该时间范围内没有带文本的助手回合",
270
+ "frustration.model-try-longer-range": "试试更长的时间范围。",
271
+ "frustration.model-legend-breakdown": "崩溃率",
272
+ "frustration.turns-n": "{count} 个回合",
273
+ "frustration.model-hint-mean": "平均每回合 {value} 个信号",
197
274
  },
198
275
  } as const;
@@ -6,17 +6,21 @@ import {
6
6
  getFrustrationEstimate,
7
7
  startFrustrationRun,
8
8
  } from "../api";
9
- import { Chart, type ChartSeries, Legend, useHiddenSeries } from "../charts";
9
+ import { BarList, Chart, type ChartSeries, Legend, TimeChart, useHiddenSeries } from "../charts";
10
10
  import { t } from "../i18n";
11
11
  import { SERIES_COLORS } from "../data/colors";
12
12
  import { formatCost, formatInteger, formatPercent } from "../data/formatters";
13
13
  import { useQuery } from "../data/query";
14
- import { rangeMeta } from "../data/range";
14
+ import { bucketAxis, rangeMeta } from "../data/range";
15
15
  import type {
16
16
  FrustrationCounts,
17
17
  FrustrationEstimate,
18
18
  FrustrationJobStatus,
19
19
  FrustrationModelStats,
20
+ ModelSideCounts,
21
+ ModelSideModelStats,
22
+ ModelSideSessionStats,
23
+ ModelSideTimeSeriesPoint,
20
24
  TimeRange,
21
25
  } from "../types";
22
26
  import {
@@ -110,6 +114,47 @@ export function FrustrationRoute({ active, range }: FrustrationRouteProps) {
110
114
  >
111
115
  {data => <FrustrationModelsSection models={data.byModel} stale={stats.stale} />}
112
116
  </QueryView>
117
+
118
+ {/* Model side: the same page, second section. Its signals are
119
+ language-neutral lexicons computed at ingest, so it needs no
120
+ judge run and appears immediately. */}
121
+ <QueryView query={stats} skeleton={<ChartSkeleton height={96} />}>
122
+ {data => (
123
+ <div className="frustration-model-summary" data-stale={stats.stale}>
124
+ <ModelSideSummary overall={data.modelSide.overall} window={t(meta.windowKey)} />
125
+ </div>
126
+ )}
127
+ </QueryView>
128
+
129
+ <QueryView
130
+ query={stats}
131
+ skeleton={
132
+ <Card index={4} title={t("frustration.model-by-model")}>
133
+ <ChartSkeleton height={300} />
134
+ <TableSkeleton rows={5} />
135
+ </Card>
136
+ }
137
+ >
138
+ {data => (
139
+ <ModelSideModels
140
+ models={data.modelSide.byModel}
141
+ series={data.modelSide.series}
142
+ range={range}
143
+ stale={stats.stale}
144
+ />
145
+ )}
146
+ </QueryView>
147
+
148
+ <QueryView
149
+ query={stats}
150
+ skeleton={
151
+ <Card index={7} title={t("frustration.model-sessions")}>
152
+ <TableSkeleton rows={5} />
153
+ </Card>
154
+ }
155
+ >
156
+ {data => <ModelSideSessions sessions={data.modelSide.bySession} stale={stats.stale} />}
157
+ </QueryView>
113
158
  </div>
114
159
  );
115
160
  }
@@ -497,7 +542,7 @@ function JudgeResultLine({ job }: { job: FrustrationJobStatus }) {
497
542
  // ---------------------------------------------------------------------------
498
543
 
499
544
  /** Chart/filter identity of a model family: `class/family`. */
500
- function familyKey(row: FrustrationModelStats): string {
545
+ function familyKey(row: { modelClass: string; family: string | null }): string {
501
546
  return `${row.modelClass}/${row.family ?? "unclassified"}`;
502
547
  }
503
548
 
@@ -520,7 +565,7 @@ function hashString(value: string): number {
520
565
  * and probes forward past taken slots, so families present together stay
521
566
  * distinct (until the palette runs out) and keep their hue across ranges.
522
567
  */
523
- function assignFamilyColors(models: readonly FrustrationModelStats[]): Map<string, string> {
568
+ function assignFamilyColors(models: readonly { modelClass: string; family: string | null }[]): Map<string, string> {
524
569
  const keys = [...new Set(models.map(familyKey))].sort();
525
570
  const taken = new Set<number>();
526
571
  const colors = new Map<string, string>();
@@ -937,3 +982,393 @@ function FrustrationTable({
937
982
  </Card>
938
983
  );
939
984
  }
985
+
986
+ // ---------------------------------------------------------------------------
987
+ // Model side (AI breakdown)
988
+ // ---------------------------------------------------------------------------
989
+
990
+ function ModelSideSummary({ overall, window }: { overall: ModelSideCounts; window: string }) {
991
+ const mean = overall.messages > 0 ? (overall.collapse / overall.messages).toFixed(2) : "–";
992
+ return (
993
+ <>
994
+ <div className="card-titles">
995
+ <h2 className="page-title">{t("frustration.model-side-title")}</h2>
996
+ <p className="page-description">{t("frustration.model-side-description", { window })}</p>
997
+ </div>
998
+ <div className="frustration-model-notes">
999
+ <StatGrid min={170}>
1000
+ <Stat
1001
+ label={t("frustration.model-turns")}
1002
+ title={t("frustration.model-turns-title")}
1003
+ value={formatInteger(overall.messages)}
1004
+ />
1005
+ <Stat
1006
+ label={t("frustration.model-breakdown")}
1007
+ title={t("frustration.model-breakdown-title")}
1008
+ value={rate(overall.breakdown, overall.messages)}
1009
+ hint={t("frustration.turns-n", { count: formatInteger(overall.breakdown) })}
1010
+ />
1011
+ <Stat
1012
+ label={t("frustration.model-mean")}
1013
+ title={t("frustration.model-mean-title")}
1014
+ value={mean}
1015
+ hint={t("frustration.model-hint-mean", { value: mean })}
1016
+ />
1017
+ <Stat
1018
+ label={t("frustration.model-self-blame")}
1019
+ title={t("frustration.model-self-blame-title")}
1020
+ value={rate(overall.blamed, overall.messages)}
1021
+ />
1022
+ <Stat
1023
+ label={t("frustration.model-hedge")}
1024
+ title={t("frustration.model-hedge-title")}
1025
+ value={rate(overall.hedged, overall.messages)}
1026
+ />
1027
+ <Stat
1028
+ label={t("frustration.model-spiral")}
1029
+ title={t("frustration.model-spiral-title")}
1030
+ value={rate(overall.spiraled, overall.messages)}
1031
+ />
1032
+ </StatGrid>
1033
+ {overall.unscored > 0 && (
1034
+ <p className="micro dim">
1035
+ {t("frustration.model-unscored-line", { count: formatInteger(overall.unscored) })}
1036
+ </p>
1037
+ )}
1038
+ </div>
1039
+ </>
1040
+ );
1041
+ }
1042
+
1043
+ /** Share of turns carrying a signal, as the 0..100 value a chart axis wants. */
1044
+ function signalShare(part: number, whole: number): number {
1045
+ return whole > 0 ? (part / whole) * 100 : 0;
1046
+ }
1047
+
1048
+ function ModelSideModels({
1049
+ models,
1050
+ series,
1051
+ range,
1052
+ stale,
1053
+ }: {
1054
+ models: ModelSideModelStats[];
1055
+ series: ModelSideTimeSeriesPoint[];
1056
+ range: TimeRange;
1057
+ stale: boolean;
1058
+ }) {
1059
+ const meta = rangeMeta(range);
1060
+ const familyColors = useMemo(() => assignFamilyColors(models), [models]);
1061
+ const buckets = useMemo(
1062
+ () =>
1063
+ bucketAxis(
1064
+ range,
1065
+ series.map(point => point.timestamp),
1066
+ meta.bucketMs,
1067
+ ),
1068
+ [series, range, meta.bucketMs],
1069
+ );
1070
+ const byBucket = useMemo(() => new Map(series.map(point => [point.timestamp, point])), [series]);
1071
+ const chartSeries = useMemo(
1072
+ (): ChartSeries[] => [
1073
+ {
1074
+ key: "breakdown",
1075
+ label: t("frustration.model-legend-breakdown"),
1076
+ color: "var(--chart-primary)",
1077
+ values: buckets.map(bucket => {
1078
+ const point = byBucket.get(bucket);
1079
+ // A bucket without turns is a gap, not a calm 0%.
1080
+ return point && point.messages > 0 ? signalShare(point.breakdown, point.messages) : null;
1081
+ }),
1082
+ },
1083
+ ],
1084
+ [buckets, byBucket],
1085
+ );
1086
+ const ranked = useMemo(
1087
+ () =>
1088
+ [...models]
1089
+ .sort(
1090
+ (a, b) =>
1091
+ signalShare(b.breakdown, b.messages) - signalShare(a.breakdown, a.messages) ||
1092
+ b.messages - a.messages,
1093
+ )
1094
+ .slice(0, 12),
1095
+ [models],
1096
+ );
1097
+
1098
+ if (models.length === 0) {
1099
+ return (
1100
+ <Card index={4} title={t("frustration.model-by-model")} stale={stale}>
1101
+ <EmptyState title={t("frustration.model-no-turns")} hint={t("frustration.model-try-longer-range")} />
1102
+ </Card>
1103
+ );
1104
+ }
1105
+
1106
+ return (
1107
+ <>
1108
+ <Card
1109
+ index={4}
1110
+ title={t("frustration.model-by-model")}
1111
+ description={t("frustration.model-by-model-description")}
1112
+ stale={stale}
1113
+ >
1114
+ <BarList
1115
+ max={100}
1116
+ items={ranked.map(row => ({
1117
+ key: row.key,
1118
+ label: row.label,
1119
+ value: signalShare(row.breakdown, row.messages),
1120
+ display: rate(row.breakdown, row.messages),
1121
+ color: familyColors.get(familyKey(row)) ?? SERIES_COLORS[0],
1122
+ }))}
1123
+ />
1124
+ </Card>
1125
+
1126
+ <Card
1127
+ index={5}
1128
+ title={t("frustration.model-over-time")}
1129
+ description={t("frustration.model-over-time-description")}
1130
+ actions={
1131
+ <Legend
1132
+ items={chartSeries.map(entry => ({
1133
+ key: entry.key,
1134
+ label: entry.label,
1135
+ color: entry.color,
1136
+ }))}
1137
+ />
1138
+ }
1139
+ stale={stale}
1140
+ >
1141
+ <TimeChart
1142
+ buckets={buckets}
1143
+ bucketMs={meta.bucketMs}
1144
+ series={chartSeries}
1145
+ height={260}
1146
+ yMax={100}
1147
+ format={value => `${value}%`}
1148
+ formatTooltip={value => `${value.toFixed(1)}%`}
1149
+ emptyLabel={t("frustration.model-no-turns")}
1150
+ />
1151
+ </Card>
1152
+
1153
+ <ModelSideTable rows={models} familyColors={familyColors} stale={stale} />
1154
+ </>
1155
+ );
1156
+ }
1157
+
1158
+ function ModelSideTable({
1159
+ rows,
1160
+ familyColors,
1161
+ stale,
1162
+ }: {
1163
+ rows: ModelSideModelStats[];
1164
+ familyColors: Map<string, string>;
1165
+ stale: boolean;
1166
+ }) {
1167
+ const columns = useMemo(
1168
+ (): Column<ModelSideModelStats>[] => [
1169
+ {
1170
+ key: "model",
1171
+ header: t("common.model"),
1172
+ sort: row => row.label,
1173
+ render: row => (
1174
+ <span className="row" title={row.key}>
1175
+ <span
1176
+ className="swatch"
1177
+ style={{ background: familyColors.get(familyKey(row)) ?? SERIES_COLORS[0] }}
1178
+ />
1179
+ <span className="cell-primary">{row.label}</span>
1180
+ </span>
1181
+ ),
1182
+ },
1183
+ {
1184
+ key: "ids",
1185
+ header: t("frustration.column-model-ids"),
1186
+ wrap: true,
1187
+ render: row => <span className="mono micro dim">{row.models.join(", ")}</span>,
1188
+ },
1189
+ {
1190
+ key: "turns",
1191
+ header: t("frustration.column-turns"),
1192
+ align: "right",
1193
+ sort: row => row.messages,
1194
+ render: row => <span className="num">{formatInteger(row.messages)}</span>,
1195
+ },
1196
+ {
1197
+ key: "selfBlame",
1198
+ header: t("frustration.model-self-blame"),
1199
+ align: "right",
1200
+ sort: row => signalShare(row.blamed, row.messages),
1201
+ render: row => (
1202
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.blamed) })}>
1203
+ {rate(row.blamed, row.messages)}
1204
+ </span>
1205
+ ),
1206
+ },
1207
+ {
1208
+ key: "restatement",
1209
+ header: t("frustration.model-restatement"),
1210
+ title: t("frustration.model-restatement-title"),
1211
+ align: "right",
1212
+ sort: row => signalShare(row.restated, row.messages),
1213
+ render: row => (
1214
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.restated) })}>
1215
+ {rate(row.restated, row.messages)}
1216
+ </span>
1217
+ ),
1218
+ },
1219
+ {
1220
+ key: "hedge",
1221
+ header: t("frustration.model-hedge"),
1222
+ align: "right",
1223
+ sort: row => signalShare(row.hedged, row.messages),
1224
+ render: row => (
1225
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.hedged) })}>
1226
+ {rate(row.hedged, row.messages)}
1227
+ </span>
1228
+ ),
1229
+ },
1230
+ {
1231
+ key: "rambling",
1232
+ header: t("frustration.model-rambling"),
1233
+ title: t("frustration.model-rambling-title"),
1234
+ align: "right",
1235
+ sort: row => signalShare(row.verbose, row.messages),
1236
+ render: row => (
1237
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.verbose) })}>
1238
+ {rate(row.verbose, row.messages)}
1239
+ </span>
1240
+ ),
1241
+ },
1242
+ {
1243
+ key: "spiral",
1244
+ header: t("frustration.model-spiral"),
1245
+ align: "right",
1246
+ sort: row => signalShare(row.spiraled, row.messages),
1247
+ render: row => (
1248
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.spiraled) })}>
1249
+ {rate(row.spiraled, row.messages)}
1250
+ </span>
1251
+ ),
1252
+ },
1253
+ {
1254
+ key: "breakdown",
1255
+ header: t("frustration.model-breakdown"),
1256
+ align: "right",
1257
+ sort: row => signalShare(row.breakdown, row.messages),
1258
+ render: row => (
1259
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.breakdown) })}>
1260
+ {rate(row.breakdown, row.messages)}
1261
+ </span>
1262
+ ),
1263
+ },
1264
+ {
1265
+ key: "mean",
1266
+ header: t("frustration.column-mean"),
1267
+ align: "right",
1268
+ sort: row => (row.messages > 0 ? row.collapse / row.messages : 0),
1269
+ render: row => (
1270
+ <span className="num">{row.messages > 0 ? (row.collapse / row.messages).toFixed(2) : "–"}</span>
1271
+ ),
1272
+ },
1273
+ ],
1274
+ [familyColors],
1275
+ );
1276
+
1277
+ return (
1278
+ <Card
1279
+ index={6}
1280
+ title={t("frustration.model-table-title")}
1281
+ description={t("frustration.model-table-description")}
1282
+ flush
1283
+ stale={stale}
1284
+ >
1285
+ <Table
1286
+ rows={rows}
1287
+ rowKey={row => row.key}
1288
+ columns={columns}
1289
+ dense
1290
+ empty={<EmptyState title={t("frustration.model-no-turns")} />}
1291
+ />
1292
+ </Card>
1293
+ );
1294
+ }
1295
+
1296
+ function ModelSideSessions({ sessions, stale }: { sessions: ModelSideSessionStats[]; stale: boolean }) {
1297
+ const columns = useMemo(
1298
+ (): Column<ModelSideSessionStats>[] => [
1299
+ {
1300
+ key: "session",
1301
+ header: t("frustration.column-session"),
1302
+ render: row => (
1303
+ <span className="stack" title={row.sessionFile}>
1304
+ <span className="cell-primary mono micro">
1305
+ {row.sessionFile.split(/[\\/]/).pop() ?? row.sessionFile}
1306
+ </span>
1307
+ <span className="micro dim truncate">{row.folder}</span>
1308
+ </span>
1309
+ ),
1310
+ },
1311
+ {
1312
+ key: "models",
1313
+ header: t("common.model"),
1314
+ wrap: true,
1315
+ render: row => <span className="mono micro dim">{row.models.join(", ")}</span>,
1316
+ },
1317
+ {
1318
+ key: "turns",
1319
+ header: t("frustration.column-turns"),
1320
+ align: "right",
1321
+ sort: row => row.messages,
1322
+ render: row => <span className="num">{formatInteger(row.messages)}</span>,
1323
+ },
1324
+ {
1325
+ key: "breakdown",
1326
+ header: t("frustration.model-breakdown"),
1327
+ align: "right",
1328
+ sort: row => signalShare(row.breakdown, row.messages),
1329
+ render: row => (
1330
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.breakdown) })}>
1331
+ {rate(row.breakdown, row.messages)}
1332
+ </span>
1333
+ ),
1334
+ },
1335
+ {
1336
+ key: "mean",
1337
+ header: t("frustration.column-mean"),
1338
+ align: "right",
1339
+ sort: row => (row.messages > 0 ? row.collapse / row.messages : 0),
1340
+ render: row => (
1341
+ <span className="num">{row.messages > 0 ? (row.collapse / row.messages).toFixed(2) : "–"}</span>
1342
+ ),
1343
+ },
1344
+ {
1345
+ key: "toolErrors",
1346
+ header: t("frustration.column-tool-errors"),
1347
+ align: "right",
1348
+ sort: row => row.toolErrors,
1349
+ render: row => (
1350
+ <span className={row.toolErrors > 0 ? "num tone-bad" : "num"}>{formatInteger(row.toolErrors)}</span>
1351
+ ),
1352
+ },
1353
+ ],
1354
+ [],
1355
+ );
1356
+
1357
+ return (
1358
+ <Card
1359
+ index={7}
1360
+ title={t("frustration.model-sessions")}
1361
+ description={t("frustration.model-sessions-description")}
1362
+ flush
1363
+ stale={stale}
1364
+ >
1365
+ <Table
1366
+ rows={sessions}
1367
+ rowKey={row => row.sessionFile}
1368
+ columns={columns}
1369
+ dense
1370
+ empty={<EmptyState title={t("frustration.model-no-turns")} />}
1371
+ />
1372
+ </Card>
1373
+ );
1374
+ }
@@ -121,3 +121,16 @@
121
121
  justify-content: flex-end;
122
122
  gap: 6px;
123
123
  }
124
+
125
+ /* Model-side summary: section header above the stat tiles. */
126
+ .frustration-model-summary {
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 20px;
130
+ }
131
+
132
+ .frustration-model-notes {
133
+ display: flex;
134
+ flex-direction: column;
135
+ gap: 8px;
136
+ }