niceeval 0.6.2 → 0.7.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.
Files changed (216) hide show
  1. package/INDEX.md +23 -23
  2. package/README.zh.md +6 -6
  3. package/dist/agents/types.d.ts +2 -2
  4. package/dist/i18n/zh-CN.d.ts +3 -3
  5. package/dist/report/aggregate.d.ts +32 -26
  6. package/dist/report/aggregate.js +157 -76
  7. package/dist/report/built-in/index.d.ts +2 -0
  8. package/dist/report/built-in/index.js +8 -0
  9. package/dist/report/components.d.ts +91 -164
  10. package/dist/report/components.js +377 -114
  11. package/dist/report/compute.d.ts +86 -73
  12. package/dist/report/compute.js +592 -432
  13. package/dist/report/flag.d.ts +28 -17
  14. package/dist/report/flag.js +86 -16
  15. package/dist/report/format.d.ts +11 -11
  16. package/dist/report/format.js +17 -15
  17. package/dist/report/index.d.ts +16 -17
  18. package/dist/report/index.js +20 -22
  19. package/dist/report/load.js +3 -2
  20. package/dist/report/locale.d.ts +49 -34
  21. package/dist/report/locale.js +106 -58
  22. package/dist/report/metrics.d.ts +10 -3
  23. package/dist/report/metrics.js +46 -12
  24. package/dist/report/primitives.d.ts +42 -15
  25. package/dist/report/primitives.js +135 -26
  26. package/dist/report/react/AttemptList.d.ts +10 -8
  27. package/dist/report/react/AttemptList.js +18 -10
  28. package/dist/report/react/DeltaTable.js +19 -18
  29. package/dist/report/react/EvalList.d.ts +3 -3
  30. package/dist/report/react/EvalList.js +0 -0
  31. package/dist/report/react/ExperimentComparison.d.ts +4 -2
  32. package/dist/report/react/ExperimentComparison.js +5 -4
  33. package/dist/report/react/ExperimentList.d.ts +3 -3
  34. package/dist/report/react/ExperimentList.js +16 -15
  35. package/dist/report/react/MetricBars.js +5 -4
  36. package/dist/report/react/MetricLine.js +12 -5
  37. package/dist/report/react/MetricMatrix.js +1 -1
  38. package/dist/report/react/MetricScatter.js +54 -17
  39. package/dist/report/react/MetricTable.js +2 -12
  40. package/dist/report/react/ScopeSummary.d.ts +10 -0
  41. package/dist/report/react/ScopeSummary.js +17 -0
  42. package/dist/report/react/Scoreboard.js +6 -6
  43. package/dist/report/react/cell.js +2 -2
  44. package/dist/report/react/fixtures.d.ts +5 -9
  45. package/dist/report/react/fixtures.js +105 -149
  46. package/dist/report/react/index.d.ts +15 -5
  47. package/dist/report/react/index.js +18 -7
  48. package/dist/report/report.d.ts +137 -20
  49. package/dist/report/report.js +261 -34
  50. package/dist/report/text/faces.d.ts +17 -19
  51. package/dist/report/text/faces.js +225 -157
  52. package/dist/report/text/plot.js +1 -1
  53. package/dist/report/text/table.js +2 -2
  54. package/dist/report/tree.d.ts +90 -40
  55. package/dist/report/tree.js +252 -94
  56. package/dist/report/types.d.ts +245 -300
  57. package/dist/report/types.js +4 -3
  58. package/dist/report/web.d.ts +21 -5
  59. package/dist/report/web.js +42 -16
  60. package/dist/results/select.d.ts +38 -16
  61. package/dist/results/select.js +73 -25
  62. package/dist/results/types.d.ts +38 -14
  63. package/dist/shared/aggregate.d.ts +3 -2
  64. package/dist/shared/aggregate.js +5 -4
  65. package/docs-site/zh/README.md +44 -0
  66. package/docs-site/zh/examples/ai-agent-application.mdx +63 -0
  67. package/docs-site/zh/examples/coding-agent-extensions.mdx +57 -0
  68. package/docs-site/zh/examples/index.mdx +50 -0
  69. package/docs-site/zh/{concepts → explanation}/adapter.mdx +11 -11
  70. package/docs-site/zh/{concepts → explanation}/assert.mdx +7 -7
  71. package/docs-site/zh/{concepts → explanation}/drive.mdx +8 -8
  72. package/docs-site/zh/{concepts → explanation}/evals.mdx +4 -4
  73. package/docs-site/zh/{concepts → explanation}/experiment.mdx +8 -8
  74. package/docs-site/zh/{concepts → explanation}/hitl.mdx +8 -8
  75. package/docs-site/zh/{concepts → explanation}/judge.mdx +5 -5
  76. package/docs-site/zh/{concepts → explanation}/overview.mdx +5 -5
  77. package/docs-site/zh/{guides → explanation}/runner.mdx +1 -1
  78. package/docs-site/zh/{concepts → explanation}/tier.mdx +6 -6
  79. package/docs-site/zh/{guides → how-to}/agent-feedback-loop.mdx +7 -7
  80. package/docs-site/zh/{guides → how-to}/authoring.mdx +2 -2
  81. package/docs-site/zh/{guides → how-to}/connect-otel.mdx +6 -6
  82. package/docs-site/zh/{guides → how-to}/connect-your-agent.mdx +18 -18
  83. package/docs-site/zh/{guides → how-to}/custom-reports.mdx +6 -6
  84. package/docs-site/zh/{guides → how-to}/experiments.mdx +3 -3
  85. package/docs-site/zh/{guides → how-to}/publish-report.mdx +2 -2
  86. package/docs-site/zh/{guides → how-to}/sandbox-agent.mdx +2 -2
  87. package/docs-site/zh/{guides → how-to}/sandbox-providers.mdx +1 -1
  88. package/docs-site/zh/{guides → how-to}/viewing-results.mdx +6 -6
  89. package/docs-site/zh/{guides → how-to}/write-experiment.mdx +3 -3
  90. package/docs-site/zh/{guides → how-to}/write-send.mdx +13 -13
  91. package/docs-site/zh/index.mdx +23 -25
  92. package/docs-site/zh/introduction.mdx +8 -8
  93. package/docs-site/zh/reference/builtin-agents.mdx +5 -5
  94. package/docs-site/zh/reference/capabilities.mdx +6 -6
  95. package/docs-site/zh/reference/cli.mdx +9 -7
  96. package/docs-site/zh/reference/define-agent.mdx +1 -1
  97. package/docs-site/zh/reference/events.mdx +3 -3
  98. package/docs-site/zh/{guides → reference}/official-adapters.mdx +7 -7
  99. package/docs-site/zh/{guides → reference}/report-components.mdx +5 -5
  100. package/docs-site/zh/{guides → reference}/results-data.mdx +5 -5
  101. package/docs-site/zh/{guides → troubleshooting}/debug-sandbox.mdx +2 -2
  102. package/docs-site/zh/{guides → troubleshooting}/debugging.mdx +4 -2
  103. package/docs-site/zh/{quickstart.mdx → tutorials/quickstart.mdx} +5 -17
  104. package/package.json +6 -2
  105. package/src/agents/index.ts +2 -2
  106. package/src/agents/openai-compat.ts +1 -1
  107. package/src/agents/streaming.ts +2 -2
  108. package/src/agents/types.ts +3 -3
  109. package/src/cli.ts +42 -23
  110. package/src/context/context.ts +1 -1
  111. package/src/context/session.test.ts +1 -1
  112. package/src/context/session.ts +1 -1
  113. package/src/i18n/en.ts +18 -16
  114. package/src/i18n/zh-CN.ts +16 -15
  115. package/src/report/aggregate.ts +175 -87
  116. package/src/report/built-in/index.tsx +9 -0
  117. package/src/report/components.tsx +625 -285
  118. package/src/report/compute.ts +717 -515
  119. package/src/report/dual-render.test.tsx +738 -1148
  120. package/src/report/flag.ts +97 -33
  121. package/src/report/format.ts +18 -22
  122. package/src/report/index.ts +113 -58
  123. package/src/report/load.ts +3 -2
  124. package/src/report/locale.ts +120 -69
  125. package/src/report/metrics.ts +42 -12
  126. package/src/report/primitives.tsx +190 -45
  127. package/src/report/react/AttemptList.tsx +32 -20
  128. package/src/report/react/DeltaTable.tsx +63 -45
  129. package/src/report/react/EvalList.tsx +0 -0
  130. package/src/report/react/ExperimentComparison.tsx +12 -7
  131. package/src/report/react/ExperimentList.tsx +38 -26
  132. package/src/report/react/MetricBars.tsx +5 -4
  133. package/src/report/react/MetricLine.tsx +13 -8
  134. package/src/report/react/MetricMatrix.tsx +2 -2
  135. package/src/report/react/MetricScatter.tsx +74 -20
  136. package/src/report/react/MetricTable.tsx +4 -76
  137. package/src/report/react/ScopeSummary.tsx +86 -0
  138. package/src/report/react/Scoreboard.tsx +28 -10
  139. package/src/report/react/cell.tsx +2 -2
  140. package/src/report/react/enhance.js +57 -5
  141. package/src/report/react/fixtures.ts +109 -156
  142. package/src/report/react/index.tsx +24 -39
  143. package/src/report/react/render.test.tsx +139 -104
  144. package/src/report/react/styles.css +181 -91
  145. package/src/report/report.test.ts +761 -1031
  146. package/src/report/report.ts +425 -47
  147. package/src/report/text/faces.ts +257 -164
  148. package/src/report/text/plot.ts +1 -1
  149. package/src/report/text/table.ts +2 -2
  150. package/src/report/tree.ts +362 -104
  151. package/src/report/types.ts +257 -287
  152. package/src/report/web.ts +63 -20
  153. package/src/results/attempt-evidence.test.ts +4 -4
  154. package/src/results/attempt-evidence.ts +5 -5
  155. package/src/results/copy.ts +6 -6
  156. package/src/results/host-equivalence.test.ts +26 -14
  157. package/src/results/index.ts +10 -4
  158. package/src/results/open.ts +8 -4
  159. package/src/results/results.test.ts +4 -3
  160. package/src/results/select.ts +104 -34
  161. package/src/results/types.ts +36 -14
  162. package/src/runner/feedback/human.test.ts +1 -1
  163. package/src/runner/run.ts +1 -1
  164. package/src/sandbox/cli-commands.ts +2 -2
  165. package/src/scoring/judge.test.ts +1 -1
  166. package/src/shared/aggregate.ts +5 -4
  167. package/src/show/compose.ts +50 -67
  168. package/src/show/index.ts +107 -56
  169. package/src/show/render.ts +43 -27
  170. package/src/show/report-host.test.ts +188 -0
  171. package/src/show/report-host.ts +375 -0
  172. package/src/show/show.test.ts +86 -36
  173. package/src/view/app/App.test.tsx +69 -0
  174. package/src/view/app/App.tsx +144 -48
  175. package/src/view/app/components/AttemptModal.tsx +324 -63
  176. package/src/view/app/components/CodeView.tsx +10 -4
  177. package/src/view/app/i18n.ts +31 -17
  178. package/src/view/app/main.tsx +13 -8
  179. package/src/view/app/pages/{RunsPage.tsx → AttemptsPage.tsx} +6 -6
  180. package/src/view/app/types.ts +4 -1
  181. package/src/view/client-dist/app.css +1 -1
  182. package/src/view/client-dist/app.js +14 -14
  183. package/src/view/data.test.ts +9 -3
  184. package/src/view/data.ts +145 -49
  185. package/src/view/index.ts +48 -44
  186. package/src/view/server.ts +35 -15
  187. package/src/view/shared/types.ts +34 -5
  188. package/src/view/styles.css +224 -0
  189. package/src/view/view-report.test.ts +161 -57
  190. package/dist/report/built-ins/experiment-comparison.d.ts +0 -39
  191. package/dist/report/built-ins/experiment-comparison.js +0 -119
  192. package/dist/report/built-ins/index.d.ts +0 -2
  193. package/dist/report/built-ins/index.js +0 -2
  194. package/dist/report/react/GroupSummary.d.ts +0 -8
  195. package/dist/report/react/GroupSummary.js +0 -8
  196. package/dist/report/react/RunOverview.d.ts +0 -8
  197. package/dist/report/react/RunOverview.js +0 -12
  198. package/docs-site/zh/example/ai-agent-application.mdx +0 -152
  199. package/docs-site/zh/example/claude-code-codex-plugin.mdx +0 -167
  200. package/docs-site/zh/example/claude-code-codex-skill.mdx +0 -152
  201. package/docs-site/zh/example/showcase.mdx +0 -39
  202. package/src/report/built-in-user-parity.test.tsx +0 -597
  203. package/src/report/built-ins/experiment-comparison.tsx +0 -179
  204. package/src/report/built-ins/index.ts +0 -7
  205. package/src/report/react/GroupSummary.tsx +0 -66
  206. package/src/report/react/RunOverview.tsx +0 -109
  207. /package/docs-site/zh/{example/tier1-ai-sdk-v7.mdx → examples/integrations/ai-sdk-v7.mdx} +0 -0
  208. /package/docs-site/zh/{example/tier1-claude-sdk.mdx → examples/integrations/claude-sdk.mdx} +0 -0
  209. /package/docs-site/zh/{example/tier1-codex-sdk.mdx → examples/integrations/codex-sdk.mdx} +0 -0
  210. /package/docs-site/zh/{example/tier1-langgraph.mdx → examples/integrations/langgraph.mdx} +0 -0
  211. /package/docs-site/zh/{example/tier1-pi-sdk.mdx → examples/integrations/pi-sdk.mdx} +0 -0
  212. /package/docs-site/zh/{guides → how-to}/ci-integration.mdx +0 -0
  213. /package/docs-site/zh/{guides → how-to}/dataset-fanout.mdx +0 -0
  214. /package/docs-site/zh/{guides → how-to}/fixtures.mdx +0 -0
  215. /package/docs-site/zh/{guides → how-to}/reporters.mdx +0 -0
  216. /package/docs-site/zh/{guides → how-to}/scoring-guide.mdx +0 -0
@@ -1,194 +1,221 @@
1
1
  // 官方组件的 text 面:同一份算好的数据,渲染成终端字符(niceeval show 的形态)。
2
- // 输出形态照 docs-site/zh/guides/report-components.mdx 的示例块;与 web 面共守
3
- // 诚实契约:排序随 better、samples < total 角标、缺数据 — 不补 0、截断报剩余。
2
+ // web 面共守诚实契约:排序随 better、samples < total 角标、缺数据 — 不补 0、截断报剩余。
4
3
  // 零 react、零 IO、纯同步 —— 这是 text 宿主不需要 react-dom 的那一半。
5
- // chrome 文案(注脚、verdict 词、截断提示)经 ctx.locale 查 locale 字典,
6
- // 默认 en 与历史输出逐字一致;数据(display、键、warnings message)不本地化。
4
+ // chrome 文案(注脚、verdict 词、截断提示)经 ctx.locale 查 locale 字典;
5
+ // 数据 display 是 LocalizedText,按 LocalizedText 回退规则取值。
7
6
 
8
7
  import type {
9
8
  AttemptListItem,
10
9
  DeltaData,
11
10
  EvalListItem,
11
+ ExperimentComparisonData,
12
+ ExperimentComparisonGroupData,
12
13
  ExperimentListItem,
13
- GroupSummaryData,
14
14
  LineData,
15
15
  MatrixData,
16
+ MetricCell,
16
17
  MetricColumn,
17
- OverviewData,
18
18
  ScatterData,
19
+ ScopeSummaryData,
19
20
  ScoreboardData,
20
21
  TableData,
22
+ VerdictTally,
21
23
  } from "../types.ts";
22
24
  import type { TextContext } from "../tree.ts";
23
25
  import type { TableColumn, TableRow } from "../primitives.tsx";
24
26
  import {
25
- attemptItemReason,
26
27
  experimentDisplayName,
28
+ fitFailureSummary,
27
29
  formatDurationMs,
28
30
  formatMetricValue,
29
31
  formatPlainNumber,
30
32
  formatUSD,
31
33
  verdictMark,
32
34
  } from "../format.ts";
33
- import { countText, localeText, resolveMetricLabel, type ReportLocale } from "../locale.ts";
35
+ import {
36
+ countText,
37
+ localeText,
38
+ resolveLocalizedText,
39
+ resolveMetricLabel,
40
+ type ReportLocale,
41
+ } from "../locale.ts";
34
42
  import { indentBlock, padDisplay, stringWidth, textBar, wrapDisplay } from "./layout.ts";
35
43
  import { renderTableText } from "./table.ts";
36
44
  import { renderCharPlot, renderCoordinateTable, type PlotPoint } from "./plot.ts";
37
45
 
38
46
  const MISSING_MARK = "—";
39
47
 
40
- /** 缺数据文案随 locale(en = "no data",与 MISSING_TEXT 一致)。 */
48
+ /** 缺数据文案随 locale(en = "no data")。 */
41
49
  function missingText(locale: ReportLocale): string {
42
50
  return localeText(locale, "cell.missing");
43
51
  }
44
52
 
45
- /** 格子的文本形态:缺数据 —,覆盖不全带 samples/total 角标。 */
46
- export function cellText(cell: { value: number | null; display: string; samples: number; total: number }): string {
53
+ /** 格子的文本形态:缺数据 —,覆盖不全带 samples/total 角标;display 按 locale 解析。 */
54
+ export function cellText(cell: MetricCell, locale: ReportLocale): string {
47
55
  if (cell.value === null) return MISSING_MARK;
48
- return cell.samples < cell.total ? `${cell.display} ${cell.samples}/${cell.total}` : cell.display;
56
+ const display = resolveLocalizedText(cell.display, locale);
57
+ return cell.samples < cell.total ? `${display} ${cell.samples}/${cell.total}` : display;
49
58
  }
50
59
 
51
- // ───────────────────────── RunOverview ─────────────────────────
60
+ /** verdict 计票的紧凑文案("3 passed / 1 failed"):非零判定逐个列,全部为零如实 —。 */
61
+ export function verdictTallyText(tally: VerdictTally, locale: ReportLocale): string {
62
+ const parts: string[] = [];
63
+ for (const kind of ["passed", "failed", "errored", "skipped"] as const) {
64
+ if (tally[kind] > 0) parts.push(`${tally[kind]} ${localeText(locale, `verdict.${kind}`)}`);
65
+ }
66
+ return parts.length > 0 ? parts.join(" / ") : MISSING_MARK;
67
+ }
52
68
 
53
- export function overviewText(data: OverviewData, ctx: TextContext): string {
54
- const { totals, snapshots } = data;
55
- const locale = ctx.locale;
56
- const runs = new Set(snapshots.map((s) => s.startedAt)).size;
57
- const latest = snapshots.map((s) => s.startedAt).sort().at(-1);
58
- const head = [
59
- countText(locale, "overview.experiments", snapshots.length),
60
- localeText(locale, "overview.evalsCount", { n: totals.evals }),
61
- localeText(locale, "overview.attemptsCount", { n: totals.attempts }),
62
- // 通过率只渲染 computeCell 算好的同一个 MetricCell(cellText 复用缺数据/coverage 角标语义),
63
- // 不从 passed/failed/errored 现场重算——与 web 面 RunOverview 同一份数据同一种读法。
64
- `${localeText(locale, "overview.passRate")} ${cellText(totals.passRate)}`,
65
- countText(locale, "composedFrom", runs),
66
- ...(latest ? [localeText(locale, "latestRun", { run: latest })] : []),
67
- ].join(" · ");
68
- const tallies = [
69
- `${localeText(locale, "verdict.passed")} ${totals.passed}`,
70
- `${localeText(locale, "verdict.failed")} ${totals.failed}`,
71
- `${localeText(locale, "verdict.errored")} ${totals.errored}`,
72
- `${localeText(locale, "verdict.skipped")} ${totals.skipped}`,
73
- totals.costUSD === null ? missingText(locale) : formatUSD(totals.costUSD),
74
- formatDurationMs(totals.durationMs),
75
- ].join(" · ");
76
- const lines = [head, tallies];
77
- for (const warning of data.warnings) lines.push(`! ${warning.message}`);
78
- return lines.join("\n");
69
+ /** ISO 时间 "YYYY-MM-DD HH:mm"(本地时区);不可解析原样返回。 */
70
+ function formatDateTimeMinute(iso: string): string {
71
+ const date = new Date(iso);
72
+ if (Number.isNaN(date.valueOf())) return iso;
73
+ const pad = (n: number) => String(n).padStart(2, "0");
74
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
79
75
  }
80
76
 
81
- // ───────────────────────── GroupSummary ─────────────────────────
77
+ // ───────────────────────── ScopeSummary ─────────────────────────
82
78
 
83
79
  /**
84
- * 一至两行:头行是通过率(GroupSummaryData.passRate.display,不重算比例)+ experiment/eval 数 +
85
- * failed(+ errored,非零才列,与旧 GroupSelector 卡片一致)+ 总成本;第二行(有则加)是最后运行时间。
86
- * 不依赖固定网格宽度,窄终端自然换行。
80
+ * 一至两行:头行是端到端成功率(官方 MetricCell,不重算)+ experiment/eval/attempt 数 +
81
+ * `votes` 选中的那级计票 + 总成本;第二行(有则加)是快照时间窗。
87
82
  */
88
- export function groupSummaryText(data: GroupSummaryData, ctx: TextContext): string {
83
+ export function scopeSummaryText(data: ScopeSummaryData, votes: "eval" | "attempt", ctx: TextContext): string {
89
84
  const locale = ctx.locale;
85
+ const tally = votes === "attempt" ? data.attemptVerdicts : data.evalVerdicts;
90
86
  const head = [
91
- `${localeText(locale, "overview.passRate")} ${cellText(data.passRate)}`,
87
+ `${localeText(locale, "scopeSummary.passRate")} ${cellText(data.endToEndPassRate, locale)}`,
92
88
  countText(locale, "overview.experiments", data.experiments),
93
89
  localeText(locale, "overview.evalsCount", { n: data.evals }),
94
- `${localeText(locale, "verdict.failed")} ${data.verdicts.failed}`,
95
- ...(data.verdicts.errored > 0 ? [`${localeText(locale, "verdict.errored")} ${data.verdicts.errored}`] : []),
96
- data.totalCostUSD === null ? missingText(locale) : formatUSD(data.totalCostUSD),
90
+ localeText(locale, "overview.attemptsCount", { n: data.attempts }),
91
+ verdictTallyText(tally, locale),
92
+ `${localeText(locale, "scopeSummary.totalCost")} ${cellText(data.totalCostUSD, locale)}`,
97
93
  ].join(" · ");
98
94
  const lines = [head];
99
- if (data.lastRunAt) lines.push(localeText(locale, "latestRun", { run: data.lastRunAt }));
95
+ if (data.range.latestStartedAt !== null) {
96
+ const from = data.range.earliestStartedAt;
97
+ const to = data.range.latestStartedAt;
98
+ lines.push(
99
+ from !== null && from !== to
100
+ ? localeText(locale, "scopeSummary.range", { from: formatDateTimeMinute(from), to: formatDateTimeMinute(to) })
101
+ : formatDateTimeMinute(to),
102
+ );
103
+ }
100
104
  return lines.join("\n");
101
105
  }
102
106
 
103
- // ───────────────────────── MetricTable ─────────────────────────
107
+ // ───────────────────────── ExperimentComparison ─────────────────────────
108
+
109
+ /** 单组详情:标题行 + 缩进的散点与实验列表(与 web 面同一份预分区数据)。 */
110
+ function comparisonGroupText(group: ExperimentComparisonGroupData, ctx: TextContext): string {
111
+ const inner = Math.max(20, ctx.width - 2);
112
+ const innerCtx: TextContext = {
113
+ width: inner,
114
+ locale: ctx.locale,
115
+ attemptCommand: ctx.attemptCommand,
116
+ experimentCommand: ctx.experimentCommand,
117
+ render: (node, w) => ctx.render(node, w ?? inner),
118
+ };
119
+ const body = [scatterText(group.scatter, innerCtx), experimentListText(group.experiments, innerCtx, group.key)]
120
+ .filter((block) => block.length > 0)
121
+ .join("\n\n");
122
+ return `${localeText(ctx.locale, "experimentComparison.group")} ${group.key}\n\n${indentBlock(body, " ")}`;
123
+ }
104
124
 
105
- /** verdict 计票的紧凑文案("3 passed / 1 failed"):非零判定逐个列,全部为零如实 —。 */
106
- export function verdictTallyText(
107
- verdicts: NonNullable<NonNullable<TableData["rows"][number]["meta"]>["verdicts"]>,
108
- locale: ReportLocale,
125
+ /**
126
+ * text 面:命中多个可比组时只输出组索引与可复制的单组查看命令,不倾倒全部组详情;
127
+ * 命中单组时省略索引,直接输出该组的散点与实验列表。
128
+ */
129
+ export function experimentComparisonText(
130
+ data: ExperimentComparisonData,
131
+ _className: string | undefined,
132
+ ctx: TextContext,
109
133
  ): string {
110
- const parts: string[] = [];
111
- for (const kind of ["passed", "failed", "errored", "skipped"] as const) {
112
- if (verdicts[kind] > 0) parts.push(`${verdicts[kind]} ${localeText(locale, `verdict.${kind}`)}`);
113
- }
114
- return parts.length > 0 ? parts.join(" / ") : MISSING_MARK;
134
+ const locale = ctx.locale;
135
+ if (data.groups.length === 0) return localeText(locale, "experimentComparison.empty");
136
+ if (data.groups.length === 1) return comparisonGroupText(data.groups[0]!, ctx);
137
+
138
+ const columns: TableColumn[] = [
139
+ { key: "group", header: localeText(locale, "experimentComparison.group") },
140
+ { key: "experiments", header: localeText(locale, "scopeSummary.experiments"), align: "right" },
141
+ { key: "evals", header: localeText(locale, "scopeSummary.evals"), align: "right" },
142
+ { key: "passRate", header: localeText(locale, "scopeSummary.passRate"), align: "right" },
143
+ { key: "results", header: localeText(locale, "experimentComparison.results") },
144
+ { key: "cost", header: localeText(locale, "scopeSummary.totalCost"), align: "right" },
145
+ { key: "lastRun", header: localeText(locale, "experimentComparison.lastRun") },
146
+ ] as unknown as TableColumn[];
147
+ const rows: TableRow[] = data.groups.map((group) => ({
148
+ key: group.key,
149
+ cells: {
150
+ group: group.key,
151
+ experiments: String(group.summary.experiments),
152
+ evals: String(group.summary.evals),
153
+ passRate: cellText(group.summary.endToEndPassRate, locale),
154
+ results: verdictTallyText(group.summary.evalVerdicts, locale),
155
+ cost: group.summary.totalCostUSD.value === null ? null : cellText(group.summary.totalCostUSD, locale),
156
+ lastRun: group.summary.range.latestStartedAt === null ? null : formatDateTimeMinute(group.summary.range.latestStartedAt),
157
+ },
158
+ }));
159
+ const table = renderTableText(
160
+ { columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale },
161
+ ctx,
162
+ );
163
+ const commands = [
164
+ localeText(locale, "experimentComparison.commandsHead"),
165
+ ...data.groups.map((group) => ` ${ctx.experimentCommand(group.key)}`),
166
+ ].join("\n");
167
+ return `${table}\n\n${commands}`;
115
168
  }
116
169
 
170
+ // ───────────────────────── MetricTable ─────────────────────────
171
+
117
172
  export function tableText(data: TableData, ctx: TextContext): string {
118
173
  const locale = ctx.locale;
119
- // meta 在场时补 Model / Agent / Verdicts 列(rows: "experiment" 的榜单 parity);
120
- // 列序对齐 view 原生榜单:experiment、model、agent、指标列…、verdicts
121
- const hasMeta = data.rows.some((row) => row.meta !== undefined);
122
- const hasModel = data.rows.some((row) => row.meta?.model !== undefined);
123
- const hasVerdicts = data.rows.some((row) => row.meta?.verdicts !== undefined);
124
174
  const columns: TableColumn[] = [
125
- { key: "dimension", header: data.dimension },
126
- ...(hasMeta && hasModel ? [{ key: "model", header: localeText(locale, "table.model") }] : []),
127
- ...(hasMeta ? [{ key: "agent", header: localeText(locale, "table.agent") }] : []),
128
- // 指标列的键用序号,不用 metric name —— 维度名、"model" 这些键都在同一个命名空间里,
129
- // 指标恰好叫 "agent" 时不能把 meta 列顶掉。
175
+ { key: "dimension", header: data.rowDimension },
176
+ // 指标列的键用序号,不用 metric name —— 维度名和指标名在同一个命名空间里,可能撞键。
130
177
  ...data.columns.map((c, i) => ({ key: `metric${i}`, header: resolveMetricLabel(c.label, locale, c.key) })),
131
- ...(hasVerdicts ? [{ key: "verdicts", header: localeText(locale, "table.verdicts") }] : []),
132
178
  ];
133
179
  const rows: TableRow[] = data.rows.map((row) => {
134
180
  const cells: Record<string, string | null> = { dimension: row.key };
135
- if (hasMeta && hasModel) cells.model = row.meta?.model ?? null;
136
- if (hasMeta) cells.agent = row.meta?.agent ?? null;
137
181
  data.columns.forEach((col, i) => {
138
- const cell = (row.cells as Record<string, TableData["rows"][number]["cells"][string]>)[col.key];
139
- cells[`metric${i}`] = cell ? cellText(cell) : null;
182
+ const cell = row.cells[col.key];
183
+ cells[`metric${i}`] = cell ? cellText(cell, locale) : null;
140
184
  });
141
- if (hasVerdicts) cells.verdicts = row.meta?.verdicts ? verdictTallyText(row.meta.verdicts, locale) : null;
142
185
  return { key: row.key, cells };
143
186
  });
144
- const table = renderTableText({ columns, rows, locale }, ctx);
145
-
146
- // rows: "experiment" 专属的行摘要(eval/attempt 数 + 最后运行时间):表格列已经挤满
147
- // dimension/model/agent/指标/verdicts,这几个数字挤进行键下面单独一行,与 web 面
148
- // 「行键下的小 sub-line」同一份信息、同一种「不进列」的取舍。
149
- const metaLines: string[] = [];
150
- for (const row of data.rows) {
151
- if (row.meta?.evals === undefined) continue;
152
- const parts = [localeText(locale, "overview.evalsCount", { n: row.meta.evals })];
153
- if (row.meta.attempts !== undefined && row.meta.attempts > row.meta.evals) {
154
- parts.push(localeText(locale, "overview.attemptsCount", { n: row.meta.attempts }));
155
- }
156
- if (row.meta.lastRunAt) parts.push(localeText(locale, "latestRun", { run: row.meta.lastRunAt }));
157
- metaLines.push(` ${row.key}: ${parts.join(" · ")}`);
158
- }
159
-
160
- const blocks = [table, ...(metaLines.length > 0 ? [metaLines.join("\n")] : [])];
161
- return blocks.join("\n\n");
187
+ return renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx);
162
188
  }
163
189
 
164
190
  // ───────────────────────── MetricMatrix ─────────────────────────
165
191
 
166
192
  export function matrixText(data: MatrixData, ctx: TextContext): string {
167
193
  // 表体全是维度键与 display,没有 chrome 文案;"next:" 是命令提示,不本地化。
194
+ const locale = ctx.locale;
168
195
  const rowKeys: string[] = [];
169
196
  const columnKeys: string[] = [];
170
- const byPosition = new Map<string, MatrixData["cells"][number]["cell"]>();
197
+ const byPosition = new Map<string, MetricCell>();
171
198
  for (const entry of data.cells) {
172
199
  if (!rowKeys.includes(entry.row)) rowKeys.push(entry.row);
173
200
  if (!columnKeys.includes(entry.column)) columnKeys.push(entry.column);
174
201
  byPosition.set(JSON.stringify([entry.row, entry.column]), entry.cell);
175
202
  }
176
203
  const columns: TableColumn[] = [
177
- { key: "dimension", header: data.rows },
204
+ { key: "dimension", header: data.rowDimension },
178
205
  ...columnKeys.map((column, i) => ({ key: `column${i}`, header: column })),
179
206
  ];
180
207
  const rows: TableRow[] = rowKeys.map((row) => {
181
208
  const cells: Record<string, string | null> = { dimension: row };
182
209
  columnKeys.forEach((column, i) => {
183
210
  const cell = byPosition.get(JSON.stringify([row, column]));
184
- cells[`column${i}`] = cell ? cellText(cell) : null; // 稀疏格子在文本里以 — 呈现,不编数
211
+ cells[`column${i}`] = cell ? cellText(cell, locale) : null; // 稀疏格子在文本里以 — 呈现,不编数
185
212
  });
186
213
  return { key: row, cells };
187
214
  });
188
- const table = renderTableText({ columns, rows, locale: ctx.locale }, ctx);
215
+ const table = renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx);
189
216
 
190
217
  // 下钻命令:行维度是 eval 时,指向最值得看的一行(先挑有缺格的,再挑按 better 最差的)
191
- if (data.rows !== "eval" || rowKeys.length === 0) return table;
218
+ if (data.rowDimension !== "eval" || rowKeys.length === 0) return table;
192
219
  const better = data.metric.better ?? "higher";
193
220
  let next: string | undefined;
194
221
  let worst: { key: string; value: number } | undefined;
@@ -218,10 +245,11 @@ export function matrixText(data: MatrixData, ctx: TextContext): string {
218
245
 
219
246
  const BAR_WIDTH = 20;
220
247
 
221
- export function barsText(data: MatrixData): string {
248
+ export function barsText(data: MatrixData, ctx: TextContext): string {
249
+ const locale = ctx.locale;
222
250
  const groupKeys: string[] = [];
223
251
  const seriesKeys: string[] = [];
224
- const byPosition = new Map<string, MatrixData["cells"][number]["cell"]>();
252
+ const byPosition = new Map<string, MetricCell>();
225
253
  for (const entry of data.cells) {
226
254
  if (!groupKeys.includes(entry.row)) groupKeys.push(entry.row);
227
255
  if (!seriesKeys.includes(entry.column)) seriesKeys.push(entry.column);
@@ -231,10 +259,9 @@ export function barsText(data: MatrixData): string {
231
259
  // 条长刻度:% 的天然域是 [0,1],其余以全图最大值为满条
232
260
  const values = data.cells.map((c) => c.cell.value).filter((v): v is number => v !== null);
233
261
  const maxValue = values.length > 0 ? Math.max(...values) : 0;
234
- const ratioOf = (value: number) =>
235
- data.metric.unit === "%" ? value : maxValue === 0 ? 0 : value / maxValue;
262
+ const ratioOf = (value: number) => (data.metric.unit === "%" ? value : maxValue === 0 ? 0 : value / maxValue);
236
263
 
237
- const seriesWidth = Math.max(...seriesKeys.map((k) => k.length), 0);
264
+ const seriesWidth = Math.max(...seriesKeys.map((k) => stringWidth(k)), 0);
238
265
  const lines: string[] = [];
239
266
  for (const group of groupKeys) {
240
267
  lines.push(group);
@@ -257,7 +284,7 @@ export function barsText(data: MatrixData): string {
257
284
  lines.push(` ${label} ${MISSING_MARK}`);
258
285
  continue;
259
286
  }
260
- lines.push(` ${label} ${textBar(ratioOf(cell.value), BAR_WIDTH)} ${cellText(cell)}`);
287
+ lines.push(` ${label} ${textBar(ratioOf(cell.value), BAR_WIDTH)} ${cellText(cell, locale)}`);
261
288
  }
262
289
  }
263
290
  return lines.join("\n");
@@ -274,14 +301,18 @@ export function scoreboardText(data: ScoreboardData, ctx: TextContext): string {
274
301
  }
275
302
  }
276
303
  const columns: TableColumn[] = [
277
- { key: "dimension", header: data.dimension },
304
+ { key: "dimension", header: data.rowDimension },
278
305
  { key: "total", header: localeText(locale, "scoreboard.totalText") },
279
306
  ...subjectKeys.map((key, i) => ({ key: `subject${i}`, header: key })),
280
307
  ];
281
308
  const rows: TableRow[] = data.rows.map((row) => {
309
+ const totalNotes = [
310
+ ...(row.total.notRun > 0 ? [localeText(locale, "scoreboard.notRunText", { n: row.total.notRun })] : []),
311
+ ...(row.total.unscorable > 0 ? [localeText(locale, "scoreboard.unscorableText", { n: row.total.unscorable })] : []),
312
+ ];
282
313
  const cells: Record<string, string | null> = {
283
314
  dimension: row.key,
284
- total: `${row.total.display}/${data.fullMarks}`,
315
+ total: `${resolveLocalizedText(row.total.display, locale)}/${data.fullMarks}${totalNotes.length > 0 ? ` ${totalNotes.join(" ")}` : ""}`,
285
316
  };
286
317
  subjectKeys.forEach((key, i) => {
287
318
  const subject = row.subjects.find((s) => s.key === key);
@@ -290,18 +321,22 @@ export function scoreboardText(data: ScoreboardData, ctx: TextContext): string {
290
321
  return;
291
322
  }
292
323
  const score = `${formatPlainNumber(subject.earned)}/${formatPlainNumber(subject.possible)}`;
293
- cells[`subject${i}`] =
294
- subject.missing > 0
295
- ? `${score} ${localeText(locale, "scoreboard.missingText", { n: subject.missing })}`
296
- : score;
324
+ const notes = [
325
+ ...(subject.notRun > 0 ? [localeText(locale, "scoreboard.notRunText", { n: subject.notRun })] : []),
326
+ ...(subject.unscorable > 0 ? [localeText(locale, "scoreboard.unscorableText", { n: subject.unscorable })] : []),
327
+ ];
328
+ cells[`subject${i}`] = notes.length > 0 ? `${score} ${notes.join(" ")}` : score;
297
329
  });
298
330
  return { key: row.key, cells };
299
331
  });
300
- const table = renderTableText({ columns, rows, locale }, ctx);
301
- if (data.weights.length === 0) return table;
302
- // 实际生效的权重表 —— 成绩单可审计
303
- const weights = data.weights.map((w) => `${w.prefix} ×${w.weight}`).join(" · ");
304
- return `${table}\n${localeText(locale, "scoreboard.weights")} ${weights} · ${localeText(locale, "scoreboard.othersWeight")}`;
332
+ const table = renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx);
333
+ const footnotes: string[] = [];
334
+ if (data.weights.length > 0) {
335
+ const weights = data.weights.map((w) => `${w.prefix} ×${w.weight}`).join(" · ");
336
+ footnotes.push(`${localeText(locale, "scoreboard.weights")} ${weights} · ${localeText(locale, "scoreboard.othersWeight")}`);
337
+ }
338
+ if (data.ignoredEvals > 0) footnotes.push(countText(locale, "scoreboard.ignored", data.ignoredEvals));
339
+ return footnotes.length > 0 ? `${table}\n${footnotes.join("\n")}` : table;
305
340
  }
306
341
 
307
342
  // ───────────────────────── MetricScatter ─────────────────────────
@@ -312,6 +347,26 @@ function axisLabel(col: MetricColumn, locale: ReportLocale): string {
312
347
  return resolveMetricLabel(col.label, locale, col.key);
313
348
  }
314
349
 
350
+ /**
351
+ * 方向提示:轴向已随 better 反正(lower 反向),「更好」恒指向右上,文案恒为
352
+ * 「越靠右上越好」;仅当 x、y 都声明 better 时显示——任一轴未声明,组件不猜
353
+ * 「更好」朝哪边,整图无提示(返回 undefined)。
354
+ */
355
+ function betterCornerText(x: MetricColumn, y: MetricColumn, locale: ReportLocale): string | undefined {
356
+ if (x.better === undefined || y.better === undefined) return undefined;
357
+ return localeText(locale, "scatter.betterUpperRight");
358
+ }
359
+
360
+ /** 轴标题行:"<x label>(越低越好) × <y label>"——better 方向如实标注,不虚构轴向。 */
361
+ function scatterHeading(data: ScatterData, locale: ReportLocale): string {
362
+ const withBetter = (col: MetricColumn) => {
363
+ const label = axisLabel(col, locale);
364
+ if (col.better === undefined) return label;
365
+ return `${label}(${localeText(locale, col.better === "lower" ? "table.lowerBetter" : "table.higherBetter")})`;
366
+ };
367
+ return `${withBetter(data.x)} × ${withBetter(data.y)}`;
368
+ }
369
+
315
370
  export function scatterText(data: ScatterData, ctx: TextContext): string {
316
371
  const locale = ctx.locale;
317
372
  const drawable = data.rows.filter((r) => r.x.value !== null && r.y.value !== null);
@@ -327,10 +382,14 @@ export function scatterText(data: ScatterData, ctx: TextContext): string {
327
382
  // 点太密排不下时降级为坐标表,不硬挤
328
383
  if (drawable.length > POINT_MARKS.length || ctx.width < 44) {
329
384
  const table = renderCoordinateTable(
330
- drawable.map((r) => ({ key: r.key, x: r.x.display, y: r.y.display })),
331
- { key: data.points, x: axisLabel(data.x, locale), y: axisLabel(data.y, locale) },
385
+ drawable.map((r) => ({
386
+ key: r.key,
387
+ x: resolveLocalizedText(r.x.display, locale),
388
+ y: resolveLocalizedText(r.y.display, locale),
389
+ })),
390
+ { key: data.pointDimension, x: axes.x, y: axes.y },
332
391
  );
333
- return [table, ...footnotes].join("\n");
392
+ return [scatterHeading(data, locale), "", table, ...footnotes].join("\n");
334
393
  }
335
394
 
336
395
  const points: PlotPoint[] = drawable.map((r, i) => ({
@@ -348,20 +407,21 @@ export function scatterText(data: ScatterData, ctx: TextContext): string {
348
407
  }
349
408
  for (const list of bySeries.values()) list.sort((a, b) => a.x - b.x);
350
409
 
351
- const invertX = data.x.better === "lower";
352
410
  const plot = renderCharPlot({
353
411
  width: ctx.width,
354
412
  points,
355
413
  lines: [...bySeries.values()].filter((l) => l.length > 1),
356
- xLabel: `${axisLabel(data.x, locale)}${invertX ? ` ${localeText(locale, "scatter.axisReversed")}` : ""}`,
357
- yLabel: axisLabel(data.y, locale),
414
+ xLabel: axes.x,
415
+ yLabel: axes.y,
358
416
  formatX: (v) => formatMetricValue(v, data.x.unit),
359
417
  formatY: (v) => formatMetricValue(v, data.y.unit),
360
- invertX,
418
+ // 轴方向跟随 better(与 web 面同规则):lower 反向,higher 与未声明正向。
419
+ invertX: data.x.better === "lower",
361
420
  invertY: data.y.better === "lower",
362
421
  });
363
422
  const legend = drawable.map((r, i) => `${POINT_MARKS[i]} ${r.key}`).join(" ");
364
- return [plot, "", localeText(locale, "scatter.betterUpperRight"), legend, ...footnotes].join("\n");
423
+ const hint = betterCornerText(data.x, data.y, locale);
424
+ return [scatterHeading(data, locale), plot, "", ...(hint !== undefined ? [hint] : []), legend, ...footnotes].join("\n");
365
425
  }
366
426
 
367
427
  // ───────────────────────── MetricLine ─────────────────────────
@@ -375,6 +435,8 @@ export function lineText(data: LineData, ctx: TextContext): string {
375
435
 
376
436
  if (drawable.length === 0) return [missingText(locale), ...footnotes].join("\n");
377
437
 
438
+ const xLabel = resolveMetricLabel(data.x.label, locale, data.x.key);
439
+
378
440
  // 系列 → 字母;无系列 = 单系列
379
441
  const seriesKeys: string[] = [];
380
442
  for (const r of drawable) {
@@ -384,8 +446,12 @@ export function lineText(data: LineData, ctx: TextContext): string {
384
446
 
385
447
  if (seriesKeys.length > POINT_MARKS.length || ctx.width < 44) {
386
448
  const table = renderCoordinateTable(
387
- drawable.map((r) => ({ key: r.series ? `${r.key} (${r.series})` : r.key, x: r.xDisplay, y: r.y.display })),
388
- { key: "experiment", x: data.x.label, y: axisLabel(data.y, locale) },
449
+ drawable.map((r) => ({
450
+ key: r.series !== undefined ? `${r.series} (${r.key})` : r.key,
451
+ x: resolveLocalizedText(r.xDisplay, locale),
452
+ y: resolveLocalizedText(r.y.display, locale),
453
+ })),
454
+ { key: data.seriesDimension ?? xLabel, x: xLabel, y: axisLabel(data.y, locale) },
389
455
  );
390
456
  return [table, ...footnotes].join("\n");
391
457
  }
@@ -407,11 +473,10 @@ export function lineText(data: LineData, ctx: TextContext): string {
407
473
  width: ctx.width,
408
474
  points,
409
475
  lines: lines.filter((l) => l.length > 1),
410
- xLabel: data.x.label,
476
+ xLabel,
411
477
  yLabel: axisLabel(data.y, locale),
412
478
  formatX: (v) => formatMetricValue(v, data.x.unit),
413
479
  formatY: (v) => formatMetricValue(v, data.y.unit),
414
- invertY: data.y.better === "lower",
415
480
  });
416
481
  const legend = seriesKeys
417
482
  .map((key, i) => `${POINT_MARKS[i]} ${key === "" ? axisLabel(data.y, locale) : key}`)
@@ -422,41 +487,62 @@ export function lineText(data: LineData, ctx: TextContext): string {
422
487
  // ───────────────────────── DeltaTable ─────────────────────────
423
488
 
424
489
  export function deltaText(data: DeltaData, ctx: TextContext): string {
490
+ const locale = ctx.locale;
491
+ // 0 对不是错误:明确空态并报告配对域实验数(派生形态携带;字面形态缺省按 0)。
492
+ if (data.rows.length === 0) {
493
+ return localeText(locale, "delta.empty", { experiments: data.experiments ?? 0 });
494
+ }
425
495
  const columns: TableColumn[] = [
426
- { key: "pair", header: "pair" },
427
- ...data.columns.map((c, i) => ({ key: `metric${i}`, header: resolveMetricLabel(c.label, ctx.locale, c.key) })),
496
+ { key: "pair", header: localeText(locale, "delta.pairHeader") },
497
+ ...data.columns.map((c, i) => ({ key: `metric${i}`, header: resolveMetricLabel(c.label, locale, c.key) })),
428
498
  ];
429
499
  const rows: TableRow[] = data.rows.map((row) => {
430
- const cells: Record<string, string | null> = { pair: row.key };
500
+ const cells: Record<string, string | null> = { pair: resolveLocalizedText(row.label, locale) };
431
501
  data.columns.forEach((col, i) => {
432
- const cell = (row.cells as Record<string, DeltaData["rows"][number]["cells"][string]>)[col.key];
502
+ const cell = row.cells[col.key];
433
503
  if (!cell) {
434
504
  cells[`metric${i}`] = null;
435
505
  return;
436
506
  }
437
- const a = cell.a.value === null ? MISSING_MARK : cell.a.display;
438
- const b = cell.b.value === null ? MISSING_MARK : cell.b.display;
439
- cells[`metric${i}`] = `${a} → ${b} ${cell.display}`;
507
+ const a = cell.a.value === null ? MISSING_MARK : resolveLocalizedText(cell.a.display, locale);
508
+ const b = cell.b.value === null ? MISSING_MARK : resolveLocalizedText(cell.b.display, locale);
509
+ cells[`metric${i}`] = `${a} → ${b} ${resolveLocalizedText(cell.display, locale)}`;
440
510
  });
441
511
  return { key: row.key, cells };
442
512
  });
443
- return renderTableText({ columns, rows, locale: ctx.locale }, ctx);
513
+ return renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx);
444
514
  }
445
515
 
446
516
  // ───────────────────────── 实体列表(ExperimentList / EvalList / AttemptList)─────────────────────────
447
517
  //
448
518
  // 三面共用的紧凑标记:`locator✓`(判定符紧跟 locator,中间不留空格)。
449
- // ExperimentList / EvalList 逐 attempt 只列这一个标记 + 各自的原因/耗时摘要,不重复整段
450
- // niceeval show 命令;要看某个 attempt 的完整证据,agent 自己拼 `niceeval show <locator>`——
451
- // 比较列表都不内联完整断言或命令模板。
519
+ // ExperimentList / EvalList 逐 attempt 只列这一个标记 + 各自的摘要,不重复整段
520
+ // niceeval show 命令;要看某个 attempt 的完整证据,agent 自己拼 `niceeval show <locator>`。
452
521
 
453
522
  function locatorBadge(item: { locator: string; verdict: AttemptListItem["verdict"] }): string {
454
523
  return `${item.locator}${verdictMark(item.verdict)}`;
455
524
  }
456
525
 
526
+ /**
527
+ * failureSummary + moreFailures 的展示形态:摘要在计算侧已按 Scoring display 契约折好,
528
+ * 这里只加 "+N more failures" 计数与宽度收口,不重算摘要。
529
+ */
530
+ function attemptReasonText(item: AttemptListItem, locale: ReportLocale, maxChars: number): string | undefined {
531
+ if (item.failureSummary === null) return undefined;
532
+ const withMore =
533
+ item.moreFailures > 0
534
+ ? `${item.failureSummary} · ${countText(locale, "entityList.moreFailures", item.moreFailures)}`
535
+ : item.failureSummary;
536
+ return fitFailureSummary(withMore, Math.max(24, maxChars));
537
+ }
538
+
457
539
  // ── ExperimentList ──
458
540
 
459
- function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext, relativeTo?: string): string {
541
+ function experimentSummaryTable(
542
+ items: readonly ExperimentListItem[],
543
+ ctx: TextContext,
544
+ relativeTo?: string,
545
+ ): string {
460
546
  const locale = ctx.locale;
461
547
  const compact = ctx.width < 100;
462
548
  const columns: TableColumn[] = [
@@ -467,7 +553,7 @@ function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext, r
467
553
  { key: "passRate", header: compact && locale === "en" ? "Pass" : localeText(locale, "experimentList.passRate"), align: "right" },
468
554
  { key: "result", header: localeText(locale, "experimentList.result") },
469
555
  { key: "tokens", header: localeText(locale, "experimentList.tokens"), align: "right" },
470
- { key: "cost", header: compact && locale === "en" ? "Cost" : localeText(locale, "experimentList.estimatedCost"), align: "right" },
556
+ { key: "cost", header: localeText(locale, "experimentList.cost"), align: "right" },
471
557
  ];
472
558
  const rows: TableRow[] = items.map((item) => ({
473
559
  key: item.experimentId,
@@ -475,11 +561,11 @@ function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext, r
475
561
  experiment: experimentDisplayName(item.experimentId, relativeTo),
476
562
  model: item.model ?? localeText(locale, "experimentList.defaultModel"),
477
563
  agent: item.agent,
478
- duration: cellText(item.duration),
479
- passRate: cellText(item.passRate),
480
- result: verdictTallyText(item.verdicts, locale),
481
- tokens: cellText(item.tokens),
482
- cost: cellText(item.cost),
564
+ duration: cellText(item.durationMs, locale),
565
+ passRate: cellText(item.endToEndPassRate, locale),
566
+ result: verdictTallyText(item.evalVerdicts, locale),
567
+ tokens: cellText(item.tokens, locale),
568
+ cost: cellText(item.costUSD, locale),
483
569
  },
484
570
  }));
485
571
  const metadata = items.flatMap((item) =>
@@ -488,7 +574,7 @@ function experimentSummaryTable(items: ExperimentListItem[], ctx: TextContext, r
488
574
  Math.max(8, ctx.width - 2),
489
575
  ).map((line) => ` ${line}`),
490
576
  );
491
- return [renderTableText({ columns, rows, locale }, ctx), metadata.join("\n")].join("\n");
577
+ return [renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx), metadata.join("\n")].join("\n");
492
578
  }
493
579
 
494
580
  function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relativeTo?: string): string {
@@ -502,7 +588,7 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relat
502
588
  { key: "cost", header: localeText(locale, "experimentList.cost"), align: "right" },
503
589
  ];
504
590
  // Result 的字符预算 ≈ 两行 × 它能分到的列宽(总宽减其它列的自然宽与列距)。这里只做
505
- // 优先级让位(标题先截、received 最后截)的粗预算;精确的按宽度收口由列的 maxLines 兜底。
591
+ // 粗预算;精确的按宽度收口由列的 maxLines 兜底。
506
592
  const statusWidth = Math.max(
507
593
  stringWidth(localeText(locale, "experimentList.status")),
508
594
  ...item.evalRows.map((row) => stringWidth(`${verdictMark(row.verdict)} ${localeText(locale, `verdict.${row.verdict}`)}`)),
@@ -514,14 +600,15 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relat
514
600
  const fixedWidth = statusWidth + entityWidth + 8 /* duration */ + 6 /* cost */ + 3 * 4; /* 4 段列距 */
515
601
  const resultBudget = Math.max(24, (ctx.width - fixedWidth) * 2);
516
602
  const rows: TableRow[] = item.evalRows.flatMap((row) => {
603
+ // Eval 父行只承载折叠判定与题级聚合;失败摘要只在 Attempt 子行出现。
517
604
  const parent: TableRow = {
518
605
  key: row.evalId,
519
606
  cells: {
520
607
  status: `${verdictMark(row.verdict)} ${localeText(locale, `verdict.${row.verdict}`)}`,
521
608
  entity: row.evalId,
522
609
  result: "",
523
- duration: localeText(locale, "entityList.average", { value: cellText(row.duration) }),
524
- cost: localeText(locale, "entityList.average", { value: cellText(row.cost) }),
610
+ duration: localeText(locale, "entityList.average", { value: cellText(row.durationMs, locale) }),
611
+ cost: localeText(locale, "entityList.average", { value: cellText(row.costUSD, locale) }),
525
612
  },
526
613
  };
527
614
  const attempts: TableRow[] = row.attempts.map((attempt, index) => ({
@@ -529,22 +616,28 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relat
529
616
  cells: {
530
617
  status: ` ${verdictMark(attempt.verdict)}`,
531
618
  entity: `${index === row.attempts.length - 1 ? "└─" : "├─"} ${attempt.locator}`,
532
- result: attemptItemReason(attempt, resultBudget) ?? MISSING_MARK,
619
+ result: attemptReasonText(attempt, locale, resultBudget) ?? MISSING_MARK,
533
620
  duration: attempt.verdict === "skipped" && attempt.durationMs === 0 ? null : formatDurationMs(attempt.durationMs),
534
- cost: attempt.costUSD === undefined ? null : formatUSD(attempt.costUSD),
621
+ cost: attempt.costUSD === null ? null : formatUSD(attempt.costUSD),
535
622
  },
536
623
  }));
537
624
  return [parent, ...attempts];
538
625
  });
539
626
  const flags = item.flags && Object.keys(item.flags).length > 0
540
- ? `${localeText(locale, "experimentList.flags")} ${Object.entries(item.flags).map(([key, value]) => `${key}=${String(value)}`).join(" · ")}`
627
+ ? `${localeText(locale, "experimentList.flags")} ${Object.entries(item.flags)
628
+ .map(([key, value]) => `${key}=${typeof value === "string" ? value : JSON.stringify(value)}`)
629
+ .join(" · ")}`
541
630
  : undefined;
542
- return [experimentDisplayName(item.experimentId, relativeTo), flags, renderTableText({ columns, rows, locale }, ctx)]
631
+ return [
632
+ experimentDisplayName(item.experimentId, relativeTo),
633
+ flags,
634
+ renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx),
635
+ ]
543
636
  .filter(Boolean)
544
637
  .join("\n");
545
638
  }
546
639
 
547
- export function experimentListText(items: ExperimentListItem[], ctx: TextContext, relativeTo?: string): string {
640
+ export function experimentListText(items: readonly ExperimentListItem[], ctx: TextContext, relativeTo?: string): string {
548
641
  if (items.length === 0) return localeText(ctx.locale, "attemptList.empty");
549
642
  return [
550
643
  experimentSummaryTable(items, ctx, relativeTo),
@@ -555,24 +648,24 @@ export function experimentListText(items: ExperimentListItem[], ctx: TextContext
555
648
  // ── EvalList ──
556
649
 
557
650
  function evalListAttemptLine(item: AttemptListItem, ctx: TextContext): string {
558
- // 行式列表同守「Result 最多两行」:预算 = 两行终端宽,超出由 fit 投影按优先级让位。
559
- const reason = attemptItemReason(item, Math.max(24, ctx.width * 2 - stringWidth(locatorBadge(item)) - 6));
651
+ // 行式列表同守「Result 最多两行」:预算 = 两行终端宽,超出按尾截收口。
652
+ const reason = attemptReasonText(item, ctx.locale, ctx.width * 2 - stringWidth(locatorBadge(item)) - 6);
560
653
  return ` ${locatorBadge(item)}${reason ? ` · ${reason}` : ""}`;
561
654
  }
562
655
 
563
- export function evalListText(items: EvalListItem[], ctx: TextContext): string {
656
+ export function evalListText(items: readonly EvalListItem[], ctx: TextContext): string {
564
657
  const locale = ctx.locale;
565
658
  if (items.length === 0) return localeText(locale, "attemptList.empty");
566
659
  const blocks = items.map((item) => {
567
660
  const identity = `${item.evalId} · ${item.experimentId} · ${localeText(locale, `verdict.${item.verdict}`)}`;
568
661
  const summary = [
569
- localeText(locale, "attemptList.score", { score: cellText(item.score) }),
662
+ localeText(locale, "attemptList.score", { score: cellText(item.examScore, locale) }),
570
663
  localeText(locale, "overview.attemptsCount", { n: item.attempts.length }),
571
664
  localeText(locale, "entityList.average", {
572
- value: item.duration.value === null ? missingText(locale) : formatDurationMs(item.duration.value),
665
+ value: item.durationMs.value === null ? missingText(locale) : formatDurationMs(item.durationMs.value),
573
666
  }),
574
667
  localeText(locale, "entityList.average", {
575
- value: item.cost.value === null ? missingText(locale) : formatUSD(item.cost.value),
668
+ value: item.costUSD.value === null ? missingText(locale) : formatUSD(item.costUSD.value),
576
669
  }),
577
670
  ].join(" · ");
578
671
  const attemptLines = item.attempts.map((attempt) => evalListAttemptLine(attempt, ctx));
@@ -583,22 +676,22 @@ export function evalListText(items: EvalListItem[], ctx: TextContext): string {
583
676
 
584
677
  // ── AttemptList ──
585
678
 
586
- /** Attempt 比较卡片:只显示一条主失败摘要(至多两行终端宽);完整 assertions 走 locator 下钻。 */
679
+ /** Attempt 比较卡片:只显示一条主失败摘要(至多两行终端宽);完整 assertions 走 locator 下钻。 */
587
680
  function attemptListItemText(item: AttemptListItem, ctx: TextContext): string {
588
681
  const head = [
589
682
  `${verdictMark(item.verdict)} ${item.locator}`,
590
683
  item.evalId,
591
684
  item.experimentId,
592
685
  formatDurationMs(item.durationMs),
593
- ...(item.costUSD !== undefined ? [formatUSD(item.costUSD)] : []),
686
+ ...(item.costUSD !== null ? [formatUSD(item.costUSD)] : []),
594
687
  ].join(" · ");
595
688
  const lines = [head];
596
- const reason = attemptItemReason(item, Math.max(24, ctx.width * 2 - 4));
689
+ const reason = attemptReasonText(item, ctx.locale, ctx.width * 2 - 4);
597
690
  if (reason) lines.push(` ${reason}`);
598
691
  return lines.join("\n");
599
692
  }
600
693
 
601
- export function attemptListText(items: AttemptListItem[], total: number | undefined, ctx: TextContext): string {
694
+ export function attemptListText(items: readonly AttemptListItem[], total: number | undefined, ctx: TextContext): string {
602
695
  const locale = ctx.locale;
603
696
  if (items.length === 0) return localeText(locale, "attemptList.empty");
604
697
  const blocks = items.map((item) => attemptListItemText(item, ctx));