trace2eval-cli 0.2.1__py3-none-any.whl

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/signals.py ADDED
@@ -0,0 +1,207 @@
1
+ """Signals: deciding which traces are worth turning into test cases.
2
+
3
+ A production log is mostly boring. If you dump ten thousand calls into a test
4
+ suite you get a suite that is slow, redundant, and ignored. What you want is the
5
+ handful of calls that carry information -- the ones where something already went
6
+ wrong, or where the shape of the answer matters.
7
+
8
+ Each signal below is a cheap, deterministic observation about a single row plus
9
+ a little global context (percentiles across the whole log). Signals carry
10
+ weights, and weights add up into a score. The score decides what gets promoted.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import math
17
+ import statistics
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Iterable
20
+
21
+ from .checks import detect_fallback
22
+ from .schema import Trace
23
+
24
+ #: Default weights. Tuned so that a single strong signal outranks several weak
25
+ #: ones: an explicit thumbs-down should always beat "this call was a bit slow".
26
+ DEFAULT_WEIGHTS: dict[str, float] = {
27
+ "negative_feedback": 3.0,
28
+ "user_retried": 2.5,
29
+ "empty_output": 3.0,
30
+ "expected_shape_violated": 3.0,
31
+ "fallback_phrase": 2.0,
32
+ "explicit_expectation": 2.0,
33
+ "output_much_shorter": 1.5,
34
+ "output_much_longer": 1.0,
35
+ "slow_response": 1.0,
36
+ "expensive_call": 1.0,
37
+ }
38
+
39
+ #: An output shorter than this is treated as suspicious on its own.
40
+ MIN_PLAUSIBLE_CHARS = 12
41
+
42
+ #: A response is "much shorter/longer" past these ratios against the log median.
43
+ SHORT_RATIO = 0.5
44
+ LONG_RATIO = 3.0
45
+
46
+ # Backwards-compatible private aliases; the underscore names are historical.
47
+ _MIN_PLAUSIBLE_CHARS = MIN_PLAUSIBLE_CHARS
48
+ _SHORT_RATIO = SHORT_RATIO
49
+ _LONG_RATIO = LONG_RATIO
50
+
51
+
52
+ def is_suspiciously_short(chars: int, median_output_chars: float) -> bool:
53
+ """Whether a response this long counts as too short for this log."""
54
+ if median_output_chars <= 0:
55
+ return False
56
+ return (
57
+ chars < median_output_chars * SHORT_RATIO
58
+ and chars < MIN_PLAUSIBLE_CHARS * 2
59
+ )
60
+
61
+
62
+ def suspiciously_short_boundary(median_output_chars: float) -> int | None:
63
+ """The length a response had to fall below to be flagged as too short.
64
+
65
+ Exposed because the generated *checks* need the same number the *signal* used.
66
+ Deriving both from one function is the only way to stop them drifting apart:
67
+ if the threshold moved and the checks did not follow, a case built from a
68
+ length failure would stop reproducing its own failure, silently.
69
+ """
70
+ if median_output_chars <= 0:
71
+ return None
72
+ return int(math.ceil(min(median_output_chars * SHORT_RATIO, MIN_PLAUSIBLE_CHARS * 2)))
73
+
74
+
75
+ @dataclass
76
+ class Signal:
77
+ name: str
78
+ weight: float
79
+ detail: str
80
+
81
+ def to_dict(self) -> dict[str, Any]:
82
+ return {"name": self.name, "weight": self.weight, "detail": self.detail}
83
+
84
+
85
+ @dataclass
86
+ class TraceContext:
87
+ """Log-wide statistics that individual signals are judged against."""
88
+
89
+ p95_latency_ms: float | None = None
90
+ p95_cost_usd: float | None = None
91
+ median_output_chars: float = 0.0
92
+
93
+ def to_dict(self) -> dict[str, Any]:
94
+ return {
95
+ "p95_latency_ms": self.p95_latency_ms,
96
+ "p95_cost_usd": self.p95_cost_usd,
97
+ "median_output_chars": round(self.median_output_chars, 1),
98
+ }
99
+
100
+
101
+ def percentile(values: list[float], fraction: float) -> float | None:
102
+ """Linear-interpolation percentile. Avoids depending on numpy for one number."""
103
+ clean = sorted(value for value in values if value is not None)
104
+ if not clean:
105
+ return None
106
+ if len(clean) == 1:
107
+ return clean[0]
108
+ position = fraction * (len(clean) - 1)
109
+ lower = int(position)
110
+ upper = min(lower + 1, len(clean) - 1)
111
+ weight = position - lower
112
+ return clean[lower] * (1 - weight) + clean[upper] * weight
113
+
114
+
115
+ def build_context(traces: Iterable[Trace]) -> TraceContext:
116
+ trace_list = list(traces)
117
+ latencies = [t.latency_ms for t in trace_list if t.latency_ms is not None]
118
+ costs = [t.cost_usd for t in trace_list if t.cost_usd is not None]
119
+ char_counts = [t.output_chars for t in trace_list if t.output_chars > 0]
120
+ return TraceContext(
121
+ p95_latency_ms=percentile(latencies, 0.95),
122
+ p95_cost_usd=percentile(costs, 0.95),
123
+ median_output_chars=statistics.median(char_counts) if char_counts else 0.0,
124
+ )
125
+
126
+
127
+ def _looks_like_json(text: str) -> bool:
128
+ stripped = text.strip()
129
+ if not stripped or stripped[0] not in "[{":
130
+ return False
131
+ try:
132
+ json.loads(stripped)
133
+ except (json.JSONDecodeError, TypeError):
134
+ return False
135
+ return True
136
+
137
+
138
+ def compute_signals(
139
+ trace: Trace,
140
+ context: TraceContext,
141
+ weights: dict[str, float] | None = None,
142
+ ) -> list[Signal]:
143
+ """Collect every signal that fires for this trace."""
144
+ active = {**DEFAULT_WEIGHTS, **(weights or {})}
145
+ signals: list[Signal] = []
146
+
147
+ def add(name: str, detail: str) -> None:
148
+ weight = active.get(name, 0.0)
149
+ if weight > 0:
150
+ signals.append(Signal(name=name, weight=weight, detail=detail))
151
+
152
+ if trace.is_negative:
153
+ add("negative_feedback", "user gave negative feedback")
154
+
155
+ if trace.retried:
156
+ add("user_retried", "the same request was retried")
157
+
158
+ chars = trace.output_chars
159
+ if chars == 0:
160
+ add("empty_output", "model returned an empty response")
161
+ else:
162
+ fallback = detect_fallback(trace.output)
163
+ if fallback:
164
+ add("fallback_phrase", f"output contains {fallback!r}")
165
+
166
+ median = context.median_output_chars
167
+ if is_suspiciously_short(chars, median):
168
+ add(
169
+ "output_much_shorter",
170
+ f"{chars} chars vs log median {median:.0f}",
171
+ )
172
+ elif median > 0 and chars > median * LONG_RATIO:
173
+ add(
174
+ "output_much_longer",
175
+ f"{chars} chars vs log median {median:.0f}",
176
+ )
177
+
178
+ if trace.expect:
179
+ add("explicit_expectation", "trace already carried an expectation block")
180
+ if trace.expect.get("json") and not _looks_like_json(trace.output):
181
+ add("expected_shape_violated", "expected JSON output, got something else")
182
+
183
+ if (
184
+ context.p95_latency_ms is not None
185
+ and trace.latency_ms is not None
186
+ and trace.latency_ms > context.p95_latency_ms
187
+ ):
188
+ add(
189
+ "slow_response",
190
+ f"{trace.latency_ms:.0f}ms above p95 {context.p95_latency_ms:.0f}ms",
191
+ )
192
+
193
+ if (
194
+ context.p95_cost_usd is not None
195
+ and trace.cost_usd is not None
196
+ and trace.cost_usd > context.p95_cost_usd
197
+ ):
198
+ add(
199
+ "expensive_call",
200
+ f"${trace.cost_usd:.6f} above p95 ${context.p95_cost_usd:.6f}",
201
+ )
202
+
203
+ return signals
204
+
205
+
206
+ def score(signals: list[Signal]) -> float:
207
+ return round(sum(signal.weight for signal in signals), 3)
@@ -0,0 +1,227 @@
1
+ Metadata-Version: 2.4
2
+ Name: trace2eval-cli
3
+ Version: 0.2.1
4
+ Summary: Turn production LLM traces into a regression eval set. Zero dependencies.
5
+ Author: rfioly
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/rfioly/trace2eval
8
+ Project-URL: Source, https://github.com/rfioly/trace2eval
9
+ Project-URL: Issues, https://github.com/rfioly/trace2eval/issues
10
+ Keywords: llm,evaluation,evals,observability,regression-testing,trace,ci
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=8.0; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ [English](README.en.md) | **简体中文**
30
+
31
+ # trace2eval
32
+
33
+ **把生产环境的 LLM 调用日志,变成一套回归测试集。**
34
+
35
+ 它是 Pytest 那个路子,区别在于用例不用手写——从你已经发生的线上调用里挖出来。
36
+
37
+ 零依赖、不用 API key、不调模型。同样的日志进,同样的用例出。
38
+
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/)
41
+ [![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](#)
42
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
43
+
44
+ ---
45
+
46
+ ## 快速开始
47
+
48
+ ```bash
49
+ pip install -e .
50
+
51
+ # 1. 把日志变成用例集
52
+ trace2eval build traces.jsonl -o evalset/
53
+
54
+ # 2. 拿用例集给一批输出打分
55
+ trace2eval run --cases evalset/cases.jsonl --outputs outputs.jsonl -o runs/current.json
56
+
57
+ # 3. 有回归就失败(退出码 1),可直接挂 CI
58
+ trace2eval check --baseline runs/baseline.json --current runs/current.json
59
+ ```
60
+
61
+ 需要 Python 3.10+。
62
+
63
+ ---
64
+
65
+ ## 三个命令
66
+
67
+ | 命令 | 做什么 |
68
+ | --- | --- |
69
+ | `build` | JSONL 调用日志 → 用例集(`cases.jsonl` + `report.md`) |
70
+ | `run` | 用例集 + 一批输出 → `metrics.json` |
71
+ | `check` | 两份指标对比 → 通过 / 失败 |
72
+
73
+ ---
74
+
75
+ ## 特性
76
+
77
+ - **零依赖,不用 API key。** 纯标准库,不调任何模型,完全离线可跑。
78
+ - **确定性。** 同一份日志产出同一套用例,逐字节一致——这条在 CI 里有断言。
79
+ - **一个问题一个用例。** 被问了 200 次的问题只变成一个用例,并且老实记录它代表 200 次调用。
80
+ - **形状从干净答案学,不从失败那条学。** 失败种子的长度下限来自同问题的干净答案中位数。
81
+ - **每个用例自检。** 报告里会标明哪些用例的检查**抓不住**它当初为什么坏,不混进"已覆盖"里。
82
+ - **相似度可换。** `--similarity module:function` 换成你自己的实现,包本身不引依赖。
83
+ - **天生适合 CI。** `check` 检出回归返回非零退出码。
84
+
85
+ ---
86
+
87
+ ## 跑一遍看看
88
+
89
+ ```console
90
+ $ trace2eval build examples/sample_traces.jsonl -o evalset
91
+ read 42 traces from examples/sample_traces.jsonl
92
+ generated 16 cases -> evalset/cases.jsonl
93
+ 8 collapsed as near-duplicates
94
+ 3 case(s) carry checks that cannot detect the failure they came from
95
+ report -> evalset/report.md
96
+ ```
97
+
98
+ 42 次调用 → 16 个用例。每个用例都记了为什么被挑中、形状从哪来、自检结论:
99
+
100
+ ```markdown
101
+ | Case | Score | In log | Shape from | Self-check | Input |
102
+ | -------- | ----- | ------ | ------------------------------ | ---------------------- | ------------------------ |
103
+ | case-001 | 8.0 | 1 | 日志的短答线(弱参考) | ok | 订单一直显示处理中,已经三天了 |
104
+ | case-007 | 4.5 | 6 | 同一问题的 5 个干净答案 | ok | 你们的退款政策是什么? |
105
+ | case-011 | 2.5 | 4 | 同一问题的 3 个干净答案 | failure_not_reproduced | 修改手机号 |
106
+ ```
107
+
108
+ 再看门禁。改一次提示词,修好一处、弄坏五处:
109
+
110
+ ```console
111
+ $ trace2eval check --baseline runs/baseline.json --current runs/current.json
112
+ | Metric | Baseline | Current | Rule |
113
+ | ----------------------- | -------- | ------- | ----------------------------------- |
114
+ | pass_rate | 1.0000 | 0.6875 | dropped by 0.3125 (allowed 0.0200) |
115
+ | format_compliance_rate | 1.0000 | 0.5000 | dropped by 0.5000 (allowed 0.0200) |
116
+ | fallback_rate | 0 | 0.0625 | rose by 0.0625 (allowed 0.0200) |
117
+
118
+ FAIL: 3 regression(s) detected
119
+ $ echo $?
120
+ 1
121
+ ```
122
+
123
+ 五个失败横跨三种失败模式:
124
+
125
+ ```
126
+ case-001: min_chars <- 答案被截断
127
+ case-002: not_fallback, min_chars <- 直接放弃作答
128
+ case-004: json <- 声明过的 JSON 契约没兑现
129
+ case-007: min_chars
130
+ case-011: min_chars
131
+ ```
132
+
133
+ ---
134
+
135
+ ## 它怎么挑用例
136
+
137
+ ```mermaid
138
+ flowchart LR
139
+ A[JSONL 日志] --> B[解析与归一化]
140
+ B --> C[计算信号得分]
141
+ C --> D[近重复聚类]
142
+ D --> E{分数高于<br/>最低分?}
143
+ E -- 否 --> F[丢弃]
144
+ E -- 是 --> G[构建用例]
145
+ G --> H[cases.jsonl<br/>report.md]
146
+ H --> I[对输出评分]
147
+ I --> J[metrics.json]
148
+ J --> K{与基线对比}
149
+ K -- 有回归 --> L[退出码 1]
150
+ K -- 干净 --> M[退出码 0]
151
+ ```
152
+
153
+ 每条 trace 按信号加权重打分,分数决定谁被提拔。信号和权重就在 `signals.py` 顶部:
154
+
155
+ | 信号 | 权重 | 为什么它算信号 |
156
+ | --- | --- | --- |
157
+ | `negative_feedback` | 3.0 | 用户已经告诉你答错了。 |
158
+ | `empty_output` | 3.0 | 什么都没返回。 |
159
+ | `expected_shape_violated` | 3.0 | 声明过的契约(比如 JSON)没被满足。 |
160
+ | `user_retried` | 2.5 | 他问了两遍,说明第一次没解决。 |
161
+ | `fallback_phrase` | 2.0 | 模型放弃回答,而不是作答。 |
162
+ | `explicit_expectation` | 2.0 | 这条 trace 自己就带了断言。 |
163
+ | `output_much_shorter` | 1.5 | 大概率被截断,和日志中位数比。 |
164
+ | `output_much_longer` | 1.0 | 通常是不受控的长篇大论。 |
165
+ | `slow_response` | 1.0 | 超过整份日志的 p95 延迟。 |
166
+ | `expensive_call` | 1.0 | 超过整份日志的 p95 成本。 |
167
+
168
+ 设计取舍的完整论证在 [`DECISIONS.md`](DECISIONS.md)。
169
+
170
+ ---
171
+
172
+ ## 输入格式
173
+
174
+ 每行一个 JSON 对象,只有 `input` 和 `output` 必填,常见别名(`prompt`/`response`、`question`/`completion` 等)也认。
175
+
176
+ ```json
177
+ {
178
+ "id": "req-0042",
179
+ "input": "账单为什么变多了",
180
+ "output": "账单增加通常是因为套餐在到期后自动续费……",
181
+ "latency_ms": 830,
182
+ "cost_usd": 0.00027,
183
+ "feedback": "negative",
184
+ "retried": true,
185
+ "expect": { "json": true, "required": ["status"], "contains": ["已受理"] }
186
+ }
187
+ ```
188
+
189
+ 格式坏掉的行、没有输入的行会跳过并计数。空的 `output` 不算坏行——它是日志里最有价值的行之一。
190
+
191
+ ---
192
+
193
+ ## 已知边界
194
+
195
+ - **相似度是字符 n-gram**,不含相同字符的改写会被当成两个问题。可用 `--similarity` 换成你自己的实现。
196
+ - **行为型失败抓不住。** 样本日志 16 个用例里有 3 个,失败原因只是"用户又问了一遍",输出本身没毛病——任何对输出文本的确定性检查都抓不到。报告里已单独标出。
197
+ - **比较成本是 O(用例数 × 每簇不同问法数)**,不是 O(日志大小):同一问题问 5000 次只留 1 个待比对指纹。`MAX_DISTINCT_FINGERPRINTS = 512` 是拍的保护值,没压测过。
198
+ - **用例仍是提案**,但会自检:`report.md` 里单列"需要人看一眼"的用例。
199
+
200
+ ---
201
+
202
+ ## 仓库结构
203
+
204
+ ```
205
+ 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/ 提交进仓库的构建产物
216
+ ```
217
+
218
+ ## 开发
219
+
220
+ ```bash
221
+ pip install -e ".[dev]"
222
+ pytest
223
+ ```
224
+
225
+ ## 许可证
226
+
227
+ MIT
@@ -0,0 +1,16 @@
1
+ trace2eval/__init__.py,sha256=U4FZFVE_8PlvsvDu36hNam03SP4eshEV3V6QIHR5cL8,1833
2
+ trace2eval/__main__.py,sha256=3-8jUqJ56SMv1AO_k9zL-n4m2thfa83lqjYyFfglvbE,198
3
+ trace2eval/checks.py,sha256=u2ZuguIZa5pQXnmwtpxNtGIyCHyFOZZuKeDPWgsRuSc,5456
4
+ trace2eval/cli.py,sha256=8qe378FOkzqVo6c8G48c4Il9ypY5c4Lyn5oXjZ6rrWo,10627
5
+ trace2eval/matchers.py,sha256=mvYM7D1hSd7MjlYya97KpVX8BEF58JbmN5-gDmJ7Clg,4079
6
+ trace2eval/report.py,sha256=sMWIZOS9Yx-4X5vF__0LSnP35JI02SYR9Jx8GkdPr8g,9207
7
+ trace2eval/runner.py,sha256=W33eWThu_Ray0xh7vf8TX_K31CE9FRmjlkIlwWa4N74,8216
8
+ trace2eval/schema.py,sha256=AFBDtOVjoe98Dtg8PvGAVXXJxByz54OzVhc5LpKmnYE,7028
9
+ trace2eval/select.py,sha256=8tuMFu6kxE9VP3TCATCyEWCK0m3tK-Zyf6EqP9U-acA,27403
10
+ trace2eval/signals.py,sha256=wUWr8JYHblx3lRz2ugfPb8Mgat3wEB8ck45XYi95B10,6857
11
+ trace2eval_cli-0.2.1.dist-info/licenses/LICENSE,sha256=V3e-V4qN_C-iuTVsa-1pRUrxkrPLWTLpqFuhS896Z_Q,1063
12
+ trace2eval_cli-0.2.1.dist-info/METADATA,sha256=YVbZJT1pYBQlZdYK7jzwVQ2Ntoz4YNCRNE89Tz4kndc,8652
13
+ trace2eval_cli-0.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
14
+ trace2eval_cli-0.2.1.dist-info/entry_points.txt,sha256=KDIbfvCzPM3uUMSFZF1-JJhlyrOZHO3SLut02AFY9gQ,51
15
+ trace2eval_cli-0.2.1.dist-info/top_level.txt,sha256=UC0TN9YklI4pHkU46JR2YZLrBqzkWXkx6njiHyxdSls,11
16
+ trace2eval_cli-0.2.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ trace2eval = trace2eval.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 rfioly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ trace2eval