trace2eval-cli 0.2.1__tar.gz → 0.3.0__tar.gz
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.
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/PKG-INFO +46 -14
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/README.md +45 -13
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/pyproject.toml +1 -1
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/__init__.py +13 -1
- trace2eval_cli-0.3.0/src/trace2eval/annotations.py +212 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/cli.py +37 -1
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/report.py +34 -4
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/select.py +51 -2
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/PKG-INFO +46 -14
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/SOURCES.txt +2 -0
- trace2eval_cli-0.3.0/tests/test_annotations.py +359 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/LICENSE +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/setup.cfg +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/__main__.py +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/checks.py +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/matchers.py +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/runner.py +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/schema.py +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/signals.py +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/dependency_links.txt +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/entry_points.txt +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/requires.txt +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/top_level.txt +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/tests/test_dedup.py +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/tests/test_matchers.py +0 -0
- {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/tests/test_pipeline.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trace2eval-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Turn production LLM traces into a regression eval set. Zero dependencies.
|
|
5
5
|
Author: rfioly
|
|
6
6
|
License: MIT
|
|
@@ -37,7 +37,9 @@ Dynamic: license-file
|
|
|
37
37
|
零依赖、不用 API key、不调模型。同样的日志进,同样的用例出。
|
|
38
38
|
|
|
39
39
|
[](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml)
|
|
40
|
-
[](https://pypi.org/project/trace2eval-cli/)
|
|
41
|
+
[](https://pypi.org/project/trace2eval-cli/)
|
|
42
|
+
[](https://pypi.org/project/trace2eval-cli/)
|
|
41
43
|
[](#)
|
|
42
44
|
[](LICENSE)
|
|
43
45
|
|
|
@@ -46,7 +48,7 @@ Dynamic: license-file
|
|
|
46
48
|
## 快速开始
|
|
47
49
|
|
|
48
50
|
```bash
|
|
49
|
-
pip install -
|
|
51
|
+
pip install trace2eval-cli
|
|
50
52
|
|
|
51
53
|
# 1. 把日志变成用例集
|
|
52
54
|
trace2eval build traces.jsonl -o evalset/
|
|
@@ -60,6 +62,9 @@ trace2eval check --baseline runs/baseline.json --current runs/current.json
|
|
|
60
62
|
|
|
61
63
|
需要 Python 3.10+。
|
|
62
64
|
|
|
65
|
+
> 分发名带 `-cli` 后缀,因为 `trace2eval` 这个名字在 PyPI 上被一个空壳占着。
|
|
66
|
+
> **命令没变**——装完还是敲 `trace2eval`。
|
|
67
|
+
|
|
63
68
|
---
|
|
64
69
|
|
|
65
70
|
## 三个命令
|
|
@@ -79,6 +84,7 @@ trace2eval check --baseline runs/baseline.json --current runs/current.json
|
|
|
79
84
|
- **一个问题一个用例。** 被问了 200 次的问题只变成一个用例,并且老实记录它代表 200 次调用。
|
|
80
85
|
- **形状从干净答案学,不从失败那条学。** 失败种子的长度下限来自同问题的干净答案中位数。
|
|
81
86
|
- **每个用例自检。** 报告里会标明哪些用例的检查**抓不住**它当初为什么坏,不混进"已覆盖"里。
|
|
87
|
+
- **抓不住的用例可以人工补。** 失败发生在"调用之外"(用户重问、差评)而不在答案里时,自动检查无能为力——报告会把它们单列出来,`build` 同时生成一份待填模板,你写好期望答案后它们就变成真正的门禁。标注按**问题**索引而不是按用例编号,所以重新生成用例集不会失效。
|
|
82
88
|
- **相似度可换。** `--similarity module:function` 换成你自己的实现,包本身不引依赖。
|
|
83
89
|
- **天生适合 CI。** `check` 检出回归返回非零退出码。
|
|
84
90
|
|
|
@@ -190,10 +196,35 @@ flowchart LR
|
|
|
190
196
|
|
|
191
197
|
---
|
|
192
198
|
|
|
199
|
+
## 人工标注(可选)
|
|
200
|
+
|
|
201
|
+
有些失败发生在"调用之外"——用户又问了一遍、点了差评、接口慢到超时。**这种答案本身往往是没问题的**,所以任何对输出文本的自动检查都抓不到它。工具能识别出这些用例,但没法凭空想出"正确答案长什么样",那得人来写。
|
|
202
|
+
|
|
203
|
+
`build` 会在用例集旁边生成一份待填模板,列出的正是这些用例:
|
|
204
|
+
|
|
205
|
+
```jsonl
|
|
206
|
+
{"input": "修改手机号", "expect": {}, "note": "TODO: fill in expect. (user_retried)"}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
把 `expect` 填好,另存为 `expectations.jsonl`,重新 `build` 就生效了:
|
|
210
|
+
|
|
211
|
+
```jsonl
|
|
212
|
+
{"input": "修改手机号", "expect": {"min_chars": 20, "contains": ["手机号"]}, "note": "用户重问说明第一版没解决,正确答案要说清验证流程"}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`expect` 里能用的键和 trace 自己的 `expect` 块**完全一致**:`min_chars`、`max_chars`、`contains`、`not_contains`、`regex`、`json`、`not_fallback`。
|
|
216
|
+
|
|
217
|
+
两条设计上要紧的地方:
|
|
218
|
+
|
|
219
|
+
- **按问题索引,不按用例编号。** 用例编号是按分数排序临时分配的,加一条日志就可能全部重排。标注要是挂在 `case-011` 上,下次重建就会**悄悄脱离**——而一个脱了钩的标注比没有标注更糟,因为那个用例看起来还是有覆盖的。
|
|
220
|
+
- **标注不会改变自检结论。** 它不会让用例"复现失败"(行为型失败本来就复现不了),它是换了个目标:从"让这个失败不可能再发生"变成"答案必须长这样"。报告里两个数分开写,不合并。
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
193
224
|
## 已知边界
|
|
194
225
|
|
|
195
226
|
- **相似度是字符 n-gram**,不含相同字符的改写会被当成两个问题。可用 `--similarity` 换成你自己的实现。
|
|
196
|
-
-
|
|
227
|
+
- **行为型失败自动抓不住,但可以人工补。** 样本日志 16 个用例里有 3 个,失败原因只是"用户又问了一遍",输出本身没毛病——任何对输出文本的确定性检查都抓不到,这是方法的边界而非欠账。它们现在由 `evalset/expectations.jsonl` 里的人工标注兜住(仓库里那份是示例)。报告会**分开**说明"有几个抓不住""有几个已有标注覆盖",不会含糊成一个数。
|
|
197
228
|
- **比较成本是 O(用例数 × 每簇不同问法数)**,不是 O(日志大小):同一问题问 5000 次只留 1 个待比对指纹。`MAX_DISTINCT_FINGERPRINTS = 512` 是拍的保护值,没压测过。
|
|
198
229
|
- **用例仍是提案**,但会自检:`report.md` 里单列"需要人看一眼"的用例。
|
|
199
230
|
|
|
@@ -203,16 +234,17 @@ flowchart LR
|
|
|
203
234
|
|
|
204
235
|
```
|
|
205
236
|
src/trace2eval/
|
|
206
|
-
schema.py
|
|
207
|
-
signals.py
|
|
208
|
-
select.py
|
|
209
|
-
checks.py
|
|
210
|
-
runner.py
|
|
211
|
-
report.py
|
|
212
|
-
matchers.py
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
237
|
+
schema.py trace 加载、字段别名、容错解析
|
|
238
|
+
signals.py 一条 trace 为什么值得测
|
|
239
|
+
select.py 相似度、聚类、用例构建
|
|
240
|
+
checks.py 确定性输出检查
|
|
241
|
+
runner.py 评分与回归对比
|
|
242
|
+
report.py markdown 渲染
|
|
243
|
+
matchers.py 可替换的相似度实现
|
|
244
|
+
annotations.py 人工标注的加载与合并
|
|
245
|
+
tests/ 62 个测试
|
|
246
|
+
examples/ 一份 42 行样本日志 + 一次基线 / 一次回归运行
|
|
247
|
+
evalset/ 提交进仓库的构建产物 + 示例标注
|
|
216
248
|
```
|
|
217
249
|
|
|
218
250
|
## 开发
|
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
零依赖、不用 API key、不调模型。同样的日志进,同样的用例出。
|
|
10
10
|
|
|
11
11
|
[](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml)
|
|
12
|
-
[](https://pypi.org/project/trace2eval-cli/)
|
|
13
|
+
[](https://pypi.org/project/trace2eval-cli/)
|
|
14
|
+
[](https://pypi.org/project/trace2eval-cli/)
|
|
13
15
|
[](#)
|
|
14
16
|
[](LICENSE)
|
|
15
17
|
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
## 快速开始
|
|
19
21
|
|
|
20
22
|
```bash
|
|
21
|
-
pip install -
|
|
23
|
+
pip install trace2eval-cli
|
|
22
24
|
|
|
23
25
|
# 1. 把日志变成用例集
|
|
24
26
|
trace2eval build traces.jsonl -o evalset/
|
|
@@ -32,6 +34,9 @@ trace2eval check --baseline runs/baseline.json --current runs/current.json
|
|
|
32
34
|
|
|
33
35
|
需要 Python 3.10+。
|
|
34
36
|
|
|
37
|
+
> 分发名带 `-cli` 后缀,因为 `trace2eval` 这个名字在 PyPI 上被一个空壳占着。
|
|
38
|
+
> **命令没变**——装完还是敲 `trace2eval`。
|
|
39
|
+
|
|
35
40
|
---
|
|
36
41
|
|
|
37
42
|
## 三个命令
|
|
@@ -51,6 +56,7 @@ trace2eval check --baseline runs/baseline.json --current runs/current.json
|
|
|
51
56
|
- **一个问题一个用例。** 被问了 200 次的问题只变成一个用例,并且老实记录它代表 200 次调用。
|
|
52
57
|
- **形状从干净答案学,不从失败那条学。** 失败种子的长度下限来自同问题的干净答案中位数。
|
|
53
58
|
- **每个用例自检。** 报告里会标明哪些用例的检查**抓不住**它当初为什么坏,不混进"已覆盖"里。
|
|
59
|
+
- **抓不住的用例可以人工补。** 失败发生在"调用之外"(用户重问、差评)而不在答案里时,自动检查无能为力——报告会把它们单列出来,`build` 同时生成一份待填模板,你写好期望答案后它们就变成真正的门禁。标注按**问题**索引而不是按用例编号,所以重新生成用例集不会失效。
|
|
54
60
|
- **相似度可换。** `--similarity module:function` 换成你自己的实现,包本身不引依赖。
|
|
55
61
|
- **天生适合 CI。** `check` 检出回归返回非零退出码。
|
|
56
62
|
|
|
@@ -162,10 +168,35 @@ flowchart LR
|
|
|
162
168
|
|
|
163
169
|
---
|
|
164
170
|
|
|
171
|
+
## 人工标注(可选)
|
|
172
|
+
|
|
173
|
+
有些失败发生在"调用之外"——用户又问了一遍、点了差评、接口慢到超时。**这种答案本身往往是没问题的**,所以任何对输出文本的自动检查都抓不到它。工具能识别出这些用例,但没法凭空想出"正确答案长什么样",那得人来写。
|
|
174
|
+
|
|
175
|
+
`build` 会在用例集旁边生成一份待填模板,列出的正是这些用例:
|
|
176
|
+
|
|
177
|
+
```jsonl
|
|
178
|
+
{"input": "修改手机号", "expect": {}, "note": "TODO: fill in expect. (user_retried)"}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
把 `expect` 填好,另存为 `expectations.jsonl`,重新 `build` 就生效了:
|
|
182
|
+
|
|
183
|
+
```jsonl
|
|
184
|
+
{"input": "修改手机号", "expect": {"min_chars": 20, "contains": ["手机号"]}, "note": "用户重问说明第一版没解决,正确答案要说清验证流程"}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`expect` 里能用的键和 trace 自己的 `expect` 块**完全一致**:`min_chars`、`max_chars`、`contains`、`not_contains`、`regex`、`json`、`not_fallback`。
|
|
188
|
+
|
|
189
|
+
两条设计上要紧的地方:
|
|
190
|
+
|
|
191
|
+
- **按问题索引,不按用例编号。** 用例编号是按分数排序临时分配的,加一条日志就可能全部重排。标注要是挂在 `case-011` 上,下次重建就会**悄悄脱离**——而一个脱了钩的标注比没有标注更糟,因为那个用例看起来还是有覆盖的。
|
|
192
|
+
- **标注不会改变自检结论。** 它不会让用例"复现失败"(行为型失败本来就复现不了),它是换了个目标:从"让这个失败不可能再发生"变成"答案必须长这样"。报告里两个数分开写,不合并。
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
165
196
|
## 已知边界
|
|
166
197
|
|
|
167
198
|
- **相似度是字符 n-gram**,不含相同字符的改写会被当成两个问题。可用 `--similarity` 换成你自己的实现。
|
|
168
|
-
-
|
|
199
|
+
- **行为型失败自动抓不住,但可以人工补。** 样本日志 16 个用例里有 3 个,失败原因只是"用户又问了一遍",输出本身没毛病——任何对输出文本的确定性检查都抓不到,这是方法的边界而非欠账。它们现在由 `evalset/expectations.jsonl` 里的人工标注兜住(仓库里那份是示例)。报告会**分开**说明"有几个抓不住""有几个已有标注覆盖",不会含糊成一个数。
|
|
169
200
|
- **比较成本是 O(用例数 × 每簇不同问法数)**,不是 O(日志大小):同一问题问 5000 次只留 1 个待比对指纹。`MAX_DISTINCT_FINGERPRINTS = 512` 是拍的保护值,没压测过。
|
|
170
201
|
- **用例仍是提案**,但会自检:`report.md` 里单列"需要人看一眼"的用例。
|
|
171
202
|
|
|
@@ -175,16 +206,17 @@ flowchart LR
|
|
|
175
206
|
|
|
176
207
|
```
|
|
177
208
|
src/trace2eval/
|
|
178
|
-
schema.py
|
|
179
|
-
signals.py
|
|
180
|
-
select.py
|
|
181
|
-
checks.py
|
|
182
|
-
runner.py
|
|
183
|
-
report.py
|
|
184
|
-
matchers.py
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
209
|
+
schema.py trace 加载、字段别名、容错解析
|
|
210
|
+
signals.py 一条 trace 为什么值得测
|
|
211
|
+
select.py 相似度、聚类、用例构建
|
|
212
|
+
checks.py 确定性输出检查
|
|
213
|
+
runner.py 评分与回归对比
|
|
214
|
+
report.py markdown 渲染
|
|
215
|
+
matchers.py 可替换的相似度实现
|
|
216
|
+
annotations.py 人工标注的加载与合并
|
|
217
|
+
tests/ 62 个测试
|
|
218
|
+
examples/ 一份 42 行样本日志 + 一次基线 / 一次回归运行
|
|
219
|
+
evalset/ 提交进仓库的构建产物 + 示例标注
|
|
188
220
|
```
|
|
189
221
|
|
|
190
222
|
## 开发
|
|
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
|
|
|
8
8
|
# suffix on the distribution name. The console script is still "trace2eval",
|
|
9
9
|
# so the command users type is unchanged.
|
|
10
10
|
name = "trace2eval-cli"
|
|
11
|
-
version = "0.
|
|
11
|
+
version = "0.3.0"
|
|
12
12
|
description = "Turn production LLM traces into a regression eval set. Zero dependencies."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.10"
|
|
@@ -16,6 +16,13 @@ Everything here is deterministic and offline. No model is called, no API key is
|
|
|
16
16
|
needed, and the same log always produces the same case set.
|
|
17
17
|
"""
|
|
18
18
|
|
|
19
|
+
from .annotations import (
|
|
20
|
+
Annotation,
|
|
21
|
+
AnnotationSet,
|
|
22
|
+
load_annotations,
|
|
23
|
+
merge_checks,
|
|
24
|
+
render_template,
|
|
25
|
+
)
|
|
19
26
|
from .checks import CheckResult, detect_fallback, run_check, run_checks
|
|
20
27
|
from .matchers import char_bigram_jaccard, word_jaccard
|
|
21
28
|
from .runner import RunMetrics, compare_runs, load_outputs, run_cases
|
|
@@ -32,10 +39,12 @@ from .select import (
|
|
|
32
39
|
)
|
|
33
40
|
from .signals import DEFAULT_WEIGHTS, Signal, build_context, compute_signals, score
|
|
34
41
|
|
|
35
|
-
__version__ = "0.
|
|
42
|
+
__version__ = "0.3.0"
|
|
36
43
|
|
|
37
44
|
__all__ = [
|
|
38
45
|
"__version__",
|
|
46
|
+
"Annotation",
|
|
47
|
+
"AnnotationSet",
|
|
39
48
|
"CheckResult",
|
|
40
49
|
"DEFAULT_DEDUP_THRESHOLD",
|
|
41
50
|
"DEFAULT_WEIGHTS",
|
|
@@ -50,11 +59,14 @@ __all__ = [
|
|
|
50
59
|
"compare_runs",
|
|
51
60
|
"compute_signals",
|
|
52
61
|
"detect_fallback",
|
|
62
|
+
"load_annotations",
|
|
53
63
|
"load_matcher",
|
|
54
64
|
"load_outputs",
|
|
55
65
|
"load_traces",
|
|
66
|
+
"merge_checks",
|
|
56
67
|
"overlap_coefficient",
|
|
57
68
|
"parse_trace",
|
|
69
|
+
"render_template",
|
|
58
70
|
"run_check",
|
|
59
71
|
"run_checks",
|
|
60
72
|
"run_cases",
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Human-supplied expected answers.
|
|
2
|
+
|
|
3
|
+
The gap this closes: a case whose failure was *behavioural* -- the user asked
|
|
4
|
+
again, the down-vote landed, the call was just slow -- has a perfectly acceptable
|
|
5
|
+
answer attached to it. No deterministic check on that text can reproduce the
|
|
6
|
+
failure, because the failure was never in the text.
|
|
7
|
+
|
|
8
|
+
The tool can detect those cases and say so (that is what the
|
|
9
|
+
``failure_not_reproduced`` verdict is for). It cannot invent the missing
|
|
10
|
+
expectation. A person has to write down what the answer should have looked like.
|
|
11
|
+
|
|
12
|
+
The design constraint that matters is **survival**. Generated case ids are not
|
|
13
|
+
stable -- they are assigned in descending score order, so one new trace can
|
|
14
|
+
renumber everything. An annotation keyed on ``case-011`` would silently detach
|
|
15
|
+
itself the next time the case set is rebuilt, and a detached annotation is worse
|
|
16
|
+
than no annotation: the case would quietly go back to being weak while still
|
|
17
|
+
looking annotated.
|
|
18
|
+
|
|
19
|
+
So annotations are keyed on the *question*, not on the case id:
|
|
20
|
+
|
|
21
|
+
{"input": "修改手机号", "expect": {"min_chars": 20, "contains": ["验证码"]}}
|
|
22
|
+
|
|
23
|
+
``input`` is matched after the same folding used for clustering (case, whitespace
|
|
24
|
+
and punctuation stripped), so the question can be rephrased in the file without
|
|
25
|
+
breaking the link. ``build`` writes a template listing every case that needs one,
|
|
26
|
+
so filling this in is closer to editing a form than writing a file from scratch.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
#: Check types a human annotation may set. Deliberately the same vocabulary a
|
|
37
|
+
#: trace's own ``expect`` block uses, so there is one thing to learn rather than
|
|
38
|
+
#: two -- and so ``_checks_from_expect`` can be reused rather than reimplemented.
|
|
39
|
+
ANNOTATABLE_CHECKS = frozenset(
|
|
40
|
+
{"not_fallback", "min_chars", "max_chars", "regex", "contains", "not_contains", "json"}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Annotation:
|
|
46
|
+
"""One human-written expectation."""
|
|
47
|
+
|
|
48
|
+
input: str
|
|
49
|
+
expect: dict[str, Any] = field(default_factory=dict)
|
|
50
|
+
note: str = ""
|
|
51
|
+
line_number: int = 0
|
|
52
|
+
|
|
53
|
+
def is_empty(self) -> bool:
|
|
54
|
+
"""True when the entry is still the unfilled template placeholder."""
|
|
55
|
+
return not self.expect
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class AnnotationSet:
|
|
60
|
+
"""Every annotation found in one file, plus what was skipped loading it."""
|
|
61
|
+
|
|
62
|
+
annotations: list[Annotation] = field(default_factory=list)
|
|
63
|
+
skipped: list[str] = field(default_factory=list)
|
|
64
|
+
path: Path | None = None
|
|
65
|
+
|
|
66
|
+
def __bool__(self) -> bool:
|
|
67
|
+
return any(not a.is_empty() for a in self.annotations)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def filled(self) -> list[Annotation]:
|
|
71
|
+
return [a for a in self.annotations if not a.is_empty()]
|
|
72
|
+
|
|
73
|
+
def lookup(self, key: str) -> Annotation | None:
|
|
74
|
+
"""The annotation for ``key`` (an already-folded input string), if any."""
|
|
75
|
+
for annotation in self.filled:
|
|
76
|
+
if _fold(annotation.input) == key:
|
|
77
|
+
return annotation
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fold_input(text: str) -> str:
|
|
82
|
+
"""Fold an input for matching purposes.
|
|
83
|
+
|
|
84
|
+
Kept local rather than imported from :mod:`trace2eval.select` so this module
|
|
85
|
+
stays free of the selection machinery -- annotations are loaded before
|
|
86
|
+
selection runs, and a cycle here would be easy to introduce and annoying to
|
|
87
|
+
find.
|
|
88
|
+
"""
|
|
89
|
+
keep = []
|
|
90
|
+
for char in text.strip().lower():
|
|
91
|
+
if char.isspace() or char in ",。!?、;:,.!?;:\"'“”‘’()()[]【】":
|
|
92
|
+
continue
|
|
93
|
+
keep.append(char)
|
|
94
|
+
return "".join(keep)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
#: Same function the lookup uses on annotation inputs. Exported so callers
|
|
98
|
+
#: cannot accidentally match against a differently-folded key.
|
|
99
|
+
_fold = fold_input
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_annotation(payload: dict[str, Any], line_number: int) -> Annotation:
|
|
103
|
+
raw_expect = payload.get("expect")
|
|
104
|
+
expect = raw_expect if isinstance(raw_expect, dict) else {}
|
|
105
|
+
unknown = set(expect) - ANNOTATABLE_CHECKS
|
|
106
|
+
if unknown:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"unknown check type(s) {sorted(unknown)}; "
|
|
109
|
+
f"allowed: {sorted(ANNOTATABLE_CHECKS)}"
|
|
110
|
+
)
|
|
111
|
+
return Annotation(
|
|
112
|
+
input=str(payload.get("input", "")).strip(),
|
|
113
|
+
expect=expect,
|
|
114
|
+
note=str(payload.get("note", "")).strip(),
|
|
115
|
+
line_number=line_number,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_annotations(path: Path) -> AnnotationSet:
|
|
120
|
+
"""Load an expectations file. Missing file is not an error -- it means none.
|
|
121
|
+
|
|
122
|
+
A malformed line is collected rather than raised. Losing every annotation to
|
|
123
|
+
one bad comma would be a bad trade: an annotation set is small, hand-edited,
|
|
124
|
+
and the person editing it is the person who most needs to know which line
|
|
125
|
+
they broke.
|
|
126
|
+
"""
|
|
127
|
+
result = AnnotationSet(path=path)
|
|
128
|
+
if not path.exists():
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
132
|
+
stripped = line.strip()
|
|
133
|
+
if not stripped or stripped.startswith("#"):
|
|
134
|
+
continue
|
|
135
|
+
if stripped.startswith("//"):
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
payload = json.loads(stripped)
|
|
139
|
+
except json.JSONDecodeError as exc:
|
|
140
|
+
result.skipped.append(f"line {number}: not valid JSON ({exc.msg})")
|
|
141
|
+
continue
|
|
142
|
+
if not isinstance(payload, dict):
|
|
143
|
+
result.skipped.append(f"line {number}: expected a JSON object")
|
|
144
|
+
continue
|
|
145
|
+
try:
|
|
146
|
+
annotation = parse_annotation(payload, number)
|
|
147
|
+
except ValueError as exc:
|
|
148
|
+
result.skipped.append(f"line {number}: {exc}")
|
|
149
|
+
continue
|
|
150
|
+
if not annotation.input:
|
|
151
|
+
result.skipped.append(f"line {number}: no 'input' field")
|
|
152
|
+
continue
|
|
153
|
+
result.annotations.append(annotation)
|
|
154
|
+
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def merge_checks(
|
|
159
|
+
inferred: list[dict[str, Any]], human: list[dict[str, Any]]
|
|
160
|
+
) -> list[dict[str, Any]]:
|
|
161
|
+
"""Overlay human checks on inferred ones, type by type.
|
|
162
|
+
|
|
163
|
+
Human wins wherever the two overlap -- it is evidence, the inferred value was
|
|
164
|
+
a guess. Where they do not overlap the inferred check is kept, because a
|
|
165
|
+
human writing ``contains`` has no opinion about truncation and silently
|
|
166
|
+
dropping the length check would make the annotation a downgrade.
|
|
167
|
+
|
|
168
|
+
``not_fallback`` is always first, whatever either side said.
|
|
169
|
+
"""
|
|
170
|
+
by_type: dict[str, dict[str, Any]] = {check["type"]: check for check in inferred}
|
|
171
|
+
for check in human:
|
|
172
|
+
by_type[check["type"]] = check
|
|
173
|
+
ordered = [by_type.pop("not_fallback")] if "not_fallback" in by_type else []
|
|
174
|
+
ordered.extend(by_type.values())
|
|
175
|
+
return ordered
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def render_template(cases: list[dict[str, Any]]) -> str:
|
|
179
|
+
"""A fill-in-the-blanks file for every case whose checks cannot catch it.
|
|
180
|
+
|
|
181
|
+
Emitted only when no expectations file exists yet, so a real one is never
|
|
182
|
+
overwritten. The ``expect`` object is left empty on purpose: a pre-filled
|
|
183
|
+
guess would be exactly the kind of over-confidence this whole mechanism
|
|
184
|
+
exists to avoid.
|
|
185
|
+
"""
|
|
186
|
+
lines = [
|
|
187
|
+
"// trace2eval expectations -- one JSON object per line, '//' comments allowed.",
|
|
188
|
+
"//",
|
|
189
|
+
"// Why these cases are listed: their failure happened around the call, not in",
|
|
190
|
+
"// the answer, so no check on the output text can reproduce it. Write down what",
|
|
191
|
+
"// a correct answer looks like and they become real gates.",
|
|
192
|
+
"//",
|
|
193
|
+
"// Available keys inside 'expect' (same vocabulary as a trace's own expect block):",
|
|
194
|
+
"// min_chars, max_chars, contains, not_contains, regex, json, not_fallback",
|
|
195
|
+
"//",
|
|
196
|
+
"// When you are done, save this file as expectations.jsonl and rebuild.",
|
|
197
|
+
"// Matching is on 'input', folded for case/space/punctuation -- so edit the",
|
|
198
|
+
"// wording of 'note' freely, but leave 'input' alone.",
|
|
199
|
+
"",
|
|
200
|
+
]
|
|
201
|
+
for case in cases:
|
|
202
|
+
payload = {
|
|
203
|
+
"input": case["input"],
|
|
204
|
+
"expect": {},
|
|
205
|
+
"note": (
|
|
206
|
+
"TODO: fill in expect. "
|
|
207
|
+
f"({', '.join(signal['name'] for signal in case['signals'])})"
|
|
208
|
+
),
|
|
209
|
+
}
|
|
210
|
+
lines.append(json.dumps(payload, ensure_ascii=False))
|
|
211
|
+
lines.append("")
|
|
212
|
+
return "\n".join(lines)
|
|
@@ -29,6 +29,7 @@ from .runner import (
|
|
|
29
29
|
run_cases,
|
|
30
30
|
)
|
|
31
31
|
from .schema import TraceFormatError, load_traces
|
|
32
|
+
from .annotations import load_annotations, render_template
|
|
32
33
|
from .select import (
|
|
33
34
|
DEFAULT_DEDUP_THRESHOLD,
|
|
34
35
|
MatcherSpecError,
|
|
@@ -80,6 +81,15 @@ def cmd_build(args: argparse.Namespace) -> int:
|
|
|
80
81
|
print(f" line {line_no}: {reason}", file=sys.stderr)
|
|
81
82
|
return EXIT_BAD_INPUT
|
|
82
83
|
|
|
84
|
+
out_dir = Path(args.out)
|
|
85
|
+
|
|
86
|
+
expectations_path = (
|
|
87
|
+
Path(args.expectations) if args.expectations else out_dir / "expectations.jsonl"
|
|
88
|
+
)
|
|
89
|
+
expectations = load_annotations(expectations_path)
|
|
90
|
+
for problem in expectations.skipped:
|
|
91
|
+
print(f"warning: {expectations_path.name}: {problem}", file=sys.stderr)
|
|
92
|
+
|
|
83
93
|
matcher = None
|
|
84
94
|
if args.similarity:
|
|
85
95
|
try:
|
|
@@ -94,14 +104,21 @@ def cmd_build(args: argparse.Namespace) -> int:
|
|
|
94
104
|
min_score=args.min_score,
|
|
95
105
|
dedup_threshold=args.dedup_threshold,
|
|
96
106
|
matcher=matcher,
|
|
107
|
+
expectations=expectations,
|
|
97
108
|
)
|
|
98
109
|
|
|
99
|
-
out_dir = Path(args.out)
|
|
100
110
|
_write_jsonl(out_dir / "cases.jsonl", result.cases)
|
|
101
111
|
# as_posix() so the report is byte-identical on Windows and Linux, which is
|
|
102
112
|
# what lets CI diff the committed case set against a fresh build.
|
|
103
113
|
_write_text(out_dir / "report.md", build_report(result, Path(args.traces).as_posix()))
|
|
104
114
|
|
|
115
|
+
# Written only when there is no expectations file yet, so a real one is never
|
|
116
|
+
# clobbered by a rebuild.
|
|
117
|
+
template_path: Path | None = None
|
|
118
|
+
if not expectations_path.exists() and result.cases_needing_annotation:
|
|
119
|
+
template_path = expectations_path.with_name("expectations.template.jsonl")
|
|
120
|
+
_write_text(template_path, render_template(result.cases_needing_annotation))
|
|
121
|
+
|
|
105
122
|
stats = result.stats()
|
|
106
123
|
print(f"read {stats['total_traces']} traces from {args.traces}")
|
|
107
124
|
if loaded.skipped:
|
|
@@ -120,10 +137,19 @@ def cmd_build(args: argparse.Namespace) -> int:
|
|
|
120
137
|
f" {stats['cases_whose_reference_fails']} trusted reference(s) fail their "
|
|
121
138
|
f"own checks -- look at these before committing the set"
|
|
122
139
|
)
|
|
140
|
+
if stats["cases_with_annotation"]:
|
|
141
|
+
print(f" {stats['cases_with_annotation']} case(s) use a human-written expectation")
|
|
123
142
|
if stats["cases_with_weak_checks"]:
|
|
143
|
+
covered = stats["cases_with_annotation"]
|
|
124
144
|
print(
|
|
125
145
|
f" {stats['cases_with_weak_checks']} case(s) carry checks that cannot "
|
|
126
146
|
f"detect the failure they came from"
|
|
147
|
+
+ (f" ({covered} covered by an annotation)" if covered else "")
|
|
148
|
+
)
|
|
149
|
+
if stats["cases_needing_annotation"]:
|
|
150
|
+
print(
|
|
151
|
+
f" {stats['cases_needing_annotation']} of those still need a human-written "
|
|
152
|
+
f"expectation -> fill in {template_path or expectations_path}"
|
|
127
153
|
)
|
|
128
154
|
print(f"report -> {out_dir / 'report.md'}")
|
|
129
155
|
return EXIT_OK
|
|
@@ -247,6 +273,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
247
273
|
"clustering gets slower -- see trace2eval.matchers."
|
|
248
274
|
),
|
|
249
275
|
)
|
|
276
|
+
build.add_argument(
|
|
277
|
+
"--expectations",
|
|
278
|
+
default=None,
|
|
279
|
+
metavar="PATH",
|
|
280
|
+
help=(
|
|
281
|
+
"human-written expected answers, one JSON object per line. Defaults to "
|
|
282
|
+
"expectations.jsonl beside the case set. If the file does not exist, a "
|
|
283
|
+
"fill-in template is written for the cases that need one."
|
|
284
|
+
),
|
|
285
|
+
)
|
|
250
286
|
build.set_defaults(func=cmd_build)
|
|
251
287
|
|
|
252
288
|
run = subparsers.add_parser(
|
|
@@ -58,6 +58,13 @@ def build_report(result: SelectionResult, source_name: str) -> str:
|
|
|
58
58
|
f"| Trusted references failing their own checks "
|
|
59
59
|
f"| {stats['cases_whose_reference_fails']} |"
|
|
60
60
|
)
|
|
61
|
+
lines.append(
|
|
62
|
+
f"| Cases using a human-written expectation "
|
|
63
|
+
f"| {stats['cases_with_annotation']} |"
|
|
64
|
+
)
|
|
65
|
+
lines.append(
|
|
66
|
+
f"| Cases still needing one | {stats['cases_needing_annotation']} |"
|
|
67
|
+
)
|
|
61
68
|
lines.append("")
|
|
62
69
|
|
|
63
70
|
lines.append("## Log-wide baselines")
|
|
@@ -102,7 +109,7 @@ def build_report(result: SelectionResult, source_name: str) -> str:
|
|
|
102
109
|
)
|
|
103
110
|
lines.append("")
|
|
104
111
|
|
|
105
|
-
flagged = result.cases_whose_reference_fails + result.
|
|
112
|
+
flagged = result.cases_whose_reference_fails + result.cases_needing_annotation
|
|
106
113
|
if flagged:
|
|
107
114
|
lines.append("## Cases that need a human eye")
|
|
108
115
|
lines.append("")
|
|
@@ -117,15 +124,14 @@ def build_report(result: SelectionResult, source_name: str) -> str:
|
|
|
117
124
|
f"checks (`{', '.join(case['self_check']['failed_checks'])}`). Either "
|
|
118
125
|
f"the reference is wrong or the checks are."
|
|
119
126
|
)
|
|
120
|
-
for case in result.
|
|
127
|
+
for case in result.cases_needing_annotation:
|
|
121
128
|
if case["failure_kind"] == "behaviour":
|
|
122
129
|
lines.append(
|
|
123
130
|
f"- {case['id']} — the failure here was behavioural "
|
|
124
131
|
f"(`{', '.join(signal['name'] for signal in case['signals'])}`): the "
|
|
125
132
|
f"call itself produced a perfectly acceptable answer, and what went "
|
|
126
133
|
f"wrong happened around it. No check on the output text can "
|
|
127
|
-
f"reproduce that.
|
|
128
|
-
f"labelled expected answer before it can gate anything."
|
|
134
|
+
f"reproduce that."
|
|
129
135
|
)
|
|
130
136
|
else:
|
|
131
137
|
lines.append(
|
|
@@ -135,6 +141,30 @@ def build_report(result: SelectionResult, source_name: str) -> str:
|
|
|
135
141
|
f"a limit of the approach -- worth investigating."
|
|
136
142
|
)
|
|
137
143
|
lines.append("")
|
|
144
|
+
lines.append(
|
|
145
|
+
"The way out for the behavioural ones is an expectation file: one JSON "
|
|
146
|
+
"object per line, keyed on `input`, holding the checks a correct answer "
|
|
147
|
+
"should satisfy. `build` writes a fill-in template next to the case set "
|
|
148
|
+
"listing exactly these cases; save it as `expectations.jsonl` and rebuild. "
|
|
149
|
+
"It is keyed on the question rather than the case id, so it survives "
|
|
150
|
+
"regeneration."
|
|
151
|
+
)
|
|
152
|
+
lines.append("")
|
|
153
|
+
|
|
154
|
+
if result.cases_with_annotation:
|
|
155
|
+
lines.append("## Cases carrying a human-written expectation")
|
|
156
|
+
lines.append("")
|
|
157
|
+
lines.append(
|
|
158
|
+
"The expectation below does not make these cases reproduce their original "
|
|
159
|
+
"failure -- nothing can, the failure was never in the answer. What it does "
|
|
160
|
+
"is give them something worth asserting."
|
|
161
|
+
)
|
|
162
|
+
lines.append("")
|
|
163
|
+
for case in result.cases_with_annotation:
|
|
164
|
+
lines.append(
|
|
165
|
+
f"- `{case['id']}` ({case['input']}) — {case['annotation']['note'] or '(no note)'}"
|
|
166
|
+
)
|
|
167
|
+
lines.append("")
|
|
138
168
|
|
|
139
169
|
if result.cases:
|
|
140
170
|
lines.append("## Why the top case was chosen")
|
|
@@ -30,6 +30,7 @@ import statistics
|
|
|
30
30
|
from dataclasses import dataclass, field
|
|
31
31
|
from typing import Any, Callable, Iterable
|
|
32
32
|
|
|
33
|
+
from .annotations import AnnotationSet, fold_input, merge_checks
|
|
33
34
|
from .checks import run_checks
|
|
34
35
|
from .schema import Trace
|
|
35
36
|
from .signals import (
|
|
@@ -220,6 +221,25 @@ class SelectionResult:
|
|
|
220
221
|
if case["self_check"]["verdict"] == "reference_fails_own_checks"
|
|
221
222
|
]
|
|
222
223
|
|
|
224
|
+
@property
|
|
225
|
+
def cases_with_annotation(self) -> list[dict[str, Any]]:
|
|
226
|
+
return [case for case in self.cases if case["annotation"]["applied"]]
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def cases_needing_annotation(self) -> list[dict[str, Any]]:
|
|
230
|
+
"""The remaining TODO list.
|
|
231
|
+
|
|
232
|
+
A weak case that already carries a human expectation is off this list:
|
|
233
|
+
the expectation does not make the case reproduce its original failure --
|
|
234
|
+
nothing can, the failure was not in the text -- but it does give the case
|
|
235
|
+
something worth asserting, which is the actual goal.
|
|
236
|
+
"""
|
|
237
|
+
return [
|
|
238
|
+
case
|
|
239
|
+
for case in self.cases_with_weak_checks
|
|
240
|
+
if not case["annotation"]["applied"]
|
|
241
|
+
]
|
|
242
|
+
|
|
223
243
|
def stats(self) -> dict[str, Any]:
|
|
224
244
|
return {
|
|
225
245
|
"total_traces": self.total_traces,
|
|
@@ -231,6 +251,8 @@ class SelectionResult:
|
|
|
231
251
|
"dropped_beyond_limit": self.dropped_beyond_limit,
|
|
232
252
|
"cases_with_weak_checks": len(self.cases_with_weak_checks),
|
|
233
253
|
"cases_whose_reference_fails": len(self.cases_whose_reference_fails),
|
|
254
|
+
"cases_with_annotation": len(self.cases_with_annotation),
|
|
255
|
+
"cases_needing_annotation": len(self.cases_needing_annotation),
|
|
234
256
|
}
|
|
235
257
|
|
|
236
258
|
|
|
@@ -569,7 +591,12 @@ def _self_check(checks: list[dict[str, Any]], output: str) -> dict[str, Any]:
|
|
|
569
591
|
return {"passed": not failed, "failed_checks": failed}
|
|
570
592
|
|
|
571
593
|
|
|
572
|
-
def build_case(
|
|
594
|
+
def build_case(
|
|
595
|
+
index: int,
|
|
596
|
+
cluster: Cluster,
|
|
597
|
+
context: TraceContext | None = None,
|
|
598
|
+
expectations: AnnotationSet | None = None,
|
|
599
|
+
) -> dict[str, Any]:
|
|
573
600
|
trace = cluster.representative
|
|
574
601
|
names = {signal.name for signal in cluster.signals}
|
|
575
602
|
is_failure_seed = bool(names & QUALITY_SIGNALS)
|
|
@@ -614,6 +641,23 @@ def build_case(index: int, cluster: Cluster, context: TraceContext | None = None
|
|
|
614
641
|
else:
|
|
615
642
|
shape_source = "none available -- only behaviour was asserted"
|
|
616
643
|
|
|
644
|
+
# A human-written expectation outranks anything inferred. It does not make
|
|
645
|
+
# the case reproduce its original failure -- for a behavioural failure
|
|
646
|
+
# nothing on the output text can, by definition -- but it does give the case
|
|
647
|
+
# a specification worth asserting, which is the actual point.
|
|
648
|
+
annotation = (
|
|
649
|
+
expectations.lookup(fold_input(trace.input)) if expectations is not None else None
|
|
650
|
+
)
|
|
651
|
+
if annotation is not None:
|
|
652
|
+
checks = merge_checks(checks, _checks_from_expect(annotation.expect))
|
|
653
|
+
if not any(check["type"] == "not_fallback" for check in checks):
|
|
654
|
+
checks.insert(0, {"type": "not_fallback"})
|
|
655
|
+
shape_source = "a human-written expectation"
|
|
656
|
+
notes.append(
|
|
657
|
+
"human expectation applied, overriding anything inferred — "
|
|
658
|
+
+ (annotation.note or "(no note given)")
|
|
659
|
+
)
|
|
660
|
+
|
|
617
661
|
duplicates = cluster.duplicate_ids
|
|
618
662
|
if duplicates:
|
|
619
663
|
notes.append(
|
|
@@ -647,6 +691,10 @@ def build_case(index: int, cluster: Cluster, context: TraceContext | None = None
|
|
|
647
691
|
"reference_is_trusted": not is_failure_seed,
|
|
648
692
|
"shape_source": shape_source,
|
|
649
693
|
"failure_kind": failure_kind,
|
|
694
|
+
"annotation": {
|
|
695
|
+
"applied": annotation is not None,
|
|
696
|
+
"note": annotation.note if annotation is not None else "",
|
|
697
|
+
},
|
|
650
698
|
"source_trace_id": trace.id,
|
|
651
699
|
"score": cluster.score,
|
|
652
700
|
"signals": [signal.to_dict() for signal in cluster.signals],
|
|
@@ -665,6 +713,7 @@ def select_cases(
|
|
|
665
713
|
dedup_threshold: float = DEFAULT_DEDUP_THRESHOLD,
|
|
666
714
|
weights: dict[str, float] | None = None,
|
|
667
715
|
matcher: Matcher | None = None,
|
|
716
|
+
expectations: AnnotationSet | None = None,
|
|
668
717
|
) -> SelectionResult:
|
|
669
718
|
trace_list = list(traces)
|
|
670
719
|
result = SelectionResult(total_traces=len(trace_list))
|
|
@@ -703,7 +752,7 @@ def select_cases(
|
|
|
703
752
|
|
|
704
753
|
result.dropped_as_duplicate = sum(cluster.duplicate_count for cluster in interesting)
|
|
705
754
|
result.cases = [
|
|
706
|
-
build_case(index, cluster, context)
|
|
755
|
+
build_case(index, cluster, context, expectations)
|
|
707
756
|
for index, cluster in enumerate(interesting, 1)
|
|
708
757
|
]
|
|
709
758
|
return result
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trace2eval-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Turn production LLM traces into a regression eval set. Zero dependencies.
|
|
5
5
|
Author: rfioly
|
|
6
6
|
License: MIT
|
|
@@ -37,7 +37,9 @@ Dynamic: license-file
|
|
|
37
37
|
零依赖、不用 API key、不调模型。同样的日志进,同样的用例出。
|
|
38
38
|
|
|
39
39
|
[](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml)
|
|
40
|
-
[](https://pypi.org/project/trace2eval-cli/)
|
|
41
|
+
[](https://pypi.org/project/trace2eval-cli/)
|
|
42
|
+
[](https://pypi.org/project/trace2eval-cli/)
|
|
41
43
|
[](#)
|
|
42
44
|
[](LICENSE)
|
|
43
45
|
|
|
@@ -46,7 +48,7 @@ Dynamic: license-file
|
|
|
46
48
|
## 快速开始
|
|
47
49
|
|
|
48
50
|
```bash
|
|
49
|
-
pip install -
|
|
51
|
+
pip install trace2eval-cli
|
|
50
52
|
|
|
51
53
|
# 1. 把日志变成用例集
|
|
52
54
|
trace2eval build traces.jsonl -o evalset/
|
|
@@ -60,6 +62,9 @@ trace2eval check --baseline runs/baseline.json --current runs/current.json
|
|
|
60
62
|
|
|
61
63
|
需要 Python 3.10+。
|
|
62
64
|
|
|
65
|
+
> 分发名带 `-cli` 后缀,因为 `trace2eval` 这个名字在 PyPI 上被一个空壳占着。
|
|
66
|
+
> **命令没变**——装完还是敲 `trace2eval`。
|
|
67
|
+
|
|
63
68
|
---
|
|
64
69
|
|
|
65
70
|
## 三个命令
|
|
@@ -79,6 +84,7 @@ trace2eval check --baseline runs/baseline.json --current runs/current.json
|
|
|
79
84
|
- **一个问题一个用例。** 被问了 200 次的问题只变成一个用例,并且老实记录它代表 200 次调用。
|
|
80
85
|
- **形状从干净答案学,不从失败那条学。** 失败种子的长度下限来自同问题的干净答案中位数。
|
|
81
86
|
- **每个用例自检。** 报告里会标明哪些用例的检查**抓不住**它当初为什么坏,不混进"已覆盖"里。
|
|
87
|
+
- **抓不住的用例可以人工补。** 失败发生在"调用之外"(用户重问、差评)而不在答案里时,自动检查无能为力——报告会把它们单列出来,`build` 同时生成一份待填模板,你写好期望答案后它们就变成真正的门禁。标注按**问题**索引而不是按用例编号,所以重新生成用例集不会失效。
|
|
82
88
|
- **相似度可换。** `--similarity module:function` 换成你自己的实现,包本身不引依赖。
|
|
83
89
|
- **天生适合 CI。** `check` 检出回归返回非零退出码。
|
|
84
90
|
|
|
@@ -190,10 +196,35 @@ flowchart LR
|
|
|
190
196
|
|
|
191
197
|
---
|
|
192
198
|
|
|
199
|
+
## 人工标注(可选)
|
|
200
|
+
|
|
201
|
+
有些失败发生在"调用之外"——用户又问了一遍、点了差评、接口慢到超时。**这种答案本身往往是没问题的**,所以任何对输出文本的自动检查都抓不到它。工具能识别出这些用例,但没法凭空想出"正确答案长什么样",那得人来写。
|
|
202
|
+
|
|
203
|
+
`build` 会在用例集旁边生成一份待填模板,列出的正是这些用例:
|
|
204
|
+
|
|
205
|
+
```jsonl
|
|
206
|
+
{"input": "修改手机号", "expect": {}, "note": "TODO: fill in expect. (user_retried)"}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
把 `expect` 填好,另存为 `expectations.jsonl`,重新 `build` 就生效了:
|
|
210
|
+
|
|
211
|
+
```jsonl
|
|
212
|
+
{"input": "修改手机号", "expect": {"min_chars": 20, "contains": ["手机号"]}, "note": "用户重问说明第一版没解决,正确答案要说清验证流程"}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`expect` 里能用的键和 trace 自己的 `expect` 块**完全一致**:`min_chars`、`max_chars`、`contains`、`not_contains`、`regex`、`json`、`not_fallback`。
|
|
216
|
+
|
|
217
|
+
两条设计上要紧的地方:
|
|
218
|
+
|
|
219
|
+
- **按问题索引,不按用例编号。** 用例编号是按分数排序临时分配的,加一条日志就可能全部重排。标注要是挂在 `case-011` 上,下次重建就会**悄悄脱离**——而一个脱了钩的标注比没有标注更糟,因为那个用例看起来还是有覆盖的。
|
|
220
|
+
- **标注不会改变自检结论。** 它不会让用例"复现失败"(行为型失败本来就复现不了),它是换了个目标:从"让这个失败不可能再发生"变成"答案必须长这样"。报告里两个数分开写,不合并。
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
193
224
|
## 已知边界
|
|
194
225
|
|
|
195
226
|
- **相似度是字符 n-gram**,不含相同字符的改写会被当成两个问题。可用 `--similarity` 换成你自己的实现。
|
|
196
|
-
-
|
|
227
|
+
- **行为型失败自动抓不住,但可以人工补。** 样本日志 16 个用例里有 3 个,失败原因只是"用户又问了一遍",输出本身没毛病——任何对输出文本的确定性检查都抓不到,这是方法的边界而非欠账。它们现在由 `evalset/expectations.jsonl` 里的人工标注兜住(仓库里那份是示例)。报告会**分开**说明"有几个抓不住""有几个已有标注覆盖",不会含糊成一个数。
|
|
197
228
|
- **比较成本是 O(用例数 × 每簇不同问法数)**,不是 O(日志大小):同一问题问 5000 次只留 1 个待比对指纹。`MAX_DISTINCT_FINGERPRINTS = 512` 是拍的保护值,没压测过。
|
|
198
229
|
- **用例仍是提案**,但会自检:`report.md` 里单列"需要人看一眼"的用例。
|
|
199
230
|
|
|
@@ -203,16 +234,17 @@ flowchart LR
|
|
|
203
234
|
|
|
204
235
|
```
|
|
205
236
|
src/trace2eval/
|
|
206
|
-
schema.py
|
|
207
|
-
signals.py
|
|
208
|
-
select.py
|
|
209
|
-
checks.py
|
|
210
|
-
runner.py
|
|
211
|
-
report.py
|
|
212
|
-
matchers.py
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
237
|
+
schema.py trace 加载、字段别名、容错解析
|
|
238
|
+
signals.py 一条 trace 为什么值得测
|
|
239
|
+
select.py 相似度、聚类、用例构建
|
|
240
|
+
checks.py 确定性输出检查
|
|
241
|
+
runner.py 评分与回归对比
|
|
242
|
+
report.py markdown 渲染
|
|
243
|
+
matchers.py 可替换的相似度实现
|
|
244
|
+
annotations.py 人工标注的加载与合并
|
|
245
|
+
tests/ 62 个测试
|
|
246
|
+
examples/ 一份 42 行样本日志 + 一次基线 / 一次回归运行
|
|
247
|
+
evalset/ 提交进仓库的构建产物 + 示例标注
|
|
216
248
|
```
|
|
217
249
|
|
|
218
250
|
## 开发
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
src/trace2eval/__init__.py
|
|
5
5
|
src/trace2eval/__main__.py
|
|
6
|
+
src/trace2eval/annotations.py
|
|
6
7
|
src/trace2eval/checks.py
|
|
7
8
|
src/trace2eval/cli.py
|
|
8
9
|
src/trace2eval/matchers.py
|
|
@@ -17,6 +18,7 @@ src/trace2eval_cli.egg-info/dependency_links.txt
|
|
|
17
18
|
src/trace2eval_cli.egg-info/entry_points.txt
|
|
18
19
|
src/trace2eval_cli.egg-info/requires.txt
|
|
19
20
|
src/trace2eval_cli.egg-info/top_level.txt
|
|
21
|
+
tests/test_annotations.py
|
|
20
22
|
tests/test_dedup.py
|
|
21
23
|
tests/test_matchers.py
|
|
22
24
|
tests/test_pipeline.py
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Tests for human-written expectations.
|
|
2
|
+
|
|
3
|
+
The property that matters most is survival: generated case ids are assigned in
|
|
4
|
+
descending score order, so adding one trace to a log can renumber everything. An
|
|
5
|
+
annotation keyed on a case id would detach itself on the next rebuild and the
|
|
6
|
+
case would quietly go back to being weak while still looking annotated. Several
|
|
7
|
+
of these tests exist to make sure that cannot happen unnoticed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from trace2eval.annotations import (
|
|
18
|
+
Annotation,
|
|
19
|
+
AnnotationSet,
|
|
20
|
+
fold_input,
|
|
21
|
+
load_annotations,
|
|
22
|
+
merge_checks,
|
|
23
|
+
render_template,
|
|
24
|
+
)
|
|
25
|
+
from trace2eval.cli import main
|
|
26
|
+
from trace2eval.schema import Trace, load_traces
|
|
27
|
+
from trace2eval.select import select_cases
|
|
28
|
+
|
|
29
|
+
SAMPLE_LOG = Path(__file__).resolve().parents[1] / "examples" / "sample_traces.jsonl"
|
|
30
|
+
SAMPLE_EXPECTATIONS = Path(__file__).resolve().parents[1] / "evalset" / "expectations.jsonl"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_lines(path: Path) -> list[dict]:
|
|
34
|
+
return [
|
|
35
|
+
json.loads(line)
|
|
36
|
+
for line in path.read_text(encoding="utf-8").splitlines()
|
|
37
|
+
if line.strip()
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
# Loading
|
|
43
|
+
# --------------------------------------------------------------------------- #
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_loads_objects_and_ignores_comments_and_blanks(tmp_path):
|
|
47
|
+
path = tmp_path / "expectations.jsonl"
|
|
48
|
+
path.write_text(
|
|
49
|
+
"// a comment\n"
|
|
50
|
+
"\n"
|
|
51
|
+
'{"input": "修改手机号", "expect": {"min_chars": 20}, "note": "why"}\n'
|
|
52
|
+
"// another comment\n",
|
|
53
|
+
encoding="utf-8",
|
|
54
|
+
)
|
|
55
|
+
loaded = load_annotations(path)
|
|
56
|
+
|
|
57
|
+
assert not loaded.skipped
|
|
58
|
+
assert len(loaded.filled) == 1
|
|
59
|
+
assert loaded.filled[0].input == "修改手机号"
|
|
60
|
+
assert loaded.filled[0].expect == {"min_chars": 20}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_a_missing_file_is_not_an_error(tmp_path):
|
|
64
|
+
loaded = load_annotations(tmp_path / "nope.jsonl")
|
|
65
|
+
assert loaded.annotations == []
|
|
66
|
+
assert not loaded
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_one_broken_line_does_not_lose_the_others(tmp_path):
|
|
70
|
+
"""An annotation set is small and hand-edited. Losing it to one bad comma is a bad trade."""
|
|
71
|
+
path = tmp_path / "expectations.jsonl"
|
|
72
|
+
path.write_text(
|
|
73
|
+
'{"input": "a", "expect": {"min_chars": 5}}\n'
|
|
74
|
+
'{"input": "b", "expect": {"min_chars": 5}\n' # unclosed brace
|
|
75
|
+
'{"input": "c", "expect": {"min_chars": 5}}\n',
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
)
|
|
78
|
+
loaded = load_annotations(path)
|
|
79
|
+
|
|
80
|
+
assert len(loaded.filled) == 2
|
|
81
|
+
assert len(loaded.skipped) == 1
|
|
82
|
+
assert "line 2" in loaded.skipped[0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_an_unknown_check_type_is_rejected_with_a_useful_message(tmp_path):
|
|
86
|
+
path = tmp_path / "expectations.jsonl"
|
|
87
|
+
path.write_text('{"input": "a", "expect": {"is_nice": true}}\n', encoding="utf-8")
|
|
88
|
+
loaded = load_annotations(path)
|
|
89
|
+
|
|
90
|
+
assert loaded.filled == []
|
|
91
|
+
assert "is_nice" in loaded.skipped[0]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_a_template_placeholder_is_not_applied(tmp_path):
|
|
95
|
+
"""'expect: {}' means 'not filled in yet', not 'assert nothing'."""
|
|
96
|
+
path = tmp_path / "expectations.jsonl"
|
|
97
|
+
path.write_text('{"input": "a", "expect": {}, "note": "TODO"}\n', encoding="utf-8")
|
|
98
|
+
loaded = load_annotations(path)
|
|
99
|
+
|
|
100
|
+
assert len(loaded.annotations) == 1
|
|
101
|
+
assert loaded.filled == []
|
|
102
|
+
assert not loaded # bool() is False when nothing is actually filled in
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
# Matching
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_matching_folds_case_space_and_punctuation():
|
|
111
|
+
assert fold_input(" 修改手机号。 ") == fold_input("修改手机号")
|
|
112
|
+
assert fold_input("API Key 轮换") == fold_input("apikey轮换")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_lookup_matches_a_rephrased_input():
|
|
116
|
+
loaded = AnnotationSet(annotations=[Annotation(input="修改手机号", expect={"min_chars": 20})])
|
|
117
|
+
assert loaded.lookup(fold_input(" 修改手机号, ")) is not None
|
|
118
|
+
assert loaded.lookup(fold_input("退款政策")) is None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --------------------------------------------------------------------------- #
|
|
122
|
+
# Merging
|
|
123
|
+
# --------------------------------------------------------------------------- #
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_human_checks_override_inferred_ones_of_the_same_type():
|
|
127
|
+
inferred = [{"type": "not_fallback"}, {"type": "min_chars", "value": 8}]
|
|
128
|
+
human = [{"type": "min_chars", "value": 20}]
|
|
129
|
+
|
|
130
|
+
merged = merge_checks(inferred, human)
|
|
131
|
+
assert {"type": "min_chars", "value": 20} in merged
|
|
132
|
+
assert {"type": "min_chars", "value": 8} not in merged
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_inferred_checks_survive_when_the_human_did_not_mention_them():
|
|
136
|
+
"""Writing 'contains' is not an opinion about truncation.
|
|
137
|
+
|
|
138
|
+
Dropping the inferred length check here would make annotating a case a
|
|
139
|
+
downgrade, which would be a strange thing for a feature to do.
|
|
140
|
+
"""
|
|
141
|
+
inferred = [{"type": "not_fallback"}, {"type": "min_chars", "value": 14}]
|
|
142
|
+
human = [{"type": "contains", "value": ["客服"]}]
|
|
143
|
+
|
|
144
|
+
types = [check["type"] for check in merge_checks(inferred, human)]
|
|
145
|
+
assert types == ["not_fallback", "min_chars", "contains"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_not_fallback_always_comes_first():
|
|
149
|
+
merged = merge_checks([], [{"type": "contains", "value": ["x"]}, {"type": "not_fallback"}])
|
|
150
|
+
assert merged[0]["type"] == "not_fallback"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------- #
|
|
154
|
+
# Survival across rebuilds -- the whole point
|
|
155
|
+
# --------------------------------------------------------------------------- #
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _trace(trace_id: str, text: str, output: str, **kwargs) -> Trace:
|
|
159
|
+
return Trace(id=trace_id, input=text, output=output, **kwargs)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_an_annotation_follows_the_question_when_case_ids_shift():
|
|
163
|
+
"""The design property. Case ids are positional; annotations must not be."""
|
|
164
|
+
weak = _trace("w1", "修改手机号", "在设置里改。", retried=True)
|
|
165
|
+
filler = _trace("f1", "退款政策是什么", "退款申请提交后会在 14 个工作日内原路退回。")
|
|
166
|
+
|
|
167
|
+
before = select_cases([weak, filler], expectations=_annotation_set("修改手机号"))
|
|
168
|
+
# Add a much higher-scoring trace. It takes case-001, pushing everything down.
|
|
169
|
+
louder = _trace(
|
|
170
|
+
"x1", "系统崩了", "抱歉,我无法回答这个问题。", feedback="negative", retried=True
|
|
171
|
+
)
|
|
172
|
+
after = select_cases([weak, filler, louder], expectations=_annotation_set("修改手机号"))
|
|
173
|
+
|
|
174
|
+
def find(cases, text):
|
|
175
|
+
return next(c for c in cases if c["input"] == text)
|
|
176
|
+
|
|
177
|
+
weak_before, weak_after = find(before.cases, "修改手机号"), find(after.cases, "修改手机号")
|
|
178
|
+
assert weak_before["id"] != weak_after["id"], "the fixture must actually renumber the case"
|
|
179
|
+
assert weak_before["annotation"]["applied"] and weak_after["annotation"]["applied"]
|
|
180
|
+
assert "contains" in [check["type"] for check in weak_after["checks"]]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _annotation_set(text: str, expect: dict | None = None) -> AnnotationSet:
|
|
184
|
+
return AnnotationSet(
|
|
185
|
+
annotations=[
|
|
186
|
+
Annotation(
|
|
187
|
+
input=text,
|
|
188
|
+
expect=expect if expect is not None else {"min_chars": 20, "contains": ["手机号"]},
|
|
189
|
+
note="test",
|
|
190
|
+
)
|
|
191
|
+
]
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_build_case_records_that_an_annotation_was_applied():
|
|
196
|
+
weak = _trace("w1", "修改手机号", "在设置里改。", retried=True)
|
|
197
|
+
result = select_cases([weak], expectations=_annotation_set("修改手机号"))
|
|
198
|
+
case = result.cases[0]
|
|
199
|
+
|
|
200
|
+
assert case["annotation"]["applied"] is True
|
|
201
|
+
assert case["annotation"]["note"] == "test"
|
|
202
|
+
assert case["shape_source"] == "a human-written expectation"
|
|
203
|
+
assert any("human expectation applied" in note for note in case["notes"])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_an_annotation_does_not_pretend_to_fix_the_verdict():
|
|
207
|
+
"""Honesty check: the failure still is not reproducible, and the report says so.
|
|
208
|
+
|
|
209
|
+
An annotation gives the case something worth asserting. It does not make the
|
|
210
|
+
original failure detectable, and pretending otherwise would be the exact
|
|
211
|
+
over-claiming this field exists to avoid.
|
|
212
|
+
|
|
213
|
+
Note the reference here is a *good* answer, which is what a behavioural
|
|
214
|
+
failure looks like -- the call went fine, the user just asked again. That is
|
|
215
|
+
precisely why no check on this text can reproach the failure, and why the
|
|
216
|
+
verdict has to stay ``failure_not_reproduced`` even after annotating.
|
|
217
|
+
|
|
218
|
+
(A failure seed whose reference is genuinely bad behaves differently: the
|
|
219
|
+
human's checks fail on it and the verdict becomes ``ok``. Also correct, just
|
|
220
|
+
a different situation.)
|
|
221
|
+
"""
|
|
222
|
+
weak = _trace(
|
|
223
|
+
"w1",
|
|
224
|
+
"修改手机号",
|
|
225
|
+
"修改绑定手机号需要先通过原手机号接收验证码,验证通过后在账户设置里填写新号码。",
|
|
226
|
+
retried=True,
|
|
227
|
+
)
|
|
228
|
+
result = select_cases([weak], expectations=_annotation_set("修改手机号"))
|
|
229
|
+
|
|
230
|
+
case = result.cases[0]
|
|
231
|
+
assert case["self_check"]["passed"] is True, "the reference is a fine answer"
|
|
232
|
+
assert case["self_check"]["verdict"] == "failure_not_reproduced"
|
|
233
|
+
assert result.stats()["cases_with_annotation"] == 1
|
|
234
|
+
assert result.stats()["cases_needing_annotation"] == 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def test_an_annotation_can_turn_a_leaky_seed_into_a_real_gate():
|
|
238
|
+
"""The other branch: when the reference really is bad, the human's checks catch it.
|
|
239
|
+
|
|
240
|
+
This is the payoff. A failure seed whose reference is a truncated answer used
|
|
241
|
+
to assert almost nothing; with an expectation attached the checks fail on it,
|
|
242
|
+
so the verdict flips to ``ok`` -- meaning the case now does reproduce a
|
|
243
|
+
failure. Same mechanism, opposite outcome, and both are honest.
|
|
244
|
+
"""
|
|
245
|
+
weak = _trace("w1", "修改手机号", "在设置里改。", retried=True)
|
|
246
|
+
result = select_cases([weak], expectations=_annotation_set("修改手机号"))
|
|
247
|
+
|
|
248
|
+
case = result.cases[0]
|
|
249
|
+
assert case["self_check"]["passed"] is False
|
|
250
|
+
assert case["self_check"]["verdict"] == "ok"
|
|
251
|
+
assert "min_chars" in case["self_check"]["failed_checks"]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_a_weak_case_without_an_annotation_stays_on_the_todo_list():
|
|
255
|
+
weak = _trace("w1", "修改手机号", "在设置里改。", retried=True)
|
|
256
|
+
result = select_cases([weak])
|
|
257
|
+
|
|
258
|
+
assert result.stats()["cases_needing_annotation"] == 1
|
|
259
|
+
assert result.stats()["cases_with_annotation"] == 0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# --------------------------------------------------------------------------- #
|
|
263
|
+
# The committed sample
|
|
264
|
+
# --------------------------------------------------------------------------- #
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_the_sample_annotation_file_covers_every_weak_case():
|
|
268
|
+
loaded = load_traces(SAMPLE_LOG)
|
|
269
|
+
result = select_cases(loaded.traces, expectations=load_annotations(SAMPLE_EXPECTATIONS))
|
|
270
|
+
|
|
271
|
+
assert not load_annotations(SAMPLE_EXPECTATIONS).skipped, "the shipped file must parse cleanly"
|
|
272
|
+
assert result.stats()["cases_with_annotation"] == 3
|
|
273
|
+
assert result.stats()["cases_needing_annotation"] == 0
|
|
274
|
+
# The 3 behavioural cases are still honestly reported as not reproducible.
|
|
275
|
+
assert result.stats()["cases_with_weak_checks"] == 3
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_the_sample_annotation_changes_the_case_set():
|
|
279
|
+
"""Without it, an answer that misses the point of the question still passes."""
|
|
280
|
+
loaded = load_traces(SAMPLE_LOG)
|
|
281
|
+
bare = select_cases(loaded.traces)
|
|
282
|
+
annotated = select_cases(
|
|
283
|
+
loaded.traces, expectations=load_annotations(SAMPLE_EXPECTATIONS)
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def checks_for(result, text):
|
|
287
|
+
case = next(c for c in result.cases if c["input"] == text)
|
|
288
|
+
return [check["type"] for check in case["checks"]]
|
|
289
|
+
|
|
290
|
+
assert checks_for(bare, "修改手机号") == ["not_fallback", "min_chars"]
|
|
291
|
+
assert checks_for(annotated, "修改手机号") == ["not_fallback", "min_chars", "contains"]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --------------------------------------------------------------------------- #
|
|
295
|
+
# The template
|
|
296
|
+
# --------------------------------------------------------------------------- #
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_the_template_is_parseable_and_lists_only_the_cases_handed_to_it():
|
|
300
|
+
loaded = load_traces(SAMPLE_LOG)
|
|
301
|
+
bare = select_cases(loaded.traces)
|
|
302
|
+
text = render_template(bare.cases_needing_annotation)
|
|
303
|
+
|
|
304
|
+
import tempfile
|
|
305
|
+
|
|
306
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
307
|
+
path = Path(tmp) / "template.jsonl"
|
|
308
|
+
path.write_text(text, encoding="utf-8")
|
|
309
|
+
parsed = load_annotations(path)
|
|
310
|
+
|
|
311
|
+
assert not parsed.skipped, "every emitted line must be valid"
|
|
312
|
+
assert len(parsed.annotations) == len(bare.cases_needing_annotation) == 3
|
|
313
|
+
# Placeholders are deliberately empty: a pre-filled guess is exactly the
|
|
314
|
+
# over-confidence this mechanism exists to avoid.
|
|
315
|
+
assert parsed.filled == []
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_build_writes_a_template_only_when_there_is_no_expectations_file(tmp_path):
|
|
319
|
+
log = tmp_path / "traces.jsonl"
|
|
320
|
+
log.write_text(
|
|
321
|
+
"\n".join(
|
|
322
|
+
json.dumps(
|
|
323
|
+
{"input": "修改手机号", "output": "在设置里改。", "retried": True},
|
|
324
|
+
ensure_ascii=False,
|
|
325
|
+
)
|
|
326
|
+
for _ in range(1)
|
|
327
|
+
)
|
|
328
|
+
+ "\n",
|
|
329
|
+
encoding="utf-8",
|
|
330
|
+
)
|
|
331
|
+
out = tmp_path / "evalset"
|
|
332
|
+
|
|
333
|
+
assert main(["build", str(log), "-o", str(out)]) == 0
|
|
334
|
+
template = out / "expectations.template.jsonl"
|
|
335
|
+
assert template.exists()
|
|
336
|
+
|
|
337
|
+
# Once a real expectations file is in place, the template is not rewritten.
|
|
338
|
+
template.unlink()
|
|
339
|
+
(out / "expectations.jsonl").write_text(
|
|
340
|
+
'{"input": "修改手机号", "expect": {"min_chars": 20}}\n', encoding="utf-8"
|
|
341
|
+
)
|
|
342
|
+
assert main(["build", str(log), "-o", str(out)]) == 0
|
|
343
|
+
assert not template.exists()
|
|
344
|
+
assert read_lines(out / "cases.jsonl")[0]["annotation"]["applied"] is True
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_a_broken_annotation_line_is_reported_but_does_not_fail_the_build(tmp_path):
|
|
348
|
+
log = tmp_path / "traces.jsonl"
|
|
349
|
+
log.write_text(
|
|
350
|
+
json.dumps({"input": "修改手机号", "output": "在设置里改。", "retried": True}, ensure_ascii=False)
|
|
351
|
+
+ "\n",
|
|
352
|
+
encoding="utf-8",
|
|
353
|
+
)
|
|
354
|
+
out = tmp_path / "evalset"
|
|
355
|
+
out.mkdir()
|
|
356
|
+
(out / "expectations.jsonl").write_text("{not json\n", encoding="utf-8")
|
|
357
|
+
|
|
358
|
+
assert main(["build", str(log), "-o", str(out)]) == 0
|
|
359
|
+
assert read_lines(out / "cases.jsonl")[0]["annotation"]["applied"] is False
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|