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/__init__.py +65 -0
- trace2eval/__main__.py +10 -0
- trace2eval/checks.py +149 -0
- trace2eval/cli.py +307 -0
- trace2eval/matchers.py +101 -0
- trace2eval/report.py +221 -0
- trace2eval/runner.py +251 -0
- trace2eval/schema.py +233 -0
- trace2eval/select.py +709 -0
- trace2eval/signals.py +207 -0
- trace2eval_cli-0.2.1.dist-info/METADATA +227 -0
- trace2eval_cli-0.2.1.dist-info/RECORD +16 -0
- trace2eval_cli-0.2.1.dist-info/WHEEL +5 -0
- trace2eval_cli-0.2.1.dist-info/entry_points.txt +2 -0
- trace2eval_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- trace2eval_cli-0.2.1.dist-info/top_level.txt +1 -0
trace2eval/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""trace2eval -- turn production LLM traces into a regression eval set.
|
|
2
|
+
|
|
3
|
+
The gap this fills: tracing tools record what happened, and eval frameworks
|
|
4
|
+
score cases you already wrote. Nothing connects the two. The step in between --
|
|
5
|
+
deciding which recorded calls deserve to become permanent test cases -- is still
|
|
6
|
+
done by hand, by scrolling through logs.
|
|
7
|
+
|
|
8
|
+
from trace2eval import load_traces, select_cases
|
|
9
|
+
|
|
10
|
+
loaded = load_traces("traces.jsonl")
|
|
11
|
+
result = select_cases(loaded.traces, max_cases=25)
|
|
12
|
+
for case in result.cases:
|
|
13
|
+
print(case["id"], case["score"], case["input"])
|
|
14
|
+
|
|
15
|
+
Everything here is deterministic and offline. No model is called, no API key is
|
|
16
|
+
needed, and the same log always produces the same case set.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .checks import CheckResult, detect_fallback, run_check, run_checks
|
|
20
|
+
from .matchers import char_bigram_jaccard, word_jaccard
|
|
21
|
+
from .runner import RunMetrics, compare_runs, load_outputs, run_cases
|
|
22
|
+
from .schema import Trace, load_traces, parse_trace
|
|
23
|
+
from .select import (
|
|
24
|
+
DEFAULT_DEDUP_THRESHOLD,
|
|
25
|
+
Matcher,
|
|
26
|
+
MatcherSpecError,
|
|
27
|
+
build_case,
|
|
28
|
+
load_matcher,
|
|
29
|
+
overlap_coefficient,
|
|
30
|
+
select_cases,
|
|
31
|
+
shingle_overlap,
|
|
32
|
+
)
|
|
33
|
+
from .signals import DEFAULT_WEIGHTS, Signal, build_context, compute_signals, score
|
|
34
|
+
|
|
35
|
+
__version__ = "0.2.1"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"__version__",
|
|
39
|
+
"CheckResult",
|
|
40
|
+
"DEFAULT_DEDUP_THRESHOLD",
|
|
41
|
+
"DEFAULT_WEIGHTS",
|
|
42
|
+
"Matcher",
|
|
43
|
+
"MatcherSpecError",
|
|
44
|
+
"RunMetrics",
|
|
45
|
+
"Signal",
|
|
46
|
+
"Trace",
|
|
47
|
+
"build_case",
|
|
48
|
+
"build_context",
|
|
49
|
+
"char_bigram_jaccard",
|
|
50
|
+
"compare_runs",
|
|
51
|
+
"compute_signals",
|
|
52
|
+
"detect_fallback",
|
|
53
|
+
"load_matcher",
|
|
54
|
+
"load_outputs",
|
|
55
|
+
"load_traces",
|
|
56
|
+
"overlap_coefficient",
|
|
57
|
+
"parse_trace",
|
|
58
|
+
"run_check",
|
|
59
|
+
"run_checks",
|
|
60
|
+
"run_cases",
|
|
61
|
+
"score",
|
|
62
|
+
"select_cases",
|
|
63
|
+
"shingle_overlap",
|
|
64
|
+
"word_jaccard",
|
|
65
|
+
]
|
trace2eval/__main__.py
ADDED
trace2eval/checks.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Deterministic checks.
|
|
2
|
+
|
|
3
|
+
Every check in this module is a pure function of the output string. No model is
|
|
4
|
+
called, nothing is paid for, and the same input always produces the same verdict.
|
|
5
|
+
|
|
6
|
+
That constraint is a feature, not a shortcut: a regression gate that depends on
|
|
7
|
+
a paid judge model is a gate that people disable the moment it becomes noisy or
|
|
8
|
+
expensive. The checks here are the boring half of evaluation, and the boring
|
|
9
|
+
half is the half that actually runs on every pull request.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
#: Phrases that mean the assistant gave up rather than answered. These are
|
|
20
|
+
#: deliberately broad and match both Chinese and English refusals.
|
|
21
|
+
FALLBACK_PATTERNS: tuple[re.Pattern[str], ...] = tuple(
|
|
22
|
+
re.compile(pattern, re.IGNORECASE)
|
|
23
|
+
for pattern in (
|
|
24
|
+
r"作为一个\s*(AI|人工智能|语言模型)",
|
|
25
|
+
r"作为\s*(AI|人工智能|语言模型)",
|
|
26
|
+
r"我(无法|不能|没有办法)(回答|提供|满足|帮助|处理)",
|
|
27
|
+
r"抱歉[,,]?\s*我(无法|不能|不会)",
|
|
28
|
+
r"对不起[,,]?\s*我(无法|不能|不会)",
|
|
29
|
+
r"暂(时)?无法(回答|提供|处理)",
|
|
30
|
+
r"没有(相关|足够)的?(信息|资料)来(回答|说明)",
|
|
31
|
+
r"as an ai\b",
|
|
32
|
+
r"i (can'?t|cannot|am unable to) (help|assist|answer|provide)",
|
|
33
|
+
r"i'?m sorry,? but i (can'?t|cannot)",
|
|
34
|
+
r"i don'?t have (enough )?(information|access)",
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
SUPPORTED_CHECK_TYPES = (
|
|
39
|
+
"not_fallback",
|
|
40
|
+
"min_chars",
|
|
41
|
+
"max_chars",
|
|
42
|
+
"contains",
|
|
43
|
+
"not_contains",
|
|
44
|
+
"regex",
|
|
45
|
+
"json",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CheckResult:
|
|
51
|
+
"""Outcome of a single check against a single output."""
|
|
52
|
+
|
|
53
|
+
type: str
|
|
54
|
+
passed: bool
|
|
55
|
+
detail: str = ""
|
|
56
|
+
spec: dict[str, Any] | None = None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detect_fallback(output: str) -> str | None:
|
|
60
|
+
"""Return the first fallback phrase found, or ``None`` if the output is clean."""
|
|
61
|
+
for pattern in FALLBACK_PATTERNS:
|
|
62
|
+
match = pattern.search(output)
|
|
63
|
+
if match:
|
|
64
|
+
return match.group(0)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _as_list(value: Any) -> list[str]:
|
|
69
|
+
if value is None:
|
|
70
|
+
return []
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
return [value]
|
|
73
|
+
if isinstance(value, (list, tuple)):
|
|
74
|
+
return [str(item) for item in value]
|
|
75
|
+
return [str(value)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def run_check(check: dict[str, Any], output: str) -> CheckResult:
|
|
79
|
+
"""Run one check spec against one output.
|
|
80
|
+
|
|
81
|
+
Unknown check types fail loudly rather than silently passing -- a test that
|
|
82
|
+
quietly does nothing is worse than no test at all.
|
|
83
|
+
"""
|
|
84
|
+
check_type = str(check.get("type", "")).strip()
|
|
85
|
+
spec = dict(check)
|
|
86
|
+
|
|
87
|
+
if check_type == "not_fallback":
|
|
88
|
+
hit = detect_fallback(output)
|
|
89
|
+
if hit:
|
|
90
|
+
return CheckResult(check_type, False, f"matched fallback phrase {hit!r}", spec)
|
|
91
|
+
return CheckResult(check_type, True, "", spec)
|
|
92
|
+
|
|
93
|
+
if check_type == "min_chars":
|
|
94
|
+
limit = int(check.get("value", 0))
|
|
95
|
+
actual = len(output.strip())
|
|
96
|
+
if actual < limit:
|
|
97
|
+
return CheckResult(check_type, False, f"{actual} chars < {limit}", spec)
|
|
98
|
+
return CheckResult(check_type, True, f"{actual} chars", spec)
|
|
99
|
+
|
|
100
|
+
if check_type == "max_chars":
|
|
101
|
+
limit = int(check.get("value", 0))
|
|
102
|
+
actual = len(output.strip())
|
|
103
|
+
if actual > limit:
|
|
104
|
+
return CheckResult(check_type, False, f"{actual} chars > {limit}", spec)
|
|
105
|
+
return CheckResult(check_type, True, f"{actual} chars", spec)
|
|
106
|
+
|
|
107
|
+
if check_type == "contains":
|
|
108
|
+
needles = _as_list(check.get("value"))
|
|
109
|
+
missing = [needle for needle in needles if needle not in output]
|
|
110
|
+
if missing:
|
|
111
|
+
return CheckResult(check_type, False, f"missing {missing}", spec)
|
|
112
|
+
return CheckResult(check_type, True, "", spec)
|
|
113
|
+
|
|
114
|
+
if check_type == "not_contains":
|
|
115
|
+
needles = _as_list(check.get("value"))
|
|
116
|
+
present = [needle for needle in needles if needle in output]
|
|
117
|
+
if present:
|
|
118
|
+
return CheckResult(check_type, False, f"found forbidden {present}", spec)
|
|
119
|
+
return CheckResult(check_type, True, "", spec)
|
|
120
|
+
|
|
121
|
+
if check_type == "regex":
|
|
122
|
+
pattern = str(check.get("value", ""))
|
|
123
|
+
try:
|
|
124
|
+
compiled = re.compile(pattern)
|
|
125
|
+
except re.error as exc:
|
|
126
|
+
return CheckResult(check_type, False, f"invalid pattern: {exc}", spec)
|
|
127
|
+
if not compiled.search(output):
|
|
128
|
+
return CheckResult(check_type, False, f"no match for {pattern!r}", spec)
|
|
129
|
+
return CheckResult(check_type, True, "", spec)
|
|
130
|
+
|
|
131
|
+
if check_type == "json":
|
|
132
|
+
required = _as_list(check.get("required"))
|
|
133
|
+
try:
|
|
134
|
+
payload = json.loads(output)
|
|
135
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
136
|
+
return CheckResult(check_type, False, f"invalid JSON: {exc}", spec)
|
|
137
|
+
if required:
|
|
138
|
+
if not isinstance(payload, dict):
|
|
139
|
+
return CheckResult(check_type, False, "expected a JSON object", spec)
|
|
140
|
+
missing = [key for key in required if key not in payload]
|
|
141
|
+
if missing:
|
|
142
|
+
return CheckResult(check_type, False, f"missing keys {missing}", spec)
|
|
143
|
+
return CheckResult(check_type, True, "", spec)
|
|
144
|
+
|
|
145
|
+
return CheckResult(check_type, False, f"unsupported check type {check_type!r}", spec)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_checks(checks: list[dict[str, Any]], output: str) -> list[CheckResult]:
|
|
149
|
+
return [run_check(check, output) for check in checks]
|
trace2eval/cli.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
Three commands, in the order you would actually use them:
|
|
4
|
+
|
|
5
|
+
trace2eval build # log -> case set
|
|
6
|
+
trace2eval run # case set + outputs -> metrics
|
|
7
|
+
trace2eval check # metrics vs metrics -> pass/fail
|
|
8
|
+
|
|
9
|
+
``check`` exits non-zero on regression, which is the whole point: it is meant to
|
|
10
|
+
be the last line of a CI job, not a report someone reads on a dashboard an hour
|
|
11
|
+
later.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Sequence
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .report import build_report, render_metrics, render_violations
|
|
24
|
+
from .runner import (
|
|
25
|
+
OutputFormatError,
|
|
26
|
+
RunMetrics,
|
|
27
|
+
compare_runs,
|
|
28
|
+
load_outputs,
|
|
29
|
+
run_cases,
|
|
30
|
+
)
|
|
31
|
+
from .schema import TraceFormatError, load_traces
|
|
32
|
+
from .select import (
|
|
33
|
+
DEFAULT_DEDUP_THRESHOLD,
|
|
34
|
+
MatcherSpecError,
|
|
35
|
+
load_matcher,
|
|
36
|
+
select_cases,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
EXIT_OK = 0
|
|
40
|
+
EXIT_GATE_FAILED = 1
|
|
41
|
+
EXIT_BAD_INPUT = 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write_text(path: Path, content: str) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
# newline="\n" disables platform newline translation. Without it, Windows
|
|
47
|
+
# writes CRLF and Linux writes LF, and the committed case set stops being
|
|
48
|
+
# byte-identical across platforms -- which is exactly what the CI job asserts.
|
|
49
|
+
path.write_text(content, encoding="utf-8", newline="\n")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _write_jsonl(path: Path, rows: Sequence[dict[str, Any]]) -> None:
|
|
53
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with path.open("w", encoding="utf-8", newline="\n") as handle:
|
|
55
|
+
for row in rows:
|
|
56
|
+
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _read_jsonl(path: Path) -> list[dict[str, Any]]:
|
|
60
|
+
rows: list[dict[str, Any]] = []
|
|
61
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
62
|
+
for line in handle:
|
|
63
|
+
stripped = line.strip()
|
|
64
|
+
if stripped:
|
|
65
|
+
rows.append(json.loads(stripped))
|
|
66
|
+
return rows
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
70
|
+
try:
|
|
71
|
+
loaded = load_traces(args.traces)
|
|
72
|
+
except TraceFormatError as exc:
|
|
73
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
74
|
+
return EXIT_BAD_INPUT
|
|
75
|
+
|
|
76
|
+
if not loaded.traces:
|
|
77
|
+
print("error: no usable traces found in the input file", file=sys.stderr)
|
|
78
|
+
if loaded.skipped:
|
|
79
|
+
for line_no, reason in loaded.skipped[:5]:
|
|
80
|
+
print(f" line {line_no}: {reason}", file=sys.stderr)
|
|
81
|
+
return EXIT_BAD_INPUT
|
|
82
|
+
|
|
83
|
+
matcher = None
|
|
84
|
+
if args.similarity:
|
|
85
|
+
try:
|
|
86
|
+
matcher = load_matcher(args.similarity)
|
|
87
|
+
except MatcherSpecError as exc:
|
|
88
|
+
print(f"error: --similarity {exc}", file=sys.stderr)
|
|
89
|
+
return EXIT_BAD_INPUT
|
|
90
|
+
|
|
91
|
+
result = select_cases(
|
|
92
|
+
loaded.traces,
|
|
93
|
+
max_cases=args.max_cases,
|
|
94
|
+
min_score=args.min_score,
|
|
95
|
+
dedup_threshold=args.dedup_threshold,
|
|
96
|
+
matcher=matcher,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
out_dir = Path(args.out)
|
|
100
|
+
_write_jsonl(out_dir / "cases.jsonl", result.cases)
|
|
101
|
+
# as_posix() so the report is byte-identical on Windows and Linux, which is
|
|
102
|
+
# what lets CI diff the committed case set against a fresh build.
|
|
103
|
+
_write_text(out_dir / "report.md", build_report(result, Path(args.traces).as_posix()))
|
|
104
|
+
|
|
105
|
+
stats = result.stats()
|
|
106
|
+
print(f"read {stats['total_traces']} traces from {args.traces}")
|
|
107
|
+
if loaded.skipped:
|
|
108
|
+
print(f" skipped {loaded.skipped_count} unreadable line(s)")
|
|
109
|
+
if matcher is not None:
|
|
110
|
+
print(
|
|
111
|
+
" custom similarity matcher: the n-gram index is bypassed, "
|
|
112
|
+
"so this run compares against every cluster"
|
|
113
|
+
)
|
|
114
|
+
print(f"generated {stats['cases']} cases -> {out_dir / 'cases.jsonl'}")
|
|
115
|
+
print(f" {stats['dropped_as_duplicate']} collapsed as near-duplicates")
|
|
116
|
+
print(f" {stats['dropped_below_min_score']} below the minimum score")
|
|
117
|
+
print(f" {stats['dropped_beyond_limit']} beyond the case limit")
|
|
118
|
+
if stats["cases_whose_reference_fails"]:
|
|
119
|
+
print(
|
|
120
|
+
f" {stats['cases_whose_reference_fails']} trusted reference(s) fail their "
|
|
121
|
+
f"own checks -- look at these before committing the set"
|
|
122
|
+
)
|
|
123
|
+
if stats["cases_with_weak_checks"]:
|
|
124
|
+
print(
|
|
125
|
+
f" {stats['cases_with_weak_checks']} case(s) carry checks that cannot "
|
|
126
|
+
f"detect the failure they came from"
|
|
127
|
+
)
|
|
128
|
+
print(f"report -> {out_dir / 'report.md'}")
|
|
129
|
+
return EXIT_OK
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
133
|
+
cases_path = Path(args.cases)
|
|
134
|
+
if not cases_path.exists():
|
|
135
|
+
print(f"error: case file not found: {cases_path}", file=sys.stderr)
|
|
136
|
+
return EXIT_BAD_INPUT
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
cases = _read_jsonl(cases_path)
|
|
140
|
+
outputs = load_outputs(args.outputs)
|
|
141
|
+
except (OutputFormatError, json.JSONDecodeError) as exc:
|
|
142
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
143
|
+
return EXIT_BAD_INPUT
|
|
144
|
+
|
|
145
|
+
metrics = run_cases(cases, outputs)
|
|
146
|
+
|
|
147
|
+
if args.out:
|
|
148
|
+
payload = {"version": __version__, "metrics": metrics.to_dict()}
|
|
149
|
+
_write_text(Path(args.out), json.dumps(payload, indent=2, ensure_ascii=False) + "\n")
|
|
150
|
+
|
|
151
|
+
print(render_metrics(metrics))
|
|
152
|
+
if args.out:
|
|
153
|
+
print(f"\nwritten -> {args.out}")
|
|
154
|
+
|
|
155
|
+
if metrics.failures and args.show_failures:
|
|
156
|
+
print("\nFailures:")
|
|
157
|
+
for failure in metrics.failures[:20]:
|
|
158
|
+
detail = ", ".join(failure["failed_checks"]) or failure["reason"]
|
|
159
|
+
print(f" {failure['case_id']}: {detail}")
|
|
160
|
+
if len(metrics.failures) > 20:
|
|
161
|
+
print(f" ... and {len(metrics.failures) - 20} more")
|
|
162
|
+
|
|
163
|
+
if args.min_pass_rate is not None and metrics.pass_rate < args.min_pass_rate:
|
|
164
|
+
print(
|
|
165
|
+
f"\nFAIL: pass rate {metrics.pass_rate:.2%} is below the required "
|
|
166
|
+
f"{args.min_pass_rate:.2%}",
|
|
167
|
+
file=sys.stderr,
|
|
168
|
+
)
|
|
169
|
+
return EXIT_GATE_FAILED
|
|
170
|
+
|
|
171
|
+
return EXIT_OK
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
175
|
+
try:
|
|
176
|
+
baseline_payload = json.loads(Path(args.baseline).read_text(encoding="utf-8"))
|
|
177
|
+
current_payload = json.loads(Path(args.current).read_text(encoding="utf-8"))
|
|
178
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
179
|
+
print(f"error: cannot read metrics file: {exc}", file=sys.stderr)
|
|
180
|
+
return EXIT_BAD_INPUT
|
|
181
|
+
|
|
182
|
+
baseline = RunMetrics.from_dict(baseline_payload.get("metrics", baseline_payload))
|
|
183
|
+
current = RunMetrics.from_dict(current_payload.get("metrics", current_payload))
|
|
184
|
+
|
|
185
|
+
print("baseline")
|
|
186
|
+
print(render_metrics(baseline))
|
|
187
|
+
print("\ncurrent")
|
|
188
|
+
print(render_metrics(current))
|
|
189
|
+
|
|
190
|
+
violations = compare_runs(baseline, current, tolerance_scale=args.tolerance_scale)
|
|
191
|
+
|
|
192
|
+
print("\nregressions")
|
|
193
|
+
print(render_violations(violations))
|
|
194
|
+
|
|
195
|
+
if violations:
|
|
196
|
+
print(f"\nFAIL: {len(violations)} regression(s) detected", file=sys.stderr)
|
|
197
|
+
return EXIT_GATE_FAILED
|
|
198
|
+
|
|
199
|
+
print("\nPASS: no regressions detected")
|
|
200
|
+
return EXIT_OK
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
204
|
+
parser = argparse.ArgumentParser(
|
|
205
|
+
prog="trace2eval",
|
|
206
|
+
description=(
|
|
207
|
+
"Turn production LLM traces into a regression eval set. "
|
|
208
|
+
"Zero dependencies, no API keys, fully offline."
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument("--version", action="version", version=f"trace2eval {__version__}")
|
|
212
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
213
|
+
|
|
214
|
+
build = subparsers.add_parser(
|
|
215
|
+
"build",
|
|
216
|
+
help="turn a JSONL trace log into a regression eval set",
|
|
217
|
+
description=(
|
|
218
|
+
"Score every trace, collapse near-duplicates, and emit the highest-value "
|
|
219
|
+
"subset as a case set."
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
build.add_argument("traces", help="input JSONL trace log")
|
|
223
|
+
build.add_argument("-o", "--out", default="evalset", help="output directory (default: evalset)")
|
|
224
|
+
build.add_argument("--max-cases", type=int, default=50, help="cap on generated cases")
|
|
225
|
+
build.add_argument(
|
|
226
|
+
"--min-score",
|
|
227
|
+
type=float,
|
|
228
|
+
default=1.0,
|
|
229
|
+
help="minimum signal weight for a trace to become a case",
|
|
230
|
+
)
|
|
231
|
+
build.add_argument(
|
|
232
|
+
"--dedup-threshold",
|
|
233
|
+
type=float,
|
|
234
|
+
default=DEFAULT_DEDUP_THRESHOLD,
|
|
235
|
+
help=(
|
|
236
|
+
"overlap similarity above which two inputs are treated as the same "
|
|
237
|
+
f"question (default: {DEFAULT_DEDUP_THRESHOLD})"
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
build.add_argument(
|
|
241
|
+
"--similarity",
|
|
242
|
+
default=None,
|
|
243
|
+
metavar="MODULE:FUNCTION",
|
|
244
|
+
help=(
|
|
245
|
+
"swap in your own matcher, e.g. 'my_embeddings:cosine'. Must take two "
|
|
246
|
+
"strings and return a score in [0, 1]. Disables the n-gram index, so "
|
|
247
|
+
"clustering gets slower -- see trace2eval.matchers."
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
build.set_defaults(func=cmd_build)
|
|
251
|
+
|
|
252
|
+
run = subparsers.add_parser(
|
|
253
|
+
"run",
|
|
254
|
+
help="score a batch of outputs against a case set",
|
|
255
|
+
description="Apply the deterministic checks in a case set to a batch of outputs.",
|
|
256
|
+
)
|
|
257
|
+
run.add_argument("--cases", required=True, help="cases.jsonl produced by build")
|
|
258
|
+
run.add_argument("--outputs", required=True, help="JSONL of {id, output} records to score")
|
|
259
|
+
run.add_argument("-o", "--out", help="write metrics JSON to this path")
|
|
260
|
+
run.add_argument(
|
|
261
|
+
"--min-pass-rate",
|
|
262
|
+
type=float,
|
|
263
|
+
default=None,
|
|
264
|
+
help="exit non-zero if the pass rate falls below this value",
|
|
265
|
+
)
|
|
266
|
+
run.add_argument(
|
|
267
|
+
"--show-failures",
|
|
268
|
+
action="store_true",
|
|
269
|
+
help="list which cases failed and on which checks",
|
|
270
|
+
)
|
|
271
|
+
run.set_defaults(func=cmd_run)
|
|
272
|
+
|
|
273
|
+
check = subparsers.add_parser(
|
|
274
|
+
"check",
|
|
275
|
+
help="compare two metrics files and fail on regression",
|
|
276
|
+
description=(
|
|
277
|
+
"Compare a baseline metrics file against a current one. Exits 1 if any "
|
|
278
|
+
"tracked metric moved the wrong way beyond its tolerance."
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
check.add_argument("--baseline", required=True, help="baseline metrics JSON")
|
|
282
|
+
check.add_argument("--current", required=True, help="current metrics JSON")
|
|
283
|
+
check.add_argument(
|
|
284
|
+
"--tolerance-scale",
|
|
285
|
+
type=float,
|
|
286
|
+
default=1.0,
|
|
287
|
+
help="multiply every tolerance; use <1 for a stricter gate",
|
|
288
|
+
)
|
|
289
|
+
check.set_defaults(func=cmd_check)
|
|
290
|
+
|
|
291
|
+
return parser
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
295
|
+
for stream in (sys.stdout, sys.stderr):
|
|
296
|
+
try:
|
|
297
|
+
stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
|
298
|
+
except (AttributeError, OSError):
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
parser = build_parser()
|
|
302
|
+
args = parser.parse_args(argv)
|
|
303
|
+
return args.func(args)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
raise SystemExit(main())
|
trace2eval/matchers.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Alternative similarity matchers.
|
|
2
|
+
|
|
3
|
+
The default matcher lives in :mod:`trace2eval.select` and works on character
|
|
4
|
+
n-grams, because that is the only thing that handles Chinese and English through
|
|
5
|
+
one code path without a dependency. Its blind spot is fixed and known: a
|
|
6
|
+
paraphrase that shares no characters with the original scores zero.
|
|
7
|
+
|
|
8
|
+
Anything with the signature ``(str, str) -> float`` returning a value in
|
|
9
|
+
``[0, 1]`` can be swapped in:
|
|
10
|
+
|
|
11
|
+
trace2eval build traces.jsonl --similarity my_embeddings:cosine
|
|
12
|
+
|
|
13
|
+
That is the intended answer to the paraphrase problem -- bring whatever model you
|
|
14
|
+
already have rather than making this package depend on one. This module ships two
|
|
15
|
+
dependency-free matchers: useful in their own right, and working examples of the
|
|
16
|
+
hook if you want to write your own.
|
|
17
|
+
|
|
18
|
+
Be aware of the cost when you swap: a custom matcher disables the n-gram inverted
|
|
19
|
+
index used to shortlist clusters, because a lexical shortlist would cap exactly
|
|
20
|
+
the recall you brought the custom matcher in for. Clustering then compares against
|
|
21
|
+
every cluster, so it gets noticeably slower on a large log.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
|
|
28
|
+
#: Latin/digit words. Split on everything else.
|
|
29
|
+
_WORD_PATTERN = re.compile(r"[a-z0-9]+")
|
|
30
|
+
|
|
31
|
+
#: CJK ideographs, treated as individual tokens because there are no word
|
|
32
|
+
#: boundaries to split on.
|
|
33
|
+
_CJK_PATTERN = re.compile(r"[\u4e00-\u9fff]")
|
|
34
|
+
|
|
35
|
+
#: Below this many shared tokens the two inputs are unrelated as far as these
|
|
36
|
+
#: matchers are concerned. Guards the same failure mode ``MIN_SHARED_SHINGLES``
|
|
37
|
+
#: guards: two short inputs sharing one token scoring an overlap of 1.0.
|
|
38
|
+
MIN_SHARED_TOKENS = 2
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tokenise(text: str) -> set[str]:
|
|
42
|
+
"""Split into a token set: words for Latin script, characters for CJK.
|
|
43
|
+
|
|
44
|
+
Mixed text gets the union, so an English error message embedded in a Chinese
|
|
45
|
+
question still contributes its words.
|
|
46
|
+
"""
|
|
47
|
+
lowered = text.lower()
|
|
48
|
+
tokens = set(_WORD_PATTERN.findall(lowered))
|
|
49
|
+
tokens |= set(_CJK_PATTERN.findall(lowered))
|
|
50
|
+
return tokens
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _overlap(left: set[str], right: set[str]) -> float:
|
|
54
|
+
if not left or not right:
|
|
55
|
+
return 0.0
|
|
56
|
+
shared = len(left & right)
|
|
57
|
+
if shared < MIN_SHARED_TOKENS:
|
|
58
|
+
return 0.0
|
|
59
|
+
return shared / min(len(left), len(right))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def word_jaccard(left: str, right: str) -> float:
|
|
63
|
+
"""Token-level overlap coefficient.
|
|
64
|
+
|
|
65
|
+
Trades the default matcher's precision for word-order robustness. Prefer it
|
|
66
|
+
when inputs are whole sentences in a whitespace-delimited language.
|
|
67
|
+
|
|
68
|
+
Two honest caveats:
|
|
69
|
+
|
|
70
|
+
- For Chinese it is *coarser* than the default. Character bigrams keep some
|
|
71
|
+
ordering information; single characters do not, so genuinely different
|
|
72
|
+
questions that share most of their characters score higher here. Measured
|
|
73
|
+
on the sample log: ``支持哪些登录方式`` vs ``支持哪些支付方式`` scores 0.571
|
|
74
|
+
under the default matcher and 0.857 under this one. If you use this on
|
|
75
|
+
Chinese traffic, raise ``--dedup-threshold``.
|
|
76
|
+
- It is a matcher for experimenting with the hook, not a recommendation. The
|
|
77
|
+
real upgrade for paraphrase recall is embeddings, which this package does
|
|
78
|
+
not bundle on purpose.
|
|
79
|
+
"""
|
|
80
|
+
return _overlap(tokenise(left), tokenise(right))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def char_bigram_jaccard(left: str, right: str) -> float:
|
|
84
|
+
"""Plain Jaccard over character bigrams.
|
|
85
|
+
|
|
86
|
+
Here to be measured against, not used. ``tests/test_dedup.py`` asserts that it
|
|
87
|
+
ranks two different questions above two phrasings of the same question on the
|
|
88
|
+
sample log, which is the observation that ruled Jaccard out for the built-in
|
|
89
|
+
matcher. Useful if you want to reproduce that measurement on your own data.
|
|
90
|
+
"""
|
|
91
|
+
from .select import SHINGLE_SIZE, jaccard, normalise
|
|
92
|
+
|
|
93
|
+
def grams(text: str) -> set[str]:
|
|
94
|
+
folded = normalise(text)
|
|
95
|
+
if not folded:
|
|
96
|
+
return set()
|
|
97
|
+
if len(folded) <= SHINGLE_SIZE:
|
|
98
|
+
return {folded}
|
|
99
|
+
return {folded[i : i + SHINGLE_SIZE] for i in range(len(folded) - SHINGLE_SIZE + 1)}
|
|
100
|
+
|
|
101
|
+
return jaccard(grams(left), grams(right))
|