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 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
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m trace2eval`` as an alternative to the console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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))