niceeval 0.9.0 → 0.9.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 (39) hide show
  1. package/dist/i18n/zh-CN.d.ts +1 -1
  2. package/dist/report/report.js +2 -2
  3. package/dist/report/tree.d.ts +1 -1
  4. package/dist/report/tree.js +1 -1
  5. package/dist/results/select.d.ts +2 -2
  6. package/dist/results/select.js +1 -1
  7. package/dist/shared/aggregate.d.ts +1 -1
  8. package/dist/shared/aggregate.js +1 -1
  9. package/docs-site/zh/how-to/custom-reports.mdx +1 -1
  10. package/docs-site/zh/how-to/experiments.mdx +2 -2
  11. package/docs-site/zh/how-to/publish-report.mdx +6 -0
  12. package/docs-site/zh/how-to/viewing-results.mdx +14 -5
  13. package/docs-site/zh/reference/cli.mdx +2 -2
  14. package/docs-site/zh/troubleshooting/debugging.mdx +3 -3
  15. package/package.json +1 -1
  16. package/src/cli.ts +7 -8
  17. package/src/i18n/en.ts +3 -3
  18. package/src/i18n/zh-CN.ts +3 -3
  19. package/src/report/dual-render.test.tsx +2 -2
  20. package/src/report/react/styles.css +12 -6
  21. package/src/report/report.ts +2 -2
  22. package/src/report/tree.ts +2 -2
  23. package/src/results/host-equivalence.test.ts +1 -1
  24. package/src/results/select.ts +2 -2
  25. package/src/shared/aggregate.ts +1 -1
  26. package/src/show/index.ts +2 -2
  27. package/src/show/render.ts +1 -1
  28. package/src/show/report-host.test.ts +2 -2
  29. package/src/show/report-host.ts +1 -1
  30. package/src/show/show.test.ts +1 -1
  31. package/src/view/app/App.test.tsx +7 -6
  32. package/src/view/app/App.tsx +18 -6
  33. package/src/view/client-dist/app.css +1 -1
  34. package/src/view/client-dist/app.js +1 -1
  35. package/src/view/data.ts +20 -9
  36. package/src/view/index.ts +2 -12
  37. package/src/view/server.ts +4 -4
  38. package/src/view/styles.css +18 -1
  39. package/src/view/view-report.test.ts +44 -15
package/src/view/data.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type HostReportAsset,
21
21
  } from "../show/report-host.ts";
22
22
  import { selectCurrentResults, filterExperiments } from "../results/select.ts";
23
+ import { evalPrefixPredicate } from "../shared/aggregate.ts";
23
24
  import type { EvalResult } from "../types.ts";
24
25
  import type {
25
26
  SkippedRunNotice,
@@ -61,9 +62,9 @@ export interface ViewScan {
61
62
 
62
63
  /** view 宿主输入的组合语义(与 show 对齐,docs/feature/reports/architecture.md「Scope 是计算入口」)。 */
63
64
  export interface ViewScanOptions {
64
- /** eval id 前缀(位置参数):收窄报告槽 Scope;证据室(快照明细)不收窄,深链恒可达。 */
65
+ /** eval id 前缀(位置参数):把根滤成有效根,页面 Scope 与证据(快照明细、artifact 清单)一致收窄。 */
65
66
  patterns?: string[];
66
- /** experiment id 前缀(--experiment):Scope 只留该实验。 */
67
+ /** experiment id 前缀(--exp):有效根只留匹配实验。 */
67
68
  experiment?: string;
68
69
  /** --report 报告文件:相对 cwd 的路径。装载失败抛 ReportLoadError(CLI 打印后退出)。 */
69
70
  report?: { path: string; cwd: string };
@@ -175,10 +176,12 @@ export async function loadLatestResultsPerEval(root = ".niceeval"): Promise<Eval
175
176
  /**
176
177
  * `niceeval view` 的数据装载入口:server 每次请求现读现算,`--out` 导出用同一份。
177
178
  * 报告槽 Selection 恒经 selectCurrentResults 合成(现刻水位;与 `niceeval show` 调同一个
178
- * 函数,裸跑与局部收窄不分叉),位置前缀 / --experiment 只作为 scope 传入,不切换选择口径。
179
+ * 函数,裸跑与局部收窄不分叉),位置前缀 / --exp 只作为 scope 传入,不切换选择口径。
179
180
  * --report 本身不改挑选——它只换报告槽的填充,注入的 Selection 与裸跑同一份,
180
181
  * 「裸跑 ≡ --report <ExperimentComparison>」靠这条成立(docs/feature/reports/architecture.md「Selection 是计算入口」)。
181
- * 证据室数据(快照明细 / skipped)恒为全量,深链在任何收窄下都可达。
182
+ * 命令行收窄作用在有效根上(docs/feature/reports/view.md 开篇):证据室数据与 artifact 清单
183
+ * 与页面一致地只含收窄后的范围,本地与导出无分叉——收窄导出的站点(烘进 HTML 的数据、
184
+ * 证据文件)只含收窄到的内容。收窄之内、不在现刻水位里的历史 attempt 仍在有效根里,深链可达。
182
185
  * 零可读结果一律抛 ViewInputError,不渲染/导出空页面(server 起不来,--out 非零退出)。
183
186
  */
184
187
  export async function loadViewScan(input?: string, opts: ViewScanOptions = {}): Promise<ViewScan> {
@@ -228,6 +231,12 @@ export async function loadViewScan(input?: string, opts: ViewScanOptions = {}):
228
231
  // 静态渲染成 HTML(en / zh-CN 各一遍,切界面语言不重算数据)。
229
232
  const slot = await renderReportSlot(opts.report, opts.page, results, selection, opts.pageFailure ?? "throw");
230
233
 
234
+ // 有效根:命令行收窄把根滤成只含匹配实验与 attempt(docs/feature/reports/view.md 开篇)。
235
+ // 证据室数据与 artifact 清单从这里取数,与页面 Scope 一致收窄——本地与导出无分叉,
236
+ // 收窄导出的站点(烘进 HTML 的数据、证据文件)只含收窄后的范围。
237
+ const scopedExperiments = filterExperiments(results.experiments, opts.experiment);
238
+ const matchEval = patterns.length > 0 ? evalPrefixPredicate(patterns) : () => true;
239
+
231
240
  // 跨快照按身份键去重:--resume 携带的条目在多份落盘里重复,只保留最新快照里的那份
232
241
  // (与官方计算函数的聚合口径一致,Runs / Traces 的计数因此不被复印件灌票)。
233
242
  const artifactDirs = new Map<string, string>();
@@ -236,13 +245,15 @@ export async function loadViewScan(input?: string, opts: ViewScanOptions = {}):
236
245
  // 与报告槽 Selection(现刻水位,可能合成自更早快照)是两个独立概念,不混用。
237
246
  const latestSet = new Set(latestPerExperiment.snapshots);
238
247
  const allAttempts: AttemptHandle[] = [];
239
- for (const exp of results.experiments) {
240
- for (const snap of exp.snapshots) allAttempts.push(...snap.attempts);
248
+ for (const exp of scopedExperiments) {
249
+ for (const snap of exp.snapshots) allAttempts.push(...snap.attempts.filter((a) => matchEval(a.evalId)));
241
250
  }
242
251
  const survivors = new Set(dedupeAttempts(allAttempts).attempts);
243
252
 
244
253
  const snapshots: ViewSnapshot[] = [];
245
- for (const exp of results.experiments) {
254
+ for (const exp of scopedExperiments) {
255
+ // 整个实验没有匹配 eval 时不携带:有效根里没有它,连快照元数据也不烘进页面。
256
+ if (patterns.length > 0 && !exp.evalIds.some((id) => matchEval(id))) continue;
246
257
  for (const snap of exp.snapshots) {
247
258
  const kept = snap.attempts.filter((a) => survivors.has(a));
248
259
  const latest = latestSet.has(snap);
@@ -267,10 +278,10 @@ export async function loadViewScan(input?: string, opts: ViewScanOptions = {}):
267
278
  }
268
279
  }
269
280
 
270
- // 全局最新快照(跨全部实验):viewData.lastRunAt 从这里取。页内 hero 的「最后运行」
281
+ // 全局最新快照(跨有效根内全部实验):viewData.lastRunAt 从这里取。页内 hero 的「最后运行」
271
282
  // 显示由 Hero 组件按 heroData(scope) 自己算,不吃这份字段。
272
283
  let latestSnapshot: Snapshot | undefined;
273
- for (const exp of results.experiments) {
284
+ for (const exp of scopedExperiments) {
274
285
  const candidate = exp.snapshots[0];
275
286
  if (!candidate) continue;
276
287
  if (!latestSnapshot || candidate.startedAt > latestSnapshot.startedAt) latestSnapshot = candidate;
package/src/view/index.ts CHANGED
@@ -80,18 +80,8 @@ export async function buildView(opts: ViewOptions = {}): Promise<string> {
80
80
  `--out expects a directory, got "${opts.out}". Single-file HTML export was removed: code, transcript and trace views need artifact files next to the page. Export a directory instead (e.g. --out site) and serve it with any static host.`,
81
81
  );
82
82
  }
83
- // --out 与位置参数 / --experiment 互斥(docs/feature/reports/view.md「静态导出」):报告槽收窄
84
- // 只影响报告,证据室恒随根完整——允许同用会让发布者误以为站点只含该实验,实际根里全部
85
- // attempt 的证据都已出站。按实验收窄发布 = 用 copySnapshots 构建只含它的发布根,再对新根导出。
86
- if ((opts.scan?.patterns?.length ?? 0) > 0 || opts.scan?.experiment !== undefined) {
87
- throw new ViewInputError(
88
- "--out exports the whole results root and cannot be combined with eval prefixes or --experiment.\n" +
89
- "To publish a site for one experiment, build a narrower results root and export that:\n" +
90
- ' const results = await openResults(".niceeval");\n' +
91
- ' await copySnapshots(results.latest().filter((s) => s.experimentId.startsWith("<prefix>/")), "<publish-root>");\n' +
92
- "Then: niceeval view --results <publish-root> --out <site>",
93
- );
94
- }
83
+ // 位置参数 / --exp 对导出同义于本地:收窄作用在有效根上,出站的页面数据与证据文件
84
+ // 只含收窄后的范围(docs/feature/reports/view.md「静态导出」:出站的就是收窄到的)。
95
85
  // 静态导出保持「任一页失败整体失败」(pageFailure 缺省 "throw"),不产出半套站点。
96
86
  const plan = await planSite(opts.input, opts.scan);
97
87
  await writeSite(plan, out);
@@ -1,8 +1,8 @@
1
1
  // HTTP server:把站点管线(site.ts 的 planSite)产出的同一份产物挂在 127.0.0.1 上按路径服务。
2
2
  // 这里不携带任何取数或布局知识——查不到清单条目就是 404,与 `--out` 写盘的文件逐字节一致
3
- // (docs/feature/reports/view.md 开篇;奇偶由 site-parity 测试守护)。宿主语义只有三条,全部
4
- // 作用在管线输入端:打开首页整份重建(数据永远是盘上最新)、单页渲染失败折成页内错误块
5
- // (pageFailure: "embed")、报告槽可被位置参数 / --experiment 收窄。
3
+ // (docs/feature/reports/view.md 开篇;奇偶由 site-parity 测试守护)。宿主语义只有两条,全部
4
+ // 作用在管线之外:打开首页整份重建(数据永远是盘上最新)、单页渲染失败折成页内错误块
5
+ // (pageFailure: "embed")。位置参数 / --exp 收窄是管线输入,不是宿主语义——两宿主同义。
6
6
 
7
7
  import { createServer, type Server } from "node:http";
8
8
  import { type ViewScanOptions } from "./data.ts";
@@ -13,7 +13,7 @@ export interface ViewOptions {
13
13
  input?: string;
14
14
  out?: string;
15
15
  port?: number;
16
- /** 报告槽的组合语义(位置前缀 / --experiment / --report),透传给站点管线。 */
16
+ /** 站点管线的组合语义(位置前缀 / --exp 收窄有效根,--report 换报告槽),透传给管线。 */
17
17
  scan?: ViewScanOptions;
18
18
  }
19
19
 
@@ -73,7 +73,24 @@ a { color: inherit; }
73
73
  z-index: 10;
74
74
  backdrop-filter: blur(10px);
75
75
  }
76
- /* 页头无品牌位:品牌是页内组件(PoweredBy / Hero 的品牌行,nre-powered-by),宿主不渲染。 */
76
+ /* 页头左端的恒定品牌位:NiceEval 字标 + 45° 方块 mark,外链官网(App.tsx .brand)
77
+ 与 hero 下 nre-powered-by 品牌行同族,由宿主渲染、报告定义不能覆盖或移除。 */
78
+ .brand {
79
+ display: flex;
80
+ align-items: baseline;
81
+ gap: 12px;
82
+ font-weight: 690;
83
+ font-size: 20px;
84
+ flex: 0 0 auto;
85
+ text-decoration: none;
86
+ }
87
+ .mark {
88
+ width: 18px;
89
+ height: 18px;
90
+ border: 1.25px solid var(--text);
91
+ transform: rotate(45deg);
92
+ display: inline-block;
93
+ }
77
94
  /* 顶部导航 .nav / .nav-tab → components/ui/tabs.tsx(Radix Tabs + Tailwind utilities)。 */
78
95
  .lang-switch {
79
96
  display: inline-flex;
@@ -2,7 +2,7 @@
2
2
  // niceeval view 的报告槽与宿主组合语义(docs/feature/reports/architecture.md「Selection 是计算入口」
3
3
  // 与裁决记录 6;公开行为准绳 docs-site/zh/how-to/viewing-results.mdx / custom-reports.mdx)。
4
4
  // 覆盖:
5
- // - 组合语义与 show 对齐:位置前缀收窄报告槽 Scope、--experiment 过滤、匹配不到直说;
5
+ // - 组合语义与 show 对齐:位置前缀收窄报告槽 Scope、--exp 过滤、匹配不到直说;
6
6
  // - 输入语义:位置参数只表示 eval id 前缀(不随文件系统状态改变),结果根走 --results,
7
7
  // 单开一份快照走 --snapshot(文件不可读时失败);
8
8
  // - 报告槽恒在:裸跑填充内建报告,--report 整槽替换;en / zh-CN 双语各渲染一遍;
@@ -162,7 +162,7 @@ function bareReportHtml(scan: ViewScan): { en: string; "zh-CN": string } {
162
162
  // ───────────────────────── 组合语义(与 show 对齐) ─────────────────────────
163
163
 
164
164
  describe("loadViewScan · 组合语义", () => {
165
- it("位置前缀收窄对全部页生效(含内建 Attempts 页);attempt 详情路由对完整结果根解析,被滤掉的深链仍可达", async () => {
165
+ it("位置前缀收窄作用在有效根上:全部页与证据室一致缩小,被滤掉的 attempt 不烘进页面数据", async () => {
166
166
  const root = await seedRoot();
167
167
  const scan = await loadViewScan(root, { patterns: ["weather"] });
168
168
  const { viewData } = scan;
@@ -176,16 +176,22 @@ describe("loadViewScan · 组合语义", () => {
176
176
  expect(pageHtml(scan, "traces").en).not.toContain("fixtures/button");
177
177
  const bare = await loadViewScan(root);
178
178
  expect(pageHtml(bare, "attempts").en).toContain("fixtures/button"); // 不收窄时在场,对照
179
- // attempt 详情路由不是页:viewData.snapshots 全量通道保留,被滤掉的 attempt 深链仍解析成功。
179
+ // 有效根即证据室:被滤掉的 attempt 不在 viewData.snapshots 里,深链两宿主一致不可达。
180
180
  const filteredOut = viewData.snapshots
181
181
  .flatMap((s) => s.results)
182
182
  .find((r) => r.id === "fixtures/button");
183
- expect(filteredOut?.locator).toBeTruthy();
183
+ expect(filteredOut).toBeUndefined();
184
+ expect(scan.artifactDirs.size).toBe(
185
+ [...scan.artifactDirs.keys()].filter((base) => base.includes("weather/brooklyn")).length,
186
+ );
187
+ // 收窄之内、不在现刻水位的口径差异由 --exp 深链测试覆盖;不收窄时深链照常可达,对照:
188
+ const inBare = bare.viewData.snapshots.flatMap((s) => s.results).find((r) => r.id === "fixtures/button");
189
+ expect(inBare?.locator).toBeTruthy();
184
190
  const { resolveAttemptLocator } = await import("./app/lib/attempt-route.ts");
185
- expect(resolveAttemptLocator(viewData.snapshots, filteredOut!.locator!)).toBe(filteredOut);
191
+ expect(resolveAttemptLocator(bare.viewData.snapshots, inBare!.locator!)).toBe(inBare);
186
192
  });
187
193
 
188
- it("--experiment 过滤:报告槽 Selection 只留该实验", async () => {
194
+ it("--exp 过滤:报告槽 Selection 只留该实验", async () => {
189
195
  const root = await seedRoot();
190
196
  const reportHtml = bareReportHtml(await loadViewScan(root, { experiment: "compare/codex" }));
191
197
  expect(reportHtml.en).toContain("compare/codex");
@@ -468,18 +474,41 @@ describe("buildView · --out 与 --report", () => {
468
474
  expect(existsSync(join(exported, "o11y.json"))).toBe(false);
469
475
  });
470
476
 
471
- it("--out 与位置参数 / --experiment 互斥:报错含 copySnapshots + filter 下一步", async () => {
477
+ it("--out 接受收窄,出站的就是收窄到的:被滤掉实验的证据文件与页面数据都不出站", async () => {
472
478
  const root = await seedRoot();
479
+ // 两个实验各写一份 events.json:导出后只有收窄内的那份在站里。
480
+ const bubDir = join(root, "compare_bub", "2026-07-08T10-00-00-000Z", "weather", "brooklyn", "a0");
481
+ const codexDir = join(root, "compare_codex", "2026-07-09T10-00-00-000Z", "weather", "brooklyn", "a0");
482
+ for (const dir of [bubDir, codexDir]) {
483
+ await mkdir(dir, { recursive: true });
484
+ await writeFile(join(dir, "events.json"), "[]", "utf-8");
485
+ }
486
+
473
487
  const out = join(root, "site");
474
- for (const scan of [{ patterns: ["weather"] }, { experiment: "compare/bub" }]) {
475
- const attempt = buildView({ input: root, out, scan });
476
- await expect(attempt).rejects.toBeInstanceOf(ViewInputError);
477
- await expect(
478
- buildView({ input: root, out, scan }),
479
- ).rejects.toThrow(/copySnapshots/);
488
+ await buildView({ input: root, out, scan: { patterns: [], experiment: "compare/codex" } });
489
+ expect(existsSync(join(out, "artifact", "compare_codex/2026-07-09T10-00-00-000Z/weather/brooklyn/a0", "events.json"))).toBe(true);
490
+ // 被滤掉的实验:证据树整目录不出站,页面数据(烘进 HTML 的 viewData 与报告块)也不含它。
491
+ expect(existsSync(join(out, "artifact", "compare_bub"))).toBe(false);
492
+ const html = await readFile(join(out, "index.html"), "utf-8");
493
+ expect(html).not.toContain("compare/bub");
494
+ expect(html).toContain("compare/codex");
495
+ });
496
+
497
+ it("eval 前缀收窄导出:不匹配 attempt 的证据不出站", async () => {
498
+ const root = await seedRoot();
499
+ const weatherDir = join(root, "compare_bub", "2026-07-08T10-00-00-000Z", "weather", "brooklyn", "a0");
500
+ const buttonDir = join(root, "compare_bub", "2026-07-08T10-00-00-000Z", "fixtures", "button", "a0");
501
+ for (const dir of [weatherDir, buttonDir]) {
502
+ await mkdir(dir, { recursive: true });
503
+ await writeFile(join(dir, "events.json"), "[]", "utf-8");
480
504
  }
481
- // 同参数不带 --out 时照常收窄报告槽(不报错)。
482
- await expect(loadViewScan(root, { patterns: ["weather"] })).resolves.toBeTruthy();
505
+
506
+ const out = join(root, "site");
507
+ await buildView({ input: root, out, scan: { patterns: ["weather"] } });
508
+ expect(existsSync(join(out, "artifact", "compare_bub/2026-07-08T10-00-00-000Z/weather/brooklyn/a0", "events.json"))).toBe(true);
509
+ expect(existsSync(join(out, "artifact", "compare_bub/2026-07-08T10-00-00-000Z/fixtures"))).toBe(false);
510
+ const html = await readFile(join(out, "index.html"), "utf-8");
511
+ expect(html).not.toContain("fixtures/button");
483
512
  });
484
513
 
485
514
  it("默认导出(无 --report):报告槽填充 ExperimentComparison,双语块与增强 runtime 恒内联", async () => {