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.
Files changed (63) hide show
  1. thinkless/__init__.py +63 -0
  2. thinkless/__main__.py +3 -0
  3. thinkless/_hub.py +84 -0
  4. thinkless/_json.py +33 -0
  5. thinkless/_version.py +1 -0
  6. thinkless/bench/__init__.py +22 -0
  7. thinkless/bench/intents.py +406 -0
  8. thinkless/bench/intents_report.py +87 -0
  9. thinkless/bench/metrics.py +115 -0
  10. thinkless/bench/report.py +123 -0
  11. thinkless/bench/support.py +342 -0
  12. thinkless/cli/__init__.py +1 -0
  13. thinkless/cli/main.py +595 -0
  14. thinkless/confidence.py +62 -0
  15. thinkless/data/pricing.toml +56 -0
  16. thinkless/data/viewer.html +411 -0
  17. thinkless/decision.py +185 -0
  18. thinkless/demo/__init__.py +1 -0
  19. thinkless/demo/support/__init__.py +19 -0
  20. thinkless/demo/support/agent.py +379 -0
  21. thinkless/demo/support/data/calibration.jsonl +48 -0
  22. thinkless/demo/support/data/scenarios.jsonl +53 -0
  23. thinkless/demo/support/data/world.json +78 -0
  24. thinkless/demo/support/questions.py +118 -0
  25. thinkless/demo/support/stack.py +93 -0
  26. thinkless/demo/support/world.py +165 -0
  27. thinkless/engine.py +605 -0
  28. thinkless/errors.py +13 -0
  29. thinkless/llm/__init__.py +47 -0
  30. thinkless/llm/anthropic.py +126 -0
  31. thinkless/llm/base.py +82 -0
  32. thinkless/llm/factory.py +91 -0
  33. thinkless/llm/local.py +168 -0
  34. thinkless/llm/openai_compat.py +165 -0
  35. thinkless/llm/openrouter.py +78 -0
  36. thinkless/llm/scripted.py +75 -0
  37. thinkless/logs.py +68 -0
  38. thinkless/pricing.py +100 -0
  39. thinkless/providers/__init__.py +43 -0
  40. thinkless/providers/base.py +108 -0
  41. thinkless/providers/gliner.py +194 -0
  42. thinkless/providers/hf.py +161 -0
  43. thinkless/providers/laya.py +121 -0
  44. thinkless/providers/llm.py +305 -0
  45. thinkless/providers/rules.py +181 -0
  46. thinkless/providers/systemone.py +147 -0
  47. thinkless/providers/wire.py +108 -0
  48. thinkless/py.typed +0 -0
  49. thinkless/questions.py +246 -0
  50. thinkless/settings.py +93 -0
  51. thinkless/tracing/__init__.py +23 -0
  52. thinkless/tracing/console.py +169 -0
  53. thinkless/tracing/otel.py +139 -0
  54. thinkless/tracing/sinks.py +142 -0
  55. thinkless/tracing/span.py +98 -0
  56. thinkless/tracing/summary.py +143 -0
  57. thinkless/tracing/tracer.py +159 -0
  58. thinkless/tracing/viewer.py +87 -0
  59. thinkless-0.2.0.dist-info/METADATA +357 -0
  60. thinkless-0.2.0.dist-info/RECORD +63 -0
  61. thinkless-0.2.0.dist-info/WHEEL +4 -0
  62. thinkless-0.2.0.dist-info/entry_points.txt +2 -0
  63. 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."""