niceeval 0.4.6 → 0.5.1

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 (174) hide show
  1. package/docs-site/zh/concepts/adapter.mdx +6 -6
  2. package/docs-site/zh/concepts/experiment.mdx +7 -7
  3. package/docs-site/zh/concepts/overview.mdx +1 -1
  4. package/docs-site/zh/concepts/tier.mdx +6 -6
  5. package/docs-site/zh/guides/agent-feedback-loop.mdx +64 -21
  6. package/docs-site/zh/guides/connect-your-agent.mdx +7 -7
  7. package/docs-site/zh/guides/custom-reports.mdx +243 -0
  8. package/docs-site/zh/guides/experiments.mdx +3 -3
  9. package/docs-site/zh/guides/report-components.mdx +252 -0
  10. package/docs-site/zh/guides/reporters.mdx +1 -1
  11. package/docs-site/zh/guides/results-data.mdx +224 -0
  12. package/docs-site/zh/guides/runner.mdx +1 -1
  13. package/docs-site/zh/guides/sandbox-agent.mdx +2 -2
  14. package/docs-site/zh/guides/viewing-results.mdx +147 -77
  15. package/docs-site/zh/guides/write-experiment.mdx +9 -9
  16. package/docs-site/zh/guides/write-send.mdx +6 -6
  17. package/docs-site/zh/index.mdx +1 -1
  18. package/docs-site/zh/introduction.mdx +1 -1
  19. package/docs-site/zh/reference/cli.mdx +11 -3
  20. package/docs-site/zh/reference/define-agent.mdx +5 -5
  21. package/docs-site/zh/reference/define-config.mdx +1 -1
  22. package/docs-site/zh/reference/define-eval.mdx +3 -3
  23. package/package.json +3 -2
  24. package/src/agents/ai-sdk.test.ts +1 -1
  25. package/src/agents/ai-sdk.ts +2 -2
  26. package/src/agents/claude-code.ts +1 -1
  27. package/src/agents/codex.ts +2 -2
  28. package/src/agents/streaming.test.ts +1 -1
  29. package/src/agents/types.ts +4 -4
  30. package/src/agents/ui-message-stream.test.ts +1 -1
  31. package/src/cli.ts +120 -18
  32. package/src/context/context.test.ts +1 -1
  33. package/src/context/context.ts +3 -3
  34. package/src/context/session.ts +2 -2
  35. package/src/context/types.ts +2 -2
  36. package/src/i18n/en.ts +29 -7
  37. package/src/i18n/zh-CN.ts +24 -2
  38. package/src/report/aggregate.ts +79 -50
  39. package/src/report/components.tsx +190 -0
  40. package/src/report/compute.ts +149 -84
  41. package/src/report/default-report.tsx +222 -0
  42. package/src/report/dual-face.test.tsx +527 -0
  43. package/src/report/format.ts +29 -0
  44. package/src/report/index.ts +79 -24
  45. package/src/report/load.ts +67 -0
  46. package/src/report/metrics.ts +2 -1
  47. package/src/report/param.ts +18 -0
  48. package/src/report/primitives.tsx +117 -0
  49. package/src/report/react/CaseList.tsx +2 -2
  50. package/src/report/react/DeltaTable.tsx +2 -2
  51. package/src/report/react/MetricBars.tsx +109 -0
  52. package/src/report/react/MetricLine.tsx +200 -0
  53. package/src/report/react/MetricMatrix.tsx +1 -1
  54. package/src/report/react/MetricScatter.tsx +1 -1
  55. package/src/report/react/MetricTable.tsx +1 -1
  56. package/src/report/react/RunOverview.tsx +4 -4
  57. package/src/report/react/Scoreboard.tsx +3 -3
  58. package/src/report/react/cell.tsx +1 -1
  59. package/src/report/react/fixtures.ts +98 -36
  60. package/src/report/react/format.ts +3 -28
  61. package/src/report/react/index.tsx +41 -24
  62. package/src/report/react/render.test.tsx +8 -8
  63. package/src/report/react/styles.css +77 -0
  64. package/src/report/report.test.ts +248 -128
  65. package/src/report/report.ts +69 -0
  66. package/src/report/text/faces.ts +356 -0
  67. package/src/report/text/layout.ts +125 -0
  68. package/src/report/text/plot.ts +127 -0
  69. package/src/report/tree.ts +220 -0
  70. package/src/report/types.ts +75 -27
  71. package/src/report/web.ts +40 -0
  72. package/src/results/copy.ts +95 -41
  73. package/src/results/format.ts +12 -11
  74. package/src/results/index.ts +34 -17
  75. package/src/results/open.ts +201 -81
  76. package/src/results/results.test.ts +595 -191
  77. package/src/results/select.ts +107 -55
  78. package/src/results/types.ts +137 -45
  79. package/src/results/writer.ts +258 -0
  80. package/src/runner/attempt.ts +3 -3
  81. package/src/runner/fingerprint.ts +1 -1
  82. package/src/runner/reporters/artifacts.ts +38 -78
  83. package/src/runner/reporters/braintrust.test.ts +2 -2
  84. package/src/runner/reporters/braintrust.ts +3 -3
  85. package/src/runner/run.ts +1 -1
  86. package/src/runner/types.ts +18 -12
  87. package/src/show/compose.ts +215 -0
  88. package/src/show/index.ts +263 -0
  89. package/src/show/render.ts +456 -0
  90. package/src/show/show.test.ts +505 -0
  91. package/src/view/app/App.tsx +85 -24
  92. package/src/view/app/components/CostScoreChart.tsx +29 -10
  93. package/src/view/app/components/ExperimentTable.tsx +27 -9
  94. package/src/view/app/components/GroupSelector.tsx +1 -1
  95. package/src/view/app/i18n.ts +18 -3
  96. package/src/view/app/lib/attempt-route.test.ts +9 -9
  97. package/src/view/app/lib/attempt-route.ts +5 -5
  98. package/src/view/app/lib/outcome.ts +1 -3
  99. package/src/view/app/lib/rows.ts +95 -15
  100. package/src/view/app/main.tsx +18 -6
  101. package/src/view/app/pages/RunsPage.tsx +4 -7
  102. package/src/view/app/pages/TracesPage.tsx +3 -8
  103. package/src/view/app/types.ts +50 -6
  104. package/src/view/client-dist/app.css +1 -1
  105. package/src/view/client-dist/app.js +20 -20
  106. package/src/view/data.test.ts +254 -0
  107. package/src/view/data.ts +336 -0
  108. package/src/view/index.ts +64 -10
  109. package/src/view/server.ts +31 -9
  110. package/src/view/shared/types.ts +47 -41
  111. package/src/view/styles.css +5 -0
  112. package/src/view/template.html +1 -0
  113. package/src/view/view-report.test.ts +308 -0
  114. package/docs-site/.mintignore +0 -13
  115. package/docs-site/AGENTS.md +0 -46
  116. package/docs-site/concepts/adapter.mdx +0 -287
  117. package/docs-site/concepts/assert.mdx +0 -243
  118. package/docs-site/concepts/drive.mdx +0 -118
  119. package/docs-site/concepts/evals.mdx +0 -108
  120. package/docs-site/concepts/experiment.mdx +0 -37
  121. package/docs-site/concepts/hitl.mdx +0 -83
  122. package/docs-site/concepts/judge.mdx +0 -129
  123. package/docs-site/concepts/overview.mdx +0 -98
  124. package/docs-site/concepts/tier.mdx +0 -42
  125. package/docs-site/docs-ref/00-index.md +0 -23
  126. package/docs-site/docs-ref/01-page-types.md +0 -43
  127. package/docs-site/docs-ref/03-style-rules.md +0 -45
  128. package/docs-site/docs-ref/04-code-examples.md +0 -37
  129. package/docs-site/docs-ref/05-dx-failure-paths.md +0 -45
  130. package/docs-site/docs-ref/06-checklists.md +0 -53
  131. package/docs-site/docs.json +0 -256
  132. package/docs-site/example/ai-agent-application.mdx +0 -148
  133. package/docs-site/example/claude-code-codex-plugin.mdx +0 -162
  134. package/docs-site/example/claude-code-codex-skill.mdx +0 -147
  135. package/docs-site/example/showcase.mdx +0 -39
  136. package/docs-site/example/tier1-ai-sdk-v7.mdx +0 -136
  137. package/docs-site/example/tier1-claude-sdk.mdx +0 -119
  138. package/docs-site/example/tier1-codex-sdk.mdx +0 -111
  139. package/docs-site/example/tier1-langgraph.mdx +0 -119
  140. package/docs-site/example/tier1-pi-sdk.mdx +0 -119
  141. package/docs-site/favicon.svg +0 -5
  142. package/docs-site/github-diff.css +0 -135
  143. package/docs-site/github-diff.js +0 -11
  144. package/docs-site/guides/authoring.mdx +0 -129
  145. package/docs-site/guides/ci-integration.mdx +0 -97
  146. package/docs-site/guides/connect-otel.mdx +0 -209
  147. package/docs-site/guides/connect-your-agent.mdx +0 -221
  148. package/docs-site/guides/dataset-fanout.mdx +0 -98
  149. package/docs-site/guides/experiments.mdx +0 -71
  150. package/docs-site/guides/fixtures.mdx +0 -147
  151. package/docs-site/guides/reporters.mdx +0 -113
  152. package/docs-site/guides/runner.mdx +0 -79
  153. package/docs-site/guides/sandbox-agent.mdx +0 -138
  154. package/docs-site/guides/sandbox-backends.mdx +0 -64
  155. package/docs-site/guides/scoring-guide.mdx +0 -92
  156. package/docs-site/guides/viewing-results.mdx +0 -195
  157. package/docs-site/guides/write-experiment.mdx +0 -81
  158. package/docs-site/guides/write-send.mdx +0 -347
  159. package/docs-site/index.mdx +0 -181
  160. package/docs-site/introduction.mdx +0 -141
  161. package/docs-site/quickstart.mdx +0 -136
  162. package/docs-site/reference/builtin-agents.mdx +0 -161
  163. package/docs-site/reference/capabilities.mdx +0 -76
  164. package/docs-site/reference/cli.mdx +0 -120
  165. package/docs-site/reference/define-agent.mdx +0 -168
  166. package/docs-site/reference/define-config.mdx +0 -41
  167. package/docs-site/reference/define-eval.mdx +0 -160
  168. package/docs-site/reference/events.mdx +0 -131
  169. package/docs-site/reference/expect.mdx +0 -112
  170. package/docs-site/tracker.js +0 -8
  171. package/src/report/react/data.ts +0 -17
  172. package/src/view/aggregate.ts +0 -103
  173. package/src/view/loader.test.ts +0 -61
  174. package/src/view/loader.ts +0 -257
@@ -1,103 +0,0 @@
1
- // 聚合层:把 loader 读到的 summary 揉成榜单行与页面 KPI。纯数据变换,不碰 fs / http。
2
- // 折叠与格式化口径在 shared/ 与前端共用;这里只产原始值(number / ISO),格式化由前端按 locale 做。
3
-
4
- import type { EvalResult, Usage } from "../types.ts";
5
- import type { LoadedSummary, ScanResult } from "./loader.ts";
6
- import { evalLevelStats } from "../shared/outcome.ts";
7
- import { OUTCOME_ORDER, avg, displayExperimentName, fallbackExperimentLabel, sumMaybe } from "../shared/aggregate.ts";
8
- import type { ViewData, ViewRow } from "./shared/types.ts";
9
-
10
- /** 烘焙进 HTML 的页面数据;绝对路径等 server 私有信息在 loader 就没进 summary,这里只挑展示字段。 */
11
- export function buildViewData(scan: ScanResult): ViewData {
12
- const latest = scan.loaded[0]?.summary;
13
- const totals = summarizeAll(scan.loaded);
14
- return {
15
- rows: aggregateRows(scan.loaded),
16
- name: latest?.name,
17
- lastRunAt: latest?.startedAt,
18
- passRate: totals.passRate,
19
- resultCount: totals.results,
20
- durationMs: totals.durationMs,
21
- estimatedCostUSD: totals.cost,
22
- skippedRuns: scan.skipped.map((run) => ({
23
- dir: run.dir,
24
- reason: run.reason,
25
- schemaVersion: run.schemaVersion,
26
- producerVersion: run.producerVersion,
27
- command: run.command,
28
- detail: run.detail,
29
- })),
30
- };
31
- }
32
-
33
- export function aggregateRows(loaded: LoadedSummary[]): ViewRow[] {
34
- const groups = new Map<string, EvalResult[]>();
35
- const lastRunAt = new Map<string, string>();
36
- for (const item of loaded) {
37
- for (const result of item.summary.results) {
38
- const key = result.experimentId ? `exp|||${result.experimentId}` : `legacy|||${result.agent}|||${result.model ?? ""}`;
39
- groups.set(key, [...(groups.get(key) ?? []), result]);
40
- const prev = lastRunAt.get(key);
41
- if (!prev || item.summary.startedAt > prev) lastRunAt.set(key, item.summary.startedAt);
42
- }
43
- }
44
-
45
- return Array.from(groups.entries()).map(([key, results]) => {
46
- const first = results[0]!;
47
- const experimentId = first.experimentId;
48
- const cost = sumMaybe(results.map((r) => r.estimatedCostUSD));
49
- // 一行 = 一个实验,results 内按 eval id 折叠计票(passed/failed/通过率都是 eval 级)。
50
- const stats = evalLevelStats(results, (r) => r.id);
51
- return {
52
- key,
53
- experimentId,
54
- experiment: first.experiment,
55
- group: experimentGroup(experimentId),
56
- label: displayExperimentName(experimentId) ?? fallbackExperimentLabel(first),
57
- agent: first.agent,
58
- model: first.model,
59
- lastRunAt: lastRunAt.get(key),
60
- runs: results.length, // 总 attempt 数(详情里作次要信息)
61
- evals: stats.evals, // 去重后的 eval 数(成功率分母的口径)
62
- passed: stats.passed,
63
- failed: stats.failed,
64
- errored: stats.errored,
65
- skipped: stats.skipped,
66
- passRate: stats.passRate,
67
- avgDurationMs: avg(results.map((r) => r.durationMs)),
68
- usage: sumUsage(results.map((r) => r.usage)),
69
- estimatedCostUSD: cost,
70
- results: results
71
- .slice()
72
- .sort((a, b) => OUTCOME_ORDER[a.outcome] - OUTCOME_ORDER[b.outcome] || a.id.localeCompare(b.id)),
73
- };
74
- });
75
- }
76
-
77
- function summarizeAll(loaded: LoadedSummary[]) {
78
- const results = loaded.flatMap((s) => s.summary.results);
79
- // 顶部总览同样按 eval 计票:每个(实验, eval)只算一份,跨实验/跨 run 不被 runs 灌票。
80
- const groupKey = (r: EvalResult) => (r.experimentId ? `exp|||${r.experimentId}` : `legacy|||${r.agent}|||${r.model ?? ""}`);
81
- const stats = evalLevelStats(results, (r) => `${groupKey(r)}|||${r.id}`);
82
- return {
83
- results: stats.evals,
84
- passRate: stats.passRate,
85
- durationMs: loaded.reduce((sum, s) => sum + (s.summary.durationMs ?? 0), 0),
86
- cost: sumMaybe(loaded.map((s) => s.summary.estimatedCostUSD)),
87
- };
88
- }
89
-
90
- function sumUsage(items: Array<Usage | undefined>): Usage {
91
- return {
92
- inputTokens: items.reduce((n, u) => n + (u?.inputTokens ?? 0), 0),
93
- outputTokens: items.reduce((n, u) => n + (u?.outputTokens ?? 0), 0),
94
- cacheReadTokens: items.reduce((n, u) => n + (u?.cacheReadTokens ?? 0), 0),
95
- cacheWriteTokens: items.reduce((n, u) => n + (u?.cacheWriteTokens ?? 0), 0),
96
- requests: items.reduce((n, u) => n + (u?.requests ?? 0), 0),
97
- };
98
- }
99
-
100
- function experimentGroup(id: string | undefined): string | undefined {
101
- if (!id || !id.includes("/")) return undefined;
102
- return id.split("/").slice(0, -1).join("/");
103
- }
@@ -1,61 +0,0 @@
1
- // loadLatestResultsPerEval:续跑携带基线必须是跨历史「每 (experimentId, evalId) 最新一份」,
2
- // 而不是最近一个 run —— 否则部分补跑 run 会把基线清空,`exp <组>` 补齐失效。
3
- import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import { afterEach, describe, expect, it } from "vitest";
7
- import { loadLatestResultsPerEval } from "./loader.ts";
8
- import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION } from "../runner/types.ts";
9
-
10
- let root: string;
11
-
12
- afterEach(async () => {
13
- if (root) await rm(root, { recursive: true, force: true });
14
- });
15
-
16
- async function writeRun(dir: string, startedAt: string, results: object[]): Promise<void> {
17
- await mkdir(join(root, dir), { recursive: true });
18
- await writeFile(
19
- join(root, dir, "summary.json"),
20
- JSON.stringify({ format: RESULTS_FORMAT, schemaVersion: RESULTS_SCHEMA_VERSION, startedAt, results }),
21
- );
22
- }
23
-
24
- function result(experimentId: string, id: string, outcome: string, extra: object = {}): object {
25
- return { experimentId, id, agent: "a", outcome, ...extra };
26
- }
27
-
28
- describe("loadLatestResultsPerEval", () => {
29
- it("部分补跑 run 只遮蔽它跑过的 eval,其它 eval 仍取自更早的全量 run", async () => {
30
- root = await mkdtemp(join(tmpdir(), "niceeval-loader-"));
31
- await writeRun("2026-01-01T00-00-00", "2026-01-01T00:00:00.000Z", [
32
- result("exp/a", "e1", "passed"),
33
- result("exp/a", "e2", "errored"),
34
- result("exp/b", "e1", "passed"),
35
- ]);
36
- // 部分补跑:只重跑了 exp/a 的 e2
37
- await writeRun("2026-01-02T00-00-00", "2026-01-02T00:00:00.000Z", [result("exp/a", "e2", "passed")]);
38
-
39
- const results = (await loadLatestResultsPerEval(root)) as Array<{ experimentId: string; id: string; outcome: string }>;
40
- const byKey = new Map(results.map((r) => [`${r.experimentId}|${r.id}`, r.outcome]));
41
- expect(byKey.get("exp/a|e1")).toBe("passed"); // 来自旧全量 run,没被部分 run 冲掉
42
- expect(byKey.get("exp/a|e2")).toBe("passed"); // 来自补跑 run(最新)
43
- expect(byKey.get("exp/b|e1")).toBe("passed");
44
- expect(results).toHaveLength(3);
45
- });
46
-
47
- it("同 (experiment, eval) 多 attempt 整批取自含它的最新 run,不跨 run 混装", async () => {
48
- root = await mkdtemp(join(tmpdir(), "niceeval-loader-"));
49
- await writeRun("2026-01-01T00-00-00", "2026-01-01T00:00:00.000Z", [
50
- result("exp/a", "e1", "passed", { fingerprint: "old" }),
51
- ]);
52
- await writeRun("2026-01-02T00-00-00", "2026-01-02T00:00:00.000Z", [
53
- result("exp/a", "e1", "failed", { fingerprint: "new" }),
54
- result("exp/a", "e1", "passed", { fingerprint: "new" }),
55
- ]);
56
-
57
- const results = (await loadLatestResultsPerEval(root)) as Array<{ fingerprint: string }>;
58
- expect(results).toHaveLength(2);
59
- expect(results.every((r) => r.fingerprint === "new")).toBe(true);
60
- });
61
- });
@@ -1,257 +0,0 @@
1
- // 结果 loader:发现并读取 summary.json,做版本判定与 normalize,不做任何聚合/渲染。
2
- // 设计见 docs/view.md「结果版本机制」:先 normalize 再渲染;目录扫描不让单个坏 run 拖垮整页,
3
- // 但「像报告却读不了/版本不同」的 run 必须记入 skipped,不许无声消失。
4
-
5
- import { existsSync, statSync } from "node:fs";
6
- import { readFile, readdir, stat } from "node:fs/promises";
7
- import { dirname, join, relative, resolve } from "node:path";
8
- import { RESULTS_FORMAT, RESULTS_SCHEMA_VERSION, type EvalResult, type RunSummary } from "../types.ts";
9
- import type { ViewEvalResult } from "./shared/types.ts";
10
- import { t } from "../i18n/index.ts";
11
-
12
- export interface LoadedSummary {
13
- path: string;
14
- summary: RunSummary;
15
- }
16
-
17
- /** schemaVersion 与当前 CLI 不同、按设计直接不兼容的 run;只占位提示,不解析内容。 */
18
- export interface IncompatibleRun {
19
- /** run 目录(summary.json 所在目录),相对 cwd;直接可拼进 npx 命令。 */
20
- dir: string;
21
- schemaVersion: number;
22
- producerVersion?: string;
23
- }
24
-
25
- /** 目录扫描里被跳过、但必须让用户知道的 run(版本不同 / 像报告却损坏)。 */
26
- export interface SkippedRun {
27
- /** summary.json 路径,相对 cwd。 */
28
- path: string;
29
- /** run 目录,相对 cwd。 */
30
- dir: string;
31
- reason: "incompatible-version" | "malformed";
32
- schemaVersion?: number;
33
- producerVersion?: string;
34
- /** incompatible-version:拼好的 npx 查看命令。 */
35
- command?: string;
36
- /** malformed:一句诊断。 */
37
- detail?: string;
38
- }
39
-
40
- export interface ScanResult {
41
- loaded: LoadedSummary[];
42
- skipped: SkippedRun[];
43
- /**
44
- * artifactBase(相对 view 根)→ 宿主机绝对目录。只在 server 端内存里保留;
45
- * 绝对路径不进 viewData,避免序列化进可分享的静态 HTML(信息泄漏且浏览器端用不到)。
46
- */
47
- artifactDirs: Map<string, string>;
48
- }
49
-
50
- /** 用能读这份报告的 niceeval 版本查看的命令。 */
51
- export function incompatibleViewCommand(run: IncompatibleRun): string {
52
- return `npx niceeval@${run.producerVersion ?? "<version>"} view ${run.dir}`;
53
- }
54
-
55
- /** 版本不匹配的完整提示文案;CLI 单文件模式和目录扫描占位共用。 */
56
- export function incompatibleHint(run: IncompatibleRun): string {
57
- return t("cli.view.incompatible", {
58
- dir: run.dir,
59
- producer: run.producerVersion ?? "?",
60
- schemaVersion: run.schemaVersion,
61
- supported: RESULTS_SCHEMA_VERSION,
62
- command: incompatibleViewCommand(run),
63
- });
64
- }
65
-
66
- /** 单文件模式读到版本不同的 summary 时抛出;CLI 捕获后打印提示退出,不当成普通错误堆栈。 */
67
- export class IncompatibleResultsError extends Error {
68
- constructor(readonly run: IncompatibleRun) {
69
- super(incompatibleHint(run));
70
- this.name = "IncompatibleResultsError";
71
- }
72
- }
73
-
74
- /** 像 niceeval 报告但读不了(JSON 损坏 / 必需字段坏);目录扫描记入 skipped,单文件模式直接失败。 */
75
- export class MalformedResultsError extends Error {
76
- constructor(readonly path: string, readonly detail: string) {
77
- super(`${path}: ${detail}. The report may be corrupted; re-run the eval or delete this run directory.`);
78
- this.name = "MalformedResultsError";
79
- }
80
- }
81
-
82
- /** 不是 niceeval 报告的无关 JSON;目录扫描静默忽略,单文件模式直接失败。 */
83
- export class NotAReportError extends Error {
84
- constructor(path: string) {
85
- super(`${path} is not a niceeval summary`);
86
- this.name = "NotAReportError";
87
- }
88
- }
89
-
90
- /**
91
- * 读跨历史「每 (experimentId, evalId) 最新一份」的 EvalResult,供续跑携带已通过结果。
92
- * 只看最近一个 run 不行:部分补跑(位置参数只跑几道题)会把携带基线换成那个部分 run,
93
- * 之后重跑任何实验都携带不到东西,`exp <组>` 的「补齐缺失」语义随之失效。
94
- * 同一 (experimentId, evalId) 的多个 attempt 整批取自含它的最新 run,不跨 run 混装。
95
- */
96
- export async function loadLatestResultsPerEval(root = ".niceeval"): Promise<EvalResult[]> {
97
- const { loaded } = await loadSummaries(root); // 已按 startedAt 降序
98
- const claimed = new Set<string>();
99
- const results: EvalResult[] = [];
100
- for (const { summary } of loaded) {
101
- const takenThisRun = new Set<string>();
102
- for (const r of summary.results ?? []) {
103
- const key = `${r.experimentId ?? `${r.agent}|${r.model ?? ""}`}|${r.id}`;
104
- if (claimed.has(key)) continue;
105
- takenThisRun.add(key);
106
- results.push(r);
107
- }
108
- for (const key of takenThisRun) claimed.add(key);
109
- }
110
- return results;
111
- }
112
-
113
- /** 服务/解析工件的根目录:输入是目录就用它,是文件就用其所在目录。 */
114
- export function viewRoot(input?: string): string {
115
- const target = resolve(input ?? ".niceeval");
116
- try {
117
- return statSync(target).isFile() ? dirname(target) : target;
118
- } catch {
119
- return target;
120
- }
121
- }
122
-
123
- export async function loadSummaries(input?: string): Promise<ScanResult> {
124
- const target = resolve(input ?? ".niceeval");
125
- const artifactDirs = new Map<string, string>();
126
- if (!existsSync(target)) return { loaded: [], skipped: [], artifactDirs };
127
- const root = viewRoot(input);
128
- const s = await stat(target);
129
- if (s.isFile()) {
130
- // 单文件模式:这是用户明确指定的目标,任何读不了(版本/损坏/无关)都直接抛,由 CLI 打印提示退出。
131
- const summary = normalizeSummary(await parseSummary(target), target);
132
- return { loaded: [{ path: target, summary: withViewRefs(summary, target, root, artifactDirs) }], skipped: [], artifactDirs };
133
- }
134
-
135
- const candidates = await findSummaryFiles(target);
136
- const loaded: LoadedSummary[] = [];
137
- const skipped: SkippedRun[] = [];
138
- for (const path of candidates) {
139
- try {
140
- const summary = normalizeSummary(await parseSummary(path), path);
141
- loaded.push({ path, summary: withViewRefs(summary, path, root, artifactDirs) });
142
- } catch (e) {
143
- const entry = classifySkip(e, path);
144
- // 无关 JSON(NotAReportError)可以静默忽略;像报告却读不了的必须记下来。
145
- if (entry) skipped.push(entry);
146
- }
147
- }
148
- loaded.sort((a, b) => b.summary.startedAt.localeCompare(a.summary.startedAt));
149
- // run 目录名是时间戳,降序 ≈ 最新在前。
150
- skipped.sort((a, b) => b.dir.localeCompare(a.dir));
151
- return { loaded, skipped, artifactDirs };
152
- }
153
-
154
- /** 只做 IO:读文件 + JSON.parse。解析失败按「可能损坏的报告」处理(文件名就叫 summary.json)。 */
155
- export async function parseSummary(path: string): Promise<unknown> {
156
- let text: string;
157
- try {
158
- text = await readFile(path, "utf-8");
159
- } catch (e) {
160
- throw new MalformedResultsError(path, `cannot read file (${e instanceof Error ? e.message : String(e)})`);
161
- }
162
- try {
163
- return JSON.parse(text);
164
- } catch {
165
- throw new MalformedResultsError(path, "invalid JSON");
166
- }
167
- }
168
-
169
- /**
170
- * 版本判定与形状校验,把磁盘 JSON 收成 view 内部统一模型:
171
- * - 带 format 信封:format 不是 niceeval.results → 无关 JSON;schemaVersion 与当前不同 → 不兼容,
172
- * 不解析、不迁移、不降级渲染(缺 schemaVersion 的早期信封按 1 处理)。
173
- * - 无信封:有 results[] + startedAt 按 legacy v0 照读(未知字段忽略);连报告痕迹都没有 → 无关 JSON。
174
- */
175
- export function normalizeSummary(raw: unknown, path: string): RunSummary {
176
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
177
- throw new MalformedResultsError(path, "not a JSON object");
178
- }
179
- const data = raw as Partial<RunSummary>;
180
- if (data.format !== undefined && data.format !== RESULTS_FORMAT) throw new NotAReportError(path);
181
- if (data.format === RESULTS_FORMAT) {
182
- const version = data.schemaVersion ?? 1;
183
- if (typeof version !== "number") throw new MalformedResultsError(path, "schemaVersion is not a number");
184
- if (version !== RESULTS_SCHEMA_VERSION) {
185
- throw new IncompatibleResultsError({
186
- dir: relative(process.cwd(), dirname(path)) || ".",
187
- schemaVersion: version,
188
- producerVersion: data.producer?.version,
189
- });
190
- }
191
- } else if (!("results" in data) && !("startedAt" in data)) {
192
- throw new NotAReportError(path);
193
- }
194
- if (!Array.isArray(data.results) || typeof data.startedAt !== "string") {
195
- throw new MalformedResultsError(path, "missing results[] or startedAt");
196
- }
197
- return data as RunSummary;
198
- }
199
-
200
- /** 把 loader 抛出的错误归类成 skipped 条目;无关 JSON 返回 undefined(不记)。 */
201
- function classifySkip(e: unknown, path: string): SkippedRun | undefined {
202
- const relPath = relative(process.cwd(), path) || path;
203
- const dir = relative(process.cwd(), dirname(path)) || ".";
204
- if (e instanceof IncompatibleResultsError) {
205
- return {
206
- path: relPath,
207
- dir: e.run.dir,
208
- reason: "incompatible-version",
209
- schemaVersion: e.run.schemaVersion,
210
- producerVersion: e.run.producerVersion,
211
- command: incompatibleViewCommand(e.run),
212
- };
213
- }
214
- if (e instanceof MalformedResultsError) {
215
- return { path: relPath, dir, reason: "malformed", detail: e.detail };
216
- }
217
- if (e instanceof NotAReportError) return undefined;
218
- // 未预期的错误也按 malformed 记:比静默吞掉更容易暴露 loader 自身的 bug。
219
- return { path: relPath, dir, reason: "malformed", detail: e instanceof Error ? e.message : String(e) };
220
- }
221
-
222
- /**
223
- * 给每条 result 注入 view 侧标注:
224
- * - attemptRef:run 目录(相对 view 根)+ summary.results 下标,`#/attempt/<run>/<result>`
225
- * 深链的身份。必须在这里(读盘顺序)捕获下标——聚合层会把 results 重排。
226
- * - artifactBase:相对 view 根的工件目录(前端据此 fetch trace.json 等)。
227
- * 返回新对象,不 mutate 读入的 summary;宿主机绝对路径只写进 artifactDirs(server 端内存),
228
- * 不挂到 result 上,避免随 viewData 进静态 HTML。
229
- */
230
- function withViewRefs(
231
- summary: RunSummary,
232
- summaryPath: string,
233
- root: string,
234
- artifactDirs: Map<string, string>,
235
- ): RunSummary {
236
- const runDir = dirname(summaryPath);
237
- // 单文件入口时 run 目录就是 view 根,relative 为空串;统一占位 "."(与 classifySkip 的目录口径一致)。
238
- const runRef = relative(root, runDir).split(/[\\/]/).join("/") || ".";
239
- return {
240
- ...summary,
241
- results: summary.results.map((r, index): ViewEvalResult => {
242
- const annotated: ViewEvalResult = { ...r, attemptRef: { run: runRef, result: index } };
243
- if (!r.artifactsDir) return annotated;
244
- const abs = join(runDir, r.artifactsDir);
245
- const base = relative(root, abs).split(/[\\/]/).join("/");
246
- artifactDirs.set(base, abs);
247
- return { ...annotated, artifactBase: base };
248
- }),
249
- };
250
- }
251
-
252
- async function findSummaryFiles(dir: string): Promise<string[]> {
253
- const entries = await readdir(dir, { withFileTypes: true });
254
- const direct = entries.filter((e) => e.isFile() && e.name === "summary.json").map((e) => join(dir, e.name));
255
- const nested = await Promise.all(entries.filter((e) => e.isDirectory()).map((e) => findSummaryFiles(join(dir, e.name))));
256
- return [...direct, ...nested.flat()];
257
- }