niceeval 0.10.0 → 0.10.2
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/dist/report/components/attempt-detail/AttemptConversation.d.ts +5 -1
- package/dist/report/components/attempt-detail/AttemptConversation.js +42 -2
- package/dist/report/components/attempt-detail/AttemptSource.js +83 -13
- package/dist/report/components/attempt-detail/compute.js +44 -1
- package/dist/report/components/attempt-detail/faces.js +4 -0
- package/dist/report/components/attempt-detail/index.d.ts +2 -2
- package/dist/report/components/attempt-detail/index.js +24 -4
- package/dist/report/components/entity-lists/ExperimentList.d.ts +1 -2
- package/dist/report/components/entity-lists/ExperimentList.js +6 -5
- package/dist/report/components/entity-lists/faces.d.ts +1 -1
- package/dist/report/components/entity-lists/faces.js +10 -9
- package/dist/report/components/entity-lists/index.d.ts +5 -7
- package/dist/report/components/entity-lists/index.js +7 -3
- package/dist/report/components/metric-views/MetricScatter.js +2 -38
- package/dist/report/index.d.ts +1 -1
- package/dist/report/model/format.d.ts +5 -5
- package/dist/report/model/format.js +34 -8
- package/dist/report/model/types.d.ts +16 -2
- package/dist/report/react/index.d.ts +1 -1
- package/dist/runner/types.d.ts +2 -1
- package/dist/sandbox/e2b.d.ts +0 -1
- package/docs-site/zh/examples/integrations/ai-sdk-v7.mdx +5 -5
- package/docs-site/zh/explanation/runner.mdx +6 -2
- package/docs-site/zh/reference/cli.mdx +2 -2
- package/docs-site/zh/reference/report-components.mdx +3 -1
- package/docs-site/zh/tutorials/experiments.mdx +1 -2
- package/docs-site/zh/tutorials/viewing-results.mdx +12 -9
- package/docs-site/zh/tutorials/write-experiment.mdx +1 -3
- package/package.json +3 -4
- package/src/cli.ts +4 -2
- package/src/o11y/prices.json +176 -99
- package/src/report/assets/styles.css +822 -0
- package/src/report/components/attempt-detail/AttemptConversation.tsx +55 -7
- package/src/report/components/attempt-detail/AttemptSource.tsx +186 -42
- package/src/report/components/attempt-detail/attempt-components.test.tsx +82 -4
- package/src/report/components/attempt-detail/compute.ts +50 -1
- package/src/report/components/attempt-detail/faces.ts +3 -0
- package/src/report/components/attempt-detail/index.tsx +33 -16
- package/src/report/components/attempt-detail/validate.test.ts +19 -2
- package/src/report/components/entity-lists/ExperimentList.tsx +13 -10
- package/src/report/components/entity-lists/faces.ts +10 -9
- package/src/report/components/entity-lists/index.tsx +7 -10
- package/src/report/components/metric-views/MetricScatter.tsx +2 -38
- package/src/report/components/render.test.tsx +19 -9
- package/src/report/index.ts +2 -0
- package/src/report/model/format.ts +33 -8
- package/src/report/model/types.ts +18 -2
- package/src/report/react/index.tsx +2 -0
- package/src/report/runtime/dual-render.test.tsx +43 -10
- package/src/runner/types.ts +2 -1
- package/src/sandbox/e2b-reconcile.test.ts +125 -0
- package/src/sandbox/e2b.ts +38 -2
- package/src/view/view-report.test.ts +3 -1
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { ReactElement } from "react";
|
|
7
7
|
import type { AttemptLocator } from "../../../results/locator.ts";
|
|
8
8
|
import type { AttemptListItem, ExperimentListEvalRow, ExperimentListItem } from "../../model/types.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { shortestUniqueLabels } from "../../model/format.ts";
|
|
10
10
|
import { DEFAULT_REPORT_LOCALE, localeText, type ReportLocale } from "../../model/locale.ts";
|
|
11
11
|
import { AttemptLocatorBadge, failureSummaryText } from "./AttemptList.tsx";
|
|
12
12
|
import { MetricCellView } from "../cell.tsx";
|
|
@@ -122,23 +122,21 @@ function Flags({ flags, locale }: { flags: Record<string, unknown> | undefined;
|
|
|
122
122
|
|
|
123
123
|
function ExperimentRow({
|
|
124
124
|
item,
|
|
125
|
+
label,
|
|
125
126
|
attemptHref,
|
|
126
127
|
locale,
|
|
127
|
-
relativeTo,
|
|
128
128
|
}: {
|
|
129
129
|
item: ExperimentListItem;
|
|
130
|
+
label: string;
|
|
130
131
|
attemptHref?: (locator: AttemptLocator) => string;
|
|
131
132
|
locale: ReportLocale;
|
|
132
|
-
relativeTo?: string;
|
|
133
133
|
}): ReactElement {
|
|
134
134
|
return (
|
|
135
135
|
<details className="nre-experiment-entry">
|
|
136
136
|
<summary className="nre-experiment-summary">
|
|
137
|
-
{/*
|
|
137
|
+
{/* label 是当前列表里的最短唯一后缀;完整 id 仍是排序 / 过滤 / 折叠的键,系列色跟随 agent */}
|
|
138
138
|
<span className="nre-experiment-name" data-sort-value={item.experimentId}>
|
|
139
|
-
<b className={cx("nre-experiment-id", "nre-key")}>
|
|
140
|
-
{experimentDisplayName(item.experimentId, relativeTo)}
|
|
141
|
-
</b>
|
|
139
|
+
<b className={cx("nre-experiment-id", "nre-key")}>{label}</b>
|
|
142
140
|
<small>
|
|
143
141
|
{localeText(locale, "overview.evalsCount", { n: item.evals })}
|
|
144
142
|
{item.attempts > item.evals ? ` · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })}` : ""}
|
|
@@ -184,15 +182,14 @@ export function ExperimentList({
|
|
|
184
182
|
filter = false,
|
|
185
183
|
className,
|
|
186
184
|
locale = DEFAULT_REPORT_LOCALE,
|
|
187
|
-
relativeTo,
|
|
188
185
|
}: {
|
|
189
186
|
data: readonly ExperimentListItem[];
|
|
190
187
|
attemptHref?: (locator: AttemptLocator) => string;
|
|
191
188
|
filter?: boolean;
|
|
192
189
|
className?: string;
|
|
193
190
|
locale?: ReportLocale;
|
|
194
|
-
relativeTo?: string;
|
|
195
191
|
}): ReactElement {
|
|
192
|
+
const experimentLabels = shortestUniqueLabels(data.map((item) => item.experimentId));
|
|
196
193
|
const labels = [
|
|
197
194
|
localeText(locale, "experimentList.experiment"),
|
|
198
195
|
localeText(locale, "table.model"),
|
|
@@ -221,7 +218,13 @@ export function ExperimentList({
|
|
|
221
218
|
</div>
|
|
222
219
|
{data.length === 0 && <p className="nre-experiment-list-empty">{localeText(locale, "attemptList.empty")}</p>}
|
|
223
220
|
{data.map((item) => (
|
|
224
|
-
<ExperimentRow
|
|
221
|
+
<ExperimentRow
|
|
222
|
+
key={item.experimentId}
|
|
223
|
+
item={item}
|
|
224
|
+
label={experimentLabels.get(item.experimentId) ?? item.experimentId}
|
|
225
|
+
attemptHref={attemptHref}
|
|
226
|
+
locale={locale}
|
|
227
|
+
/>
|
|
225
228
|
))}
|
|
226
229
|
</div>
|
|
227
230
|
);
|
|
@@ -8,10 +8,10 @@ import type { AttemptListItem, EvalListItem, ExperimentListItem } from "../../mo
|
|
|
8
8
|
import type { TextContext } from "../../definition/tree.ts";
|
|
9
9
|
import type { TableColumn, TableRow } from "../../definition/primitives.tsx";
|
|
10
10
|
import {
|
|
11
|
-
experimentDisplayName,
|
|
12
11
|
fitFailureSummary,
|
|
13
12
|
formatDurationMs,
|
|
14
13
|
formatUSD,
|
|
14
|
+
shortestUniqueLabels,
|
|
15
15
|
verdictMark,
|
|
16
16
|
} from "../../model/format.ts";
|
|
17
17
|
import { countText, localeText, type ReportLocale } from "../../model/locale.ts";
|
|
@@ -47,7 +47,7 @@ function attemptReasonText(item: AttemptListItem, locale: ReportLocale, maxChars
|
|
|
47
47
|
function experimentSummaryTable(
|
|
48
48
|
items: readonly ExperimentListItem[],
|
|
49
49
|
ctx: TextContext,
|
|
50
|
-
|
|
50
|
+
labels: Map<string, string>,
|
|
51
51
|
): string {
|
|
52
52
|
const locale = ctx.locale;
|
|
53
53
|
const compact = ctx.width < 100;
|
|
@@ -64,7 +64,7 @@ function experimentSummaryTable(
|
|
|
64
64
|
const rows: TableRow[] = items.map((item) => ({
|
|
65
65
|
key: item.experimentId,
|
|
66
66
|
cells: {
|
|
67
|
-
experiment:
|
|
67
|
+
experiment: labels.get(item.experimentId) ?? item.experimentId,
|
|
68
68
|
model: item.model ?? localeText(locale, "experimentList.defaultModel"),
|
|
69
69
|
agent: item.agent,
|
|
70
70
|
duration: cellText(item.durationMs, locale),
|
|
@@ -76,14 +76,14 @@ function experimentSummaryTable(
|
|
|
76
76
|
}));
|
|
77
77
|
const metadata = items.flatMap((item) =>
|
|
78
78
|
wrapDisplay(
|
|
79
|
-
`${
|
|
79
|
+
`${labels.get(item.experimentId) ?? item.experimentId}: ${localeText(locale, "overview.evalsCount", { n: item.evals })} · ${localeText(locale, "overview.attemptsCount", { n: item.attempts })} · ${item.lastRunAt}`,
|
|
80
80
|
Math.max(8, ctx.width - 2),
|
|
81
81
|
).map((line) => ` ${line}`),
|
|
82
82
|
);
|
|
83
83
|
return [renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx), metadata.join("\n")].join("\n");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
function experimentDetailTable(item: ExperimentListItem, ctx: TextContext,
|
|
86
|
+
function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, label: string): string {
|
|
87
87
|
const locale = ctx.locale;
|
|
88
88
|
const columns: TableColumn[] = [
|
|
89
89
|
{ key: "status", header: localeText(locale, "experimentList.status") },
|
|
@@ -135,7 +135,7 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relat
|
|
|
135
135
|
.join(" · ")}`
|
|
136
136
|
: undefined;
|
|
137
137
|
return [
|
|
138
|
-
|
|
138
|
+
label,
|
|
139
139
|
flags,
|
|
140
140
|
renderTableText({ columns: columns as unknown as [TableColumn, ...TableColumn[]], rows, locale }, ctx),
|
|
141
141
|
]
|
|
@@ -143,11 +143,12 @@ function experimentDetailTable(item: ExperimentListItem, ctx: TextContext, relat
|
|
|
143
143
|
.join("\n");
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
-
export function experimentListText(items: readonly ExperimentListItem[], ctx: TextContext
|
|
146
|
+
export function experimentListText(items: readonly ExperimentListItem[], ctx: TextContext): string {
|
|
147
147
|
if (items.length === 0) return localeText(ctx.locale, "attemptList.empty");
|
|
148
|
+
const labels = shortestUniqueLabels(items.map((item) => item.experimentId));
|
|
148
149
|
return [
|
|
149
|
-
experimentSummaryTable(items, ctx,
|
|
150
|
-
...items.map((item) => experimentDetailTable(item, ctx,
|
|
150
|
+
experimentSummaryTable(items, ctx, labels),
|
|
151
|
+
...items.map((item) => experimentDetailTable(item, ctx, labels.get(item.experimentId) ?? item.experimentId)),
|
|
151
152
|
].join("\n\n");
|
|
152
153
|
}
|
|
153
154
|
|
|
@@ -102,20 +102,18 @@ export type ExperimentListProps = DataProps<
|
|
|
102
102
|
EntityListChrome & {
|
|
103
103
|
/** web 面在比较表前显示实验过滤框;text 面忽略。 */
|
|
104
104
|
filter?: boolean;
|
|
105
|
-
/**
|
|
106
|
-
* 可选父路径:两面的行标签去掉与它相同的前缀,只显示 experiment id 末段。自定义报告
|
|
107
|
-
* 显式传入使用;默认 `ExperimentComparison` 不传,完整 id 始终可见。完整 id 仍是
|
|
108
|
-
* 排序 / 着色 / 过滤 / 折叠的键。
|
|
109
|
-
*/
|
|
110
|
-
relativeTo?: string;
|
|
111
105
|
}
|
|
112
106
|
>;
|
|
113
107
|
|
|
114
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* 实验列表:每项一个 experiment,固定八列比较表 + 展开到 Eval / Attempt。行标签是
|
|
110
|
+
* experiment id 在当前列表里的最短唯一后缀(与 MetricScatter 点标签同一算法,重名逐步
|
|
111
|
+
* 加长到能区分为止);完整 id 不受影响,仍是排序 / 过滤 / 折叠的键。
|
|
112
|
+
*/
|
|
115
113
|
export const ExperimentList = makeDataComponent<
|
|
116
114
|
readonly ExperimentListItem[],
|
|
117
115
|
Record<never, never>,
|
|
118
|
-
EntityListChrome & { filter?: boolean
|
|
116
|
+
EntityListChrome & { filter?: boolean }
|
|
119
117
|
>({
|
|
120
118
|
name: "ExperimentList",
|
|
121
119
|
dataFnName: "experimentListData",
|
|
@@ -127,13 +125,12 @@ export const ExperimentList = makeDataComponent<
|
|
|
127
125
|
<ExperimentListWeb
|
|
128
126
|
data={props.data}
|
|
129
127
|
filter={props.filter}
|
|
130
|
-
relativeTo={props.relativeTo}
|
|
131
128
|
locale={props.locale ?? ctx.locale}
|
|
132
129
|
attemptHref={hrefOf(props, ctx)}
|
|
133
130
|
className={props.className}
|
|
134
131
|
/>
|
|
135
132
|
),
|
|
136
|
-
text: (props, ctx) => experimentListText(props.data, ctx
|
|
133
|
+
text: (props, ctx) => experimentListText(props.data, ctx),
|
|
137
134
|
}) as unknown as ReportComponent<ExperimentListProps>;
|
|
138
135
|
|
|
139
136
|
export type EvalListProps = DataProps<readonly EvalListItem[], Record<never, never>, EntityListChrome>;
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { ReactElement } from "react";
|
|
14
14
|
import type { MetricColumn, ScatterData } from "../../model/types.ts";
|
|
15
|
-
import { formatMetricValue } from "../../model/format.ts";
|
|
15
|
+
import { formatMetricValue, shortestUniqueLabels } from "../../model/format.ts";
|
|
16
16
|
import { DEFAULT_REPORT_LOCALE, countText, localeText, resolveLocalizedText, resolveMetricLabel, type ReportLocale } from "../../model/locale.ts";
|
|
17
17
|
import { niceTicks, placePointLabels } from "./chart-math.ts";
|
|
18
18
|
import { colorIndicesForKeys } from "../../assets/colors.ts";
|
|
@@ -36,42 +36,6 @@ interface DrawablePoint {
|
|
|
36
36
|
py: number;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
* 点的直接标签:末段在当前 data 中唯一才缩成末段;重名时逐步加长为能区分它们的最短
|
|
41
|
-
* 路径后缀(完整 id 与两轴值仍进 <title>)。
|
|
42
|
-
*/
|
|
43
|
-
function pointLabels(keys: readonly string[]): Map<string, string> {
|
|
44
|
-
const segsOf = (key: string) => key.split("/").filter(Boolean);
|
|
45
|
-
const depth = new Map<string, number>(keys.map((key) => [key, 1]));
|
|
46
|
-
for (;;) {
|
|
47
|
-
const byLabel = new Map<string, string[]>();
|
|
48
|
-
for (const key of keys) {
|
|
49
|
-
const segs = segsOf(key);
|
|
50
|
-
const label = segs.slice(-Math.min(depth.get(key)!, segs.length)).join("/") || key;
|
|
51
|
-
byLabel.set(label, [...(byLabel.get(label) ?? []), key]);
|
|
52
|
-
}
|
|
53
|
-
let grew = false;
|
|
54
|
-
for (const group of byLabel.values()) {
|
|
55
|
-
if (group.length < 2) continue;
|
|
56
|
-
for (const key of group) {
|
|
57
|
-
const segs = segsOf(key);
|
|
58
|
-
if (depth.get(key)! < segs.length) {
|
|
59
|
-
depth.set(key, depth.get(key)! + 1);
|
|
60
|
-
grew = true;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if (!grew) {
|
|
65
|
-
const out = new Map<string, string>();
|
|
66
|
-
for (const key of keys) {
|
|
67
|
-
const segs = segsOf(key);
|
|
68
|
-
out.set(key, segs.slice(-Math.min(depth.get(key)!, segs.length)).join("/") || key);
|
|
69
|
-
}
|
|
70
|
-
return out;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
39
|
/**
|
|
76
40
|
* 一根轴:niceTicks 撑出整齐的值域,值 → 像素做线性映射。轴方向跟随指标的 `better`:
|
|
77
41
|
* `better: "lower"` 的轴反向渲染(值大的一端在左 / 下),「更好」因此恒指向右与上;
|
|
@@ -143,7 +107,7 @@ export function MetricScatter({
|
|
|
143
107
|
MARGIN.top,
|
|
144
108
|
data.y.better === "lower",
|
|
145
109
|
);
|
|
146
|
-
const labelByKey =
|
|
110
|
+
const labelByKey = shortestUniqueLabels(drawableRows.map((r) => r.key));
|
|
147
111
|
// 方向提示恒为「越靠右上越好」,仅当两轴都声明 better 时显示——任一轴未声明,
|
|
148
112
|
// 组件不猜「更好」朝哪边,整图无提示。
|
|
149
113
|
const showBetterHint = data.x.better !== undefined && data.y.better !== undefined;
|
|
@@ -390,9 +390,11 @@ describe("ExperimentList", () => {
|
|
|
390
390
|
expect(html).toContain("2 evals");
|
|
391
391
|
});
|
|
392
392
|
|
|
393
|
-
it("
|
|
394
|
-
expect(html).toContain("
|
|
395
|
-
expect(html).toContain("
|
|
393
|
+
it("主行:身份(最短唯一后缀)、agent/model、官方两级聚合指标与 eval 级判定构成;完整 id 仍是排序键", () => {
|
|
394
|
+
expect(html).toContain(">bub</b>");
|
|
395
|
+
expect(html).toContain(">codex</b>");
|
|
396
|
+
expect(html).toContain('data-sort-value="compare/bub"');
|
|
397
|
+
expect(html).toContain('data-sort-value="compare/codex"');
|
|
396
398
|
expect(html).toContain("gpt-5.4");
|
|
397
399
|
expect(html).toContain("50%"); // endToEndPassRate.display
|
|
398
400
|
expect(html).toContain("1 passed · 1 failed");
|
|
@@ -416,12 +418,20 @@ describe("ExperimentList", () => {
|
|
|
416
418
|
expect(html.match(/gate: roots-correct/g)).toHaveLength(2);
|
|
417
419
|
});
|
|
418
420
|
|
|
419
|
-
it("
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
421
|
+
it("行标签默认缩成当前列表里的最短唯一后缀;末段重名时逐步加长到能区分,data-sort-value 仍是完整 id", () => {
|
|
422
|
+
// 两个不同 experiment 末段撞名(a/run 与 b/run 都叫 run)时,两行都要加长到能区分为止。
|
|
423
|
+
const dupes = renderToStaticMarkup(
|
|
424
|
+
<ExperimentList
|
|
425
|
+
data={[
|
|
426
|
+
{ ...experimentListItems[0]!, experimentId: "compare/a/run" },
|
|
427
|
+
{ ...experimentListItems[1]!, experimentId: "compare/b/run" },
|
|
428
|
+
]}
|
|
429
|
+
/>,
|
|
430
|
+
);
|
|
431
|
+
expect(dupes).toContain(">a/run</b>");
|
|
432
|
+
expect(dupes).toContain(">b/run</b>");
|
|
433
|
+
expect(dupes).toContain('data-sort-value="compare/a/run"');
|
|
434
|
+
expect(dupes).toContain('data-sort-value="compare/b/run"');
|
|
425
435
|
});
|
|
426
436
|
|
|
427
437
|
it("零 JS 靠原生 <details>,无 <script>", () => {
|
package/src/report/index.ts
CHANGED
|
@@ -6,16 +6,41 @@ import type { Verdict } from "../../types.ts";
|
|
|
6
6
|
import { DISPLAY_LOCALES, type LocalizedText, type ReportLocale } from "./locale.ts";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* 一组 id 的显示名:每个 id 缩成在这组里唯一的最短路径后缀,重名逐步加长到能区分为止
|
|
10
|
+
* (与 `MetricScatter` 点标签同一算法,两处共用本函数以保证同一份 experiment id 在散点和
|
|
11
|
+
* 列表里缩成同一个显示名)。单个 id、或所有 id 深度不同时也照常缩到各自的最短唯一后缀。
|
|
12
|
+
* 完整 id 不受影响,调用方仍用它做排序 / 过滤 / 折叠的身份键,这里只产出显示名。
|
|
13
13
|
*/
|
|
14
|
-
export function
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
export function shortestUniqueLabels(ids: readonly string[]): Map<string, string> {
|
|
15
|
+
const segsOf = (id: string) => id.split("/").filter(Boolean);
|
|
16
|
+
const depth = new Map<string, number>(ids.map((id) => [id, 1]));
|
|
17
|
+
for (;;) {
|
|
18
|
+
const byLabel = new Map<string, string[]>();
|
|
19
|
+
for (const id of ids) {
|
|
20
|
+
const segs = segsOf(id);
|
|
21
|
+
const label = segs.slice(-Math.min(depth.get(id)!, segs.length)).join("/") || id;
|
|
22
|
+
byLabel.set(label, [...(byLabel.get(label) ?? []), id]);
|
|
23
|
+
}
|
|
24
|
+
let grew = false;
|
|
25
|
+
for (const group of byLabel.values()) {
|
|
26
|
+
if (group.length < 2) continue;
|
|
27
|
+
for (const id of group) {
|
|
28
|
+
const segs = segsOf(id);
|
|
29
|
+
if (depth.get(id)! < segs.length) {
|
|
30
|
+
depth.set(id, depth.get(id)! + 1);
|
|
31
|
+
grew = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (!grew) {
|
|
36
|
+
const out = new Map<string, string>();
|
|
37
|
+
for (const id of ids) {
|
|
38
|
+
const segs = segsOf(id);
|
|
39
|
+
out.set(id, segs.slice(-Math.min(depth.get(id)!, segs.length)).join("/") || id);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
17
43
|
}
|
|
18
|
-
return experimentId;
|
|
19
44
|
}
|
|
20
45
|
|
|
21
46
|
/** 一位小数、去掉无意义的 ".0" 尾巴。 */
|
|
@@ -484,13 +484,29 @@ export interface AttemptAssertionsData {
|
|
|
484
484
|
passedGroups: { group: string; items: AssertionResult[] }[];
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
-
/** `AttemptSource`
|
|
487
|
+
/** `AttemptSource` 源码行内的一轮执行:send 头事实 + 标准事件流归并出的完整回复。 */
|
|
488
|
+
export interface AttemptSourceTurn {
|
|
489
|
+
label: string;
|
|
490
|
+
status: "completed" | "failed" | "waiting";
|
|
491
|
+
durationMs?: number;
|
|
492
|
+
sentText: string;
|
|
493
|
+
replies: AttemptConversationReply[];
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** AnnotatedSourceLine 加上 web 源码视图需要的行内执行轮。 */
|
|
497
|
+
export interface AttemptSourceLineData extends AnnotatedSourceLine {
|
|
498
|
+
turns: AttemptSourceTurn[];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `AttemptSource` 的 data:AnnotatedEvalSource + 按 loc 投影的标准事件流;没有 source 时 null。 */
|
|
488
502
|
export interface AttemptSourceData {
|
|
489
503
|
/** text 面拼 `niceeval show <locator> --source` 下钻命令用;web 面不需要。 */
|
|
490
504
|
locator: AttemptLocator;
|
|
491
505
|
sourcePath: string;
|
|
492
|
-
lines:
|
|
506
|
+
lines: AttemptSourceLineData[];
|
|
493
507
|
unmapped: AssertionResult[];
|
|
508
|
+
/** 没有 loc、指向其它文件或越界的轮次;不能静默丢弃,放在源码块末尾。 */
|
|
509
|
+
unlocatedTurns: AttemptSourceTurn[];
|
|
494
510
|
summary: AnnotatedEvalSourceSummary;
|
|
495
511
|
}
|
|
496
512
|
|
|
@@ -521,13 +521,13 @@ describe("text/web 双面同源", () => {
|
|
|
521
521
|
res("algebra/retry", "passed", { attempt: 1 }),
|
|
522
522
|
],
|
|
523
523
|
});
|
|
524
|
-
const text = await renderTreeText(<ExperimentList
|
|
524
|
+
const text = await renderTreeText(<ExperimentList />, scopeOf([s]), 160);
|
|
525
525
|
expect(text.match(/algebra\/retry/g)).toHaveLength(1); // Eval 父行只出现一次
|
|
526
526
|
expect(text).toContain("├─");
|
|
527
527
|
expect(text).toContain("└─");
|
|
528
528
|
// 失败摘要只在 Attempt 子行(父行不复述)
|
|
529
529
|
expect(text.match(/equals\(42\)/g)).toHaveLength(1);
|
|
530
|
-
//
|
|
530
|
+
// 行标签默认缩成最短唯一后缀
|
|
531
531
|
expect(text).toContain("exp-a");
|
|
532
532
|
});
|
|
533
533
|
});
|
|
@@ -1276,18 +1276,21 @@ describe("内建报告", () => {
|
|
|
1276
1276
|
);
|
|
1277
1277
|
});
|
|
1278
1278
|
|
|
1279
|
-
it("首页 web/text 两面都展示完整 Scope,不同深度目录的 experiment
|
|
1280
|
-
const g1 = snap({ experimentId: "compare/
|
|
1281
|
-
const g2 = snap({ experimentId: "dev/
|
|
1279
|
+
it("首页 web/text 两面都展示完整 Scope,不同深度目录的 experiment 同屏且行标签缩成最短唯一后缀;无组选择器或组索引", async () => {
|
|
1280
|
+
const g1 = snap({ experimentId: "compare/alpha-exp", results: [res("q", "passed")] });
|
|
1281
|
+
const g2 = snap({ experimentId: "dev/beta-exp", results: [res("q", "failed")] });
|
|
1282
1282
|
const ctx = { scope: scopeOf([g1, g2]), results: resultsOf([g1, g2]) };
|
|
1283
1283
|
|
|
1284
1284
|
const text = await renderReportToText(builtInReport, ctx, { width: 120 });
|
|
1285
1285
|
// Hero text 面:标题行(回退链终点)+ 最后运行 meta 行在页首
|
|
1286
1286
|
expect(text.split("\n")[0]).toBe("Eval Results");
|
|
1287
1287
|
expect(text).toContain("Last run");
|
|
1288
|
-
// 完整 Scope 直接展示:两个不同路径的 experiment 都可见,不是组索引 +
|
|
1289
|
-
|
|
1290
|
-
expect(text).toContain("
|
|
1288
|
+
// 完整 Scope 直接展示:两个不同路径的 experiment 都可见,不是组索引 + 单组查看命令;
|
|
1289
|
+
// 行标签是各自末段(两个 id 互不撞名,不必加长),不带父路径。
|
|
1290
|
+
expect(text).toContain("alpha-exp");
|
|
1291
|
+
expect(text).toContain("beta-exp");
|
|
1292
|
+
expect(text).not.toContain("compare/alpha-exp");
|
|
1293
|
+
expect(text).not.toContain("dev/beta-exp");
|
|
1291
1294
|
expect(text).not.toContain("niceeval show --exp compare");
|
|
1292
1295
|
expect(text).not.toContain("niceeval show --exp dev");
|
|
1293
1296
|
expect(text).not.toContain("niceeval exp compare");
|
|
@@ -1296,8 +1299,11 @@ describe("内建报告", () => {
|
|
|
1296
1299
|
const html = await renderReportToStaticHtml(builtInReport, ctx);
|
|
1297
1300
|
expect(html).not.toContain('role="tablist"');
|
|
1298
1301
|
expect(html).not.toContain("data-nre-experiment-group");
|
|
1299
|
-
expect(html).toContain("
|
|
1300
|
-
expect(html).toContain("
|
|
1302
|
+
expect(html).toContain(">alpha-exp</b>");
|
|
1303
|
+
expect(html).toContain(">beta-exp</b>");
|
|
1304
|
+
// 完整 id 仍是排序键,只是不再重复出现在显示文字里
|
|
1305
|
+
expect(html).toContain('data-sort-value="compare/alpha-exp"');
|
|
1306
|
+
expect(html).toContain('data-sort-value="dev/beta-exp"');
|
|
1301
1307
|
});
|
|
1302
1308
|
|
|
1303
1309
|
it("0/1/多 experiment 均可渲染;0 个时三个叶子组件各自显示自己的空态", async () => {
|
|
@@ -1431,6 +1437,33 @@ describe("ExperimentComparison(组合组件)", () => {
|
|
|
1431
1437
|
const [, explicitScatterEl] = await resolveComparisonChildren(<ExperimentComparison series={label("memory")} />, all);
|
|
1432
1438
|
expect((explicitScatterEl.props.data as { seriesDimension?: string }).seriesDimension).toBe("memory");
|
|
1433
1439
|
});
|
|
1440
|
+
|
|
1441
|
+
it("ExperimentList 行标签默认缩成 Scope 内的最短唯一后缀,不改变 ScopeSummary / MetricScatter 输出或排序键", async () => {
|
|
1442
|
+
const a = snap({ experimentId: "compare/a", agent: "bub", results: [res("q", "passed")] });
|
|
1443
|
+
const b = snap({ experimentId: "compare/b", agent: "codex", results: [res("q", "failed")] });
|
|
1444
|
+
const ctx = { scope: scopeOf([a, b]), results: resultsOf([a, b]) };
|
|
1445
|
+
|
|
1446
|
+
const definition = defineReport(<ExperimentComparison />);
|
|
1447
|
+
const html = await renderReportToStaticHtml(definition, ctx);
|
|
1448
|
+
expect(html).toContain(">a</b>");
|
|
1449
|
+
expect(html).toContain(">b</b>");
|
|
1450
|
+
expect(html).toContain('data-sort-value="compare/a"');
|
|
1451
|
+
expect(html).toContain('data-sort-value="compare/b"');
|
|
1452
|
+
|
|
1453
|
+
// 换成末段撞名的一对(a/run、b/run 都叫 run)时两行都要加长到能区分为止,不是随口断言。
|
|
1454
|
+
const dupeA = snap({ experimentId: "compare/x/run", agent: "bub", results: [res("q", "passed")] });
|
|
1455
|
+
const dupeB = snap({ experimentId: "compare/y/run", agent: "codex", results: [res("q", "failed")] });
|
|
1456
|
+
const dupeHtml = await renderReportToStaticHtml(definition, { scope: scopeOf([dupeA, dupeB]), results: resultsOf([dupeA, dupeB]) });
|
|
1457
|
+
expect(dupeHtml).toContain(">x/run</b>");
|
|
1458
|
+
expect(dupeHtml).toContain(">y/run</b>");
|
|
1459
|
+
|
|
1460
|
+
// ScopeSummary / MetricScatter 完全不受行标签缩短影响
|
|
1461
|
+
const [summaryEl, scatterEl] = await resolveComparisonChildren(<ExperimentComparison />, [a, b]);
|
|
1462
|
+
expect(summaryEl.props.data).toEqual(await scopeSummaryData([a, b]));
|
|
1463
|
+
expect(scatterEl.props.data).toEqual(
|
|
1464
|
+
await metricScatterData([a, b], { points: "experiment", series: "agent", x: costUSD, y: endToEndPassRate }),
|
|
1465
|
+
);
|
|
1466
|
+
});
|
|
1434
1467
|
});
|
|
1435
1468
|
|
|
1436
1469
|
// ───────────────────────── extends 与内建视图集合 ─────────────────────────
|
package/src/runner/types.ts
CHANGED
|
@@ -422,7 +422,8 @@ export interface ExperimentDef {
|
|
|
422
422
|
labels?: Record<string, string | number>;
|
|
423
423
|
/** 同一 eval 重复跑几次(结果各计一条 attempt);省略/CLI `--runs` 覆盖时默认 1。 */
|
|
424
424
|
runs?: number;
|
|
425
|
-
/** 一次重复(runs > 1)里某次 attempt
|
|
425
|
+
/** 一次重复(runs > 1)里某次 attempt 通过后是否跳过剩余重复;省略默认 false(`runs` 跑满、测完整通过率),
|
|
426
|
+
* 显式打开用于「只想知道能不能过」的省钱场景。 */
|
|
426
427
|
earlyExit?: boolean;
|
|
427
428
|
/**
|
|
428
429
|
* 这个实验覆盖哪些 eval:`"*"` 全部、字符串数组按 id 前缀、或自定义谓词(逐条收到发现并扇出后的
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// 覆盖 reconcileProvision 对账逻辑,不打真实 e2b API——mock "e2b" 整个模块。
|
|
2
|
+
// 背景:线上跑分撞见过 nextItems() 之后 `for...of` 抛 "X is not iterable" 的一次,
|
|
3
|
+
// 没能稳定复现出确切成因;这里补的是"对账这条路径本身撞上瞬时错误该怎么办"这层契约,
|
|
4
|
+
// 不是去复现那次具体的 SDK 行为。
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
class FakeNotFoundError extends Error {}
|
|
8
|
+
class FakeRateLimitError extends Error {}
|
|
9
|
+
class FakeCommandExitError extends Error {}
|
|
10
|
+
|
|
11
|
+
const listMock = vi.fn();
|
|
12
|
+
const killMock = vi.fn();
|
|
13
|
+
|
|
14
|
+
vi.mock("e2b", () => ({
|
|
15
|
+
Sandbox: { list: (...args: unknown[]) => listMock(...args), kill: (...args: unknown[]) => killMock(...args) },
|
|
16
|
+
NotFoundError: FakeNotFoundError,
|
|
17
|
+
RateLimitError: FakeRateLimitError,
|
|
18
|
+
CommandExitError: FakeCommandExitError,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
function fakePaginator(pages: Array<() => Promise<unknown[]>>) {
|
|
22
|
+
let i = 0;
|
|
23
|
+
return {
|
|
24
|
+
get hasNext() {
|
|
25
|
+
return i < pages.length;
|
|
26
|
+
},
|
|
27
|
+
nextItems: async () => {
|
|
28
|
+
const page = pages[i];
|
|
29
|
+
i += 1;
|
|
30
|
+
return page();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("reconcileProvision", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
listMock.mockReset();
|
|
38
|
+
killMock.mockReset();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("kills every sandbox found under the provision token, across pages", async () => {
|
|
42
|
+
const { reconcileProvision } = await import("./e2b.ts");
|
|
43
|
+
listMock.mockReturnValue(
|
|
44
|
+
fakePaginator([
|
|
45
|
+
async () => [{ sandboxId: "a" }],
|
|
46
|
+
async () => [{ sandboxId: "b" }],
|
|
47
|
+
]),
|
|
48
|
+
);
|
|
49
|
+
killMock.mockResolvedValue(undefined);
|
|
50
|
+
|
|
51
|
+
await reconcileProvision("tok");
|
|
52
|
+
|
|
53
|
+
expect(killMock).toHaveBeenCalledTimes(2);
|
|
54
|
+
expect(killMock.mock.calls.map((c) => c[0])).toEqual(["a", "b"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("treats a NotFound on kill as already-reconciled, not an error", async () => {
|
|
58
|
+
const { reconcileProvision } = await import("./e2b.ts");
|
|
59
|
+
listMock.mockReturnValue(fakePaginator([async () => [{ sandboxId: "gone" }]]));
|
|
60
|
+
killMock.mockRejectedValue(new FakeNotFoundError("gone"));
|
|
61
|
+
|
|
62
|
+
await expect(reconcileProvision("tok")).resolves.toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("retries nextItems() on a transient (ambiguous) error and still succeeds", async () => {
|
|
66
|
+
vi.useFakeTimers();
|
|
67
|
+
try {
|
|
68
|
+
const { reconcileProvision } = await import("./e2b.ts");
|
|
69
|
+
let calls = 0;
|
|
70
|
+
let done = false;
|
|
71
|
+
listMock.mockReturnValue({
|
|
72
|
+
get hasNext() {
|
|
73
|
+
return !done;
|
|
74
|
+
},
|
|
75
|
+
nextItems: vi.fn(async () => {
|
|
76
|
+
calls += 1;
|
|
77
|
+
if (calls === 1) throw new Error("fetch failed");
|
|
78
|
+
done = true;
|
|
79
|
+
return [];
|
|
80
|
+
}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const promise = reconcileProvision("tok");
|
|
84
|
+
await vi.runAllTimersAsync();
|
|
85
|
+
await promise;
|
|
86
|
+
expect(calls).toBe(2);
|
|
87
|
+
} finally {
|
|
88
|
+
vi.useRealTimers();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("does not swallow a nextItems() result that isn't an array", async () => {
|
|
93
|
+
const { reconcileProvision } = await import("./e2b.ts");
|
|
94
|
+
listMock.mockReturnValue({ hasNext: true, nextItems: vi.fn().mockResolvedValue(undefined) });
|
|
95
|
+
|
|
96
|
+
await expect(reconcileProvision("tok")).rejects.toThrow(/not an array|SandboxInfo/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("gives up after repeated transient nextItems() failures instead of retrying forever", async () => {
|
|
100
|
+
vi.useFakeTimers();
|
|
101
|
+
try {
|
|
102
|
+
const { reconcileProvision } = await import("./e2b.ts");
|
|
103
|
+
const err = new Error("fetch failed");
|
|
104
|
+
listMock.mockReturnValue({
|
|
105
|
+
hasNext: true,
|
|
106
|
+
nextItems: vi.fn().mockRejectedValue(err),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const assertion = expect(reconcileProvision("tok")).rejects.toBe(err);
|
|
110
|
+
await vi.runAllTimersAsync();
|
|
111
|
+
await assertion;
|
|
112
|
+
} finally {
|
|
113
|
+
vi.useRealTimers();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("propagates a non-retryable kill error immediately", async () => {
|
|
118
|
+
const { reconcileProvision } = await import("./e2b.ts");
|
|
119
|
+
listMock.mockReturnValue(fakePaginator([async () => [{ sandboxId: "a" }]]));
|
|
120
|
+
const err = new Error("permission denied");
|
|
121
|
+
killMock.mockRejectedValue(err);
|
|
122
|
+
|
|
123
|
+
await expect(reconcileProvision("tok")).rejects.toBe(err);
|
|
124
|
+
});
|
|
125
|
+
});
|