niceeval 0.10.2 → 0.10.3-canary.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/INDEX.md +4 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/dist/report/components/attempt-detail/AttemptAssertions.js +2 -1
- package/dist/report/components/attempt-detail/AttemptSource.js +2 -1
- package/dist/scoring/display.d.ts +7 -1
- package/dist/scoring/display.js +22 -2
- package/docs-site/zh/explanation/runner.mdx +4 -0
- package/docs-site/zh/index.mdx +1 -1
- package/docs-site/zh/reference/expect.mdx +21 -1
- package/docs-site/zh/troubleshooting/debugging.mdx +8 -8
- package/docs-site/zh/troubleshooting/recover-after-kill.mdx +62 -0
- package/docs-site/zh/tutorials/agent-onboarding.mdx +14 -2
- package/docs-site/zh/tutorials/quickstart.mdx +2 -2
- package/docs-site/zh/tutorials/sandbox-providers.mdx +27 -0
- package/docs-site/zh/tutorials/viewing-results.mdx +1 -1
- package/package.json +1 -1
- package/src/cli.ts +6 -0
- package/src/expect/index.test.ts +39 -0
- package/src/expect/index.ts +33 -0
- package/src/report/components/attempt-detail/AttemptAssertions.tsx +4 -3
- package/src/report/components/attempt-detail/AttemptSource.tsx +5 -4
- package/src/sandbox/e2b-reconcile.test.ts +1 -0
- package/src/scoring/display.test.ts +26 -0
- package/src/scoring/display.ts +24 -2
package/INDEX.md
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
按当前任务从树里挑 1–3 页读取(通常是一页 tutorial 或 explanation 搭一页 reference);页面再引用其它概念或参考时,继续读取包内文件。
|
|
10
10
|
|
|
11
|
+
随包文档目前只有中文。不要为了找英文版而去抓官网或 GitHub——那里的页面可能对应另一个版本,「另一个版本的英文页」比「当前版本的中文页」更危险。中文页读起来没有障碍就直接读;与用户交流仍用用户的语言。
|
|
12
|
+
|
|
11
13
|
<!-- GENERATED:BEGIN bundled-docs-tree -->
|
|
12
14
|
|
|
13
15
|
<!-- 本文件是构建产物(pnpm run build:index),勿手改:树区文案改对应页面的 frontmatter title/description,导语改 INDEX.template.md(生成逻辑见 scripts/generate-reference.ts) -->
|
|
@@ -56,7 +58,7 @@
|
|
|
56
58
|
- `docs-site/zh/reference/define-config.mdx` — defineConfig:项目默认配置:defineConfig 参考:judge、reporters、并发、超时和 sandbox 默认值。
|
|
57
59
|
- `docs-site/zh/reference/define-eval.mdx` — defineEval:声明、配置并运行 NiceEval 评估用例:defineEval 参考:选项、test context t、Turn 返回值、Sandbox helper,以及数组和 keyed record 数据集导出。
|
|
58
60
|
- `docs-site/zh/reference/events.mdx` — 标准事件流参考:StreamEvent 的十种事件:每种的字段、什么时候吐、哪些断言消费它。adapter 的核心工作就是产出这条流。
|
|
59
|
-
- `docs-site/zh/reference/expect.mdx` — niceeval/expect matchers 与自定义断言参考:niceeval/expect 参考:includes、equals、matches、similarity、satisfies。可链式调用 .gate() 或 .atLeast(0.7),也可以用 makeAssertion 构建自定义 matcher。
|
|
61
|
+
- `docs-site/zh/reference/expect.mdx` — niceeval/expect matchers 与自定义断言参考:niceeval/expect 参考:includes、equals、matches、similarity、satisfies,以及形状断言 includesUrl、hasSections。可链式调用 .gate() 或 .atLeast(0.7),也可以用 makeAssertion 构建自定义 matcher。
|
|
60
62
|
- `docs-site/zh/reference/official-adapters.mdx` — 官方适配器一览:NiceEval 内置的 Sandbox 和非 Sandbox 适配器分别是什么、怎么鉴权,Sandbox 型里怎么装 MCP server、Skill、插件,怎么使用 Agent 官方配置文件。
|
|
61
63
|
- `docs-site/zh/reference/report-components.mdx` — 报告组件一览:报告文件里能摆的全部官方双面组件:每个组件回答什么问题、怎么调用、网页面长什么样、终端字符输出长什么样。
|
|
62
64
|
- `docs-site/zh/reference/results-data.mdx` — 用 niceeval/results 直接读写结果数据:报告积木脚下的数据层:openResults 把 .niceeval/ 的落盘 artifact parse 成「实验 → 结果快照 → 评估 → attempt」的类型化层次,createResultsWriter 把别家结果写成 NiceEval 格式,copySnapshots 负责发布瘦身。
|
|
@@ -65,6 +67,7 @@
|
|
|
65
67
|
|
|
66
68
|
- `docs-site/zh/troubleshooting/debug-sandbox.mdx` — 保留 Sandbox 现场排查问题:用 --keep-sandbox 把失败 Attempt 的 Sandbox 保留成可随时唤醒的现场,用 niceeval sandbox enter 进去手动排查,用 sandbox list / stop 查看和清理。
|
|
67
69
|
- `docs-site/zh/troubleshooting/debugging.mdx` — 排查失败与复盘历史运行:一份按场景组织的排查手册:断言失败怎么定位、环境错误怎么进 Sandbox、agent 改了什么怎么看、旧的运行怎么翻出来复盘——每一步都有命令顺序和输出示例。
|
|
70
|
+
- `docs-site/zh/troubleshooting/recover-after-kill.mdx` — 运行被强杀后恢复:进程被 kill -9、CI 超时或断电杀掉后,重跑同一条命令续跑没跑完的部分,用 niceeval sandbox prune 收回没清理的容器,用 --teardown 补齐实验收尾。
|
|
68
71
|
|
|
69
72
|
## `docs-site/zh/examples/`
|
|
70
73
|
|
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ pnpm exec niceeval view // view eval results
|
|
|
129
129
|
## Quick Start
|
|
130
130
|
|
|
131
131
|
```text
|
|
132
|
-
READ https://niceeval.com/INIT.md and
|
|
132
|
+
READ https://niceeval.com/INIT.md and set up niceeval for this repo: install it, integrate it with this project, and run the first eval end to end.
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
Start from the scenario that matches what you need to evaluate:
|
package/README.zh.md
CHANGED
|
@@ -129,7 +129,7 @@ pnpm exec niceeval view // 查看评估结果
|
|
|
129
129
|
## 快速开始
|
|
130
130
|
|
|
131
131
|
```text
|
|
132
|
-
READ https://niceeval.com/INIT.md and
|
|
132
|
+
READ https://niceeval.com/INIT.md and set up niceeval for this repo: install it, integrate it with this project, and run the first eval end to end.
|
|
133
133
|
```
|
|
134
134
|
|
|
135
135
|
从你的场景开始:
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { stripControl } from "../../../scoring/display.js";
|
|
2
3
|
import { cx } from "../shared.js";
|
|
3
4
|
function assertTone(a) {
|
|
4
5
|
if (a.outcome === "unavailable")
|
|
@@ -8,7 +9,7 @@ function assertTone(a) {
|
|
|
8
9
|
return a.severity === "soft" ? "warn" : "bad";
|
|
9
10
|
}
|
|
10
11
|
function AssertionRow({ a }) {
|
|
11
|
-
return (_jsxs("details", { className: "nre-assertion-row", open: true, children: [_jsxs("summary", { children: [_jsx("span", { className: `nre-assertion-badge nre-tone-${assertTone(a)}`, children: a.outcome === "unavailable" ? "unavailable" : a.outcome }), _jsxs("span", { className: "nre-assertion-name", children: [a.groupPath?.length ? `${a.groupPath.join(" > ")} · ` : "", a.name] }), a.detail && a.detail !== a.name ? _jsx("span", { className: "nre-assertion-detail", children: a.detail }) : null] }), _jsxs("div", { className: "nre-assertion-body", children: [a.outcome === "unavailable" ? _jsx("div", { children: a.reason }) : null, a.outcome !== "unavailable" && a.expected !== undefined ? _jsxs("div", { children: ["expected: ", a.expected] }) : null, a.outcome !== "unavailable" && a.received !== undefined ? _jsxs("div", { children: ["received: ", a.received] }) : null] })] }));
|
|
12
|
+
return (_jsxs("details", { className: "nre-assertion-row", open: true, children: [_jsxs("summary", { children: [_jsx("span", { className: `nre-assertion-badge nre-tone-${assertTone(a)}`, children: a.outcome === "unavailable" ? "unavailable" : a.outcome }), _jsxs("span", { className: "nre-assertion-name", children: [a.groupPath?.length ? `${a.groupPath.join(" > ")} · ` : "", a.name] }), a.detail && a.detail !== a.name ? _jsx("span", { className: "nre-assertion-detail", children: a.detail }) : null] }), _jsxs("div", { className: "nre-assertion-body", children: [a.outcome === "unavailable" ? _jsx("div", { children: a.reason === undefined ? undefined : stripControl(a.reason) }) : null, a.outcome !== "unavailable" && a.expected !== undefined ? _jsxs("div", { children: ["expected: ", stripControl(a.expected)] }) : null, a.outcome !== "unavailable" && a.received !== undefined ? _jsxs("div", { children: ["received: ", stripControl(a.received)] }) : null] })] }));
|
|
12
13
|
}
|
|
13
14
|
export function AttemptAssertions({ data, className, }) {
|
|
14
15
|
if (data === null)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { stripControl } from "../../../scoring/display.js";
|
|
2
3
|
import { cx } from "../shared.js";
|
|
3
4
|
import { ConversationReplies } from "./AttemptConversation.js";
|
|
4
5
|
const TS_HL_RE = /(\/\/[^\n]*)|(\/\*[^]*?\*\/)|(`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|\b(import|from|export|default|const|let|var|async|await|function|return|if|else|for|of|in|new|class|extends|typeof|void|true|false|null|undefined)\b|\b(\d[\d_.]*)\b|([A-Za-z_$][\w$]*)(?=\s*\()/g;
|
|
@@ -76,7 +77,7 @@ function TurnDetail({ turn, showMeta = false, showSent = false }) {
|
|
|
76
77
|
return (_jsxs("div", { className: cx("nre-source-turn", `nre-source-turn-${turn.status}`), children: [showMeta ? (_jsxs("div", { className: "nre-source-turn-head", children: [_jsx("span", { children: turn.label }), _jsx("span", { children: turn.status }), turn.durationMs === undefined ? null : _jsx("span", { children: formatDuration(turn.durationMs) })] })) : null, showSent && turn.sentText ? _jsx("div", { className: "nre-conv-sent", children: turn.sentText }) : null, _jsx(ConversationReplies, { replies: turn.replies })] }));
|
|
77
78
|
}
|
|
78
79
|
function AssertionDetail({ assertion }) {
|
|
79
|
-
return (_jsxs("div", { className: `nre-assertion-row nre-tone-${assertTone(assertion)}`, children: [_jsxs("div", { className: "nre-source-assertion-head", children: [_jsx("span", { className: "nre-assertion-badge", children: assertion.outcome }), _jsx("span", { className: "nre-assertion-name", children: assertion.name }), assertion.outcome !== "unavailable" ? _jsx("span", { className: "nre-source-assertion-score", children: assertion.score }) : null] }), assertion.detail ? _jsx("div", { className: "nre-assertion-detail", children: assertion.detail }) : null, assertion.outcome === "unavailable" ? (_jsxs("div", { className: "nre-assertion-body", children: ["reason: ", assertion.reason] })) : assertion.expected !== undefined || assertion.received !== undefined || assertion.evidence !== undefined ? (_jsxs("div", { className: "nre-assertion-body", children: [assertion.expected !== undefined ? _jsxs("span", { children: ["expected: ", assertion.expected] }) : null, assertion.received !== undefined ? _jsxs("span", { children: ["received: ", assertion.received] }) : null, assertion.evidence !== undefined ? _jsxs("span", { children: ["evidence: ", assertion.evidence] }) : null] })) : null] }));
|
|
80
|
+
return (_jsxs("div", { className: `nre-assertion-row nre-tone-${assertTone(assertion)}`, children: [_jsxs("div", { className: "nre-source-assertion-head", children: [_jsx("span", { className: "nre-assertion-badge", children: assertion.outcome }), _jsx("span", { className: "nre-assertion-name", children: assertion.name }), assertion.outcome !== "unavailable" ? _jsx("span", { className: "nre-source-assertion-score", children: assertion.score }) : null] }), assertion.detail ? _jsx("div", { className: "nre-assertion-detail", children: assertion.detail }) : null, assertion.outcome === "unavailable" ? (_jsxs("div", { className: "nre-assertion-body", children: ["reason: ", assertion.reason === undefined ? undefined : stripControl(assertion.reason)] })) : assertion.expected !== undefined || assertion.received !== undefined || assertion.evidence !== undefined ? (_jsxs("div", { className: "nre-assertion-body", children: [assertion.expected !== undefined ? _jsxs("span", { children: ["expected: ", stripControl(assertion.expected)] }) : null, assertion.received !== undefined ? _jsxs("span", { children: ["received: ", stripControl(assertion.received)] }) : null, assertion.evidence !== undefined ? _jsxs("span", { children: ["evidence: ", stripControl(assertion.evidence)] }) : null] })) : null] }));
|
|
80
81
|
}
|
|
81
82
|
export function AttemptSource({ data, className }) {
|
|
82
83
|
if (data === null)
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { AssertionResult, PrimaryAssertionSummary, Verdict } from "./types.ts";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* 剥离 ANSI 转义与其余不可打印控制字节,保留可打印字符与结构性空白(换行 / 制表)。给需要
|
|
4
|
+
* 完整多行值的面(报告详情)直接用;`summaryText` 在此基础上再折单行 + 截断。jest 合法打印的
|
|
5
|
+
* `✕ ✓ › ❯ ↓ │`(均 ≥ U+2020)在保留范围内,不误删。
|
|
6
|
+
*/
|
|
7
|
+
export declare function stripControl(value: string): string;
|
|
8
|
+
/** 摘要面的单值收口:剥控制字节 + 折单行 + 240 字符上限。任何把断言事实放进「行」里的面共用这一条。 */
|
|
3
9
|
export declare function summaryText(value: string): string;
|
|
4
10
|
/**
|
|
5
11
|
* 按公开展示契约选择主失败断言:failed gate 优先;只有 soft 促成 failed verdict 时才取 soft;
|
package/dist/scoring/display.js
CHANGED
|
@@ -11,9 +11,29 @@ const SUMMARY_TEXT_MAX_CHARS = 240;
|
|
|
11
11
|
* 终端 columns——agent profile 的 handoff 不是 TTY,不能按运行时宽度变化。
|
|
12
12
|
*/
|
|
13
13
|
const DETAIL_LINE_MAX_CHARS = 100;
|
|
14
|
-
|
|
14
|
+
// 捕获内容(received=命令输出 / expected=源码 / evidence)常带被测工具的着色:jest/vitest 的
|
|
15
|
+
// 代码帧、行号、✕ 都由 ANSI 转义(ESC[…m 等)上色。这些 ESC(U+001B)不是 \s,若原样落进任何
|
|
16
|
+
// 面,终端会重新解释它们(被单行截断从序列中间切开时尤其乱),HTML 报告则把 ESC[2m28|ESC[22m
|
|
17
|
+
// 当字面文本渲染。所以任何展示面在渲染捕获内容前先剥控制字节;剥的是展示投影,不改存进
|
|
18
|
+
// AssertionResult / artifact 的原始字节(完整证据仍在 events.json / diff.json)。
|
|
19
|
+
// CSI(ESC[…,含 SGR 着色 / 光标控制)与 OSC(ESC]…,以 BEL 或 ST 收尾);OSC 的 payload 一并吃掉,
|
|
20
|
+
// 不让它作为裸文本泄漏。没配成序列的裸 ESC 由 OTHER_CONTROL 兜底。
|
|
21
|
+
// eslint-disable-next-line no-control-regex
|
|
22
|
+
const ANSI_ESCAPE = /\u001B(?:\[[0-9;:?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\))/g;
|
|
23
|
+
// 其余不可打印 C0/C1(含裸 ESC);保留 \t\n\f\r 交给下游折空白规则,不在这里塌成空。
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
const OTHER_CONTROL = /[\u0000-\u0008\u000B\u000E-\u001F\u007F-\u009F]/g;
|
|
26
|
+
/**
|
|
27
|
+
* 剥离 ANSI 转义与其余不可打印控制字节,保留可打印字符与结构性空白(换行 / 制表)。给需要
|
|
28
|
+
* 完整多行值的面(报告详情)直接用;`summaryText` 在此基础上再折单行 + 截断。jest 合法打印的
|
|
29
|
+
* `✕ ✓ › ❯ ↓ │`(均 ≥ U+2020)在保留范围内,不误删。
|
|
30
|
+
*/
|
|
31
|
+
export function stripControl(value) {
|
|
32
|
+
return value.replace(ANSI_ESCAPE, "").replace(OTHER_CONTROL, "");
|
|
33
|
+
}
|
|
34
|
+
/** 摘要面的单值收口:剥控制字节 + 折单行 + 240 字符上限。任何把断言事实放进「行」里的面共用这一条。 */
|
|
15
35
|
export function summaryText(value) {
|
|
16
|
-
const singleLine = value.replace(/\s+/g, " ").trim();
|
|
36
|
+
const singleLine = stripControl(value).replace(/\s+/g, " ").trim();
|
|
17
37
|
return singleLine.length <= SUMMARY_TEXT_MAX_CHARS
|
|
18
38
|
? singleLine
|
|
19
39
|
: `${singleLine.slice(0, SUMMARY_TEXT_MAX_CHARS - 1)}…`;
|
|
@@ -56,6 +56,10 @@ npx niceeval exp local fixtures/button --runs 5 --early-exit
|
|
|
56
56
|
|
|
57
57
|
[NiceEval](https://niceeval.com/) 可以根据输入、配置和相关文件 fingerprint 跳过已判定为 `passed` 或 `failed` 的结果——两者都是判定确定的终态。`errored`(超时、Sandbox 异常等框架/环境层面的不确定失败)永远重试。缓存适合加速迭代,但如果你在调试非确定性行为,应该明确关闭(`--force`)或清理相关缓存。
|
|
58
58
|
|
|
59
|
+
## Turn 瞬时错误重试
|
|
60
|
+
|
|
61
|
+
限流、连接建立失败这类瞬时错误,NiceEval 会在同一个 Turn 里自动做有限次数的指数退避重试,不需要你重跑整个实验;等待重试的 Attempt 会让出并发名额给别的 Attempt。只有能确认 Agent 还没开始处理这次输入的错误才会重试——请求已经开始、中途断流的情况不重试,直接记为 `errored`,避免 Agent 把已经做过的操作再做一遍。重试用尽仍失败时,该 Attempt 记为 `errored`,下次运行照常重试(见上面的缓存规则)。
|
|
62
|
+
|
|
59
63
|
## 超时和预算
|
|
60
64
|
|
|
61
65
|
```bash
|
package/docs-site/zh/index.mdx
CHANGED
|
@@ -145,7 +145,7 @@ npx niceeval view # 网页交互浏览
|
|
|
145
145
|
如果你想让 Coding Agent 直接帮项目接入 [NiceEval](https://niceeval.com/),让它先读安装入口:
|
|
146
146
|
|
|
147
147
|
```text
|
|
148
|
-
READ https://niceeval.com/INIT.md and
|
|
148
|
+
READ https://niceeval.com/INIT.md and set up niceeval for this repo: install it, integrate it with this project, and run the first eval end to end.
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
<CardGroup cols={3}>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "niceeval/expect matchers 与自定义断言参考"
|
|
3
3
|
sidebarTitle: "断言 Matchers"
|
|
4
|
-
description: "niceeval/expect 参考:includes、equals、matches、similarity、satisfies。可链式调用 .gate() 或 .atLeast(0.7),也可以用 makeAssertion 构建自定义 matcher。"
|
|
4
|
+
description: "niceeval/expect 参考:includes、equals、matches、similarity、satisfies,以及形状断言 includesUrl、hasSections。可链式调用 .gate() 或 .atLeast(0.7),也可以用 makeAssertion 构建自定义 matcher。"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
`niceeval/expect` 提供一组可组合 matcher,传给 `t.check()` 或 `t.require()`。matcher 会返回一个 `Assertion`,并带有默认严重性:`gate` 或 `soft`。
|
|
@@ -74,6 +74,26 @@ export function similarity(expected: string): ValueAssertion { ... }
|
|
|
74
74
|
纯字符串编辑距离,不是语义相似度——归一化 Levenshtein 距离 [0,1](1 - 编辑距离 / 较长串长度),
|
|
75
75
|
不理解含义,同义改写 / 语序调整会被判低分。默认软分,阈值 0.6。
|
|
76
76
|
|
|
77
|
+
#### `includesUrl`
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
export function includesUrl(min = 1): ValueAssertion { ... }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
文本含至少 min 条(默认 1)去重后的 http(s) 链接则 1,否则 0。默认硬门槛。
|
|
84
|
+
「回答有没有引用真实来源」的形状断言:没有 Judge key 时,它是「至少引用一条来源链接」
|
|
85
|
+
的最低成本兜底——被测方复读题目糊弄不过去,编造的链接则留给负例或 Judge 去抓。
|
|
86
|
+
|
|
87
|
+
#### `hasSections`
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
export function hasSections(min = 2): ValueAssertion { ... }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
文本含至少 min 个(默认 2)Markdown 标题(行首 # 到 ######)则 1,否则 0。默认硬门槛。
|
|
94
|
+
「回答是不是一份有结构的文档」的形状断言,适合研究报告、方案文档这类产出型回答——
|
|
95
|
+
一段没有任何小标题的流水文本过不了。
|
|
96
|
+
|
|
77
97
|
#### `satisfies`
|
|
78
98
|
|
|
79
99
|
```ts
|
|
@@ -46,12 +46,12 @@ available:
|
|
|
46
46
|
|
|
47
47
|
排查顺序是「哪条断言挂了 → agent 当时做了什么 → 它到底改了什么」。
|
|
48
48
|
|
|
49
|
-
**1. 把断言放回源码。** `--source` 显示运行时保存的那份评估用例源码(不是你工作区里可能已经改过的版本),失败的断言直接标在对应行上;`t.send(...)` 的调用行标出它产生的那一轮——轮标签(`
|
|
49
|
+
**1. 把断言放回源码。** `--source` 显示运行时保存的那份评估用例源码(不是你工作区里可能已经改过的版本),失败的断言直接标在对应行上;`t.send(...)` 的调用行标出它产生的那一轮——轮标签(`turn1`,与 `--execution` / `--timing` 用同一套;`t.newSession()` 开的会话记 `session2/turn1`)、这轮成没成、花了多久:
|
|
50
50
|
|
|
51
51
|
```text
|
|
52
52
|
$ niceeval show @1qrdcfq8 --source
|
|
53
53
|
21✓ await t.send("Review the proposals and record your decision…");
|
|
54
|
-
|
|
54
|
+
turn1 · completed · 22.4s
|
|
55
55
|
38 for (const [issue, label] of Object.entries(expected)) {
|
|
56
56
|
39 await t.group(`Issue ${issue}: selected proposal matches…`, async () => {
|
|
57
57
|
40✗ t.check(Number(decisions[issue]?.selected_proposal_id), equals(label.selected_proposal_id));
|
|
@@ -64,7 +64,7 @@ $ niceeval show @1qrdcfq8 --source
|
|
|
64
64
|
|
|
65
65
|
```text
|
|
66
66
|
$ niceeval show @1qrdcfq8 --execution
|
|
67
|
-
|
|
67
|
+
turn1 · completed · 22.4s · 12.4k tok · $0.02
|
|
68
68
|
USER
|
|
69
69
|
Review the proposals and record your decision for each issue…
|
|
70
70
|
|
|
@@ -78,7 +78,7 @@ TURN s1/t1 · completed · 22.4s · 12.4k tok · $0.02
|
|
|
78
78
|
Proposal 1: …
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
|
|
81
|
+
对话按轮分段,每轮头行给出轮标签(`turn1`)、状态、耗时和用量——这个标签和 `--diff`、`--timing` 里的轮次标签是同一套,能互相对照。
|
|
82
82
|
|
|
83
83
|
不想通读全文时,接 `grep` 定向查。列出这次 Attempt 用过哪些工具、各多少次:
|
|
84
84
|
|
|
@@ -99,17 +99,17 @@ niceeval show @1qrdcfq8 --execution | grep proposals
|
|
|
99
99
|
```text
|
|
100
100
|
$ niceeval show @1qrdcfq8 --diff
|
|
101
101
|
2 files changed by agent
|
|
102
|
-
M manager_decisions.json +6 -2
|
|
103
|
-
A notes/decision-log.md +18
|
|
102
|
+
M manager_decisions.json +6 -2 turn1, turn2
|
|
103
|
+
A notes/decision-log.md +18 turn2
|
|
104
104
|
|
|
105
105
|
single file: niceeval show @1qrdcfq8 --diff=manager_decisions.json
|
|
106
106
|
```
|
|
107
107
|
|
|
108
|
-
行尾的 `
|
|
108
|
+
行尾的 `turn1` 表示这个文件是在第几轮对话里被改的,能和 `--execution` 的轮次对上。要看单个文件的逐行改动,用 `=` 连写文件路径:
|
|
109
109
|
|
|
110
110
|
```text
|
|
111
111
|
$ niceeval show @1qrdcfq8 --diff=manager_decisions.json
|
|
112
|
-
M manager_decisions.json · changed in
|
|
112
|
+
M manager_decisions.json · changed in turn1, turn2
|
|
113
113
|
@@ -1,5 +1,7 @@
|
|
114
114
|
{
|
|
115
115
|
- "15193": { "selected_proposal_id": 1 },
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "运行被强杀后恢复"
|
|
3
|
+
description: "进程被 kill -9、CI 超时或断电杀掉后,重跑同一条命令续跑没跑完的部分,用 niceeval sandbox prune 收回没清理的容器,用 --teardown 补齐实验收尾。"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
`niceeval exp` 跑到一半被 `kill -9`、CI 时限或断电直接杀掉时,进程没有机会做任何清理。你会碰到三种残留:没跑完的评估、还在 Provider 侧占资源的 Sandbox 实例、实验 `setup` 起过但没关掉的外部服务(隧道、共享服务、license 席位)。三种各有一个恢复入口,都不需要手工翻 Docker 或云控制台。
|
|
7
|
+
|
|
8
|
+
正常的 Ctrl+C 或 SIGTERM 不在本页范围:那些路径 NiceEval 会自己走完全部收尾,不留残留。
|
|
9
|
+
|
|
10
|
+
## 重跑同一条命令,续跑没跑完的部分
|
|
11
|
+
|
|
12
|
+
已经跑完并落盘的 Attempt 是可信结果,重跑时自动带入,不再花一次 Agent 和 Sandbox 的成本:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
niceeval exp compare/bub-e2b memory/commit0
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- 只补跑缺的部分:`--runs 5` 已经落盘 3 次,就只再跑 2 次。
|
|
19
|
+
- 被强杀的实验如果留了没做完的收尾,重跑会先补一次实验级 `teardown` 再开始,泄漏不会越积越多。
|
|
20
|
+
- 判定为 `errored` 的 Attempt 不复用,照常重跑。
|
|
21
|
+
- 想全部重来,加 `--force`。
|
|
22
|
+
|
|
23
|
+
## 收回没清理的 Sandbox 实例
|
|
24
|
+
|
|
25
|
+
先核对有哪些实例属于已经死掉的运行:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
niceeval sandbox list --orphans
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
ID PROVIDER OWNER STARTED STATE
|
|
33
|
+
f31b9a02 docker pid 4242@mbp dead 2026-07-20 14:02 orphan
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
确认后一条命令收回:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
niceeval sandbox prune
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- `orphan` 表示属主进程已确认死亡,可以安全销毁;正在跑的运行的实例不会出现在列表里。
|
|
43
|
+
- 从别的机器创建、无法核对的实例标为 `unverified`,默认不动;确认后用 `niceeval sandbox prune --force`。
|
|
44
|
+
- Vercel Sandbox 无法按元数据核对,到 Provider 的保留期限后自动回收,不需要处理。
|
|
45
|
+
- `--keep-sandbox` 留存的现场不受 `prune` 影响,仍用 `niceeval sandbox stop` 管理。
|
|
46
|
+
|
|
47
|
+
## 补齐实验收尾
|
|
48
|
+
|
|
49
|
+
实验 `setup` 起的外部服务要靠实验 `teardown` 关掉。被强杀的运行如果你暂时不想重跑,单独补一次收尾:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
niceeval exp compare/bub-e2b --teardown
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- 只执行选中实验的 `teardown`,不跑任何评估、不跑 `setup`。
|
|
56
|
+
- 随时可以执行,不依赖上次运行留下的记录;`teardown` 自身要能容忍重复执行。
|
|
57
|
+
- 强杀后原进程的内存已经丢失,`teardown` 里要从容器名、pid 文件或幂等的关停脚本这类持久信息找回要关的资源,不要依赖 `setup` 存在内存里的对象。
|
|
58
|
+
|
|
59
|
+
## 预防:让下一次强杀无害
|
|
60
|
+
|
|
61
|
+
- 长任务放在有时限的环境(CI、外部看门狗)里跑时,把时限内跑不完当成常态:靠上面的续跑机制分多次跑完,不必强求单次完成。
|
|
62
|
+
- 实验 `teardown` 写成幂等的:重复执行不报错、目标已经关掉也算成功。这是补收尾机制正确工作的前提。
|
|
@@ -55,15 +55,19 @@ export default defineConfig({
|
|
|
55
55
|
两个要提醒用户的点:
|
|
56
56
|
|
|
57
57
|
- **key 解析不到时 Judge 断言会静默跳过**(不报错、不记分)——评估用例全绿不代表 Judge 真的跑了。所以配完先跑一条带 `t.judge` 的评估用例,在 `niceeval view` 里确认有 Judge 分数。
|
|
58
|
+
- **没有用户在场(自治接入)时不要直接跳过 Judge**:先探测环境变量里有没有可用的 key(`OPENAI_API_KEY`、`DEEPSEEK_API_KEY` 等常见变量),探测到就配上并按上一条验证;确实没有才降级到精确断言 + 形状断言(`includesUrl()` / `hasSections()` 这类,见[评分指南](/zh/tutorials/scoring-guide)),并在收尾总结里明确说明 Judge 未配置及原因。
|
|
58
59
|
- Judge 模型要**与被测 Agent 分离**,避免同一个模型给自己打分。模型解析优先级(单次调用 → 评估用例级 → 全局配置)和三种评分形状见 [Judge](/zh/explanation/judge),`judge` 字段全集见 [defineConfig 参考](/zh/reference/define-config)。
|
|
59
60
|
|
|
60
61
|
## 第 3 步:写三件套
|
|
61
62
|
|
|
62
63
|
按第 1 步选中的方向读完对应文档后,依次写:
|
|
63
64
|
|
|
64
|
-
1. **Adapter**(`agents/*.ts` 或用户项目里约定的目录)——只填 `defineAgent` 的 `send`,配置走工厂参数,不写死、不读 `process.env`。契约见 [Adapter](/zh/explanation/adapter),API 签名见 [defineAgent 参考](/zh/reference/define-agent),事件映射见[事件参考](/zh/reference/events)
|
|
65
|
+
1. **Adapter**(`agents/*.ts` 或用户项目里约定的目录)——只填 `defineAgent` 的 `send`,配置走工厂参数,不写死、不读 `process.env`。契约见 [Adapter](/zh/explanation/adapter),API 签名见 [defineAgent 参考](/zh/reference/define-agent),事件映射见[事件参考](/zh/reference/events)。两个容易踩的点:**端点/模式要选被测系统核心能力的入口,不是最容易跑通的入口**——比如被测平台既有「纯 LLM 聊天」又有「连库执行」两种模式,接前者等于评了个底层模型代理,没评到产品本身;**`coverage` 按实际映射如实声明**——只把最终文本映射出来就不要声明 complete,声明会被报告采信,虚报比不报更糟。
|
|
65
66
|
2. **Experiment**(`experiments/*.ts`)——引用上面的 Adapter,声明 `model`、`flags`、`runs` 等。模型对比写两个实验文件,各自钉一个 `model`;`evals: (eval) => boolean` 决定各自运行哪些评估用例。路径只负责 id 和批量运行,报告读取每份快照的 `selectedEvalIds`。
|
|
66
|
-
3. **评估用例**(`evals/*.eval.ts`)——**先探明这个应用是干嘛的,再写一条贴着它真实功能的评估用例**:读它的 README、路由、工具定义或系统提示,找出它的核心用例(客服机器人就问一条真实的客服问题、SQL agent
|
|
67
|
+
3. **评估用例**(`evals/*.eval.ts`)——**先探明这个应用是干嘛的,再写一条贴着它真实功能的评估用例**:读它的 README、路由、工具定义或系统提示,找出它的核心用例(客服机器人就问一条真实的客服问题、SQL agent 就给一个真实的查询任务),拿这个用例做第一条评估用例的输入和断言。两类输入都不合格:「你好」这种和应用无关的占位输入,以及「你是什么/你能做什么」这种**问被测系统它自己的元问题**——那不是用户拿它干活的用例。形式上仍从最小写起:一句输入,`t.succeeded()` + 一个针对预期回答的内容断言——但最小形式只是调通的脚手架,不是交付标准,收尾前还要满足两条:
|
|
68
|
+
- **断言在被测系统胡编时要会变红**。不要断言输入里本来就有的词(问「X 是什么」再断言回答含「X」,被测方复读题目就能通过);断言预期回答独有的实质内容——具体事实、结构(`hasSections()`)、真实链接(`includesUrl()`),或用 `t.judge` 做语义判定。
|
|
69
|
+
- **至少一条负例**。喂一个被测系统应该答不了的输入(不存在的表、检索不到的主题),断言它明确说查不到/做不到,而不是编造一个看似合理的结果——对连着真实数据/检索源的 agent,这是最值得先测的失败形态。
|
|
70
|
+
写法见[编写评估用例](/zh/tutorials/authoring),断言与评分见[评分指南](/zh/tutorials/scoring-guide),签名见 [defineEval 参考](/zh/reference/define-eval)。
|
|
67
71
|
|
|
68
72
|
参数怎么从 Experiment 流到 Adapter、静态配置和每轮动态值怎么分(工厂参数 vs `ctx`),见[接入自己的 Agent](/zh/tutorials/connect-your-agent)。
|
|
69
73
|
|
|
@@ -83,6 +87,14 @@ export default defineConfig({
|
|
|
83
87
|
|
|
84
88
|
## 第 5 步:收尾,告诉用户做了什么
|
|
85
89
|
|
|
90
|
+
总结之前先过一遍收尾自检——**任何一条不满足就回第 3 步补,不要在总结里含糊带过**:
|
|
91
|
+
|
|
92
|
+
- [ ] 评估用例的输入是被测系统的核心用例,不是问它自己的元问题或占位寒暄
|
|
93
|
+
- [ ] 每条内容断言在被测系统复读题目/胡编时会变红(断言的词不是输入里本来就有的)
|
|
94
|
+
- [ ] 至少有一条负例(应该答不了的输入,断言它明确说做不到)
|
|
95
|
+
- [ ] 有 key 时 Judge 已配置,且在 `niceeval view` 里看到过 Judge 分数;没 key 时总结里说明了
|
|
96
|
+
- [ ] Experiment 里声明的 `model` / `flags` 确实被 Adapter 消费(没有写了没人读的死配置,也没有编造被测系统不存在的 model 值)
|
|
97
|
+
|
|
86
98
|
跑通之后先总结,再谈下一步。总结要说清:接了什么被测对象、生成了哪几个文件(Adapter / Experiment / 评估用例各在哪)、`niceeval exp compare-models` 和 `niceeval view` 怎么跑、第一次运行的结果是什么样。不要在没被要求的情况下顺手重构用户已有代码,也不要在这几个文件之外新增抽象。
|
|
87
99
|
|
|
88
100
|
## 第 6 步:问用户要不要往深了接
|
|
@@ -8,9 +8,9 @@ description: "安装 NiceEval,写三个文件,10 分钟内对你自己的应
|
|
|
8
8
|
|
|
9
9
|
## 使用 Coding Agent 接入(推荐)
|
|
10
10
|
<Steps>
|
|
11
|
-
<Step title="
|
|
11
|
+
<Step title="接入">
|
|
12
12
|
```text
|
|
13
|
-
READ https://niceeval.com/INIT.md and
|
|
13
|
+
READ https://niceeval.com/INIT.md and set up niceeval for this repo: install it, integrate it with this project, and run the first eval end to end.
|
|
14
14
|
|
|
15
15
|
```
|
|
16
16
|
</Step>
|
|
@@ -46,6 +46,7 @@ export default defineExperiment({
|
|
|
46
46
|
| `dockerSandbox()` | `pnpm add dockerode @types/dockerode` |
|
|
47
47
|
| `vercelSandbox()` | `pnpm add @vercel/sandbox` |
|
|
48
48
|
| `e2bSandbox()` | `pnpm add e2b` |
|
|
49
|
+
| `localSandbox()` | 无需额外安装 |
|
|
49
50
|
|
|
50
51
|
漏装时不会静默失败:NiceEval 在创建 sandbox 的那一刻报错并直接给出上面的安装命令,例如 `Docker sandbox requires 'dockerode'. Install it with: pnpm add dockerode @types/dockerode`。
|
|
51
52
|
|
|
@@ -327,6 +328,32 @@ Docker 适合本地开发和标准 CI。优点是简单、可控、无云端依
|
|
|
327
328
|
|
|
328
329
|
Vercel Sandbox 适合需要云端隔离、更多资源或更稳定环境的任务。需要相应 token 或 OIDC 配置。
|
|
329
330
|
|
|
331
|
+
## 本地目录
|
|
332
|
+
|
|
333
|
+
`localSandbox()` 让 Agent 直接在你机器上的一个 Git 仓库里跑,不需要 Docker,也不需要云凭据:
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
import { defineExperiment } from "niceeval";
|
|
337
|
+
import { localSandbox } from "niceeval/sandbox";
|
|
338
|
+
|
|
339
|
+
export default defineExperiment({
|
|
340
|
+
agent: myCodingAgent,
|
|
341
|
+
sandbox: localSandbox(), // 默认用当前 Git 仓库根做工作目录
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
要评别的目录,传 `localSandbox({ dir: "/path/to/repo" })`。
|
|
346
|
+
|
|
347
|
+
NiceEval 只观察、不还原:Agent 改了什么会真实落在你的工作树上;NiceEval 用一份独立的私有 Git 记录采集 diff 和评分,不碰你自己的 `.git`、暂存区和未提交改动,跑完也不会执行任何 `git reset`。要保留还是丢弃 Agent 的改动,由你决定。
|
|
348
|
+
|
|
349
|
+
使用前记住三件事:
|
|
350
|
+
|
|
351
|
+
- Agent 以你的身份、在你的机器上执行命令。只在你信任任务和 Prompt 的时候用它;要隔离就用 Docker 或云 Provider。
|
|
352
|
+
- 本地目录同一时刻只跑一个 Attempt(强制串行),`--max-concurrency` 抬不高它。
|
|
353
|
+
- 连续跑多个评估用例时,前一个的改动会留在工作树上,成为后一个的起点。要每题干净起点,用容器 Provider。
|
|
354
|
+
|
|
355
|
+
`{ root: true }` 和 `--keep-sandbox` 对本地目录不可用,会直接报错:NiceEval 不在你的机器上提权,也不需要「留存」一个本来就在你工作树里的现场。
|
|
356
|
+
|
|
330
357
|
## 自定义 Provider
|
|
331
358
|
|
|
332
359
|
用 `defineSandbox` 接入其它服务。`create` 的 `feedback` 已绑定到 `sandbox.create` 阶段,可以报告分配实例、拉镜像或恢复沙箱快照的状态:
|
|
@@ -257,7 +257,7 @@ agent.setup 41.5s
|
|
|
257
257
|
├─ shell · npm install -g @openai/codex… 39.8s
|
|
258
258
|
└─ shell · write ~/.codex/config.toml 1.7s
|
|
259
259
|
eval.run 50.9s
|
|
260
|
-
└─
|
|
260
|
+
└─ turn1 50.9s ✗ agent-runtime-error
|
|
261
261
|
└─ shell · codex exec … 50.7s
|
|
262
262
|
├─ agent · codex.exec 50.5s OTel
|
|
263
263
|
└─ model · chat 44.2s OTel
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -431,6 +431,12 @@ async function initProject(cwd: string): Promise<void> {
|
|
|
431
431
|
"",
|
|
432
432
|
"export default defineConfig({",
|
|
433
433
|
" // Add experiments/ with defineExperiment(...) to run evals.",
|
|
434
|
+
" //",
|
|
435
|
+
" // TODO(judge): semantic assertions (t.judge.*) are silently skipped until a judge",
|
|
436
|
+
" // model is configured — an all-green run does not mean the judge ran. Any",
|
|
437
|
+
" // OpenAI-compatible /chat/completions service works; the key is read from",
|
|
438
|
+
" // OPENAI_API_KEY unless apiKeyEnv says otherwise.",
|
|
439
|
+
' // judge: { model: "gpt-5.4-mini" },',
|
|
434
440
|
"});",
|
|
435
441
|
"",
|
|
436
442
|
].join("\n"),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// cases: docs/engineering/unit-tests/scoring/cases.md
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { hasSections, includesUrl } from "./index.ts";
|
|
4
|
+
|
|
5
|
+
describe("includesUrl", () => {
|
|
6
|
+
it("scores 1 when the text contains a real http(s) link", async () => {
|
|
7
|
+
const m = includesUrl();
|
|
8
|
+
expect(await m.score("依据见 https://example.com/report 第 3 节")).toBe(1);
|
|
9
|
+
expect(m.severity).toBe("gate");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("scores 0 on no link, and bare domains without a protocol do not count", async () => {
|
|
13
|
+
expect(await includesUrl().score("来源:example.com(官网)")).toBe(0);
|
|
14
|
+
expect(await includesUrl().score("一段没有任何引用的回答")).toBe(0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("counts distinct URLs: the same link repeated is one", async () => {
|
|
18
|
+
const twice = "见 https://a.dev/x 与 https://a.dev/x";
|
|
19
|
+
expect(await includesUrl(2).score(twice)).toBe(0);
|
|
20
|
+
expect(await includesUrl(2).score("见 https://a.dev/x 与 https://b.dev/y")).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("hasSections", () => {
|
|
25
|
+
it("scores 1 when the text has at least min markdown headings", async () => {
|
|
26
|
+
const m = hasSections();
|
|
27
|
+
expect(await m.score("# 概述\n正文\n## 结论\n正文")).toBe(1);
|
|
28
|
+
expect(m.severity).toBe("gate");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("scores 0 on flowing text without headings; a mid-line # does not count", async () => {
|
|
32
|
+
expect(await hasSections().score("一整段没有结构的流水回答,写了很多但没有小节。")).toBe(0);
|
|
33
|
+
expect(await hasSections(1).score("价格是 #1 的选择\n继续正文")).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("counts a level-6 heading too", async () => {
|
|
37
|
+
expect(await hasSections(1).score("###### 附录\n内容")).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/expect/index.ts
CHANGED
|
@@ -165,6 +165,39 @@ export function similarity(expected: string): ValueAssertion {
|
|
|
165
165
|
);
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/**
|
|
169
|
+
* 文本含至少 min 条(默认 1)去重后的 http(s) 链接则 1,否则 0。默认硬门槛。
|
|
170
|
+
* 「回答有没有引用真实来源」的形状断言:没有 Judge key 时,它是「至少引用一条来源链接」
|
|
171
|
+
* 的最低成本兜底——被测方复读题目糊弄不过去,编造的链接则留给负例或 Judge 去抓。
|
|
172
|
+
*/
|
|
173
|
+
export function includesUrl(min = 1): ValueAssertion {
|
|
174
|
+
return createAssertion(
|
|
175
|
+
`includesUrl(min=${min})`,
|
|
176
|
+
"gate",
|
|
177
|
+
(value) => {
|
|
178
|
+
const urls = new Set(String(value).match(/https?:\/\/[^\s<>()"'`]+/g) ?? []);
|
|
179
|
+
return urls.size >= min ? 1 : 0;
|
|
180
|
+
},
|
|
181
|
+
undefined,
|
|
182
|
+
{ expected: `contains >= ${min} distinct http(s) URL(s)` },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 文本含至少 min 个(默认 2)Markdown 标题(行首 # 到 ######)则 1,否则 0。默认硬门槛。
|
|
188
|
+
* 「回答是不是一份有结构的文档」的形状断言,适合研究报告、方案文档这类产出型回答——
|
|
189
|
+
* 一段没有任何小标题的流水文本过不了。
|
|
190
|
+
*/
|
|
191
|
+
export function hasSections(min = 2): ValueAssertion {
|
|
192
|
+
return createAssertion(
|
|
193
|
+
`hasSections(min=${min})`,
|
|
194
|
+
"gate",
|
|
195
|
+
(value) => ((String(value).match(/^#{1,6}\s+\S/gm) ?? []).length >= min ? 1 : 0),
|
|
196
|
+
undefined,
|
|
197
|
+
{ expected: `contains >= ${min} markdown heading(s)` },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
168
201
|
/** 谓词为真则 1,否则 0。默认硬门槛;name 带上 label 便于报告辨认。 */
|
|
169
202
|
export function satisfies(predicate: (v: unknown) => boolean, label?: string): ValueAssertion {
|
|
170
203
|
const name = label ? `satisfies(${label})` : "satisfies(predicate)";
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { ReactElement } from "react";
|
|
5
5
|
import type { AttemptAssertionsData } from "../../model/types.ts";
|
|
6
6
|
import type { AssertionResult } from "../../../types.ts";
|
|
7
|
+
import { stripControl } from "../../../scoring/display.ts";
|
|
7
8
|
import { cx } from "../shared.ts";
|
|
8
9
|
|
|
9
10
|
function assertTone(a: AssertionResult): "good" | "warn" | "bad" | "na" {
|
|
@@ -26,9 +27,9 @@ function AssertionRow({ a }: { a: AssertionResult }): ReactElement {
|
|
|
26
27
|
{a.detail && a.detail !== a.name ? <span className="nre-assertion-detail">{a.detail}</span> : null}
|
|
27
28
|
</summary>
|
|
28
29
|
<div className="nre-assertion-body">
|
|
29
|
-
{a.outcome === "unavailable" ? <div>{a.reason}</div> : null}
|
|
30
|
-
{a.outcome !== "unavailable" && a.expected !== undefined ? <div>expected: {a.expected}</div> : null}
|
|
31
|
-
{a.outcome !== "unavailable" && a.received !== undefined ? <div>received: {a.received}</div> : null}
|
|
30
|
+
{a.outcome === "unavailable" ? <div>{a.reason === undefined ? undefined : stripControl(a.reason)}</div> : null}
|
|
31
|
+
{a.outcome !== "unavailable" && a.expected !== undefined ? <div>expected: {stripControl(a.expected)}</div> : null}
|
|
32
|
+
{a.outcome !== "unavailable" && a.received !== undefined ? <div>received: {stripControl(a.received)}</div> : null}
|
|
32
33
|
</div>
|
|
33
34
|
</details>
|
|
34
35
|
);
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { ReactElement, ReactNode } from "react";
|
|
6
6
|
import type { AttemptSourceData, AttemptSourceLineData, AttemptSourceTurn } from "../../model/types.ts";
|
|
7
7
|
import type { AssertionResult } from "../../../types.ts";
|
|
8
|
+
import { stripControl } from "../../../scoring/display.ts";
|
|
8
9
|
import { cx } from "../shared.ts";
|
|
9
10
|
import { ConversationReplies } from "./AttemptConversation.tsx";
|
|
10
11
|
|
|
@@ -146,12 +147,12 @@ function AssertionDetail({ assertion }: { assertion: AssertionResult }): ReactEl
|
|
|
146
147
|
</div>
|
|
147
148
|
{assertion.detail ? <div className="nre-assertion-detail">{assertion.detail}</div> : null}
|
|
148
149
|
{assertion.outcome === "unavailable" ? (
|
|
149
|
-
<div className="nre-assertion-body">reason: {assertion.reason}</div>
|
|
150
|
+
<div className="nre-assertion-body">reason: {assertion.reason === undefined ? undefined : stripControl(assertion.reason)}</div>
|
|
150
151
|
) : assertion.expected !== undefined || assertion.received !== undefined || assertion.evidence !== undefined ? (
|
|
151
152
|
<div className="nre-assertion-body">
|
|
152
|
-
{assertion.expected !== undefined ? <span>expected: {assertion.expected}</span> : null}
|
|
153
|
-
{assertion.received !== undefined ? <span>received: {assertion.received}</span> : null}
|
|
154
|
-
{assertion.evidence !== undefined ? <span>evidence: {assertion.evidence}</span> : null}
|
|
153
|
+
{assertion.expected !== undefined ? <span>expected: {stripControl(assertion.expected)}</span> : null}
|
|
154
|
+
{assertion.received !== undefined ? <span>received: {stripControl(assertion.received)}</span> : null}
|
|
155
|
+
{assertion.evidence !== undefined ? <span>evidence: {stripControl(assertion.evidence)}</span> : null}
|
|
155
156
|
</div>
|
|
156
157
|
) : null}
|
|
157
158
|
</div>
|
|
@@ -6,8 +6,34 @@ import {
|
|
|
6
6
|
compactAssertionSummary,
|
|
7
7
|
fitCompactAssertionSummary,
|
|
8
8
|
primaryAssertionSummary,
|
|
9
|
+
stripControl,
|
|
10
|
+
summaryText,
|
|
9
11
|
} from "./display.ts";
|
|
10
12
|
|
|
13
|
+
// 用字符码构造真实控制字节,避免源码里嵌裸控制字符。
|
|
14
|
+
const ESC = String.fromCharCode(0x1b);
|
|
15
|
+
const BEL = String.fromCharCode(0x07);
|
|
16
|
+
const BS = String.fromCharCode(0x08);
|
|
17
|
+
|
|
18
|
+
describe("stripControl / summaryText 控制字节收口", () => {
|
|
19
|
+
it("剥 ANSI/OSC/裸控制字节,保留可打印字符与结构性换行", () => {
|
|
20
|
+
const colored = `${ESC}[2m 28 |${ESC}[22m code ${ESC}[31m✕${ESC}[39m Tests: 2 failed`;
|
|
21
|
+
const stripped = stripControl(colored);
|
|
22
|
+
expect(stripped).not.toContain(ESC);
|
|
23
|
+
expect(stripped).toContain("✕"); // jest 合法 glyph 保留
|
|
24
|
+
expect(stripControl(`${ESC}]0;title${BEL}kept`)).toBe("kept"); // OSC 连 payload 去除
|
|
25
|
+
expect(stripControl(`a${BS}b`)).toBe("ab"); // 裸 BS 去除
|
|
26
|
+
expect(stripControl(`${ESC}[2mline1${ESC}[22m\nline2`)).toBe("line1\nline2"); // 保留换行
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("summaryText 先剥控制字节再折单行,输出不含任何 ESC", () => {
|
|
30
|
+
const s = summaryText(`exit 1 · "${ESC}[2m 28 |${ESC}[22m\n ${ESC}[31m✕${ESC}[39m failed"`);
|
|
31
|
+
expect(s).not.toContain(ESC);
|
|
32
|
+
expect(s).not.toContain("\n");
|
|
33
|
+
expect(s).toContain("✕");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
11
37
|
describe("primaryAssertionSummary", () => {
|
|
12
38
|
it("选择第一条失败 gate,保留领域 group、matcher 与 expected/received,并只计数其余 gate", () => {
|
|
13
39
|
const assertions: AssertionResult[] = [
|
package/src/scoring/display.ts
CHANGED
|
@@ -16,9 +16,31 @@ const SUMMARY_TEXT_MAX_CHARS = 240;
|
|
|
16
16
|
*/
|
|
17
17
|
const DETAIL_LINE_MAX_CHARS = 100;
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// 捕获内容(received=命令输出 / expected=源码 / evidence)常带被测工具的着色:jest/vitest 的
|
|
20
|
+
// 代码帧、行号、✕ 都由 ANSI 转义(ESC[…m 等)上色。这些 ESC(U+001B)不是 \s,若原样落进任何
|
|
21
|
+
// 面,终端会重新解释它们(被单行截断从序列中间切开时尤其乱),HTML 报告则把 ESC[2m28|ESC[22m
|
|
22
|
+
// 当字面文本渲染。所以任何展示面在渲染捕获内容前先剥控制字节;剥的是展示投影,不改存进
|
|
23
|
+
// AssertionResult / artifact 的原始字节(完整证据仍在 events.json / diff.json)。
|
|
24
|
+
// CSI(ESC[…,含 SGR 着色 / 光标控制)与 OSC(ESC]…,以 BEL 或 ST 收尾);OSC 的 payload 一并吃掉,
|
|
25
|
+
// 不让它作为裸文本泄漏。没配成序列的裸 ESC 由 OTHER_CONTROL 兜底。
|
|
26
|
+
// eslint-disable-next-line no-control-regex
|
|
27
|
+
const ANSI_ESCAPE = /\u001B(?:\[[0-9;:?]*[ -/]*[@-~]|\][^\u0007\u001B]*(?:\u0007|\u001B\\))/g;
|
|
28
|
+
// 其余不可打印 C0/C1(含裸 ESC);保留 \t\n\f\r 交给下游折空白规则,不在这里塌成空。
|
|
29
|
+
// eslint-disable-next-line no-control-regex
|
|
30
|
+
const OTHER_CONTROL = /[\u0000-\u0008\u000B\u000E-\u001F\u007F-\u009F]/g;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 剥离 ANSI 转义与其余不可打印控制字节,保留可打印字符与结构性空白(换行 / 制表)。给需要
|
|
34
|
+
* 完整多行值的面(报告详情)直接用;`summaryText` 在此基础上再折单行 + 截断。jest 合法打印的
|
|
35
|
+
* `✕ ✓ › ❯ ↓ │`(均 ≥ U+2020)在保留范围内,不误删。
|
|
36
|
+
*/
|
|
37
|
+
export function stripControl(value: string): string {
|
|
38
|
+
return value.replace(ANSI_ESCAPE, "").replace(OTHER_CONTROL, "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 摘要面的单值收口:剥控制字节 + 折单行 + 240 字符上限。任何把断言事实放进「行」里的面共用这一条。 */
|
|
20
42
|
export function summaryText(value: string): string {
|
|
21
|
-
const singleLine = value.replace(/\s+/g, " ").trim();
|
|
43
|
+
const singleLine = stripControl(value).replace(/\s+/g, " ").trim();
|
|
22
44
|
return singleLine.length <= SUMMARY_TEXT_MAX_CHARS
|
|
23
45
|
? singleLine
|
|
24
46
|
: `${singleLine.slice(0, SUMMARY_TEXT_MAX_CHARS - 1)}…`;
|