niceeval 0.3.0 → 0.4.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.
- package/README.zh.md +11 -0
- package/docs/README.md +2 -1
- package/docs/adapters/collection.md +1 -1
- package/docs/adapters/contract.md +6 -5
- package/docs/adapters/reference/claude-code-otel-telemetry.md +1 -1
- package/docs/assertions.md +0 -1
- package/docs/capabilities-by-construction.md +2 -2
- package/docs/cli.md +4 -2
- package/docs/concepts.md +2 -2
- package/docs/e2e-ci.md +135 -98
- package/docs/eval-authoring.md +3 -3
- package/docs/experiments.md +7 -4
- package/docs/observability.md +9 -5
- package/docs/origin-integration.md +18 -18
- package/docs/references.md +1 -1
- package/docs/reports.md +551 -0
- package/docs/results-format.md +3 -3
- package/docs/results-lib.md +191 -0
- package/docs/runner.md +1 -1
- package/docs/sandbox.md +1 -1
- package/docs/source-map.md +22 -2
- package/docs/tier-sync.md +74 -58
- package/docs/view.md +39 -5
- package/package.json +29 -3
- package/src/agents/ai-sdk.ts +3 -0
- package/src/agents/claude-code.ts +18 -1
- package/src/agents/codex.ts +3 -2
- package/src/agents/index.ts +16 -0
- package/src/agents/openai-compat.test.ts +56 -0
- package/src/agents/openai-compat.ts +151 -0
- package/src/agents/types.ts +3 -1
- package/src/cli.ts +4 -3
- package/src/context/context.test.ts +78 -1
- package/src/context/context.ts +19 -11
- package/src/context/session.ts +2 -0
- package/src/context/types.ts +11 -5
- package/src/i18n/en.ts +3 -4
- package/src/i18n/zh-CN.ts +2 -3
- package/src/o11y/cost.test.ts +40 -0
- package/src/o11y/cost.ts +27 -5
- package/src/o11y/derive.ts +2 -2
- package/src/o11y/otlp/mappers/claude-code.test.ts +31 -0
- package/src/o11y/otlp/mappers/claude-code.ts +24 -0
- package/src/o11y/otlp/mappers/index.ts +1 -0
- package/src/o11y/otlp/parse.test.ts +127 -0
- package/src/o11y/prices.json +176 -119
- package/src/o11y/types.ts +2 -1
- package/src/report/aggregate.ts +215 -0
- package/src/report/compute.ts +405 -0
- package/src/report/format.ts +47 -0
- package/src/report/index.ts +40 -0
- package/src/report/metrics.ts +96 -0
- package/src/report/react/CaseList.tsx +70 -0
- package/src/report/react/DeltaTable.tsx +79 -0
- package/src/report/react/MetricMatrix.tsx +75 -0
- package/src/report/react/MetricScatter.tsx +217 -0
- package/src/report/react/MetricTable.tsx +71 -0
- package/src/report/react/RunOverview.tsx +87 -0
- package/src/report/react/Scoreboard.tsx +87 -0
- package/src/report/react/cell.tsx +49 -0
- package/src/report/react/colors.ts +42 -0
- package/src/report/react/data.ts +17 -0
- package/src/report/react/fixtures.ts +236 -0
- package/src/report/react/format.ts +34 -0
- package/src/report/react/index.tsx +34 -0
- package/src/report/react/render.test.tsx +303 -0
- package/src/report/react/styles.css +302 -0
- package/src/report/report.test.ts +667 -0
- package/src/report/types.ts +210 -0
- package/src/results/copy.ts +200 -0
- package/src/results/format.ts +75 -0
- package/src/results/index.ts +27 -0
- package/src/results/open.ts +207 -0
- package/src/results/results.test.ts +359 -0
- package/src/results/select.ts +101 -0
- package/src/results/types.ts +98 -0
- package/src/runner/attempt.ts +3 -1
- package/src/runner/report.test.ts +111 -0
- package/src/runner/report.ts +52 -1
- package/src/runner/reporters/artifacts.ts +1 -1
- package/src/runner/reporters/braintrust.test.ts +106 -0
- package/src/runner/reporters/braintrust.ts +197 -0
- package/src/runner/reporters/index.ts +3 -1
- package/src/runner/run.ts +30 -19
- package/src/runner/types.ts +17 -0
- package/src/sandbox/types.ts +3 -3
- package/src/view/app/App.tsx +82 -17
- package/src/view/app/components/CostScoreChart.tsx +127 -0
- package/src/view/app/i18n.ts +10 -1
- package/src/view/app/lib/attempt-route.test.ts +89 -0
- package/src/view/app/lib/attempt-route.ts +52 -0
- package/src/view/app/lib/chart.ts +72 -0
- package/src/view/app/lib/outcome.ts +1 -1
- package/src/view/app/types.ts +4 -5
- package/src/view/client-dist/app.css +1 -1
- package/src/view/client-dist/app.js +18 -18
- package/src/view/index.ts +13 -19
- package/src/view/loader.test.ts +61 -0
- package/src/view/loader.ts +35 -12
- package/src/view/shared/types.ts +14 -1
- package/src/view/styles.css +43 -0
- package/src/view/template.html +0 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// results 域类型:openResults 返回的类型化句柄(读取面契约,见 docs/results-lib.md)。
|
|
2
|
+
//
|
|
3
|
+
// 结果数据类型(EvalResult / RunSummary / StreamEvent / …)仍住在各自的域文件里,
|
|
4
|
+
// 这里只 import,不搬家 —— 「类型的家」迁移是 results-lib 设计里单独的一步,不在本次范围。
|
|
5
|
+
|
|
6
|
+
import type { EvalResult, RunSummary } from "../runner/types.ts";
|
|
7
|
+
import type { O11ySummary, StreamEvent, TraceSpan } from "../o11y/types.ts";
|
|
8
|
+
import type { DiffData } from "../scoring/types.ts";
|
|
9
|
+
import type { SourceArtifact } from "../shared/types.ts";
|
|
10
|
+
|
|
11
|
+
/** attempt 级工件的种类;文件名恒为 `<kind>.json`,布局见 docs/results-format.md。 */
|
|
12
|
+
export const ARTIFACT_KINDS = ["events", "sources", "trace", "o11y", "diff"] as const;
|
|
13
|
+
export type ArtifactKind = (typeof ARTIFACT_KINDS)[number];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 回到证据的引用:run 目录名(相对结果根目录)+ summary.results[] 下标。
|
|
17
|
+
* Reports 的 MetricCell.refs 与 view 深链 `#/attempt/<run>/<result>` 用的是同一个身份。
|
|
18
|
+
*/
|
|
19
|
+
export interface AttemptRef {
|
|
20
|
+
run: string;
|
|
21
|
+
result: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 一个落盘 run 目录;忠实反映磁盘,不合并、不去重。 */
|
|
25
|
+
export interface RunHandle {
|
|
26
|
+
/** run 目录的绝对路径(summary.json 所在目录)。 */
|
|
27
|
+
dir: string;
|
|
28
|
+
/** 与写入侧同一类型的 RunSummary(results[] 是瘦身条目)。 */
|
|
29
|
+
summary: RunSummary;
|
|
30
|
+
/** summary.results[] 逐条包一层懒加载句柄。 */
|
|
31
|
+
attempts: AttemptHandle[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 单个 eval attempt:瘦身条目 + 重工件的懒加载方法。
|
|
36
|
+
* 懒加载即存在性判断:工件缺失返回 null,不抛错;同一 handle 内读过一次即缓存。
|
|
37
|
+
*/
|
|
38
|
+
export interface AttemptHandle {
|
|
39
|
+
/** 所属物理 run;去重「保留最新 run 的那份」靠它比较新旧。 */
|
|
40
|
+
run: RunHandle;
|
|
41
|
+
/** 本 attempt 的证据引用(run 目录名 + summary.results 下标);消费方不必自己反查下标。 */
|
|
42
|
+
ref: AttemptRef;
|
|
43
|
+
/** EvalResult 瘦身条目:判决、断言、用量、成本、experiment 元数据。 */
|
|
44
|
+
result: EvalResult;
|
|
45
|
+
events(): Promise<StreamEvent[] | null>;
|
|
46
|
+
trace(): Promise<TraceSpan[] | null>;
|
|
47
|
+
o11y(): Promise<O11ySummary | null>;
|
|
48
|
+
diff(): Promise<DiffData | null>;
|
|
49
|
+
sources(): Promise<SourceArtifact[] | null>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 快照 = 一个 experiment 在一个 run 里的那部分 attempt(experiment × run 切片)。
|
|
54
|
+
* 一个 run 目录可以装多个 experiment(`niceeval exp compare`),所以「每个 experiment
|
|
55
|
+
* 最新一次」只能用快照粒度表达,不能用 run 粒度。
|
|
56
|
+
*/
|
|
57
|
+
export interface SnapshotHandle {
|
|
58
|
+
/** experiment id;结果里缺 experimentId 时以 "<agent>/<model>" 合成,并记入集合级 warnings。 */
|
|
59
|
+
experimentId: string;
|
|
60
|
+
/** 所属物理 run。 */
|
|
61
|
+
run: RunHandle;
|
|
62
|
+
/** = run.summary.startedAt;快照新旧按它比较。 */
|
|
63
|
+
startedAt: string;
|
|
64
|
+
/** 本快照自己的 agent —— 不是 run 顶层那个「第一个配置」。 */
|
|
65
|
+
agent: string;
|
|
66
|
+
model?: string;
|
|
67
|
+
attempts: AttemptHandle[];
|
|
68
|
+
/** 覆盖的 eval 集合(去重,保持首次出现顺序),供选择器做残缺检测。 */
|
|
69
|
+
evalIds: string[];
|
|
70
|
+
/** experimentId 是合成键(结果里没有 experimentId)时为 true;选择器据此调整提示文案。 */
|
|
71
|
+
synthetic?: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 目录扫描里读不了、但必须让调用方知道的 run;无关 JSON 不记(静默忽略)。 */
|
|
75
|
+
export interface SkippedResultsRun {
|
|
76
|
+
/** run 目录的绝对路径。 */
|
|
77
|
+
dir: string;
|
|
78
|
+
/** summary.json 的绝对路径。 */
|
|
79
|
+
path: string;
|
|
80
|
+
reason: "incompatible-version" | "malformed";
|
|
81
|
+
/** incompatible-version:那份报告声明的 schemaVersion。 */
|
|
82
|
+
schemaVersion?: number;
|
|
83
|
+
/** incompatible-version:写报告的 niceeval 版本,拼 `npx niceeval@<version> view` 提示用。 */
|
|
84
|
+
producerVersion?: string;
|
|
85
|
+
/** malformed:一句英文诊断。 */
|
|
86
|
+
detail?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** openResults 的返回:runs 忠实磁盘;snapshots 只切片、不合并、不去重;skipped 不静默丢。 */
|
|
90
|
+
export interface ResultsCollection {
|
|
91
|
+
/** 按 startedAt 降序(最新在前),同刻按目录名降序。 */
|
|
92
|
+
runs: RunHandle[];
|
|
93
|
+
/** 按 runs 顺序逐 run 切片;每个 experiment 在每个 run 里至多一个快照。 */
|
|
94
|
+
snapshots: SnapshotHandle[];
|
|
95
|
+
skipped: SkippedResultsRun[];
|
|
96
|
+
/** 集合级警告(如 experimentId 缺失时的合成键);渲染与否在消费方,缺口永远被算出来。 */
|
|
97
|
+
warnings: string[];
|
|
98
|
+
}
|
package/src/runner/attempt.ts
CHANGED
|
@@ -226,6 +226,7 @@ async function runAttemptBody(
|
|
|
226
226
|
const attemptCtx: AgentContext = {
|
|
227
227
|
signal,
|
|
228
228
|
model: run.model,
|
|
229
|
+
reasoningEffort: run.reasoningEffort,
|
|
229
230
|
flags: run.flags,
|
|
230
231
|
sandbox,
|
|
231
232
|
session: createAgentSession(),
|
|
@@ -267,6 +268,7 @@ async function runAttemptBody(
|
|
|
267
268
|
agent: run.agent,
|
|
268
269
|
sandbox,
|
|
269
270
|
model: run.model,
|
|
271
|
+
reasoningEffort: run.reasoningEffort,
|
|
270
272
|
flags: run.flags,
|
|
271
273
|
signal,
|
|
272
274
|
log,
|
|
@@ -366,7 +368,7 @@ async function runAttemptBody(
|
|
|
366
368
|
const durationMs = Date.now() - t0;
|
|
367
369
|
const o11y = buildO11ySummary(events, usage, durationMs);
|
|
368
370
|
// 实测成本(网关带回)优先,缺则按 model + 用量查价格表估算(见 o11y/cost.ts)。
|
|
369
|
-
const cost = usage.costUSD ?? estimateCost(run.model, usage);
|
|
371
|
+
const cost = usage.costUSD ?? estimateCost(run.model, usage, config.pricing);
|
|
370
372
|
if (cost !== undefined) o11y.estimatedCostUSD = cost;
|
|
371
373
|
|
|
372
374
|
// 收 test 引用到的 eval 源码(按 send / 断言的 loc 去重),供 view 渲染代码视图。
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { filterSummary, scopeReporter } from "./report.ts";
|
|
3
|
+
import type { Agent, EvalResult, Reporter, RunShape, RunSummary } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
function result(id: string, overrides: Partial<EvalResult> = {}): EvalResult {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
agent: "codex",
|
|
9
|
+
outcome: "passed",
|
|
10
|
+
attempt: 0,
|
|
11
|
+
durationMs: 1000,
|
|
12
|
+
assertions: [],
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function summary(results: EvalResult[]): RunSummary {
|
|
18
|
+
return {
|
|
19
|
+
agent: "codex",
|
|
20
|
+
startedAt: "2026-07-07T00:00:00.000Z",
|
|
21
|
+
completedAt: "2026-07-07T00:01:00.000Z",
|
|
22
|
+
passed: results.filter((r) => r.outcome === "passed").length,
|
|
23
|
+
failed: results.filter((r) => r.outcome === "failed").length,
|
|
24
|
+
skipped: 0,
|
|
25
|
+
errored: 0,
|
|
26
|
+
durationMs: 60_000,
|
|
27
|
+
results,
|
|
28
|
+
outputDir: ".niceeval/x",
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("filterSummary", () => {
|
|
33
|
+
it("按 eval id 过滤结果并重新计数,保留原 completedAt / outputDir", () => {
|
|
34
|
+
const s = summary([
|
|
35
|
+
result("a/1", { usage: { inputTokens: 10, outputTokens: 5 }, estimatedCostUSD: 0.1 }),
|
|
36
|
+
result("a/1", { outcome: "failed", attempt: 1 }),
|
|
37
|
+
result("b/1", { usage: { inputTokens: 100, outputTokens: 50 }, estimatedCostUSD: 1 }),
|
|
38
|
+
]);
|
|
39
|
+
const sub = filterSummary(s, new Set(["a/1"]));
|
|
40
|
+
expect(sub.results.map((r) => r.id)).toEqual(["a/1", "a/1"]);
|
|
41
|
+
expect(sub.passed).toBe(1);
|
|
42
|
+
expect(sub.failed).toBe(1);
|
|
43
|
+
expect(sub.usage).toEqual({ inputTokens: 10, outputTokens: 5 });
|
|
44
|
+
expect(sub.estimatedCostUSD).toBe(0.1);
|
|
45
|
+
expect(sub.completedAt).toBe("2026-07-07T00:01:00.000Z");
|
|
46
|
+
expect(sub.outputDir).toBe(".niceeval/x");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("scopeReporter", () => {
|
|
51
|
+
const agent = { name: "codex" } as Agent;
|
|
52
|
+
const scopedShape: RunShape = { evals: 1, configs: 1, totalRuns: 2, maxConcurrency: 4 };
|
|
53
|
+
|
|
54
|
+
function recordingReporter() {
|
|
55
|
+
const calls: { method: string; args: unknown[] }[] = [];
|
|
56
|
+
const reporter: Reporter = {
|
|
57
|
+
onRunStart: (...args) => void calls.push({ method: "onRunStart", args }),
|
|
58
|
+
onEvalComplete: (...args) => void calls.push({ method: "onEvalComplete", args }),
|
|
59
|
+
onRunComplete: (...args) => void calls.push({ method: "onRunComplete", args }),
|
|
60
|
+
onEvent: (...args) => void calls.push({ method: "onEvent", args }),
|
|
61
|
+
};
|
|
62
|
+
return { calls, reporter };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
it("onRunStart 收到过滤后的 eval 列表和作用域 shape", async () => {
|
|
66
|
+
const { calls, reporter } = recordingReporter();
|
|
67
|
+
const scoped = scopeReporter(reporter, new Set(["a/1"]), scopedShape);
|
|
68
|
+
await scoped.onRunStart?.([{ id: "a/1" }, { id: "b/1" }], agent, { evals: 2, configs: 1, totalRuns: 4, maxConcurrency: 4 });
|
|
69
|
+
expect(calls[0]?.args).toEqual([[{ id: "a/1" }], agent, scopedShape]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("onEvalComplete 只转发被观测 eval 的结果", async () => {
|
|
73
|
+
const { calls, reporter } = recordingReporter();
|
|
74
|
+
const scoped = scopeReporter(reporter, new Set(["a/1"]));
|
|
75
|
+
await scoped.onEvalComplete?.(result("b/1"));
|
|
76
|
+
await scoped.onEvalComplete?.(result("a/1"));
|
|
77
|
+
expect(calls).toHaveLength(1);
|
|
78
|
+
expect((calls[0]?.args[0] as EvalResult).id).toBe("a/1");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("onRunComplete 收到重新计数的子集汇总", async () => {
|
|
82
|
+
const { calls, reporter } = recordingReporter();
|
|
83
|
+
const scoped = scopeReporter(reporter, new Set(["a/1"]));
|
|
84
|
+
await scoped.onRunComplete?.(summary([result("a/1"), result("b/1", { outcome: "failed" })]));
|
|
85
|
+
const got = calls[0]?.args[0] as RunSummary;
|
|
86
|
+
expect(got.results.map((r) => r.id)).toEqual(["a/1"]);
|
|
87
|
+
expect(got.passed).toBe(1);
|
|
88
|
+
expect(got.failed).toBe(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("onEvent 过滤 eval 级事件,重写汇总类事件", async () => {
|
|
92
|
+
const { calls, reporter } = recordingReporter();
|
|
93
|
+
const scoped = scopeReporter(reporter, new Set(["a/1"]), scopedShape);
|
|
94
|
+
await scoped.onEvent?.({ type: "eval:start", eval: { id: "b/1" }, agent, attempt: 0 });
|
|
95
|
+
await scoped.onEvent?.({ type: "eval:start", eval: { id: "a/1" }, agent, attempt: 0 });
|
|
96
|
+
await scoped.onEvent?.({ type: "run:summary", summary: summary([result("a/1"), result("b/1")]) });
|
|
97
|
+
expect(calls).toHaveLength(2);
|
|
98
|
+
expect(calls[0]?.args[0]).toMatchObject({ type: "eval:start", eval: { id: "a/1" } });
|
|
99
|
+
const summaryEvent = calls[1]?.args[0] as { type: string; summary: RunSummary };
|
|
100
|
+
expect(summaryEvent.type).toBe("run:summary");
|
|
101
|
+
expect(summaryEvent.summary.results.map((r) => r.id)).toEqual(["a/1"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("底层 reporter 未实现的回调不会被包装出来", () => {
|
|
105
|
+
const scoped = scopeReporter({}, new Set(["a/1"]));
|
|
106
|
+
expect(scoped.onRunStart).toBeUndefined();
|
|
107
|
+
expect(scoped.onEvalComplete).toBeUndefined();
|
|
108
|
+
expect(scoped.onRunComplete).toBeUndefined();
|
|
109
|
+
expect(scoped.onEvent).toBeUndefined();
|
|
110
|
+
});
|
|
111
|
+
});
|
package/src/runner/report.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// reporter 编排与运行级汇总。reporter 是「结果消费方」:单个 reporter 抛错只记
|
|
2
2
|
// diagnostic,不能让整次调度崩。
|
|
3
3
|
|
|
4
|
-
import type { EvalResult, LocalizedText, Reporter, ReporterEvent, RunSummary } from "../types.ts";
|
|
4
|
+
import type { EvalResult, LocalizedText, Reporter, ReporterEvent, RunShape, RunSummary } from "../types.ts";
|
|
5
5
|
import { t } from "../i18n/index.ts";
|
|
6
6
|
import { formatThrown } from "../util.ts";
|
|
7
7
|
|
|
@@ -18,6 +18,57 @@ export async function emitReporterEvent(reporters: readonly Reporter[], event: R
|
|
|
18
18
|
await Promise.all(reporters.map((r) => runReporter(`event:${event.type}`, () => r.onEvent?.(event))));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/** 按 eval id 过滤 RunSummary 并重新计数 —— eval 级 reporter 只看它观测的那部分。 */
|
|
22
|
+
export function filterSummary(summary: RunSummary, ids: ReadonlySet<string>): RunSummary {
|
|
23
|
+
const results = summary.results.filter((r) => ids.has(r.id));
|
|
24
|
+
const sub = summarize(results, summary.agent, summary.startedAt, summary.durationMs, summary.name);
|
|
25
|
+
// completedAt 用原值(summarize 会重新取 now);format / producer / outputDir 等元数据原样保留。
|
|
26
|
+
return { ...summary, ...sub, completedAt: summary.completedAt };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* eval 级 reporter 的作用域包装:实例只观测「引用它的那些 eval」。
|
|
31
|
+
* 结果类回调按 eval id 过滤,汇总类回调收到重新计数的子集汇总;
|
|
32
|
+
* shape 由调用方按作用域预先算好(包装器自己看不到 attempts)。
|
|
33
|
+
*/
|
|
34
|
+
export function scopeReporter(r: Reporter, ids: ReadonlySet<string>, shape?: RunShape): Reporter {
|
|
35
|
+
const scoped: Reporter = {};
|
|
36
|
+
if (r.onRunStart) {
|
|
37
|
+
scoped.onRunStart = (evals, agent, fullShape) =>
|
|
38
|
+
r.onRunStart!(evals.filter((e) => ids.has(e.id)), agent, shape ?? fullShape);
|
|
39
|
+
}
|
|
40
|
+
if (r.onEvalComplete) {
|
|
41
|
+
scoped.onEvalComplete = (result) => (ids.has(result.id) ? r.onEvalComplete!(result) : undefined);
|
|
42
|
+
}
|
|
43
|
+
if (r.onRunComplete) {
|
|
44
|
+
scoped.onRunComplete = (summary) => r.onRunComplete!(filterSummary(summary, ids));
|
|
45
|
+
}
|
|
46
|
+
if (r.onEvent) {
|
|
47
|
+
scoped.onEvent = (event) => {
|
|
48
|
+
switch (event.type) {
|
|
49
|
+
case "run:start":
|
|
50
|
+
return r.onEvent!({
|
|
51
|
+
...event,
|
|
52
|
+
evals: event.evals.filter((e) => ids.has(e.id)),
|
|
53
|
+
shape: shape ?? event.shape,
|
|
54
|
+
});
|
|
55
|
+
case "eval:start":
|
|
56
|
+
return ids.has(event.eval.id) ? r.onEvent!(event) : undefined;
|
|
57
|
+
case "eval:complete":
|
|
58
|
+
return ids.has(event.result.id) ? r.onEvent!(event) : undefined;
|
|
59
|
+
case "run:earlyExit":
|
|
60
|
+
return ids.has(event.evalId) ? r.onEvent!(event) : undefined;
|
|
61
|
+
case "run:summary":
|
|
62
|
+
case "run:saved":
|
|
63
|
+
return r.onEvent!({ ...event, summary: filterSummary(event.summary, ids) });
|
|
64
|
+
default:
|
|
65
|
+
return r.onEvent!(event);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return scoped;
|
|
70
|
+
}
|
|
71
|
+
|
|
21
72
|
/** 全局汇总:outcome 计数 + token / cost 折叠。按 attempt 计(eval 级折叠见 shared/outcome.ts)。 */
|
|
22
73
|
export function summarize(
|
|
23
74
|
results: EvalResult[],
|
|
@@ -40,7 +40,7 @@ function slimResult(r: EvalResult): EvalResult {
|
|
|
40
40
|
// 携带结果(跨实验复用上次 pass,见 run.ts 的 carriedResults):本轮没有任何新数据,
|
|
41
41
|
// rest 上已经带着 artifactBase 指向旧 run 的产物目录,hasSources/hasEvents/hasTrace
|
|
42
42
|
// 也是从旧 summary 带过来的真值——不能因为"这轮没数据"就重新推导成 false / 编出一个
|
|
43
|
-
// 这轮压根没写过文件的新 artifactsDir(会让 artifactBase 在下次
|
|
43
|
+
// 这轮压根没写过文件的新 artifactsDir(会让 artifactBase 在下次 withViewRefs 时被覆盖)。
|
|
44
44
|
if (rest.artifactBase) return rest;
|
|
45
45
|
return {
|
|
46
46
|
...rest,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { toBraintrustEvent } from "./braintrust.ts";
|
|
3
|
+
import type { EvalResult } from "../../types.ts";
|
|
4
|
+
|
|
5
|
+
function baseResult(overrides: Partial<EvalResult> = {}): EvalResult {
|
|
6
|
+
return {
|
|
7
|
+
id: "algebra/quadratic",
|
|
8
|
+
agent: "codex",
|
|
9
|
+
outcome: "passed",
|
|
10
|
+
attempt: 0,
|
|
11
|
+
durationMs: 42_000,
|
|
12
|
+
assertions: [],
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("toBraintrustEvent", () => {
|
|
18
|
+
it("soft 断言按名字记分,gate 断言带 gate: 前缀", () => {
|
|
19
|
+
const event = toBraintrustEvent(
|
|
20
|
+
baseResult({
|
|
21
|
+
assertions: [
|
|
22
|
+
{ name: "compiles", severity: "gate", score: 1, passed: true },
|
|
23
|
+
{ name: "closedQA", severity: "soft", score: 0.7, passed: true },
|
|
24
|
+
],
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
expect(event.scores).toEqual({ "gate:compiles": 1, closedQA: 0.7 });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("重名断言追加 #n 消歧,分数被夹到 0..1", () => {
|
|
31
|
+
const event = toBraintrustEvent(
|
|
32
|
+
baseResult({
|
|
33
|
+
assertions: [
|
|
34
|
+
{ name: "check", severity: "soft", score: 0.5, passed: true },
|
|
35
|
+
{ name: "check", severity: "soft", score: 2, passed: true },
|
|
36
|
+
{ name: "check", severity: "soft", score: -1, passed: false },
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
expect(event.scores).toEqual({ check: 0.5, "check#2": 1, "check#3": 0 });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("metrics:start/end 由 startedAt+durationMs 推出,token/成本缺就不写", () => {
|
|
44
|
+
const event = toBraintrustEvent(
|
|
45
|
+
baseResult({
|
|
46
|
+
startedAt: "2026-07-07T00:00:00.000Z",
|
|
47
|
+
durationMs: 2_000,
|
|
48
|
+
usage: { inputTokens: 100, outputTokens: 50, cacheReadTokens: 10 },
|
|
49
|
+
estimatedCostUSD: 0.31,
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
const start = Date.parse("2026-07-07T00:00:00.000Z") / 1000;
|
|
53
|
+
expect(event.metrics).toEqual({
|
|
54
|
+
start,
|
|
55
|
+
end: start + 2,
|
|
56
|
+
prompt_tokens: 100,
|
|
57
|
+
completion_tokens: 50,
|
|
58
|
+
tokens: 150,
|
|
59
|
+
cache_read_tokens: 10,
|
|
60
|
+
estimated_cost_usd: 0.31,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const bare = toBraintrustEvent(baseResult());
|
|
64
|
+
expect(bare.metrics).toEqual({});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("output 取事件流里最后一条 assistant message", () => {
|
|
68
|
+
const event = toBraintrustEvent(
|
|
69
|
+
baseResult({
|
|
70
|
+
events: [
|
|
71
|
+
{ type: "message", role: "user", text: "question" },
|
|
72
|
+
{ type: "message", role: "assistant", text: "draft" },
|
|
73
|
+
{ type: "message", role: "assistant", text: "final answer" },
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
expect(event.output).toBe("final answer");
|
|
78
|
+
expect(toBraintrustEvent(baseResult()).output).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("metadata 带身份维度与失败断言明细;id 在 (experiment, eval, agent, model, attempt) 上唯一", () => {
|
|
82
|
+
const event = toBraintrustEvent(
|
|
83
|
+
baseResult({
|
|
84
|
+
experimentId: "compare/codex",
|
|
85
|
+
model: "gpt-5.2",
|
|
86
|
+
attempt: 1,
|
|
87
|
+
outcome: "failed",
|
|
88
|
+
experiment: { id: "compare/codex", flags: { tape: true } },
|
|
89
|
+
assertions: [{ name: "compiles", severity: "gate", score: 0, passed: false, detail: "tsc failed" }],
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
expect(event.metadata).toEqual({
|
|
93
|
+
eval: "algebra/quadratic",
|
|
94
|
+
agent: "codex",
|
|
95
|
+
attempt: 1,
|
|
96
|
+
outcome: "failed",
|
|
97
|
+
model: "gpt-5.2",
|
|
98
|
+
experiment: "compare/codex",
|
|
99
|
+
flags: { tape: true },
|
|
100
|
+
failedAssertions: [{ name: "compiles", detail: "tsc failed" }],
|
|
101
|
+
});
|
|
102
|
+
expect(event.id).toBe("compare/codex|algebra/quadratic|codex|gpt-5.2|a1");
|
|
103
|
+
const other = toBraintrustEvent(baseResult({ experimentId: "compare/codex", model: "gpt-5.2", attempt: 2 }));
|
|
104
|
+
expect(other.id).not.toBe(event.id);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Braintrust 报告器:把一次 niceeval 运行作为一个 Braintrust experiment 上报,
|
|
2
|
+
// 每个 attempt 一行(scores = 断言,metrics = 时长/token/成本),跨提交比较与团队共享。
|
|
3
|
+
// `braintrust` 是可选依赖:动态 import,装了才用得上,没装在 onRunStart 报错
|
|
4
|
+
// (reporter 错误按框架约定只记 diagnostic,不会让运行崩)。
|
|
5
|
+
|
|
6
|
+
import type { EvalResult, Reporter } from "../../types.ts";
|
|
7
|
+
|
|
8
|
+
export interface BraintrustConfig {
|
|
9
|
+
/** Braintrust 项目名;省略时用 "niceeval"。 */
|
|
10
|
+
project?: string;
|
|
11
|
+
/** Braintrust 项目 id;与 project 给一个即可。 */
|
|
12
|
+
projectId?: string;
|
|
13
|
+
/** 实验名;省略时由 Braintrust 自动命名。 */
|
|
14
|
+
experiment?: string;
|
|
15
|
+
/** 作为对比基线(diff base)的既有实验名。 */
|
|
16
|
+
baseExperiment?: string;
|
|
17
|
+
/** 作为对比基线(diff base)的既有实验 id。 */
|
|
18
|
+
baseExperimentId?: string;
|
|
19
|
+
/** true = 更新同名既有实验,而不是新建一个。 */
|
|
20
|
+
update?: boolean;
|
|
21
|
+
/** 实验级附加 metadata;与 niceeval 自动写入的字段合并,同名以这里为准。 */
|
|
22
|
+
metadata?: Record<string, unknown>;
|
|
23
|
+
/** API key;省略时 SDK 读 BRAINTRUST_API_KEY 环境变量。 */
|
|
24
|
+
apiKey?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Braintrust SDK 的最小类型面。本地声明是为了不把 `braintrust` 变成编译期依赖;
|
|
29
|
+
* 动态 import 后按这个形状断言。
|
|
30
|
+
*/
|
|
31
|
+
interface BraintrustSdk {
|
|
32
|
+
// SDK 里 init 同步返回(登录与建实验都是惰性的);统一 await,同步/异步都兼容。
|
|
33
|
+
init(options: {
|
|
34
|
+
project?: string;
|
|
35
|
+
projectId?: string;
|
|
36
|
+
experiment?: string;
|
|
37
|
+
baseExperiment?: string;
|
|
38
|
+
baseExperimentId?: string;
|
|
39
|
+
update?: boolean;
|
|
40
|
+
apiKey?: string;
|
|
41
|
+
metadata?: Record<string, unknown>;
|
|
42
|
+
setCurrent?: boolean;
|
|
43
|
+
}): BraintrustExperiment | Promise<BraintrustExperiment>;
|
|
44
|
+
flush(): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BraintrustExperiment {
|
|
48
|
+
log(event: BraintrustLogEvent): void;
|
|
49
|
+
summarize(): Promise<{ experimentUrl?: string }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface BraintrustLogEvent {
|
|
53
|
+
id?: string;
|
|
54
|
+
input?: unknown;
|
|
55
|
+
output?: unknown;
|
|
56
|
+
error?: unknown;
|
|
57
|
+
scores?: Record<string, number>;
|
|
58
|
+
metadata?: Record<string, unknown>;
|
|
59
|
+
metrics?: Record<string, number>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 创建 Braintrust 报告器。挂在 `defineConfig({ reporters })` 上观测整次运行,
|
|
64
|
+
* 或挂在单个 eval 的 `reporters` 上只观测它(同一实例被多个 eval 引用时共享一个实验)。
|
|
65
|
+
*/
|
|
66
|
+
export function Braintrust(config: BraintrustConfig = {}): Reporter {
|
|
67
|
+
let sdk: BraintrustSdk | undefined;
|
|
68
|
+
let experiment: BraintrustExperiment | undefined;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
async onRunStart(evals, agent) {
|
|
72
|
+
sdk = await loadBraintrustSdk();
|
|
73
|
+
experiment = await sdk.init({
|
|
74
|
+
project: config.projectId ? undefined : (config.project ?? "niceeval"),
|
|
75
|
+
projectId: config.projectId,
|
|
76
|
+
experiment: config.experiment,
|
|
77
|
+
baseExperiment: config.baseExperiment,
|
|
78
|
+
baseExperimentId: config.baseExperimentId,
|
|
79
|
+
update: config.update,
|
|
80
|
+
apiKey: config.apiKey,
|
|
81
|
+
metadata: {
|
|
82
|
+
agent: agent?.name,
|
|
83
|
+
evals: evals.map((e) => e.id),
|
|
84
|
+
...config.metadata,
|
|
85
|
+
},
|
|
86
|
+
// 不设为全局 current experiment,避免污染用户代码里的 braintrust 全局态。
|
|
87
|
+
setCurrent: false,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
onEvalComplete(result) {
|
|
92
|
+
experiment?.log(toBraintrustEvent(result));
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async onRunComplete() {
|
|
96
|
+
if (!experiment) return;
|
|
97
|
+
try {
|
|
98
|
+
await sdk?.flush();
|
|
99
|
+
const summary = await experiment.summarize();
|
|
100
|
+
if (summary.experimentUrl) {
|
|
101
|
+
process.stderr.write(`Braintrust experiment: ${summary.experimentUrl}\n`);
|
|
102
|
+
}
|
|
103
|
+
} finally {
|
|
104
|
+
experiment = undefined;
|
|
105
|
+
sdk = undefined;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* EvalResult → Braintrust 一行。导出仅为单测;映射口径:
|
|
113
|
+
* - scores:soft 断言按名字记分,gate 断言记在 `gate:` 前缀下 —— 实验 diff 里
|
|
114
|
+
* gate 回归和 soft 分数回归用同一套机制看。重名断言追加 `#n` 消歧,不静默覆盖。
|
|
115
|
+
* - metrics:start/end(Braintrust 由此算时长)+ token 用量 + 估算成本;缺就不写,不编 0。
|
|
116
|
+
* - metadata:身份维度(agent / model / experiment / attempt / flags)+ 失败断言明细。
|
|
117
|
+
*/
|
|
118
|
+
export function toBraintrustEvent(result: EvalResult): BraintrustLogEvent {
|
|
119
|
+
const scores: Record<string, number> = {};
|
|
120
|
+
for (const a of result.assertions) {
|
|
121
|
+
const base = a.severity === "gate" ? `gate:${a.name}` : a.name;
|
|
122
|
+
let key = base;
|
|
123
|
+
for (let n = 2; key in scores; n++) key = `${base}#${n}`;
|
|
124
|
+
// Braintrust 要求 0..1
|
|
125
|
+
scores[key] = Math.min(1, Math.max(0, a.score));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const metrics: Record<string, number> = {};
|
|
129
|
+
if (result.startedAt) {
|
|
130
|
+
const start = Date.parse(result.startedAt) / 1000;
|
|
131
|
+
if (Number.isFinite(start)) {
|
|
132
|
+
metrics.start = start;
|
|
133
|
+
metrics.end = start + result.durationMs / 1000;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (result.usage) {
|
|
137
|
+
metrics.prompt_tokens = result.usage.inputTokens;
|
|
138
|
+
metrics.completion_tokens = result.usage.outputTokens;
|
|
139
|
+
metrics.tokens = result.usage.inputTokens + result.usage.outputTokens;
|
|
140
|
+
if (result.usage.cacheReadTokens !== undefined) metrics.cache_read_tokens = result.usage.cacheReadTokens;
|
|
141
|
+
if (result.usage.cacheWriteTokens !== undefined) metrics.cache_write_tokens = result.usage.cacheWriteTokens;
|
|
142
|
+
}
|
|
143
|
+
if (result.estimatedCostUSD !== undefined) metrics.estimated_cost_usd = result.estimatedCostUSD;
|
|
144
|
+
|
|
145
|
+
const metadata: Record<string, unknown> = {
|
|
146
|
+
eval: result.id,
|
|
147
|
+
agent: result.agent,
|
|
148
|
+
attempt: result.attempt,
|
|
149
|
+
outcome: result.outcome,
|
|
150
|
+
};
|
|
151
|
+
if (result.model !== undefined) metadata.model = result.model;
|
|
152
|
+
if (result.experimentId !== undefined) metadata.experiment = result.experimentId;
|
|
153
|
+
if (result.experiment?.flags && Object.keys(result.experiment.flags).length > 0) {
|
|
154
|
+
metadata.flags = result.experiment.flags;
|
|
155
|
+
}
|
|
156
|
+
if (result.skipReason !== undefined) metadata.skipReason = result.skipReason;
|
|
157
|
+
const failed = result.assertions
|
|
158
|
+
.filter((a) => !a.passed)
|
|
159
|
+
.map((a) => ({ name: a.name, detail: a.detail }));
|
|
160
|
+
if (failed.length > 0) metadata.failedAssertions = failed;
|
|
161
|
+
|
|
162
|
+
// 一次运行内 (experiment, eval, agent, model, attempt) 唯一;Braintrust 按 id 合并重复行。
|
|
163
|
+
const id = [result.experimentId ?? "", result.id, result.agent, result.model ?? "", `a${result.attempt}`].join("|");
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
id,
|
|
167
|
+
input: result.description ?? result.id,
|
|
168
|
+
output: lastAssistantText(result.events),
|
|
169
|
+
error: result.error,
|
|
170
|
+
scores,
|
|
171
|
+
metadata,
|
|
172
|
+
metrics,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** agent 的最终回复文本(事件流里最后一条 assistant message);没有就不填。 */
|
|
177
|
+
function lastAssistantText(events: EvalResult["events"]): string | undefined {
|
|
178
|
+
if (!events) return undefined;
|
|
179
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
180
|
+
const e = events[i];
|
|
181
|
+
if (e.type === "message" && e.role === "assistant") return e.text;
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 动态 import 说明:specifier 放变量里,`braintrust` 不进编译期依赖,没装也能 typecheck。 */
|
|
187
|
+
const BRAINTRUST_PACKAGE = "braintrust";
|
|
188
|
+
|
|
189
|
+
async function loadBraintrustSdk(): Promise<BraintrustSdk> {
|
|
190
|
+
try {
|
|
191
|
+
return (await import(BRAINTRUST_PACKAGE)) as unknown as BraintrustSdk;
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error(
|
|
194
|
+
"The 'braintrust' package is required for the Braintrust reporter but was not found. Install it with: npm install braintrust (and set BRAINTRUST_API_KEY).",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
// 内置报告器:Console(默认)/ Artifacts / Json / JUnit
|
|
1
|
+
// 内置报告器:Console(默认)/ Artifacts / Json / JUnit / Braintrust。
|
|
2
|
+
// 其它第三方实验跟踪平台也走同一条 Reporter 通道。
|
|
2
3
|
|
|
3
4
|
export { Console } from "./console.ts";
|
|
4
5
|
export { Artifacts } from "./artifacts.ts";
|
|
5
6
|
export { Json, JUnit } from "./json.ts";
|
|
7
|
+
export { Braintrust, type BraintrustConfig } from "./braintrust.ts";
|