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.
Files changed (26) hide show
  1. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/PKG-INFO +46 -14
  2. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/README.md +45 -13
  3. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/pyproject.toml +1 -1
  4. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/__init__.py +13 -1
  5. trace2eval_cli-0.3.0/src/trace2eval/annotations.py +212 -0
  6. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/cli.py +37 -1
  7. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/report.py +34 -4
  8. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/select.py +51 -2
  9. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/PKG-INFO +46 -14
  10. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/SOURCES.txt +2 -0
  11. trace2eval_cli-0.3.0/tests/test_annotations.py +359 -0
  12. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/LICENSE +0 -0
  13. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/setup.cfg +0 -0
  14. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/__main__.py +0 -0
  15. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/checks.py +0 -0
  16. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/matchers.py +0 -0
  17. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/runner.py +0 -0
  18. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/schema.py +0 -0
  19. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval/signals.py +0 -0
  20. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/dependency_links.txt +0 -0
  21. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/entry_points.txt +0 -0
  22. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/requires.txt +0 -0
  23. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/src/trace2eval_cli.egg-info/top_level.txt +0 -0
  24. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/tests/test_dedup.py +0 -0
  25. {trace2eval_cli-0.2.1 → trace2eval_cli-0.3.0}/tests/test_matchers.py +0 -0
  26. {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.2.1
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
  [![CI](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml/badge.svg)](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml)
40
- [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
40
+ [![PyPI](https://img.shields.io/pypi/v/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
41
+ [![Downloads](https://img.shields.io/pypi/dm/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
42
+ [![Python versions](https://img.shields.io/pypi/pyversions/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
41
43
  [![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](#)
42
44
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
43
45
 
@@ -46,7 +48,7 @@ Dynamic: license-file
46
48
  ## 快速开始
47
49
 
48
50
  ```bash
49
- pip install -e .
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
- - **行为型失败抓不住。** 样本日志 16 个用例里有 3 个,失败原因只是"用户又问了一遍",输出本身没毛病——任何对输出文本的确定性检查都抓不到。报告里已单独标出。
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 trace 加载、字段别名、容错解析
207
- signals.py 一条 trace 为什么值得测
208
- select.py 相似度、聚类、用例构建
209
- checks.py 确定性输出检查
210
- runner.py 评分与回归对比
211
- report.py markdown 渲染
212
- matchers.py 可替换的相似度实现
213
- tests/ 42 个测试
214
- examples/ 一份 42 行样本日志 + 一次基线 / 一次回归运行
215
- evalset/ 提交进仓库的构建产物
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
  [![CI](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml/badge.svg)](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml)
12
- [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
12
+ [![PyPI](https://img.shields.io/pypi/v/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
13
+ [![Downloads](https://img.shields.io/pypi/dm/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
14
+ [![Python versions](https://img.shields.io/pypi/pyversions/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
13
15
  [![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](#)
14
16
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
15
17
 
@@ -18,7 +20,7 @@
18
20
  ## 快速开始
19
21
 
20
22
  ```bash
21
- pip install -e .
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
- - **行为型失败抓不住。** 样本日志 16 个用例里有 3 个,失败原因只是"用户又问了一遍",输出本身没毛病——任何对输出文本的确定性检查都抓不到。报告里已单独标出。
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 trace 加载、字段别名、容错解析
179
- signals.py 一条 trace 为什么值得测
180
- select.py 相似度、聚类、用例构建
181
- checks.py 确定性输出检查
182
- runner.py 评分与回归对比
183
- report.py markdown 渲染
184
- matchers.py 可替换的相似度实现
185
- tests/ 42 个测试
186
- examples/ 一份 42 行样本日志 + 一次基线 / 一次回归运行
187
- evalset/ 提交进仓库的构建产物
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.2.1"
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.2.1"
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.cases_with_weak_checks
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.cases_with_weak_checks:
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. Keep the case as a pinned input, but it needs a "
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(index: int, cluster: Cluster, context: TraceContext | None = None) -> dict[str, Any]:
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.2.1
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
  [![CI](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml/badge.svg)](https://github.com/rfioly/trace2eval/actions/workflows/ci.yml)
40
- [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
40
+ [![PyPI](https://img.shields.io/pypi/v/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
41
+ [![Downloads](https://img.shields.io/pypi/dm/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
42
+ [![Python versions](https://img.shields.io/pypi/pyversions/trace2eval-cli)](https://pypi.org/project/trace2eval-cli/)
41
43
  [![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](#)
42
44
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
43
45
 
@@ -46,7 +48,7 @@ Dynamic: license-file
46
48
  ## 快速开始
47
49
 
48
50
  ```bash
49
- pip install -e .
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
- - **行为型失败抓不住。** 样本日志 16 个用例里有 3 个,失败原因只是"用户又问了一遍",输出本身没毛病——任何对输出文本的确定性检查都抓不到。报告里已单独标出。
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 trace 加载、字段别名、容错解析
207
- signals.py 一条 trace 为什么值得测
208
- select.py 相似度、聚类、用例构建
209
- checks.py 确定性输出检查
210
- runner.py 评分与回归对比
211
- report.py markdown 渲染
212
- matchers.py 可替换的相似度实现
213
- tests/ 42 个测试
214
- examples/ 一份 42 行样本日志 + 一次基线 / 一次回归运行
215
- evalset/ 提交进仓库的构建产物
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