niceeval 0.1.1 → 0.2.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 (136) hide show
  1. package/README.md +8 -8
  2. package/README.zh.md +13 -13
  3. package/docs/README.md +119 -0
  4. package/docs/adapters/README.md +60 -0
  5. package/docs/adapters/authoring.md +179 -0
  6. package/docs/adapters/coding-agent-skills-plugins.md +324 -0
  7. package/docs/adapters/collection.md +128 -0
  8. package/docs/adapters/contract.md +263 -0
  9. package/docs/adapters/reference/agent-eval.md +215 -0
  10. package/docs/adapters/reference/agent-loop-apis.md +69 -0
  11. package/docs/adapters/reference/claude-code-otel-telemetry.md +100 -0
  12. package/docs/adapters/reference/eve-protocol.md +127 -0
  13. package/docs/adapters/reference/otel-genai.md +107 -0
  14. package/docs/adapters/reference/otel-instrumentation.md +65 -0
  15. package/docs/adapters/targets.md +99 -0
  16. package/docs/architecture.md +140 -0
  17. package/docs/assertions.md +388 -0
  18. package/docs/capabilities-by-construction.md +48 -0
  19. package/docs/cli.md +171 -0
  20. package/docs/concepts.md +106 -0
  21. package/docs/e2e-ci.md +295 -0
  22. package/docs/eval-authoring.md +319 -0
  23. package/docs/experiments.md +154 -0
  24. package/docs/getting-started.md +224 -0
  25. package/docs/multi-agent.md +145 -0
  26. package/docs/observability.md +333 -0
  27. package/docs/origin-integration.md +195 -0
  28. package/docs/references.md +35 -0
  29. package/docs/results-format.md +228 -0
  30. package/docs/runner.md +104 -0
  31. package/docs/sandbox.md +276 -0
  32. package/docs/scoring.md +119 -0
  33. package/docs/source-map.md +106 -0
  34. package/docs/tier-sync.md +177 -0
  35. package/docs/view.md +157 -0
  36. package/docs/vision.md +88 -0
  37. package/package.json +39 -5
  38. package/src/agents/ai-sdk-otel.test.ts +22 -0
  39. package/src/agents/ai-sdk-otel.ts +62 -0
  40. package/src/agents/ai-sdk.test.ts +462 -0
  41. package/src/agents/ai-sdk.ts +575 -0
  42. package/src/agents/bub.ts +30 -37
  43. package/src/agents/claude-code.ts +7 -4
  44. package/src/agents/codex.ts +15 -10
  45. package/src/agents/index.ts +48 -1
  46. package/src/agents/sdk-streams.test.ts +145 -0
  47. package/src/agents/sdk-streams.ts +457 -0
  48. package/src/agents/shared.ts +77 -39
  49. package/src/agents/streaming.test.ts +152 -0
  50. package/src/agents/streaming.ts +195 -0
  51. package/src/agents/types.ts +218 -0
  52. package/src/agents/ui-message-stream.test.ts +241 -0
  53. package/src/agents/ui-message-stream.ts +368 -0
  54. package/src/cli.ts +124 -77
  55. package/src/context/context.test.ts +203 -7
  56. package/src/context/context.ts +158 -49
  57. package/src/context/session.test.ts +96 -0
  58. package/src/context/session.ts +119 -9
  59. package/src/context/types.ts +205 -0
  60. package/src/define.ts +4 -14
  61. package/src/expect/index.ts +8 -71
  62. package/src/i18n/core.ts +28 -0
  63. package/src/i18n/en.ts +47 -5
  64. package/src/i18n/index.ts +5 -17
  65. package/src/i18n/zh-CN.ts +47 -5
  66. package/src/index.ts +12 -0
  67. package/src/loaders/index.ts +8 -39
  68. package/src/o11y/otlp/canonical.ts +7 -6
  69. package/src/o11y/otlp/mappers/codex.ts +10 -8
  70. package/src/o11y/otlp/mappers/index.ts +9 -21
  71. package/src/o11y/otlp/receiver.ts +25 -7
  72. package/src/o11y/otlp/sandbox-receiver.ts +57 -21
  73. package/src/o11y/otlp/turn-otel.test.ts +110 -0
  74. package/src/o11y/otlp/turn-otel.ts +125 -0
  75. package/src/o11y/parsers/bub.ts +13 -11
  76. package/src/o11y/parsers/claude-code.ts +27 -51
  77. package/src/o11y/parsers/codex.ts +29 -46
  78. package/src/o11y/parsers/index.ts +5 -14
  79. package/src/o11y/tool-names.test.ts +61 -0
  80. package/src/o11y/tool-names.ts +74 -0
  81. package/src/o11y/types.ts +135 -0
  82. package/src/runner/attempt.ts +456 -0
  83. package/src/runner/fingerprint.ts +59 -0
  84. package/src/runner/remote-sandbox.ts +53 -0
  85. package/src/runner/report.ts +53 -0
  86. package/src/runner/reporters/artifacts.ts +25 -4
  87. package/src/runner/reporters/console.ts +2 -8
  88. package/src/runner/reporters/live.ts +2 -8
  89. package/src/runner/reporters/shared.ts +15 -0
  90. package/src/runner/reporters/table.ts +10 -62
  91. package/src/runner/run.ts +114 -619
  92. package/src/runner/types.ts +237 -0
  93. package/src/sandbox/checkpoint.ts +15 -13
  94. package/src/sandbox/docker-stream.ts +87 -0
  95. package/src/sandbox/docker.ts +42 -120
  96. package/src/sandbox/e2b.ts +24 -75
  97. package/src/sandbox/local-files.ts +33 -0
  98. package/src/sandbox/paths.test.ts +85 -0
  99. package/src/sandbox/paths.ts +45 -0
  100. package/src/sandbox/resolve.ts +10 -34
  101. package/src/sandbox/shell.ts +17 -0
  102. package/src/sandbox/source-files.ts +42 -2
  103. package/src/sandbox/types.ts +158 -0
  104. package/src/sandbox/vercel.ts +55 -71
  105. package/src/scoring/collector.ts +3 -2
  106. package/src/scoring/judge.ts +72 -146
  107. package/src/scoring/match.ts +79 -0
  108. package/src/scoring/scoped.ts +66 -18
  109. package/src/scoring/types.ts +76 -0
  110. package/src/shared/aggregate.ts +48 -0
  111. package/src/shared/format.ts +20 -0
  112. package/src/shared/outcome.ts +48 -0
  113. package/src/shared/types.ts +37 -0
  114. package/src/types.ts +20 -911
  115. package/src/view/aggregate.ts +103 -0
  116. package/src/view/app/App.tsx +26 -5
  117. package/src/view/app/components/AttemptModal.tsx +12 -3
  118. package/src/view/app/components/CodeView.tsx +16 -19
  119. package/src/view/app/components/CopyControls.tsx +4 -4
  120. package/src/view/app/components/ExperimentTable.tsx +5 -5
  121. package/src/view/app/i18n.ts +31 -7
  122. package/src/view/app/lib/format.ts +17 -20
  123. package/src/view/app/lib/outcome.ts +4 -16
  124. package/src/view/app/lib/transcript-data.tsx +20 -4
  125. package/src/view/app/main.tsx +3 -5
  126. package/src/view/app/pages/RunsPage.tsx +2 -2
  127. package/src/view/app/pages/TracesPage.tsx +2 -2
  128. package/src/view/app/shared.ts +2 -1
  129. package/src/view/app/types.ts +9 -44
  130. package/src/view/client-dist/app.css +1 -1
  131. package/src/view/client-dist/app.js +18 -18
  132. package/src/view/index.ts +24 -401
  133. package/src/view/loader.ts +234 -0
  134. package/src/view/server.ts +136 -0
  135. package/src/view/shared/types.ts +64 -0
  136. package/src/view/styles.css +60 -9
@@ -0,0 +1,119 @@
1
+ # Scoring —— 评分器与判决
2
+
3
+ 评分把"一个 attempt 的结果"折叠成一个 **Outcome**。niceeval 有五类评分手段,它们产出统一的 `Assertion`(带名字、严重级、分数),最后由判决规则汇总。**这一篇只讲断言收集完之后怎么变成判决**;每类断言具体是什么、看哪一轮、来源哪里,查 [Assertions](assertions.md)。
4
+
5
+ 五类(详情见 Assertions 对应小节):
6
+
7
+ 1. **值级断言** —— `t.check` / `t.require` 配 `expect` 里的匹配器。`check` 同步记录并继续收集其它断言;`require` 立即等待 matcher 结果,作为前置条件失败中止。见 [Assertions · 值级断言](assertions.md#值级断言)。
8
+ 2. **作用域断言** —— `t.succeeded()` / `t.calledTool()` 等,在 `test` 结束后对本次 eval run 聚合评估;同一套断言挂在 `session` 上看单条 session,挂在 `turn` 上只看这一轮。见 [Assertions · API 分组速查](assertions.md#api-分组速查)。
9
+ 3. **LLM-as-judge** —— 用一个评判模型给开放式回答打分,`t.judge` / `session.judge` 默认评对应 session,`turn.judge` 默认评当前 turn,细节见下文。
10
+ 4. **测试即评分**(沙箱型) —— 手工在沙箱里跑测试与命令,把命令结果交给 `t.check`。
11
+ 5. **效率 / 成本断言** —— `t.maxTokens()` / `t.maxCost()` 等,把 token 花费也变成可判的维度。见 [Assertions · 作用域断言共享词汇](assertions.md#作用域断言共享词汇)。
12
+
13
+ ## 严重级:gate vs soft
14
+
15
+ 每个断言有一档严重级,决定它如何影响判决(完整定义见 [Assertions · 严重级](assertions.md#严重级gate-vs-soft)):
16
+
17
+ - **gate** —— 硬性要求,不过 → 整个 eval `failed`,任何时候都生效。`includes` / `equals` 等默认 gate。
18
+ - **soft** —— 质量分,不会单独让 eval 立即 fail。`.atLeast(x)` 本身就是 soft 带阈值的写法:非 `--strict` 下低于 x 仍 `passed`(分数照样如实记录),`--strict` 下才改判 `failed`。不调 `.atLeast()` 也不调 `.gate()` 时,走匹配器自己的默认档:`similarity` 默认 soft、阈值 0.6;judge 默认 soft、没有阈值,纯记分,任何时候都不会 fail。
19
+
20
+ > 判决只有 **passed / failed / errored / skipped** 四态,没有 `scored` 中间态。"分数不够任何时候都要 fail" → 用 `.gate()`(或默认就是 gate 的匹配器);"分数不够只在 `--strict` 下才算 fail" → 用 `.atLeast(x)`;"只想记个分,永不影响判决" → 不调 `.atLeast()`,用默认走 soft、无阈值的匹配器(如裸的 judge 调用)。
21
+
22
+ 这条规则横跨值级匹配器和 judge,行为一致:
23
+
24
+ ```typescript
25
+ t.check(t.reply, includes("晴")); // 默认 gate
26
+ t.check(t.reply, similarity(expected).atLeast(0.8)); // soft + 阈值:非 --strict 只记分;--strict 下 < 0.8 才 fail
27
+ t.judge.autoevals.closedQA("礼貌"); // 无阈值 = 默认 soft、纯分数(永不挂)
28
+ t.judge.autoevals.closedQA("礼貌").atLeast(0.7); // soft + 阈值:--strict 下 < 0.7 才 fail
29
+ ```
30
+
31
+ ## 3. LLM-as-judge
32
+
33
+ 用于"对不对靠规则说不清"的开放式回答。评判模型与被测 agent **完全分离**,避免自评。
34
+
35
+ ```typescript
36
+ t.judge.autoevals.factuality(expected).atLeast(0.8); // 事实一致性
37
+ t.judge.autoevals.closedQA("是否适合 10 岁小孩理解"); // 闭合式判断
38
+ t.judge.autoevals.summarizes(source); // 是否忠实摘要
39
+
40
+ const turn = await t.send("解释一下量子隧穿。");
41
+ turn.judge.autoevals.closedQA("这一轮回答是否适合高中生理解");
42
+ ```
43
+
44
+ `closedQA`/`factuality`/`summarizes` 挂在 `t.judge.autoevals.*`、`session.judge.autoevals.*` 和 `turn.judge.autoevals.*` 下,不留平铺别名。judge 就是这三个固定形状,评什么都落进 `closedQA`/`factuality`/`summarizes` 之一,材料也可以通过 `{ on }` 显式传。
45
+
46
+ `{ on }` 指定被评的值,`{ model }` 可单次覆盖评判模型。默认材料由接收者决定:`t.judge` / `session.judge` 默认评对应 session 的对话文本;`turn.judge` 默认评这一轮的 `turn.message`。
47
+
48
+ > **judge 默认材料按接收者分层。** `t.judge` / `session.judge` 是 session 级,适合评当前会话的整体回答质量或跨轮一致性;`turn.judge` 是 turn 级,只评这一轮的 `turn.message`。要评 sandbox 产物/diff 或其它自定义材料,显式传 `{ on }`,例如 `t.judge.autoevals.closedQA("…", { on: t.sandbox.diff.get(path) }).atLeast(0.7)`。每条断言看哪一轮、各自来源,见 [Assertions](assertions.md)。
49
+
50
+ **模型解析优先级**(高 → 低):单次调用的 `{ model }` → 这个 eval 的 `judge.model` → 配置的 `judge.model` → 环境变量 `NICEEVAL_JUDGE_MODEL`。**没有内置默认模型**:解析不到 model 而 eval 用到了 judge 断言,会报清晰错误(而不是静默打向某个写死的模型)。
51
+
52
+ ```typescript
53
+ // niceeval.config.ts —— 全局默认
54
+ defineConfig({ judge: { model: "anthropic/claude-haiku-4-5" } });
55
+
56
+ // 某个 eval 覆盖
57
+ defineEval({ judge: { model: "anthropic/claude-opus-4-8" }, async test(t) { ... } });
58
+ ```
59
+
60
+ ## 4. 测试即评分(沙箱型)
61
+
62
+ 沙箱型里,你在 `test(t)` 里手工跑的验证命令本身就是评分材料:调 `t.sandbox.runCommand(...)` 跑测试(vitest 或别的什么都行),再用 `t.check(result, commandSucceeded())` 断言退出码 0。命令执行和评分分开,不会再有 `scriptPassed()` / `testsPassed()` 这种既像执行又像断言的 API。
63
+
64
+ ```typescript
65
+ import { commandSucceeded } from "niceeval/expect";
66
+
67
+ const test = await t.sandbox.runCommand("npm", ["test"]);
68
+ t.check(test, commandSucceeded());
69
+ ```
70
+
71
+ 这让你用熟悉的测试语法表达"什么算对",并能断言文件内容、构建结果、甚至 agent 行为(经 `__niceeval__/results.json`)。没有另一层"validation 模式"开关——跑不跑测试、跑什么测试,都是 `test(t)` 里的普通代码决定的。详见 [Authoring](eval-authoring.md#沙箱型手工把文件放进沙箱)。
72
+
73
+ ## 5. 效率 / 成本断言
74
+
75
+ token 用量是评分的一等维度 —— agent 答对了但烧掉十倍 token,不该和省着用的拿一样的分。这把「质量」和「效率」拆成两组断言,跨 agent 对比时就能同时看通过率和花费。用量自动随结果带回(沙箱型从 transcript 抠,见 [Observability](observability.md#用量与成本token--计费));`maxTokens()` / `maxCost()` 是作用域断言词汇的一部分,挂在 `t` 上看整个 attempt,挂在 `session` 上看单条 session,挂在 `turn` 上看单轮。具体用法、默认严重级见 [Assertions · 作用域断言共享词汇](assertions.md#作用域断言共享词汇)。
76
+
77
+ ## 判决规则
78
+
79
+ 所有断言收齐后,运行器直接折叠成一个互斥的 **Outcome**——只有四态,没有中间的 `scored`(定义见 [Concepts · Outcome](concepts.md#评测核心词汇)):
80
+
81
+ ```text
82
+ 显式 t.skip(reason) → skipped
83
+ 执行出错(超时 / 异常 / 作者错误) → errored
84
+ 任一 gate 断言不过,或 --strict 下有 soft 断言低于阈值 → failed
85
+ 否则 → passed
86
+ ```
87
+
88
+ `failed` 只表示断言 / 评分不通过,`errored` 是环境、超时、adapter、agent runtime 等执行问题——两者互斥,`summary.failed` 与 `summary.errored` 分开计数。看报告、JUnit 或 CI 判红时按这个口径区分"agent 做错了"和"环境出问题了",不要混着看。
89
+
90
+ soft 断言(`.atLeast(x)`,或匹配器自己默认走 soft 档)不会单独造成 `failed`——除非开了 `--strict` 且它是带阈值的 `.atLeast(x)`。分数以 chip / 行尾徽章展示在每条 eval 详情里,供横向对比质量用。要让"分数不够"任何时候都 fail,用默认就是 gate 的匹配器,或显式 `.gate()`。
91
+
92
+ 多次运行(`runs > 1`)时,eval 的汇总是**通过率**(pass 占比)与平均耗时,而非单一 Outcome。
93
+
94
+ ## 自定义评分器
95
+
96
+ 值级断言就是 `(value) => number | Promise<number>`,直接写:
97
+
98
+ ```typescript
99
+ import { makeAssertion, type Assertion } from "niceeval/expect";
100
+
101
+ function jsonValid(): Assertion {
102
+ return makeAssertion({
103
+ name: "jsonValid",
104
+ severity: "gate",
105
+ score: (value) => { try { JSON.parse(String(value)); return 1; } catch { return 0; } },
106
+ });
107
+ }
108
+
109
+ t.check(t.reply, jsonValid());
110
+ ```
111
+
112
+ 需要跨多次运行聚合的指标(如 pass@k、平均工具数),在 reporter 层做,见 [Observability](observability.md#reporters)。
113
+
114
+ ## 相关阅读
115
+
116
+ - [Assertions](assertions.md) —— 每条断言做什么、看哪一轮、来源哪里(值级 / 作用域 / sandbox 结果 / 轮级的完整速查表)。
117
+ - [Authoring](eval-authoring.md) —— 断言出现在哪种 eval 里。
118
+ - [Observability](observability.md) —— transcript / o11y,作用域断言的数据来源。
119
+ - [Concepts](concepts.md) —— Severity / Outcome 的术语定义。
@@ -0,0 +1,106 @@
1
+ # Source Map —— 文档行为 → 实现文件
2
+
3
+ 文档是设计依据。这一页把每条文档行为映射回具体源码文件(参考 crabbox 的做法),
4
+ 方便对照「设计 vs 实现」;如果代码实现与文档设计不一致,应进一步讨论并决定是修代码、修设计,还是记录为明确的阶段性差异。niceeval 以 TS 源码经 `tsx` 运行,无编译步骤(`bin/niceeval.mjs` 注册
5
+ `tsx/esm/api` 后加载 `src/cli.ts`)。
6
+
7
+ ## 总览:模块 → 文件
8
+
9
+ | 设计文档里的概念 | 实现文件 |
10
+ |---|---|
11
+ | 核心类型契约(聚合 facade;类型按域住各自目录) | `src/types.ts`(re-export)← `src/shared/types.ts`(原子)、`src/o11y/types.ts`、`src/sandbox/types.ts`、`src/agents/types.ts`、`src/scoring/types.ts`、`src/context/types.ts`、`src/runner/types.ts` |
12
+ | 公开导出(`niceeval`,eval 作者用的核心面) | `src/index.ts` |
13
+ | 公开导出(`niceeval/adapter`,Agent/Adapter) | `src/agents/index.ts` |
14
+ | 公开导出(`niceeval/sandbox`,Sandbox) | `src/sandbox/index.ts` |
15
+ | `defineEval` / `defineConfig` / `defineExperiment` / `defineAgent` / `defineSandboxAgent` / `defineSandbox` | `src/define.ts` |
16
+ | `requireEnv` / 工具 | `src/util.ts` |
17
+
18
+ ## Agents 与 Adapters([adapters/contract.md](adapters/contract.md) / [adapters/authoring.md](adapters/authoring.md))
19
+
20
+ | 行为 | 文件 |
21
+ |---|---|
22
+ | Agent 契约(`kind: "sandbox" | "remote"`,无能力位字段)/ 三类配置归属 | `src/agents/types.ts`(`Agent` / `AgentContext` / `AgentSession` / `SpanMapper`) |
23
+ | 能力调用守卫(缺声明的动作第一次调用即报清晰错误;conversation gate 第二轮起) | `src/context/context.ts`(`capabilityGuard`) |
24
+ | 逐 API 适配义务(send / newSession / respond 的运行器侧翻译) | `src/context/session.ts`(`SessionManager` / `RunSession`)、`src/context/context.ts` |
25
+ | `defineSandboxAgent` / `defineAgent`(`kind: "sandbox" | "remote"`,无能力位字段) | `src/define.ts` |
26
+ | `shared` 工具袋(ensureInstalled / captureLatestJsonl(可按 sessionId 精确定位)/ writeFile / extractJsonlFromStdout / codexThreadId / firstJsonField / shellSingleQuote / diagnoseFailure / parseCodex·parseClaudeCode·parseBub) | `src/agents/shared.ts` |
27
+ | 采集矩阵(collection.md:每 agent 的通道 / 字段来源) | `src/agents/{claude-code,codex,bub}.ts`(采集)+ `src/o11y/parsers/*.ts`(字段提取) |
28
+ | `fromAiSdk`(AI SDK 结果 → 标准事件流,v4/v5/v7 字段漂移兜底;v7 tool approval → `input.requested` + `status: "waiting"`) | `src/agents/ai-sdk.ts`(+ 同目录 `.test.ts`) |
29
+ | 内置 adapter(claude-code / codex / bub) | **由被测项目自带**(`agents/*.ts`),niceeval 提供 `shared` + 解析器 |
30
+ | `uiMessageStreamAgent`(AI SDK UI Message Stream 协议的内置无侵入 adapter) | `src/agents/ui-message-stream.ts` |
31
+
32
+ ## Coding Agent Skills / Plugins DX([adapters/coding-agent-skills-plugins.md](adapters/coding-agent-skills-plugins.md))
33
+
34
+ | 行为 | 文件 |
35
+ |---|---|
36
+ | Claude Code skill / MCP setup | `src/agents/claude-code.ts`(`ClaudeCodeConfig.skills` / `mcpServers`) |
37
+ | Codex skill / MCP setup | `src/agents/codex.ts`(`CodexConfig.skills` / `mcpServers`) |
38
+ | bub Python plugin setup | `src/agents/bub.ts`(`BubConfig.pythonPlugins`) |
39
+ | 本地 skill A/B 示例 | `examples/zh/coding-agent-skill/experiments/*.ts` |
40
+
41
+ ## 标准事件流与可观测性([observability.md](observability.md))
42
+
43
+ | 行为 | 文件 |
44
+ |---|---|
45
+ | 原始 transcript → 标准 `StreamEvent[]` + 用量 + 压缩计数 | `src/o11y/parsers/{codex,claude-code,bub}.ts`、`parsers/index.ts`(`ParsedTranscript`;无按名字分派的入口,adapter 直连具体 parser) |
46
+ | 规范工具名归一(共享基表 + per-agent 差异叠加) | `src/o11y/tool-names.ts` |
47
+ | 原生 OTLP span → canonical GenAI semconv(mapper 由 Agent 经 `spanMapper` 声明,core 不按名字分支;缺省走通用 heuristic) | `src/o11y/otlp/mappers/{codex,bub}.ts`、`src/o11y/otlp/canonical.ts`(`heuristicTag` / `mapGenericSpans`) |
48
+ | run 级共享 OTLP 接收 + 逐轮归属(traceparent → `ctx.telemetry.headers`;窗口兜底 + 未确认时该 agent 轮次串行) | `src/o11y/otlp/turn-otel.ts`(`AgentOtelChannel` / `OtelReceiverPool`);接线在 `src/runner/attempt.ts`(池取通道)与 `src/context/session.ts`(`sendWithOtel`:归属 / 派生 / 合并) |
49
+ | 固定端口 / 自定义接收 host 模式(`defineConfig({ telemetry: { host, port } })`,niceeval 项目内唯一入口,不读环境变量) | `src/runner/run.ts`(`OtelReceiverPool` 取 `config.telemetry.port`)、`src/runner/attempt.ts`(`config.telemetry.host`)、`src/o11y/otlp/receiver.ts`(`makeTraceReceiver(port)`,端口被占用时报 `otel.portInUse`) |
50
+ | `deriveRunFacts`(toolCalls / subagents / parked / compactions) | `src/o11y/derive.ts` |
51
+ | o11y 摘要(注入 `__niceeval__/results.json` 的字段) | `src/o11y/derive.ts`(`buildO11ySummary`) |
52
+ | codex 用量从 `turn.completed.usage` 抠出 | `src/o11y/parsers/codex.ts` |
53
+ | 用量 → 成本(实测优先 → 用户覆盖 → 内置快照) | `src/o11y/cost.ts` |
54
+
55
+ ## Sandbox([sandbox.md](sandbox.md))
56
+
57
+ | 行为 | 文件 |
58
+ |---|---|
59
+ | `Sandbox` 统一接口 | `src/sandbox/types.ts`(`Sandbox`) |
60
+ | Docker 后端(dockerode,node:24-slim,非 root,tar 上传) | `src/sandbox/docker.ts`(编排)+ `src/sandbox/docker-stream.ts`(exec 流解复用 / tar 工具) |
61
+ | 三后端共享工具(shellQuote / find 脚本构造 / 宿主文件遍历) | `src/sandbox/shell.ts`、`src/sandbox/local-files.ts` |
62
+ | 后端选择(`auto` / `docker` / `vercel`,核心不按名字分支) | `src/sandbox/resolve.ts` |
63
+ | `defineSandbox`(自定义后端逃生舱:`create()` 直接产出 `Sandbox` 实例,`resolve.ts` 里 `r.create` 优先于内置 backend switch) | `src/define.ts`、`src/sandbox/resolve.ts`(`createBackend`) |
64
+ | 沙箱编排固定段(git 基线 / 采 diff;起始文件上传已改为 `test()` 里手工调用,不再是固定段) | `src/runner/sandbox-prep.ts` |
65
+
66
+ ## Scoring([scoring.md](scoring.md))
67
+
68
+ | 行为 | 文件 |
69
+ |---|---|
70
+ | 值级断言匹配器(includes / equals / matches / similarity / satisfies / makeAssertion) | `src/expect/index.ts` |
71
+ | 作用域断言(succeeded / calledTool / event / fileChanged / notInDiff …) | `src/scoring/scoped.ts` |
72
+ | 断言收集器(延迟评估 + 链式 gate/soft/atLeast) | `src/scoring/collector.ts` |
73
+ | LLM-as-judge(OpenAI 兼容 /chat/completions) | `src/scoring/judge.ts` |
74
+ | 判决规则(passed / failed / errored / skipped,无 `scored` 中间态) | `src/scoring/verdict.ts` |
75
+
76
+ ## `t` 上下文与会话([eval-authoring.md](eval-authoring.md))
77
+
78
+ | 行为 | 文件 |
79
+ |---|---|
80
+ | 构造 `t`(send / reply / newSession / check / 作用域断言 / judge / sandbox) | `src/context/context.ts` |
81
+ | 会话驱动(多轮 send → agent.send,事件 / 用量累加,newSession) | `src/context/session.ts` |
82
+ | 控制流信号(skip / require 失败 / turn 失败) | `src/context/control-flow.ts` |
83
+ | `t.sandbox.file(path)` 延迟引用(到 finalize 才读沙箱文件) | `src/context/context.ts`(`FileRef`) |
84
+
85
+ ## Runner / CLI / Experiments([runner.md](runner.md) / [cli.md](cli.md) / [experiments.md](experiments.md))
86
+
87
+ | 行为 | 文件 |
88
+ |---|---|
89
+ | 发现(evals/ 的 *.eval.ts,experiments/ 的实验,路径推导 id) | `src/runner/discover.ts` |
90
+ | 有界并发调度 + 早停 + budget 在飞预扣 | `src/runner/run.ts` |
91
+ | 单 attempt 生命周期(沙箱 / OTLP 接收器 Scope、超时硬边界、沙箱编排固定段) | `src/runner/attempt.ts` |
92
+ | 指纹缓存((eval 源码 + 运行配置) 哈希,跨 run 结果携入) | `src/runner/fingerprint.ts` |
93
+ | reporter 编排 + 运行级汇总 | `src/runner/report.ts` |
94
+ | remote 占位 Sandbox / eval 级本地路径视图(Proxy) | `src/runner/remote-sandbox.ts` |
95
+ | 报告器(Console / Json / JUnit / Live / 符号表) | `src/runner/reporters/{console,json,live,table,shared,index}.ts` |
96
+ | eval 级折叠 / 计票口径(CLI 表格与 view 共用) | `src/shared/outcome.ts` |
97
+ | 本地结果保存格式(`.niceeval/<run>/summary.json` + attempt 级 JSON 工件) | `src/runner/reporters/artifacts.ts`、`src/runner/types.ts`(`RunSummary` / `EvalResult`) |
98
+ | CLI(exp / list / view / clean / init,--help,parseArgs 表驱动,.env 加载,NICEEVAL_* 环境变量层) | `src/cli.ts` |
99
+ | 数据集加载器(loadJson / loadYaml) | `src/loaders/index.ts` |
100
+
101
+ ## 与设计文档的已知差异(实现取舍)
102
+
103
+ - **judge 走 OpenAI 兼容 `/chat/completions`**,base/key 解析顺序:`judge.baseUrl/apiKeyEnv` → `NICEEVAL_JUDGE_BASE`/`CODEX_BASE_URL` → OpenAI 官方。这样在只有 OpenAI 兼容代理(无 Anthropic key)的环境里 judge 自动复用代理。model 解析:eval/config 的 `judge.model` → `NICEEVAL_JUDGE_MODEL`;**没有内置默认模型**,解析不到而用到 judge 断言时报清晰错误。
104
+ - **MVP 范围**:`niceeval view` 已实现为本地 web 查看器;`init`、指纹缓存、Vercel/E2B 沙箱、失败分类、budget/strict/tag/JUnit flag 已实现。`watch` 仍未实现。运行器支持 remote `defineAgent` 的会话型 eval;文件写入、diff、验证命令仍只属于沙箱型 agent。
105
+ - **TestContext 类型**:用一个宽接口承载全部动作(运行时按 capability 守卫),而非文档设想的 TS 条件类型 —— 因为被测项目经 `tsx` 运行(不做类型检查),宽接口更省心且不影响运行时正确性。
106
+ - **接收者与评分 API 已按目标设计落地**:作用域断言对齐 eve 的接收者模型(`t` = run 级聚合视图、`session` = 单 session snapshot、`turn` = 单轮 snapshot,同一套作用域断言词汇)、会话驱动 API 补齐到 eve 形状(`t.send(input)` / `t.sendFile(path, text?)` / `t.requireInputRequest` / `t.respond` / `t.respondAll` / `t.newSession()`)、结果读取字段按接收者分开、judge 按接收者决定默认材料、Verdict/Outcome 合并成单一 `Outcome`、链式断言收窄成 `.atLeast(x)` / `.gate(x?)`、移除 `defineEval.workspace`、`t.sandbox` 作为唯一沙箱作者 API 且不暴露 `stop()`、验证命令改成 `t.sandbox.runCommand` + `t.check(result, commandSucceeded())`、judge 收窄成固定的 `autoevals.{closedQA,factuality,summarizes}`、`t.transcript` 命名空间已移除。
@@ -0,0 +1,177 @@
1
+ # Tier Sync:examples 的 origin → tier1 → tier2 同步维护方案
2
+
3
+ > **状态:已实现。** `scripts/sync-tiers.mjs`、`pnpm tiers:sync`、`pnpm tiers:check` 已落地,CI 在 `pnpm run typecheck` 之后跑 `tiers:check`。`examples/zh/.tier-sync.json` 已为现有五对目录初始化 baseTree,首次运行 `tiers:sync` 确认是无操作。
4
+
5
+ ## 问题
6
+
7
+ `examples/zh/` 里同一个应用存两份:`origin/<name>` 是接入 niceeval 之前的原始应用,`tier1/<name>` 是它的完整副本加上接入产物。存两份是刻意的——`gen:diff-code` diff 这两个目录生成 before/after 文档页,"应用侧零改动"是产品卖点,所以副本文件必须和 origin 逐字节相同(只有 `package.json` / `pnpm-workspace.yaml` / `tsconfig.json` 三个脚手架文件例外)。以 codex-sdk 为例,tier1 的 21 个跟踪文件里 9 个是 origin 的逐字节副本,4 个是允许有差异的脚手架/机器产物(`package.json`、`tsconfig.json`、`pnpm-workspace.yaml`、`pnpm-lock.yaml`),其余 8 个(`agents/`、`evals/`、`experiments/`、`niceeval.config.ts`、`README.md`)是 tier 私有新增。
8
+
9
+ 麻烦在于 origin 改得很勤(修 bug、调演示场景、升依赖),而副本不会自己跟上。今天给 `origin/codex-sdk/src/backend/server.ts` 修个 bug,就得记得 cp 一份到 tier1 的同名路径;要是动的是 `package.json`,连 cp 都不行——tier1 那份多着 niceeval 的集成行,只能打开手工重放同一行,再进 tier1 跑一遍 `pnpm install` 让 lockfile 跟上。这套动作对每个受影响的示例(现在 5 个)各来一遍,tier2 落地后再乘一层,而且全程只靠改动者的记忆串着。
10
+
11
+ 忘了会怎样?什么都不会发生——这正是最糟的部分。没有检查、没有报警,review 时"改了 origin 而 tier1 没动"的 diff 看起来完全正常。漂移安静地躺着,直到某天重新生成 diff 文档页,陈旧差异被当成"接入 niceeval 需要改的代码"展示出来,把"零改动"卖点砸掉;或者用户跑 tier1 示例,踩到 origin 早就修掉的 bug。到那时离引入漂移的那次改动已经很远,没人记得该同步什么。
12
+
13
+ 值得说清的一点:这不是"两边各改各的"的双向合并难题。实测五个示例,tier1 对共享文件的改动只有脚手架那三个文件各 2~4 行,应用源码两边完全一致;其余差异全是 tier 私有的新增文件(`agents/`、`evals/`……)和机器产物 lockfile。tier1 本质上就是"origin + 一小层接入 delta",我们缺的操作就是 `tier1 rebase origin`:origin 动了,一条命令把 delta 重放到新 origin 之上。只是原生 `git rebase` 作用于分支,而这里是同一棵树里的两个目录,原生命令用不上——本方案就是给目录对补上这个动词。
14
+
15
+ ## 方案一句话
16
+
17
+ **用 rebase 的底层机制直接作用于目录树。** git 的 rebase/cherry-pick 每重放一步,内部就是一次"以共同祖先为 base 的三方合并";这个机制不要求输入是分支,`git merge-tree --write-tree --merge-base=<base>`(git ≥ 2.38)可以对任意三棵 tree 使用。于是:
18
+
19
+ - 每对目录记录一个"上次同步时上游目录的 tree hash"作为 base(相当于 rebase 里的分叉点);
20
+ - `tiers:sync` = 一条 `git merge-tree --write-tree --merge-base=<base> <tier树> <上游树>`,把 origin 自分叉点以来的全部变更重放进 tier,同时保住 tier 的接入 delta——**这就是 `tier1 rebase origin`**,冲突体验也和 rebase 一致(就地留标记,人解完继续);
21
+ - CI 用 tree hash 比对做秒级防漂移检查。
22
+
23
+ **不维护 overlay 清单**("哪些文件是 tier 私有的"由三方合并自动推导),**patch 只作为同步后自动导出的阅读产物,不作为事实源**。这也是 Google copybara / `git subtree` 解决的"目录级 vendoring + 上游追踪"问题的仓库内轻量版:合并机制 100% 是 git 自己的,脚本只做粘合。
24
+
25
+ ## 为什么这样做(以及否决了什么)
26
+
27
+ ### 否决:把示例做成真分支,用原生 `git rebase`
28
+
29
+ 既然想要的是 rebase,最直觉的做法是让它真的可 rebase:每个示例的 origin 放一条独立分支,tier1 是它之上的接入提交,同步 = 原生 `git rebase`。否决理由:
30
+
31
+ - **示例必须同时存在于 main 的工作树里。** 文档链接指向真实目录、用户 clone 后并排浏览 origin 与 tier1、`gen:diff-code` 直接 diff 两个目录——这些都要求两份代码在同一棵树上共存。分支化之后,还得把每条分支的内容物化回 main 的目录(否则以上全断),于是"分支"与"物化目录"变成双份记账,比现在更糟;
32
+ - **分支数量是 示例数 × tier 层数,** 五个示例两层就是 10+ 条长期分支,rebase 后全部需要 force-push,历史被反复改写,协作成本失控;
33
+ - `git subtree split` 能把目录合成出伪分支再 rebase 回填,但那是三步咒语级的操作,比直接对目录树做三方合并绕远得多。
34
+
35
+ 结论:要的是 rebase 的**机制**,不是 rebase 的**命令**。机制(同 base 三方合并)可以脱离分支直接用在目录树上,见"方案一句话"。
36
+
37
+ ### 否决:patch 作为事实源(tier 只存 diff patch,apply 出目录)
38
+
39
+ 即"tier1 只存对 origin 的 diff patch,tier2 存对 tier1 的 patch"(Debian quilt / 内核补丁队列模式)。patch 的"方便阅读"很诱人,但作为**存储格式**有四个硬伤:
40
+
41
+ - **双事实源,DX 崩坏。** 改接入代码(evals、agents、脚手架几行)时,要么手写 patch 文件——没人受得了;要么改物化目录再重新导出——目录和 patch 变成两份事实源,永远在打架。git history 也不可读了:改一行 eval,commit diff 是"diff 的 diff"。
42
+ - **示例必须是可运行、可浏览的真实目录。** 仓库规则要求文档链接指向真实目录,用户会 clone 后直接 `cd examples/zh/tier1/codex-sdk` 跑起来、在 GitHub 上直接读代码(有语法高亮和跳转,patch 文件没有)。物化产物要么提交(回到原点),要么 gitignore(文档链接与 GitHub 浏览全断)。
43
+ - **lockfile 无法用 patch 维护。** `pnpm-lock.yaml` 的内容随依赖版本剧烈漂移,patch 很快 apply 失败;它只能由 `pnpm install` 重新生成。
44
+ - **堆叠 patch 是出了名的维护地狱。** origin 改一行,可能要连修 tier1、tier2 两层 patch 的 fuzz/reject。现在的痛是"复制一遍",换成堆叠 patch 后痛变成"手工解 reject",更糟。
45
+
46
+ **"方便阅读"这个需求单独满足,不必绑架存储格式**:`gen:diff-code` 已经从两个物化目录生成 before/after 阅读页;本方案再让 `tiers:sync` 顺手导出一份 `<name>.patch` 纯阅读件(见下文)。patch 当**输出**,不当**输入**。
47
+
48
+ ### 否决:纯复制 + allowDiff 清单
49
+
50
+ 即维护一份清单声明"哪些文件 tier 私有、哪些允许有差异",同步 = 清单之外的文件从上游整文件复制。比 patch 好,但有两个硬伤:
51
+
52
+ - **清单要人工维护,且各示例不同**(langgraph 的 `package.json` 是 tier 私有,其它示例的是"共享但微改"),清单本身会漂移;
53
+ - **allowDiff 即检查盲区**:`package.json` 一旦列入 allowDiff,origin 给应用加依赖时,检查发现不了 tier1 没跟上。
54
+
55
+ 三方合并没有这两个问题:私有文件自动推导,`package.json` 走真合并——origin 的新依赖能不能干净合入 tier1 的 `package.json`,取决于新依赖是否落在 tier 那 +2 行改动的同一位置(两者都紧挨着追加时会冲突,见下文"已知取舍");但即便冲突,也是显式报错,而不是像 allowDiff 清单那样检查不到。
56
+
57
+ ### 采纳三方合并的正面理由
58
+
59
+ - **改 origin 后 tier 自动快进**:tier 从不改 `src/`,所以 `src/` 的所有变更都是无冲突快进复制,覆盖日常 95% 的场景;
60
+ - **逐字节不变的铁律从"靠自觉"变成"CI 保证"**:检查模式发现漂移直接红;
61
+ - **`gen:diff-code`、"接入只要 10-50 行"的验收方式完全不变**:两边仍是完整目录,`diff -r` 照旧;
62
+ - **tier2 免费获得同样机制**:上游指向 tier1 即可,形成 origin → tier1 → tier2 的链。
63
+
64
+ 已知取舍:三方合并按行进行,两边在文件同一位置追加会冲突。这不是理论风险——沙盘验证时就复现了一例:origin 给 `dependencies` 加 `zod`、tier 在同一位置有 `niceeval`,merge-tree 就地留下标记要求人工裁决,体验与 rebase 冲突完全一致。按现有 diff 形状(脚手架文件各自只动 2~4 行)冲突频率低,且永远是**显式报错**(留标记 + check 拦截)而非静默错合。文件重命名被视为"删除 + 新增",无 rename 检测;origin 重命名文件时 tier 侧若改过该文件会报一次冲突,人工确认即可。
65
+
66
+ ## 如何实现
67
+
68
+ ### 状态文件
69
+
70
+ 单独一份 `examples/zh/.tier-sync.json`,**不放进各示例目录**——tier 目录里多任何一个文件都会出现在 `gen:diff-code` 的 before/after 页上,污染"接入只新增了这些文件"的叙事:
71
+
72
+ ```json
73
+ {
74
+ "pairs": [
75
+ { "from": "examples/zh/origin/codex-sdk", "to": "examples/zh/tier1/codex-sdk", "baseTree": "a1b2c3..." },
76
+ { "from": "examples/zh/origin/langgraph", "to": "examples/zh/tier1/langgraph", "baseTree": "d4e5f6..." }
77
+ ]
78
+ }
79
+ ```
80
+
81
+ `baseTree` 是上次同步时上游目录的 git tree hash(`git rev-parse HEAD:examples/zh/origin/codex-sdk`)。base 的**内容**不需要另存——git 对象库天然保管,`git cat-file blob <baseTree>:<相对路径>` 随取随用。配对关系也可由约定推导(同名目录出现在相邻两层即为一对),状态文件里只需 baseTree;先按显式 pairs 实现,更直白。
82
+
83
+ ### 同步算法(`pnpm tiers:sync [name]`)
84
+
85
+ 前置条件:**上游与 tier 两侧目录都必须无未提交改动**(`git status --porcelain <from> <to>` 为空)——合并的三个输入都取自提交过的 tree,同步才可复现、可回溯。工作流固定为:改 origin → 提交 → sync → review → 提交 tier。
86
+
87
+ 对每一对 (from, to),核心是一条 git 命令:
88
+
89
+ ```sh
90
+ git merge-tree --write-tree --merge-base=<baseTree> \
91
+ $(git rev-parse HEAD:examples/zh/tier1/codex-sdk) \
92
+ $(git rev-parse HEAD:examples/zh/origin/codex-sdk)
93
+ ```
94
+
95
+ 输出第一行是合并后的 tree hash(退出码非零表示有冲突,后续行给出冲突文件清单;冲突文件在结果 tree 里已含 `<<<<<<<` 标记)。脚本把结果 tree 检出到 tier 目录(如 `git archive <tree> | tar -x -C <to>`)即完成同步。该命令已在 git 2.50 上沙盘验证:源码修改与新增文件干净重放、tier 私有文件原样保留、同点追加正确报冲突。逐文件语义与 git merge/rebase 完全一致,等价于下表:
96
+
97
+ | 上游侧(base → 现在) | tier 侧(base → 现在) | 动作 |
98
+ | --- | --- | --- |
99
+ | 没变 | 任意 | 不动 |
100
+ | 变了 | 未改(== base) | **快进**:整文件复制 |
101
+ | 变了 | 改过 | `git merge-file` 三方合并;冲突留标记并报出 |
102
+ | 新增 | tier 无同名文件 | 复制过来 |
103
+ | 新增 | tier 已有同名文件 | 报冲突(极罕见,人工裁决) |
104
+ | 删除 | 未改(== base) | 跟着删 |
105
+ | 删除 | 改过 | 报冲突 |
106
+
107
+ 只出现在 tier 侧、base 与上游都没有的文件,自动视为 **tier 私有**,永远不碰——`agents/`、`evals/`、langgraph 的 `package.json` 都落在这条规则里,无需任何配置。
108
+
109
+ 排除项与特例:
110
+
111
+ - `pnpm-lock.yaml` 从合并结果中剔除(检出时跳过),`node_modules/`、`.venv/` 本就未被 git 跟踪;合并后若 `package.json` / `pnpm-workspace.yaml` 有变动,在 tier 目录执行 `pnpm install` 重新生成 lockfile;
112
+ - 二进制文件 git 无法文本合并,两侧都改过时会作为冲突报出,人工裁决;
113
+ - 全部干净(或冲突已解)后,把该 pair 的 `baseTree` 更新为上游当前 tree hash,写回状态文件;
114
+ - 收尾时导出阅读件:`git diff <上游tree> <tier tree> > examples/zh/diffs/<name>.patch`(排除 lockfile),这份 patch 是自动再生的**展示产物**,供快速阅读"接入改了什么",与 `gen:diff-code` 的文档页同源同性质,永远不作为同步输入。
115
+
116
+ 链式同步按拓扑顺序:先 origin → tier1,再 tier1 → tier2。
117
+
118
+ 冲突处理与 git 一致:冲突文件就地留 `<<<<<<<` 标记,脚本列出清单并以非零码退出;人解完标记后重跑 `tiers:sync`(此时 tier 侧内容视为"改过",与上游一致或合并干净即收尾)。
119
+
120
+ ### 检查模式(`pnpm tiers:check`,进 CI)
121
+
122
+ 不做合并,只做两件事,秒级完成:
123
+
124
+ 1. 每对的 `baseTree` ≟ `git rev-parse HEAD:<from>`——不等即"上游变了但 tier 未同步",红,提示跑 `pnpm tiers:sync`;
125
+ 2. 扫描 tier 跟踪文件中的 `<<<<<<<` 冲突标记,有则红。
126
+
127
+ ### 实现载体
128
+
129
+ - `scripts/sync-tiers.mjs`(约 200 行粘合代码:读状态文件、调 `git merge-tree --write-tree`、检出结果、跑 `pnpm install`、导出 patch 阅读件),合并机制本身 100% 由 git 提供,不引第三方依赖;要求 git ≥ 2.38(`merge-tree --write-tree` 的最低版本);
130
+ - `package.json` 增加 `"tiers:sync": "node scripts/sync-tiers.mjs sync"`、`"tiers:check": "node scripts/sync-tiers.mjs check"`;
131
+ - CI(现有 lint/typecheck 步骤旁)加一步 `pnpm tiers:check`。
132
+
133
+ ## 日常工作流(before / after)
134
+
135
+ 改 origin 应用源码——现在:
136
+
137
+ ```sh
138
+ vim examples/zh/origin/codex-sdk/src/backend/agent.ts
139
+ # 然后必须人肉记得 tier1 有一份同样的文件:
140
+ cp examples/zh/origin/codex-sdk/src/backend/agent.ts \
141
+ examples/zh/tier1/codex-sdk/src/backend/agent.ts
142
+ # 忘了 cp?没有任何检查会发现,diff 文档页从此静默失真。
143
+ ```
144
+
145
+ 改 origin 应用源码——方案落地后:
146
+
147
+ ```sh
148
+ vim examples/zh/origin/codex-sdk/src/backend/agent.ts
149
+ git add examples/zh/origin/codex-sdk && git commit -m "..."
150
+ pnpm tiers:sync # ≈ tier1 rebase origin;必要时自动 pnpm install
151
+ git diff --stat # review tier1 侧的机器改动
152
+ git add -A examples/zh && git commit -m "sync tiers"
153
+ ```
154
+
155
+ 改 tier 私有文件(evals / agents / README):直接改,与同步机制无关。
156
+
157
+ 改 tier1/tier2 里"应该跟上游逐字节一致"的共享文件(比如临时改一行 `src/backend/server.ts` 去验证个问题):这条路径不是方案设计要覆盖的——前提就是"tier 从不改 src/"(见"采纳三方合并的正面理由"),所以没有专门命令。改完之后有两条路:要么把改动誊回对应的 origin 文件、提交、再跑一遍 `tiers:sync` 走正常快进,让改动"转正"成一次 origin 变更;要么先放着,等下次 origin 改到同一文件同一处时被 `tiers:sync` 报冲突——冲突文件里是 `<<<<<<<` 标记,把两边改动合到一起、删掉标记、重跑 `tiers:sync` 收尾,和解普通 git rebase 冲突是同一套动作。
158
+
159
+ 反过来,如果是在 tier1/tier2 里先调试出的修复,想让 origin 也拿到:同步方向是单向的 origin → tier1 → tier2,没有 backport 命令。得手工把改动誊回 origin 对应文件、提交、跑 `tiers:sync`,让 origin 补上这次变更、baseTree 也随之前进——不然下次同步会把这处改动误判成冲突。
160
+
161
+ origin 给应用加依赖(动了 `package.json`)——三方合并把新依赖行合进 tier1 的 `package.json`(tier 自己的 `"niceeval": "file:../../../.."` 在另一行,不冲突),随后自动 `pnpm install` 更新 lockfile。
162
+
163
+ 忘了同步就提交?CI 的 `tiers:check` 红:
164
+
165
+ ```text
166
+ ✗ examples/zh/tier1/codex-sdk 落后于 origin/codex-sdk
167
+ base a1b2c3… ≠ 当前 9f8e7d…,运行 pnpm tiers:sync 后重新提交
168
+ ```
169
+
170
+ ## 验收标准(实现时逐条核对)
171
+
172
+ 1. 对现有五个示例首次初始化 base 后,`tiers:sync` 是无操作(两边已一致);
173
+ 2. 改 origin 任一 `src/` 文件 → sync 后 tier1 同文件逐字节一致,`gen:diff-code` 输出不含该文件;
174
+ 3. 改 origin `package.json`(加一个依赖)→ sync 后 tier1 的 `package.json` 同时含新依赖与 niceeval 集成行,lockfile 已重装;
175
+ 4. origin 与 tier1 在同一文件同一区域都有改动 → sync 报冲突、留标记、非零退出;`tiers:check` 在标记未解前保持红;
176
+ 5. langgraph(origin 为 Python、`package.json` 为 tier 私有)全流程不误伤 tier 私有文件;
177
+ 6. `tiers:check` 在 base 落后时红、同步后绿,全程不写任何文件。
package/docs/view.md ADDED
@@ -0,0 +1,157 @@
1
+ # View —— 本地结果查看器(`niceeval view`)
2
+
3
+ 控制台和 `summary.json` 是「当下」的;`niceeval view` 是「事后看图」——不连任何外部服务,只读 `.niceeval/<时间戳>/` 这些结构化工件。结果保存格式见 [Results Format](results-format.md),数据来源见 [Observability](observability.md#结果可视化niceeval-view)。
4
+
5
+ ```sh
6
+ niceeval view # 起本地 web,自动打开浏览器,读 .niceeval/ 下所有历史运行
7
+ niceeval view .niceeval/<run>/summary.json
8
+ niceeval view --no-open # 只打印 URL,不打开浏览器
9
+ niceeval view --out .niceeval/report.html # 导出静态 HTML
10
+ ```
11
+
12
+ 架构上是**一次性烘焙进单个 HTML+JSON 的静态产物**(`src/view/index.ts` 的 `renderHtml`),不是常驻的多页面 server——`niceeval view` 起的 web 服务每次请求现读现渲染,`--out` 则直接导出成一个可以当 CI 附件传、单文件分享的 HTML。这是刻意的取舍,详见 [References](references.md#调研过判断不值得抄的及理由)。
13
+
14
+ ## 结果版本机制
15
+
16
+ `niceeval view` 读取的是已经落盘的报告,而报告会比 CLI 活得更久:用户可能几个月后打开 `.niceeval/<run>/summary.json`,也可能把 CI 产物下载到另一台机器上看。所以 view 的版本策略要优先保证两件事:
17
+
18
+ 1. 新版本 CLI 不轻易丢下旧报告。
19
+ 2. 真读不了时,错误信息要告诉用户「该用哪个版本看」或「这份历史结果可以删掉」。
20
+
21
+ 具体设计:
22
+
23
+ - **报告自己带版本。** `summary.json` 顶层放 `format: "niceeval.results"`、`schemaVersion` 和 `producer.version`,设计见 [Results Format · 版本与升级设计](results-format.md#版本与升级设计)。没有这些字段的现有报告视为 legacy v0。
24
+ - **view 有一个支持区间。** 例如当前 view 支持 `schemaVersion` 0 和 1。支持区间写在 loader 常量里,不要散落在 React 组件里。
25
+ - **先 normalize,再渲染。** `readSummary` 不直接返回磁盘 JSON,而是走 `normalizeSummary(raw, path)`。legacy v0、v1、未来 v2 都在这里转成 view 内部统一模型;`aggregateRows`、`AttemptModal`、`TracesPage` 只吃 normalized model。
26
+ - **未知字段不是错误。** 新增 `git`、`environment`、`agentSetup`、`classification` 等字段时,旧 view 可以忽略;新增 artifact kind 也只是不展示。只有必需字段缺失、字段类型错误、或 `schemaVersion` 超出支持区间才算版本/格式错误。
27
+
28
+ ### 报错与降级
29
+
30
+ `view` 有两种入口,错误处理不同:
31
+
32
+ - **读整个 `.niceeval/` 目录。** 遇到某个 run 读不了,不要让整个页面失败。loader 应收集 `skippedRuns[]`,继续渲染其它 run,并在页面顶部显示一条可展开提示:哪些 `summary.json` 被跳过、原因是什么、建议怎么处理。
33
+ - **读单个 `summary.json`。** 这是用户明确指定的目标,读不了就应该让命令失败,打印可执行的下一步,不要打开一个空页面。
34
+
35
+ 错误分类:
36
+
37
+ | 场景 | 行为 | 文案要点 |
38
+ |---|---|---|
39
+ | 没有 `format` / `schemaVersion`,但有 `results[]` + `startedAt` | 按 legacy v0 读 | 可弱提示「旧格式报告」,不阻断 |
40
+ | `format` 不是 `niceeval.results` | 跳过或失败 | 说明这不是 niceeval 报告 |
41
+ | `schemaVersion` 小于最低支持版本 | 跳过或失败 | 建议用写出该报告的 `producer.version` 打开,或先导出/归档后 `niceeval clean` |
42
+ | `schemaVersion` 大于当前支持版本 | 跳过或失败 | 建议升级 niceeval,或用报告里的 `producer.version` 对应版本打开 |
43
+ | 必需字段坏了,例如 `results` 不是数组 | 跳过或失败 | 说明报告可能损坏,给出文件路径 |
44
+ | attempt 工件缺失,例如 `events.json` 不存在 | 页面仍可打开 | 只在展开该 attempt 时显示「artifact missing」 |
45
+
46
+ 命令行错误要给到具体命令,例如:
47
+
48
+ ```text
49
+ Cannot open .niceeval/2026-07-02T03-10-24-123Z/summary.json.
50
+ This report was written by niceeval 0.12.0 with results schema 3,
51
+ but this viewer supports schema 0-1.
52
+
53
+ Try:
54
+ npx niceeval@0.12.0 view .niceeval/2026-07-02T03-10-24-123Z/summary.json
55
+ pnpm dlx niceeval@0.12.0 view .niceeval/2026-07-02T03-10-24-123Z/summary.json
56
+
57
+ If you no longer need historical reports:
58
+ niceeval clean
59
+ ```
60
+
61
+ 如果 `producer.version` 缺失,文案退化成「upgrade niceeval」或「try an older niceeval matching when the report was created」,不要编造版本号。
62
+
63
+ ### 迁移策略
64
+
65
+ 默认不做隐式迁移。理由:
66
+
67
+ - eval 结果是审计材料,原地改写会让「当时到底写出了什么」变模糊。
68
+ - view 是读工具,不应该因为用户想看报告而修改 `.niceeval/`。
69
+ - 大多数版本不需要迁移:新增字段、未知 artifact、UI 展示变化都可以通过兼容 loader 解决。
70
+
71
+ 真正需要迁移时再加显式命令,并且默认写到新目录:
72
+
73
+ ```sh
74
+ niceeval migrate-results .niceeval/<old-run>/summary.json --out .niceeval/<new-run>/
75
+ ```
76
+
77
+ 迁移命令只解决「旧格式还能确定转换到新格式」的情况;如果某份报告依赖旧版本 view 的展示逻辑,更好的建议仍然是:
78
+
79
+ ```sh
80
+ npx niceeval@<producer.version> view .niceeval/<run>/summary.json
81
+ ```
82
+
83
+ `niceeval clean` 不是迁移工具,只负责删除当前项目的历史运行结果。它适合用户明确表示「旧报告不要了,只想让 view 干净」的场景;不应该在 view 报错时自动执行。
84
+
85
+ ## 现状(已实现)
86
+
87
+ - **三个 tab**:Experiments(按 `experimentId` 聚合的对比榜单,`GroupSelector` 选组、`ExperimentTable` 展示同组内配置并排,点开一行钻到 eval / attempt 级明细)、Runs(所有 run 打平成一张表)、Traces(trace 瀑布图)。
88
+ - **运行总览指标** —— pass / fail / error / skip 计数、总 token、总 $。
89
+ - **eval attempt 钻取** —— `AttemptModal` 点开单个 attempt 看断言、错误、耗时、用量、transcript、trace。
90
+ - **trace 瀑布图** —— 把 `trace.json` 画成时间轴瀑布,只读 canonical(`gen_ai.operation.name` → `kind`、`gen_ai.*`),不认任何原生 span 名,所以不同 agent 的图天然对齐、可叠加对比。
91
+
92
+ ## 已知的文档 vs 实现差异
93
+
94
+ 这两条之前被 [Observability](observability.md) 的能力列表当成已实现的写了,这次审查代码(`src/view/index.ts`、`src/view/app/`)发现对不上,已经从那边挪过来,归到下面「计划中」:
95
+
96
+ - **"跨运行趋势"实际是合并,不是可对比的历史。** `aggregateRows`(`src/view/index.ts`)把 `.niceeval/` 下**所有**历史 `summary.json` 按 `experimentId` 揉进同一行——通过率、平均耗时、成本都是跨全部历史 run 的累计值,不是"最新一次"或"某一次"的快照,更谈不上画成随时间变化的线。
97
+ - **"质量 × 成本散点图"没有实现。** `src/view/app` 下没有任何图表 / scatter / canvas 组件,现有可视化都是表格和文字指标。
98
+
99
+ ## 外部参考
100
+
101
+ ### agent-eval playground
102
+
103
+ **是什么:** Vercel `agent-eval` 项目下的 `packages/playground`,发布为 `@vercel/agent-eval-playground`。一个独立的 Next.js web 应用,`npx @vercel/agent-eval-playground` 直接跑,提供 `/`(总览)、`/experiments`、`/experiments/[name]/[timestamp]`、`/evals`、`/evals/[name]`、`/compare`、`/transcript/[...]` 几个路由。零数据库、零 API 路由——所有页面是 Server Component,`force-dynamic`,每次请求都现读 fs,永远是盘上最新数据。
104
+
105
+ **怎么做的:**
106
+
107
+ - `bin.mjs` 解析 `--results-dir` / `--evals-dir` / `--port` 几个 flag,resolve 成绝对路径塞进 `RESULTS_DIR` / `EVALS_DIR` 环境变量,再 `spawn` 包自带的 `next start -p <port>`(注意:README 写的是 `next dev`,实际跑的是 production 的 `next start`)。
108
+ - `lib/data.ts` 是所有数据读取的唯一入口,纯 `fs.readdirSync`/`readFileSync`,没有缓存也没有数据库:
109
+ - `listExperiments`/`getExperiment` 递归 walk `results/` 目录树,遇到子目录名匹配 ISO 时间戳(`/^\d{4}-\d{2}-\d{2}T/`)就判定它的父目录是一个 experiment、这些时间戳目录就是它的历史 run 列表。
110
+ - `getExperimentDetail(name, timestamp)` 在某次 run 目录下再递归找带 `summary.json` 的子目录(= 一个 eval 的结果),读 `summary.json` + 每个 `run-N/result.json`。
111
+ - `listEvals`/`getEvalDetail` 递归 walk `evals/` 目录,遇到带 `PROMPT.md` 的目录就判定是一个 eval fixture。
112
+ - `/compare`(`components/ComparePage.tsx`,client component)两个下拉框选"某个 experiment 的某次 run",候选项和对应的完整 `ExperimentDetail` 都由服务端预先读好、一次性传给客户端(不是选中后才 fetch)。选中两边后纯前端算 delta:整体 `avgPassRate`/`avgDuration` 对两边的 `evals[]` 取平均相减;per-eval 按 eval name 取并集,逐行对比 `passRate`/`meanDuration`,delta 用颜色区分涨跌。
113
+ - **关键点:** "能任意选两次运行对比"完全建立在**目录结构天然保留时间戳身份**上——`results/<experiment>/<ISO-timestamp>/` 从不合并,每次 run 落一个新目录,`getExperiment` 返回的 `timestamps: string[]` 就是完整历史列表,`/compare` 只是在这份现成的列表上做了个下拉选择器 + 前端减法。
114
+
115
+ **跟 niceeval 的差异(为什么不能直接照搬这套形状):** playground 是多页面、每次请求都读 fs 的 live Next server;niceeval `view` 是一次性烘焙进单个 HTML+JSON 的静态产物(见上文"架构上"一段)。playground 靠"存储层本来就是每次 run 一个新目录"天然拿到历史身份;niceeval 现在的 `aggregateRows` 反而是**主动把**同一个 `experimentId` 的所有历史 run **合并**成一行(见上文"已知的文档 vs 实现差异")。所以 niceeval 要做 Compare,抄的是"保留快照身份、不要提前合并"这个**原则**,不是 playground 的目录结构或 API 形状——数据仍然得在生成 HTML 那一刻就把所有候选快照的统计算好塞进 `viewData`,不能假设前端能像 playground 一样随时再去问 fs。
116
+
117
+ 调研时更完整的"抄了什么 / 为什么不抄"决策记录见 [References](references.md#vercel-agent-eval--packagesplayground)。
118
+
119
+ ## 计划中的小功能
120
+
121
+ ### 结果版本提示与跳过列表
122
+
123
+ 实现上先补 loader,再补 UI:
124
+
125
+ 1. `loadSummaries` 返回 `{ loaded, skipped }`,其中 `skipped` 记录 path、reason、detected `schemaVersion`、detected `producer.version`。
126
+ 2. `readSummary` 拆成 `parseSummary` + `normalizeSummary`。normalize 支持 legacy v0 和当前 v1。
127
+ 3. 目录入口继续渲染已成功读取的 run,页面顶部增加可展开的 skipped report 提示。
128
+ 4. 单文件入口遇到不兼容 schema 时直接退出,打印上文的 `npx niceeval@... view ...` 建议。
129
+
130
+ 这比一开始就做 migration 更实际:多数用户只是想看当前报告;旧报告读不了时,用写出它的旧版本 viewer 打开比自动转换更可控。
131
+
132
+ ### Compare —— 挑两次运行对比
133
+
134
+ 跟 `experiments/compare/`(文档里"一组可对比实验"的示例文件夹名,见 [Experiments](experiments.md#实验怎么组织文件夹--一组可对比的实验))是两回事,别混——这里指 view 里一个新增的小 tab。
135
+
136
+ **动机:** 上面"已知的文档 vs 实现差异"提到的问题——现在选不出"这次 vs 上次",只有累计历史。参考对象是上面[外部参考](#agent-eval-playground)里的 playground `/compare` 页。
137
+
138
+ **数据模型:** 现有的 `rows`(累计视图)继续服务 Experiments / Runs / Traces 三个 tab——"这个 agent 整体现在什么水平"仍然是合并全部历史更有用的默认视图,不动它的语义。新增一份**不合并**的快照列表,按 `(experimentId, startedAt)` 索引,每个快照携带该次 run 里这个 experiment 的 eval 级统计(复用 `evalLevelStats` 的输出形状)。这份数据随 `viewData` 一起烘焙进静态 HTML(不像 playground 能按需查 fs)。
139
+
140
+ **UI:** 在 `src/view/app/App.tsx` 的 `navItems` 加一个 `compare`。两个下拉选"快照"(`experimentId @ startedAt`),不限制两边必须是同一个 `experimentId`;选完出整体通过率 / 平均耗时 / 总成本三个 KPI delta,加一张 per-eval 并排表(复用现成的 `outcomeOf` / `formatPercent` / `formatDuration` / `formatCost`)。只跑过一次、没有历史快照时,下拉只有一项,提示"再跑一次才能对比",不报错。
141
+
142
+ **明确不做的:** 不做时间序列折线图(历史快照一多不适合塞进单个静态 HTML,而且这次要补的是"挑两点"这个最小能力);不改 Experiments tab 现有的"累计历史"默认语义(这是另一个值得讨论的问题,不在这次一起改)。
143
+
144
+ ### 质量 × 成本散点图
145
+
146
+ 之前文档写过、实际没做。没有具体设计,先记一句:每个 eval(或每个 agent)一个点,一眼看出"贵且不准"的角落——值得补,但优先级低于 Compare。
147
+
148
+ ### Eval 目录页
149
+
150
+ 独立于"跑过的结果",单纯浏览 `evals/` 目录下每个 fixture 的 `PROMPT.md` 和文件列表,不用先跑一次才能看"有哪些 eval、prompt 写的什么"(learnings 见 [References](references.md#vercel-agent-eval--packagesplayground))。没有具体设计,优先级低于 Compare。
151
+
152
+ ## 相关阅读
153
+
154
+ - [Observability](observability.md#结果可视化niceeval-view) —— 事件流、trace、usage/cost 这些 view 渲染的数据从哪来。
155
+ - [Results Format](results-format.md) —— view 读取的 `.niceeval/<run>/summary.json` 与 attempt 级 JSON 工件。
156
+ - [References](references.md#vercel-agent-eval--packagesplayground) —— 这次调研 agent-eval playground 的完整记录。
157
+ - [Experiments](experiments.md) —— `experimentId`、可对比组、`niceeval exp` 怎么产生这些历史快照。