thinkless 0.2.0__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.
- thinkless/__init__.py +63 -0
- thinkless/__main__.py +3 -0
- thinkless/_hub.py +84 -0
- thinkless/_json.py +33 -0
- thinkless/_version.py +1 -0
- thinkless/bench/__init__.py +22 -0
- thinkless/bench/intents.py +406 -0
- thinkless/bench/intents_report.py +87 -0
- thinkless/bench/metrics.py +115 -0
- thinkless/bench/report.py +123 -0
- thinkless/bench/support.py +342 -0
- thinkless/cli/__init__.py +1 -0
- thinkless/cli/main.py +595 -0
- thinkless/confidence.py +62 -0
- thinkless/data/pricing.toml +56 -0
- thinkless/data/viewer.html +411 -0
- thinkless/decision.py +185 -0
- thinkless/demo/__init__.py +1 -0
- thinkless/demo/support/__init__.py +19 -0
- thinkless/demo/support/agent.py +379 -0
- thinkless/demo/support/data/calibration.jsonl +48 -0
- thinkless/demo/support/data/scenarios.jsonl +53 -0
- thinkless/demo/support/data/world.json +78 -0
- thinkless/demo/support/questions.py +118 -0
- thinkless/demo/support/stack.py +93 -0
- thinkless/demo/support/world.py +165 -0
- thinkless/engine.py +605 -0
- thinkless/errors.py +13 -0
- thinkless/llm/__init__.py +47 -0
- thinkless/llm/anthropic.py +126 -0
- thinkless/llm/base.py +82 -0
- thinkless/llm/factory.py +91 -0
- thinkless/llm/local.py +168 -0
- thinkless/llm/openai_compat.py +165 -0
- thinkless/llm/openrouter.py +78 -0
- thinkless/llm/scripted.py +75 -0
- thinkless/logs.py +68 -0
- thinkless/pricing.py +100 -0
- thinkless/providers/__init__.py +43 -0
- thinkless/providers/base.py +108 -0
- thinkless/providers/gliner.py +194 -0
- thinkless/providers/hf.py +161 -0
- thinkless/providers/laya.py +121 -0
- thinkless/providers/llm.py +305 -0
- thinkless/providers/rules.py +181 -0
- thinkless/providers/systemone.py +147 -0
- thinkless/providers/wire.py +108 -0
- thinkless/py.typed +0 -0
- thinkless/questions.py +246 -0
- thinkless/settings.py +93 -0
- thinkless/tracing/__init__.py +23 -0
- thinkless/tracing/console.py +169 -0
- thinkless/tracing/otel.py +139 -0
- thinkless/tracing/sinks.py +142 -0
- thinkless/tracing/span.py +98 -0
- thinkless/tracing/summary.py +143 -0
- thinkless/tracing/tracer.py +159 -0
- thinkless/tracing/viewer.py +87 -0
- thinkless-0.2.0.dist-info/METADATA +357 -0
- thinkless-0.2.0.dist-info/RECORD +63 -0
- thinkless-0.2.0.dist-info/WHEEL +4 -0
- thinkless-0.2.0.dist-info/entry_points.txt +2 -0
- thinkless-0.2.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Reports for the intents benchmark."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from .intents import CascadePoint, IntentsBenchmark
|
|
8
|
+
|
|
9
|
+
__all__ = ["cascade_highlights", "intents_markdown", "intents_table"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _pct(value: float | None) -> str:
|
|
13
|
+
return "n/a" if value is None else f"{value * 100:.1f}%"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cascade_highlights(result: IntentsBenchmark) -> list[CascadePoint]:
|
|
17
|
+
"""A few informative cascade operating points, lowest threshold first."""
|
|
18
|
+
wanted = (0.5, 0.7, 0.8, 0.9, 0.95)
|
|
19
|
+
return [p for p in result.cascade if p.threshold in wanted]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def intents_table(result: IntentsBenchmark) -> Table:
|
|
23
|
+
table = Table(title=f"{result.dataset_title}: {result.examples} examples")
|
|
24
|
+
for column in (
|
|
25
|
+
"provider",
|
|
26
|
+
"accuracy",
|
|
27
|
+
"ECE",
|
|
28
|
+
"p50 latency",
|
|
29
|
+
"p95 latency",
|
|
30
|
+
f"threshold for {result.target_accuracy:.0%}",
|
|
31
|
+
):
|
|
32
|
+
table.add_column(column, justify="right")
|
|
33
|
+
for name, report in result.providers.items():
|
|
34
|
+
rec = report.recommended
|
|
35
|
+
table.add_row(
|
|
36
|
+
name,
|
|
37
|
+
_pct(report.accuracy),
|
|
38
|
+
"n/a" if report.ece is None else f"{report.ece:.3f}",
|
|
39
|
+
f"{report.latency_p50_ms:.1f} ms",
|
|
40
|
+
f"{report.latency_p95_ms:.1f} ms",
|
|
41
|
+
f"{rec.threshold:.2f} (covers {_pct(rec.coverage)})" if rec else "not reached",
|
|
42
|
+
)
|
|
43
|
+
return table
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def intents_markdown(result: IntentsBenchmark) -> str:
|
|
47
|
+
lines = [
|
|
48
|
+
f"# Intent benchmark: {result.dataset}",
|
|
49
|
+
"",
|
|
50
|
+
f"- Dataset: {result.dataset_title} ([source]({result.source}))",
|
|
51
|
+
f"- Sample: {result.examples} test examples, seed {result.seed}, {result.labels} labels",
|
|
52
|
+
f"- LLM: `{result.llm}`",
|
|
53
|
+
f"- Environment: {result.environment.get('device', 'cpu')}, torch {result.environment.get('torch', 'n/a')}",
|
|
54
|
+
f"- ThinkLess {result.thinkless_version}, run {result.created_at}",
|
|
55
|
+
"",
|
|
56
|
+
"## Providers",
|
|
57
|
+
"",
|
|
58
|
+
f"| Provider | Accuracy | ECE | p50 latency | p95 latency | Cost per 1k | Threshold for {result.target_accuracy:.0%} accuracy |",
|
|
59
|
+
"|---|---:|---:|---:|---:|---:|---|",
|
|
60
|
+
]
|
|
61
|
+
for name, report in result.providers.items():
|
|
62
|
+
rec = report.recommended
|
|
63
|
+
lines.append(
|
|
64
|
+
f"| `{name}` | {_pct(report.accuracy)} | {'n/a' if report.ece is None else f'{report.ece:.3f}'} | "
|
|
65
|
+
f"{report.latency_p50_ms:.1f} ms | {report.latency_p95_ms:.1f} ms | ${report.cost_per_1k:.4f} | "
|
|
66
|
+
+ (f"{rec.threshold:.2f}, answers {_pct(rec.coverage)} alone" if rec else "not reached")
|
|
67
|
+
+ " |"
|
|
68
|
+
)
|
|
69
|
+
if result.cascade:
|
|
70
|
+
lines += [
|
|
71
|
+
"",
|
|
72
|
+
"## Cascade",
|
|
73
|
+
"",
|
|
74
|
+
"Small models are tried in order; an answer below the threshold goes to the next one, and "
|
|
75
|
+
"finally to the LLM. Accuracy is for the whole cascade.",
|
|
76
|
+
"",
|
|
77
|
+
"| Threshold | Accuracy | Calls reaching the LLM | Mean latency | Cost per 1k | Answered by |",
|
|
78
|
+
"|---:|---:|---:|---:|---:|---|",
|
|
79
|
+
]
|
|
80
|
+
for point in result.cascade:
|
|
81
|
+
answered = ", ".join(f"{k} {_pct(v)}" for k, v in point.answered_by.items() if v)
|
|
82
|
+
label = "never accept" if point.threshold > 1 else f"{point.threshold:.2f}"
|
|
83
|
+
lines.append(
|
|
84
|
+
f"| {label} | {_pct(point.accuracy)} | {_pct(point.llm_share)} | {point.mean_latency_ms:.0f} ms | "
|
|
85
|
+
f"${point.cost_per_1k:.4f} | {answered} |"
|
|
86
|
+
)
|
|
87
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Metrics shared by the benchmarks and the calibration tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ThresholdPoint",
|
|
12
|
+
"expected_calibration_error",
|
|
13
|
+
"percentile",
|
|
14
|
+
"recommend_threshold",
|
|
15
|
+
"threshold_sweep",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def percentile(values: Sequence[float], q: float) -> float:
|
|
20
|
+
"""Linear-interpolated percentile, ``q`` in [0, 100]."""
|
|
21
|
+
if not values:
|
|
22
|
+
return 0.0
|
|
23
|
+
ordered = sorted(values)
|
|
24
|
+
if len(ordered) == 1:
|
|
25
|
+
return float(ordered[0])
|
|
26
|
+
rank = (len(ordered) - 1) * q / 100.0
|
|
27
|
+
low, high = math.floor(rank), math.ceil(rank)
|
|
28
|
+
if low == high:
|
|
29
|
+
return float(ordered[low])
|
|
30
|
+
return float(ordered[low] + (ordered[high] - ordered[low]) * (rank - low))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def expected_calibration_error(
|
|
34
|
+
confidences: Sequence[float], correct: Sequence[bool], bins: int = 10
|
|
35
|
+
) -> float:
|
|
36
|
+
"""ECE: the gap between confidence and accuracy, averaged over equal-width bins."""
|
|
37
|
+
if not confidences:
|
|
38
|
+
return 0.0
|
|
39
|
+
total = len(confidences)
|
|
40
|
+
error = 0.0
|
|
41
|
+
for index in range(bins):
|
|
42
|
+
low, high = index / bins, (index + 1) / bins
|
|
43
|
+
members = [
|
|
44
|
+
i
|
|
45
|
+
for i, c in enumerate(confidences)
|
|
46
|
+
if (low <= c < high) or (index == bins - 1 and c == 1.0)
|
|
47
|
+
]
|
|
48
|
+
if not members:
|
|
49
|
+
continue
|
|
50
|
+
accuracy = sum(correct[i] for i in members) / len(members)
|
|
51
|
+
confidence = sum(confidences[i] for i in members) / len(members)
|
|
52
|
+
error += abs(accuracy - confidence) * len(members) / total
|
|
53
|
+
return error
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ThresholdPoint(BaseModel):
|
|
57
|
+
"""What happens at one threshold.
|
|
58
|
+
|
|
59
|
+
Attributes:
|
|
60
|
+
threshold: Minimum confidence to accept the model's answer.
|
|
61
|
+
coverage: Share of inputs the model answers on its own.
|
|
62
|
+
accuracy: Accuracy on the inputs it answers.
|
|
63
|
+
cascade_accuracy: Accuracy of the whole cascade when the rest go to
|
|
64
|
+
the fallback (only when fallback predictions are supplied).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
threshold: float
|
|
68
|
+
coverage: float
|
|
69
|
+
accuracy: float | None
|
|
70
|
+
cascade_accuracy: float | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def threshold_sweep(
|
|
74
|
+
confidences: Sequence[float],
|
|
75
|
+
correct: Sequence[bool],
|
|
76
|
+
fallback_correct: Sequence[bool] | None = None,
|
|
77
|
+
thresholds: Sequence[float] | None = None,
|
|
78
|
+
) -> list[ThresholdPoint]:
|
|
79
|
+
"""Coverage and accuracy of a model across thresholds.
|
|
80
|
+
|
|
81
|
+
With ``fallback_correct``, also reports the accuracy of a cascade that
|
|
82
|
+
sends every input below threshold to the fallback.
|
|
83
|
+
"""
|
|
84
|
+
grid = thresholds or [round(x * 0.05, 2) for x in range(0, 20)] + [0.97, 0.99]
|
|
85
|
+
points = []
|
|
86
|
+
n = len(confidences)
|
|
87
|
+
for threshold in grid:
|
|
88
|
+
accepted = [i for i in range(n) if confidences[i] >= threshold]
|
|
89
|
+
coverage = len(accepted) / n if n else 0.0
|
|
90
|
+
accuracy = sum(correct[i] for i in accepted) / len(accepted) if accepted else None
|
|
91
|
+
cascade = None
|
|
92
|
+
if fallback_correct is not None and n:
|
|
93
|
+
hits = sum(
|
|
94
|
+
correct[i] if confidences[i] >= threshold else fallback_correct[i] for i in range(n)
|
|
95
|
+
)
|
|
96
|
+
cascade = hits / n
|
|
97
|
+
points.append(
|
|
98
|
+
ThresholdPoint(
|
|
99
|
+
threshold=threshold, coverage=coverage, accuracy=accuracy, cascade_accuracy=cascade
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return points
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def recommend_threshold(
|
|
106
|
+
points: Sequence[ThresholdPoint], target_accuracy: float
|
|
107
|
+
) -> ThresholdPoint | None:
|
|
108
|
+
"""The lowest threshold whose accepted answers reach ``target_accuracy``.
|
|
109
|
+
|
|
110
|
+
Lowest threshold means the highest coverage, so the fewest escalations.
|
|
111
|
+
"""
|
|
112
|
+
for point in sorted(points, key=lambda p: p.threshold):
|
|
113
|
+
if point.accuracy is not None and point.accuracy >= target_accuracy and point.coverage > 0:
|
|
114
|
+
return point
|
|
115
|
+
return None
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Human-readable reports for benchmark results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from .support import ModeReport, SupportBenchmark
|
|
11
|
+
|
|
12
|
+
__all__ = ["support_markdown", "support_table"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _pct(value: float | None) -> str:
|
|
16
|
+
return "n/a" if value is None else f"{value * 100:.1f}%"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ms(value: float) -> str:
|
|
20
|
+
return f"{value / 1000:.2f} s" if value >= 1000 else f"{value:.0f} ms"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ROWS: Sequence[tuple[str, Any]] = (
|
|
24
|
+
("Task success (correct action)", lambda m: _pct(m.task_success)),
|
|
25
|
+
("Intent accuracy", lambda m: _pct(m.intent_accuracy)),
|
|
26
|
+
("Order id accuracy", lambda m: _pct(m.order_id_accuracy)),
|
|
27
|
+
("LLM calls per ticket", lambda m: f"{m.llm_calls:.2f}"),
|
|
28
|
+
(" for decisions", lambda m: f"{m.decision_llm_calls:.2f}"),
|
|
29
|
+
(" for replies", lambda m: f"{m.generation_calls:.2f}"),
|
|
30
|
+
("Decision time per ticket", lambda m: _ms(m.decision_ms)),
|
|
31
|
+
("Reply generation time per ticket", lambda m: _ms(m.generation_ms)),
|
|
32
|
+
("End-to-end latency p50", lambda m: _ms(m.latency_p50_ms)),
|
|
33
|
+
("End-to-end latency p95", lambda m: _ms(m.latency_p95_ms)),
|
|
34
|
+
(
|
|
35
|
+
"LLM tokens per ticket (in / out)",
|
|
36
|
+
lambda m: f"{m.llm_input_tokens:.0f} / {m.llm_output_tokens:.0f}",
|
|
37
|
+
),
|
|
38
|
+
("LLM tokens spent on decisions", lambda m: f"{m.decision_llm_tokens:.0f}"),
|
|
39
|
+
(
|
|
40
|
+
"Billed cost per 1k tickets",
|
|
41
|
+
lambda m: f"${m.cost_usd * 1000:.3f}" if m.cost_usd else "n/a (local)",
|
|
42
|
+
),
|
|
43
|
+
("Reference cost per 1k tickets", lambda m: f"${m.reference_cost_per_1k_tickets:.2f}"),
|
|
44
|
+
("Replies passing the grounding check", lambda m: _pct(m.grounded_rate)),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _planes(mode: ModeReport) -> str:
|
|
49
|
+
total = sum(mode.decisions_by_plane.values()) or 1
|
|
50
|
+
order = ("rule", "model", "llm", "unresolved")
|
|
51
|
+
return ", ".join(
|
|
52
|
+
f"{plane} {mode.decisions_by_plane.get(plane, 0) / total * 100:.0f}%"
|
|
53
|
+
for plane in order
|
|
54
|
+
if mode.decisions_by_plane.get(plane)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def support_table(bench: SupportBenchmark) -> Table:
|
|
59
|
+
"""A Rich table comparing modes side by side."""
|
|
60
|
+
table = Table(
|
|
61
|
+
title=f"Support benchmark ({bench.modes[next(iter(bench.modes))].tickets} tickets)"
|
|
62
|
+
)
|
|
63
|
+
table.add_column("metric", style="dim")
|
|
64
|
+
for name in bench.modes:
|
|
65
|
+
table.add_column(name, justify="right")
|
|
66
|
+
for label, fn in ROWS:
|
|
67
|
+
table.add_row(label, *(fn(mode) for mode in bench.modes.values()))
|
|
68
|
+
table.add_row("Decisions by plane", *(_planes(m) for m in bench.modes.values()))
|
|
69
|
+
return table
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def support_markdown(bench: SupportBenchmark) -> str:
|
|
73
|
+
"""The benchmark as a Markdown report."""
|
|
74
|
+
modes = list(bench.modes.values())
|
|
75
|
+
lines = [
|
|
76
|
+
"# Support benchmark",
|
|
77
|
+
"",
|
|
78
|
+
f"- ThinkLess {bench.thinkless_version}, run {bench.created_at}",
|
|
79
|
+
f"- Reasoning model: `{bench.reasoning_model}`",
|
|
80
|
+
f"- Engine threshold: {bench.threshold}",
|
|
81
|
+
f"- Reference price for cost estimates: `{bench.reference_price}`",
|
|
82
|
+
f"- Environment: {bench.environment.get('device', 'cpu')}, Python {bench.environment.get('python')}, "
|
|
83
|
+
f"torch {bench.environment.get('torch', 'n/a')}",
|
|
84
|
+
f"- Tickets: {modes[0].tickets}",
|
|
85
|
+
"- Billed cost is what the provider reported for each call (OpenRouter does). Reference cost "
|
|
86
|
+
"prices the same LLM tokens at the reference model's published rates, so runs on different "
|
|
87
|
+
"models and local runs can be compared; tokenizers differ, so compare it by ratio.",
|
|
88
|
+
"",
|
|
89
|
+
"| Metric | " + " | ".join(f"`{m.mode}`" for m in modes) + " |",
|
|
90
|
+
"|---|" + "---:|" * len(modes),
|
|
91
|
+
]
|
|
92
|
+
for label, fn in ROWS:
|
|
93
|
+
lines.append(f"| {label.strip()} | " + " | ".join(fn(m) for m in modes) + " |")
|
|
94
|
+
lines.append("| Decisions by plane | " + " | ".join(_planes(m) for m in modes) + " |")
|
|
95
|
+
lines.append("")
|
|
96
|
+
|
|
97
|
+
for mode in modes:
|
|
98
|
+
lines += [
|
|
99
|
+
f"## Questions in `{mode.mode}` mode",
|
|
100
|
+
"",
|
|
101
|
+
"| Question | Answered by | Escalation rate | Accuracy (labeled) |",
|
|
102
|
+
"|---|---|---:|---:|",
|
|
103
|
+
]
|
|
104
|
+
for name, stats in mode.questions.items():
|
|
105
|
+
answered = ", ".join(
|
|
106
|
+
f"{k} {v}" for k, v in sorted(stats["answered_by"].items(), key=lambda kv: -kv[1])
|
|
107
|
+
)
|
|
108
|
+
accuracy = _pct(stats["accuracy"]) + (
|
|
109
|
+
f" of {stats['labeled']}" if stats["labeled"] else ""
|
|
110
|
+
)
|
|
111
|
+
lines.append(
|
|
112
|
+
f"| `{name}` | {answered} | {_pct(stats['escalation_rate'])} | {accuracy} |"
|
|
113
|
+
)
|
|
114
|
+
lines.append("")
|
|
115
|
+
if mode.failures:
|
|
116
|
+
lines += [f"Failures in `{mode.mode}`:", ""]
|
|
117
|
+
for failure in mode.failures:
|
|
118
|
+
lines.append(
|
|
119
|
+
f"- {failure['ticket_id']}: expected `{failure['expected']}`, got `{failure['got']}` "
|
|
120
|
+
f"(intent `{failure['intent']}` from {failure['intent_provider']})"
|
|
121
|
+
)
|
|
122
|
+
lines.append("")
|
|
123
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""The support benchmark: the same agent and tickets, one run per decision-plane mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import time
|
|
8
|
+
from collections import Counter, defaultdict
|
|
9
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from .._version import __version__
|
|
16
|
+
from ..decision import Usage
|
|
17
|
+
from ..engine import Engine
|
|
18
|
+
from ..pricing import PriceTable, default_prices
|
|
19
|
+
from ..tracing import JSONLSink, MemorySink, Tracer
|
|
20
|
+
from ..tracing.summary import TraceSummary
|
|
21
|
+
from .metrics import percentile
|
|
22
|
+
|
|
23
|
+
__all__ = ["ModeReport", "SupportBenchmark", "TicketResult", "run_support_benchmark"]
|
|
24
|
+
|
|
25
|
+
# For these yes/no questions the expected action implies the true answer.
|
|
26
|
+
LABELED_YES_NO: dict[str, Callable[[Mapping[str, Any]], bool]] = {
|
|
27
|
+
"wants_human": lambda expected: expected["action"] == "escalate_human",
|
|
28
|
+
"injection": lambda expected: expected["action"] == "escalate_security",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TicketResult(BaseModel):
|
|
33
|
+
"""One ticket processed in one mode."""
|
|
34
|
+
|
|
35
|
+
ticket_id: str
|
|
36
|
+
mode: str
|
|
37
|
+
expected_action: str
|
|
38
|
+
action: str
|
|
39
|
+
correct: bool
|
|
40
|
+
expected_intent: str | None = None
|
|
41
|
+
intent: Any = None
|
|
42
|
+
intent_provider: str | None = None
|
|
43
|
+
expected_order_id: str | None = None
|
|
44
|
+
order_id: str | None = None
|
|
45
|
+
reply_source: str
|
|
46
|
+
grounded: bool
|
|
47
|
+
decisions: dict[str, Any] = Field(default_factory=dict)
|
|
48
|
+
escalated: list[str] = Field(default_factory=list)
|
|
49
|
+
decision_llm_calls: int = 0
|
|
50
|
+
decision_llm_tokens: int = 0
|
|
51
|
+
decision_llm_ms: float = 0.0
|
|
52
|
+
summary: TraceSummary
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ModeReport(BaseModel):
|
|
56
|
+
"""Aggregates for one mode. Per-ticket values are means."""
|
|
57
|
+
|
|
58
|
+
mode: str
|
|
59
|
+
tickets: int
|
|
60
|
+
task_success: float
|
|
61
|
+
intent_accuracy: float | None
|
|
62
|
+
order_id_accuracy: float | None
|
|
63
|
+
llm_calls: float
|
|
64
|
+
decision_llm_calls: float
|
|
65
|
+
generation_calls: float
|
|
66
|
+
decisions_by_plane: dict[str, int]
|
|
67
|
+
escalations: float
|
|
68
|
+
latency_p50_ms: float
|
|
69
|
+
latency_p95_ms: float
|
|
70
|
+
decision_ms: float
|
|
71
|
+
generation_ms: float
|
|
72
|
+
llm_input_tokens: float
|
|
73
|
+
llm_output_tokens: float
|
|
74
|
+
decision_llm_tokens: float
|
|
75
|
+
cost_usd: float
|
|
76
|
+
reference_cost_per_1k_tickets: float
|
|
77
|
+
grounded_rate: float
|
|
78
|
+
template_fallbacks: int
|
|
79
|
+
questions: dict[str, dict[str, Any]]
|
|
80
|
+
failures: list[dict[str, Any]]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class SupportBenchmark(BaseModel):
|
|
84
|
+
"""A complete benchmark run, as written to ``results.json``."""
|
|
85
|
+
|
|
86
|
+
thinkless_version: str = __version__
|
|
87
|
+
created_at: str
|
|
88
|
+
reasoning_model: str
|
|
89
|
+
reference_price: str
|
|
90
|
+
threshold: float
|
|
91
|
+
environment: dict[str, Any]
|
|
92
|
+
modes: dict[str, ModeReport]
|
|
93
|
+
tickets: list[TicketResult]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def environment_info(extra: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
97
|
+
"""Python, platform and accelerator, recorded with every result file."""
|
|
98
|
+
env: dict[str, Any] = {"python": platform.python_version(), "platform": platform.platform()}
|
|
99
|
+
try:
|
|
100
|
+
import torch
|
|
101
|
+
|
|
102
|
+
env["torch"] = torch.__version__
|
|
103
|
+
env["device"] = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "cpu"
|
|
104
|
+
except ImportError:
|
|
105
|
+
env["device"] = "cpu"
|
|
106
|
+
env.update(extra or {})
|
|
107
|
+
return env
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _truth(name: str, expected: Mapping[str, Any]) -> tuple[bool, Any]:
|
|
111
|
+
if name == "intent":
|
|
112
|
+
return expected.get("intent") is not None, expected.get("intent")
|
|
113
|
+
if name in LABELED_YES_NO:
|
|
114
|
+
return True, LABELED_YES_NO[name](expected)
|
|
115
|
+
if name == "order":
|
|
116
|
+
return "order_id" in expected, expected.get("order_id")
|
|
117
|
+
return False, None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _question_stats(
|
|
121
|
+
results: Sequence[TicketResult], scenarios: Mapping[str, Mapping[str, Any]]
|
|
122
|
+
) -> dict[str, dict[str, Any]]:
|
|
123
|
+
providers: dict[str, Counter[str]] = defaultdict(Counter)
|
|
124
|
+
asked: Counter[str] = Counter()
|
|
125
|
+
escalated: Counter[str] = Counter()
|
|
126
|
+
hits: Counter[str] = Counter()
|
|
127
|
+
labeled: Counter[str] = Counter()
|
|
128
|
+
for result in results:
|
|
129
|
+
expected = scenarios[result.ticket_id]["expected"]
|
|
130
|
+
for name, view in result.decisions.items():
|
|
131
|
+
asked[name] += 1
|
|
132
|
+
providers[name][view.get("provider") or "none"] += 1
|
|
133
|
+
escalated[name] += int(name in result.escalated)
|
|
134
|
+
has_label, truth = _truth(name, expected)
|
|
135
|
+
if not has_label:
|
|
136
|
+
continue
|
|
137
|
+
value = view.get("value")
|
|
138
|
+
if name == "order":
|
|
139
|
+
value = value.get("order_id") if isinstance(value, Mapping) else None
|
|
140
|
+
labeled[name] += 1
|
|
141
|
+
hits[name] += int(value == truth)
|
|
142
|
+
return {
|
|
143
|
+
name: {
|
|
144
|
+
"asked": asked[name],
|
|
145
|
+
"answered_by": dict(providers[name]),
|
|
146
|
+
"escalation_rate": round(escalated[name] / asked[name], 4),
|
|
147
|
+
"accuracy": round(hits[name] / labeled[name], 4) if labeled[name] else None,
|
|
148
|
+
"labeled": labeled[name],
|
|
149
|
+
}
|
|
150
|
+
for name in asked
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _mode_report(
|
|
155
|
+
mode: str,
|
|
156
|
+
results: Sequence[TicketResult],
|
|
157
|
+
scenarios: Mapping[str, Mapping[str, Any]],
|
|
158
|
+
reference: tuple[str, str],
|
|
159
|
+
prices: PriceTable,
|
|
160
|
+
) -> ModeReport:
|
|
161
|
+
n = len(results)
|
|
162
|
+
summaries = [r.summary for r in results]
|
|
163
|
+
planes: Counter[str] = Counter()
|
|
164
|
+
for summary in summaries:
|
|
165
|
+
planes.update(summary.decisions_by_plane)
|
|
166
|
+
intent_rows = [r for r in results if r.expected_intent is not None and "intent" in r.decisions]
|
|
167
|
+
order_rows = [r for r in results if "order" in r.decisions]
|
|
168
|
+
llm_in = sum(s.llm_input_tokens for s in summaries)
|
|
169
|
+
llm_out = sum(s.llm_output_tokens for s in summaries)
|
|
170
|
+
reference_cost, _ = prices.cost(
|
|
171
|
+
reference[0], reference[1], Usage(input_tokens=llm_in, output_tokens=llm_out)
|
|
172
|
+
)
|
|
173
|
+
small_model_ms = sum(
|
|
174
|
+
s.time_by_plane_ms.get("model", 0.0) + s.time_by_plane_ms.get("rule", 0.0)
|
|
175
|
+
for s in summaries
|
|
176
|
+
)
|
|
177
|
+
decision_llm_ms = sum(r.decision_llm_ms for r in results)
|
|
178
|
+
all_llm_ms = sum(s.time_by_plane_ms.get("llm", 0.0) for s in summaries)
|
|
179
|
+
|
|
180
|
+
def mean(total: float, digits: int = 3) -> float:
|
|
181
|
+
return round(total / n, digits) if n else 0.0
|
|
182
|
+
|
|
183
|
+
return ModeReport(
|
|
184
|
+
mode=mode,
|
|
185
|
+
tickets=n,
|
|
186
|
+
task_success=mean(sum(r.correct for r in results), 4),
|
|
187
|
+
intent_accuracy=round(
|
|
188
|
+
sum(r.intent == r.expected_intent for r in intent_rows) / len(intent_rows), 4
|
|
189
|
+
)
|
|
190
|
+
if intent_rows
|
|
191
|
+
else None,
|
|
192
|
+
order_id_accuracy=round(
|
|
193
|
+
sum(r.order_id == r.expected_order_id for r in order_rows) / len(order_rows), 4
|
|
194
|
+
)
|
|
195
|
+
if order_rows
|
|
196
|
+
else None,
|
|
197
|
+
llm_calls=mean(sum(s.llm_calls for s in summaries)),
|
|
198
|
+
decision_llm_calls=mean(sum(r.decision_llm_calls for r in results)),
|
|
199
|
+
generation_calls=mean(sum(s.generation_calls for s in summaries)),
|
|
200
|
+
decisions_by_plane=dict(planes),
|
|
201
|
+
escalations=mean(sum(s.escalations for s in summaries)),
|
|
202
|
+
latency_p50_ms=round(percentile([s.duration_ms for s in summaries], 50), 1),
|
|
203
|
+
latency_p95_ms=round(percentile([s.duration_ms for s in summaries], 95), 1),
|
|
204
|
+
decision_ms=mean(small_model_ms + decision_llm_ms, 1),
|
|
205
|
+
generation_ms=mean(all_llm_ms - decision_llm_ms, 1),
|
|
206
|
+
llm_input_tokens=mean(llm_in, 1),
|
|
207
|
+
llm_output_tokens=mean(llm_out, 1),
|
|
208
|
+
decision_llm_tokens=mean(sum(r.decision_llm_tokens for r in results), 1),
|
|
209
|
+
cost_usd=mean(sum(s.cost_usd for s in summaries), 8),
|
|
210
|
+
reference_cost_per_1k_tickets=round(reference_cost / n * 1000, 4) if n else 0.0,
|
|
211
|
+
grounded_rate=mean(sum(r.grounded for r in results), 4),
|
|
212
|
+
template_fallbacks=sum(r.reply_source == "template_fallback" for r in results),
|
|
213
|
+
questions=_question_stats(results, scenarios),
|
|
214
|
+
failures=[
|
|
215
|
+
{
|
|
216
|
+
"ticket_id": r.ticket_id,
|
|
217
|
+
"expected": r.expected_action,
|
|
218
|
+
"got": r.action,
|
|
219
|
+
"intent": r.decisions.get("intent", {}).get("value"),
|
|
220
|
+
"intent_provider": r.intent_provider,
|
|
221
|
+
}
|
|
222
|
+
for r in results
|
|
223
|
+
if not r.correct
|
|
224
|
+
],
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _trace_facts(spans: Sequence[Mapping[str, Any]]) -> tuple[list[str], int, int, float]:
|
|
229
|
+
"""Escalated question names and LLM decision usage, read from a trace."""
|
|
230
|
+
escalated: set[str] = set()
|
|
231
|
+
calls = tokens = 0
|
|
232
|
+
ms = 0.0
|
|
233
|
+
for span in spans:
|
|
234
|
+
attrs = span.get("attributes") or {}
|
|
235
|
+
if span.get("kind") == "decide":
|
|
236
|
+
escalated.update(d["name"] for d in attrs.get("decisions") or [] if d.get("escalated"))
|
|
237
|
+
if span.get("kind") != "attempt":
|
|
238
|
+
continue
|
|
239
|
+
if span.get("plane") == "llm":
|
|
240
|
+
usage = attrs.get("usage") or {}
|
|
241
|
+
calls += 1
|
|
242
|
+
tokens += int(usage.get("input_tokens") or 0) + int(usage.get("output_tokens") or 0)
|
|
243
|
+
ms += float(span.get("duration_ms") or 0.0)
|
|
244
|
+
return sorted(escalated), calls, tokens, ms
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def run_support_benchmark(
|
|
248
|
+
engines: Mapping[str, Callable[[Tracer], Engine]],
|
|
249
|
+
*,
|
|
250
|
+
reasoning_model: str,
|
|
251
|
+
scenarios: Sequence[Mapping[str, Any]] | None = None,
|
|
252
|
+
output_dir: str | Path | None = None,
|
|
253
|
+
reference_price: str = "anthropic:claude-sonnet-5",
|
|
254
|
+
threshold: float = 0.8,
|
|
255
|
+
prices: PriceTable | None = None,
|
|
256
|
+
on_ticket: Callable[[TicketResult], None] | None = None,
|
|
257
|
+
environment: Mapping[str, Any] | None = None,
|
|
258
|
+
) -> SupportBenchmark:
|
|
259
|
+
"""Run every ticket through every mode.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
engines: Mode name to a factory that builds that mode's engine around
|
|
263
|
+
the tracer it is given.
|
|
264
|
+
reasoning_model: Label of the LLM, recorded in the results.
|
|
265
|
+
scenarios: Tickets with expected outcomes. Defaults to the bundled set.
|
|
266
|
+
output_dir: Where to write ``results.json`` and one trace file per
|
|
267
|
+
ticket and mode. Nothing is written when omitted.
|
|
268
|
+
reference_price: ``provider:model`` whose price list turns LLM tokens
|
|
269
|
+
into an estimated dollar cost, so local runs can be compared with
|
|
270
|
+
a hosted deployment.
|
|
271
|
+
threshold: The engine threshold, recorded in the results.
|
|
272
|
+
on_ticket: Progress callback.
|
|
273
|
+
"""
|
|
274
|
+
from ..demo.support import SupportAgent, World, load_scenarios
|
|
275
|
+
|
|
276
|
+
tickets = list(scenarios or load_scenarios())
|
|
277
|
+
by_id = {str(t["id"]): t for t in tickets}
|
|
278
|
+
prices = prices or default_prices()
|
|
279
|
+
provider, _, model = reference_price.partition(":")
|
|
280
|
+
out = Path(output_dir) if output_dir else None
|
|
281
|
+
|
|
282
|
+
results: list[TicketResult] = []
|
|
283
|
+
for mode, factory in engines.items():
|
|
284
|
+
memory = MemorySink()
|
|
285
|
+
sinks: list[Any] = [memory]
|
|
286
|
+
if out is not None:
|
|
287
|
+
sinks.append(JSONLSink(out / "traces" / mode))
|
|
288
|
+
agent = SupportAgent(factory(Tracer(sinks)))
|
|
289
|
+
for ticket in tickets:
|
|
290
|
+
outcome, summary = agent.handle(
|
|
291
|
+
ticket, World(), mode=mode, expected_action=ticket["expected"]["action"]
|
|
292
|
+
)
|
|
293
|
+
escalated, calls, tokens, ms = _trace_facts(memory.trace(outcome.trace_id or ""))
|
|
294
|
+
memory.clear()
|
|
295
|
+
expected = ticket["expected"]
|
|
296
|
+
intent = outcome.decisions.get("intent") or {}
|
|
297
|
+
result = TicketResult(
|
|
298
|
+
ticket_id=str(ticket["id"]),
|
|
299
|
+
mode=mode,
|
|
300
|
+
expected_action=expected["action"],
|
|
301
|
+
action=outcome.action,
|
|
302
|
+
correct=outcome.action == expected["action"],
|
|
303
|
+
expected_intent=expected.get("intent"),
|
|
304
|
+
intent=intent.get("value") if intent.get("status") == "accepted" else None,
|
|
305
|
+
intent_provider=intent.get("provider"),
|
|
306
|
+
expected_order_id=expected.get("order_id"),
|
|
307
|
+
order_id=outcome.order_id,
|
|
308
|
+
reply_source=outcome.reply_source,
|
|
309
|
+
grounded=outcome.grounded,
|
|
310
|
+
decisions=outcome.decisions,
|
|
311
|
+
escalated=escalated,
|
|
312
|
+
decision_llm_calls=calls,
|
|
313
|
+
decision_llm_tokens=tokens,
|
|
314
|
+
decision_llm_ms=ms,
|
|
315
|
+
summary=summary,
|
|
316
|
+
)
|
|
317
|
+
results.append(result)
|
|
318
|
+
if on_ticket is not None:
|
|
319
|
+
on_ticket(result)
|
|
320
|
+
|
|
321
|
+
modes = {
|
|
322
|
+
mode: _mode_report(
|
|
323
|
+
mode, [r for r in results if r.mode == mode], by_id, (provider, model), prices
|
|
324
|
+
)
|
|
325
|
+
for mode in engines
|
|
326
|
+
}
|
|
327
|
+
benchmark = SupportBenchmark(
|
|
328
|
+
created_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
329
|
+
reasoning_model=reasoning_model,
|
|
330
|
+
reference_price=reference_price,
|
|
331
|
+
threshold=threshold,
|
|
332
|
+
environment=environment_info(environment),
|
|
333
|
+
modes=modes,
|
|
334
|
+
tickets=results,
|
|
335
|
+
)
|
|
336
|
+
if out is not None:
|
|
337
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
(out / "results.json").write_text(
|
|
339
|
+
json.dumps(benchmark.model_dump(mode="json"), indent=2, ensure_ascii=False),
|
|
340
|
+
encoding="utf-8",
|
|
341
|
+
)
|
|
342
|
+
return benchmark
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command line interface."""
|