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
thinkless/cli/main.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""The ``thinkless`` command line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import platform
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import webbrowser
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Annotated, Any
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from .._version import __version__
|
|
21
|
+
from ..logs import configure_logging
|
|
22
|
+
from ..settings import Settings, load_env
|
|
23
|
+
|
|
24
|
+
app = typer.Typer(
|
|
25
|
+
name="thinkless",
|
|
26
|
+
help="ThinkLess: fast typed decisions for AI agents, with the LLM only where thinking is needed.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
add_completion=False,
|
|
29
|
+
pretty_exceptions_show_locals=False,
|
|
30
|
+
)
|
|
31
|
+
bench_app = typer.Typer(help="Run the benchmarks.", no_args_is_help=True)
|
|
32
|
+
trace_app = typer.Typer(help="Inspect saved traces.", no_args_is_help=True)
|
|
33
|
+
app.add_typer(bench_app, name="bench")
|
|
34
|
+
app.add_typer(trace_app, name="trace")
|
|
35
|
+
|
|
36
|
+
console = Console()
|
|
37
|
+
err = Console(stderr=True)
|
|
38
|
+
|
|
39
|
+
LlmOption = Annotated[
|
|
40
|
+
str,
|
|
41
|
+
typer.Option(
|
|
42
|
+
"--llm",
|
|
43
|
+
help="Reasoning model as backend[:model]: local, local:Qwen/Qwen3-4B, "
|
|
44
|
+
"openrouter:qwen/qwen3.7-flash, anthropic, anthropic:claude-haiku-4-5, openai:<model>, "
|
|
45
|
+
"ollama:qwen3:8b, vllm:<model>.",
|
|
46
|
+
),
|
|
47
|
+
]
|
|
48
|
+
DeviceOption = Annotated[str, typer.Option(help="Device for local models: auto, cpu, cuda or mps.")]
|
|
49
|
+
ReasoningOption = Annotated[
|
|
50
|
+
str,
|
|
51
|
+
typer.Option(
|
|
52
|
+
help="Reasoning for the LLM: default, off, minimal, low, medium or high. "
|
|
53
|
+
"Reasoning tokens are billed and count against output limits."
|
|
54
|
+
),
|
|
55
|
+
]
|
|
56
|
+
VerboseOption = Annotated[bool, typer.Option("--verbose", "-v", help="Show info logs.")]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _version_callback(value: bool) -> None:
|
|
60
|
+
if value:
|
|
61
|
+
console.print(f"thinkless {__version__}")
|
|
62
|
+
raise typer.Exit()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.callback()
|
|
66
|
+
def main(
|
|
67
|
+
version: Annotated[
|
|
68
|
+
bool,
|
|
69
|
+
typer.Option(
|
|
70
|
+
"--version", callback=_version_callback, is_eager=True, help="Show the version."
|
|
71
|
+
),
|
|
72
|
+
] = False,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""ThinkLess command line."""
|
|
75
|
+
load_env()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _setup(verbose: bool) -> None:
|
|
79
|
+
configure_logging("INFO" if verbose else Settings().log_level)
|
|
80
|
+
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
|
|
81
|
+
if not verbose:
|
|
82
|
+
import warnings
|
|
83
|
+
|
|
84
|
+
warnings.filterwarnings("ignore")
|
|
85
|
+
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _installed(package: str) -> str | None:
|
|
89
|
+
try:
|
|
90
|
+
return importlib.metadata.version(package)
|
|
91
|
+
except importlib.metadata.PackageNotFoundError:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ----------------------------------------------------------------- doctor
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _nvidia_gpu_present() -> bool:
|
|
99
|
+
"""True when an NVIDIA driver is installed, even if torch cannot use it."""
|
|
100
|
+
import shutil
|
|
101
|
+
import subprocess
|
|
102
|
+
|
|
103
|
+
if shutil.which("nvidia-smi") is None:
|
|
104
|
+
return False
|
|
105
|
+
try:
|
|
106
|
+
result = subprocess.run(["nvidia-smi", "-L"], capture_output=True, text=True, timeout=10)
|
|
107
|
+
except (OSError, subprocess.SubprocessError):
|
|
108
|
+
return False
|
|
109
|
+
return result.returncode == 0 and "GPU" in result.stdout
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@app.command()
|
|
113
|
+
def doctor() -> None:
|
|
114
|
+
"""Check the environment: accelerators, optional backends, keys and settings."""
|
|
115
|
+
settings = Settings()
|
|
116
|
+
warnings: list[str] = []
|
|
117
|
+
table = Table(show_header=False, box=None)
|
|
118
|
+
table.add_column(style="dim")
|
|
119
|
+
table.add_column()
|
|
120
|
+
table.add_row("thinkless", __version__)
|
|
121
|
+
table.add_row("python", f"{platform.python_version()} ({sys.executable})")
|
|
122
|
+
table.add_row("platform", platform.platform())
|
|
123
|
+
|
|
124
|
+
torch_version = _installed("torch")
|
|
125
|
+
if torch_version:
|
|
126
|
+
try:
|
|
127
|
+
import torch
|
|
128
|
+
|
|
129
|
+
if torch.cuda.is_available():
|
|
130
|
+
props = torch.cuda.get_device_properties(0)
|
|
131
|
+
accel = f"CUDA: {props.name}, {props.total_memory / 1e9:.1f} GB"
|
|
132
|
+
elif getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
|
133
|
+
accel = "Apple MPS"
|
|
134
|
+
elif _nvidia_gpu_present():
|
|
135
|
+
accel = "[yellow]CPU only, but an NVIDIA GPU is present[/]"
|
|
136
|
+
warnings.append(
|
|
137
|
+
"This torch build has no CUDA support, so local models run on the CPU. Install a CUDA "
|
|
138
|
+
"build first, for example: pip install torch --index-url "
|
|
139
|
+
"https://download.pytorch.org/whl/cu130 (see docs/guides/installation.md)."
|
|
140
|
+
)
|
|
141
|
+
else:
|
|
142
|
+
accel = "CPU only"
|
|
143
|
+
except Exception as exc: # pragma: no cover - environment specific
|
|
144
|
+
accel = f"torch failed to initialize: {exc}"
|
|
145
|
+
table.add_row("torch", f"{torch_version} ({accel})")
|
|
146
|
+
else:
|
|
147
|
+
table.add_row("torch", "not installed (local models unavailable)")
|
|
148
|
+
|
|
149
|
+
extras = {
|
|
150
|
+
"laya": "laya",
|
|
151
|
+
"gliner": "gliner2",
|
|
152
|
+
"transformers": "transformers",
|
|
153
|
+
"openai": "openai",
|
|
154
|
+
"anthropic": "anthropic",
|
|
155
|
+
"otel": "opentelemetry-sdk",
|
|
156
|
+
"bench": "datasets",
|
|
157
|
+
}
|
|
158
|
+
for label, package in extras.items():
|
|
159
|
+
version = _installed(package)
|
|
160
|
+
table.add_row(label, f"[green]{version}[/]" if version else "[yellow]not installed[/]")
|
|
161
|
+
|
|
162
|
+
for key in ("TYPESAFE_API_KEY", "OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"):
|
|
163
|
+
table.add_row(key, "[green]set[/]" if os.environ.get(key) else "[dim]not set[/]")
|
|
164
|
+
table.add_row("trace dir", str(settings.trace_dir))
|
|
165
|
+
table.add_row("device", settings.device)
|
|
166
|
+
table.add_row("capture content", str(settings.capture_content))
|
|
167
|
+
table.add_row(
|
|
168
|
+
"HF cache", os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface"))
|
|
169
|
+
)
|
|
170
|
+
from .._hub import DEFAULT_CHECKPOINTS, cached_checkpoints
|
|
171
|
+
|
|
172
|
+
for repo, size in cached_checkpoints().items():
|
|
173
|
+
state = f"[green]cached, {size} GB[/]" if size else "[dim]downloads on first use[/]"
|
|
174
|
+
table.add_row(repo, f"{state} ({DEFAULT_CHECKPOINTS[repo]})")
|
|
175
|
+
console.print(table)
|
|
176
|
+
for message in warnings:
|
|
177
|
+
console.print(f"[yellow]Warning:[/] {message}")
|
|
178
|
+
if platform.system() == "Windows":
|
|
179
|
+
console.print(
|
|
180
|
+
"[dim]Windows: models download one file at a time to avoid a symlink race in the "
|
|
181
|
+
"Hugging Face cache. If a download still fails with WinError 1314, run the command again "
|
|
182
|
+
"or enable Developer Mode (docs/guides/troubleshooting.md).[/]"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------- demo
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def demo(
|
|
191
|
+
ticket: Annotated[
|
|
192
|
+
str, typer.Option(help="Scenario id from the bundled set, for example T-016.")
|
|
193
|
+
] = "T-001",
|
|
194
|
+
message: Annotated[
|
|
195
|
+
str | None, typer.Option(help="Your own ticket text instead of a scenario.")
|
|
196
|
+
] = None,
|
|
197
|
+
customer: Annotated[str, typer.Option(help="Customer id used with --message.")] = "C-1001",
|
|
198
|
+
mode: Annotated[
|
|
199
|
+
list[str] | None, typer.Option(help="hybrid, llm or models. Repeat to compare.")
|
|
200
|
+
] = None,
|
|
201
|
+
llm: LlmOption = "local",
|
|
202
|
+
device: DeviceOption = "auto",
|
|
203
|
+
reasoning: ReasoningOption = "default",
|
|
204
|
+
threshold: Annotated[float, typer.Option(help="Engine confidence threshold.")] = 0.8,
|
|
205
|
+
view: Annotated[bool, typer.Option(help="Open the HTML trace viewer afterwards.")] = False,
|
|
206
|
+
verbose: VerboseOption = False,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Run the support agent on one ticket and print its trace."""
|
|
209
|
+
_setup(verbose)
|
|
210
|
+
from ..demo.support import MODES, SupportAgent, SupportStack, World, load_scenarios
|
|
211
|
+
from ..llm import from_spec
|
|
212
|
+
from ..tracing import JSONLSink, MemorySink, Tracer
|
|
213
|
+
from ..tracing.console import print_trace
|
|
214
|
+
|
|
215
|
+
modes = mode or ["hybrid"]
|
|
216
|
+
for m in modes:
|
|
217
|
+
if m not in MODES:
|
|
218
|
+
raise typer.BadParameter(f"unknown mode {m!r}; choose from {', '.join(MODES)}")
|
|
219
|
+
if message:
|
|
220
|
+
scenario: dict[str, Any] = {
|
|
221
|
+
"id": "custom",
|
|
222
|
+
"customer_id": customer,
|
|
223
|
+
"subject": "",
|
|
224
|
+
"message": message,
|
|
225
|
+
}
|
|
226
|
+
else:
|
|
227
|
+
scenarios = {s["id"]: s for s in load_scenarios()}
|
|
228
|
+
if ticket not in scenarios:
|
|
229
|
+
raise typer.BadParameter(
|
|
230
|
+
f"unknown ticket {ticket!r}; ids run from T-001 to T-{len(scenarios):03d}"
|
|
231
|
+
)
|
|
232
|
+
scenario = scenarios[ticket]
|
|
233
|
+
|
|
234
|
+
settings = Settings()
|
|
235
|
+
err.print(f"[dim]Loading models for {', '.join(modes)} ({llm})...[/]")
|
|
236
|
+
stack = SupportStack(from_spec(llm, device=device, reasoning=reasoning), device=device)
|
|
237
|
+
stack.warmup(tuple(modes))
|
|
238
|
+
trace_dir = settings.trace_dir / "demo"
|
|
239
|
+
written: list[str] = []
|
|
240
|
+
for m in modes:
|
|
241
|
+
memory = MemorySink()
|
|
242
|
+
sink = JSONLSink(trace_dir)
|
|
243
|
+
agent = SupportAgent(
|
|
244
|
+
stack.engine(
|
|
245
|
+
m,
|
|
246
|
+
tracer=Tracer([memory, sink], capture_content=settings.capture_content),
|
|
247
|
+
threshold=threshold,
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
outcome, _ = agent.handle(scenario, World(), mode=m)
|
|
251
|
+
console.rule(f"[bold]{m}[/] {scenario['id']}")
|
|
252
|
+
console.print(f"[dim]customer:[/] {scenario['message']}")
|
|
253
|
+
print_trace(memory.trace(outcome.trace_id or ""), console=console)
|
|
254
|
+
expected = (scenario.get("expected") or {}).get("action")
|
|
255
|
+
verdict = (
|
|
256
|
+
""
|
|
257
|
+
if not expected
|
|
258
|
+
else (
|
|
259
|
+
" [green]matches expected[/]"
|
|
260
|
+
if expected == outcome.action
|
|
261
|
+
else f" [red]expected {expected}[/]"
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
console.print(f"\n[bold]action[/] {outcome.action}{verdict}")
|
|
265
|
+
console.print(f"[bold]reply[/] ({outcome.reply_source}) {outcome.reply}\n")
|
|
266
|
+
written.append(outcome.trace_id or "")
|
|
267
|
+
console.print(f"[dim]Traces written to {trace_dir}[/]")
|
|
268
|
+
if view:
|
|
269
|
+
from ..tracing.viewer import write_viewer
|
|
270
|
+
|
|
271
|
+
output = write_viewer(
|
|
272
|
+
[trace_dir], settings.trace_dir / "viewer.html", title="ThinkLess demo", limit=20
|
|
273
|
+
)
|
|
274
|
+
console.print(f"Viewer: {output}")
|
|
275
|
+
webbrowser.open(output.resolve().as_uri())
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ------------------------------------------------------------------ bench
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@bench_app.command("support")
|
|
282
|
+
def bench_support(
|
|
283
|
+
mode: Annotated[
|
|
284
|
+
list[str] | None,
|
|
285
|
+
typer.Option(help="Modes to run. Repeat the option. Default: llm, hybrid, models."),
|
|
286
|
+
] = None,
|
|
287
|
+
llm: LlmOption = "local",
|
|
288
|
+
device: DeviceOption = "auto",
|
|
289
|
+
reasoning: ReasoningOption = "default",
|
|
290
|
+
limit: Annotated[int | None, typer.Option(help="Only the first N tickets.")] = None,
|
|
291
|
+
out: Annotated[
|
|
292
|
+
Path | None,
|
|
293
|
+
typer.Option(help="Output directory. Default: .thinkless/bench/support-<timestamp>."),
|
|
294
|
+
] = None,
|
|
295
|
+
reference: Annotated[
|
|
296
|
+
str, typer.Option(help="provider:model whose prices turn tokens into an estimated cost.")
|
|
297
|
+
] = "anthropic:claude-sonnet-5",
|
|
298
|
+
threshold: Annotated[float, typer.Option(help="Engine confidence threshold.")] = 0.8,
|
|
299
|
+
jev: Annotated[
|
|
300
|
+
bool, typer.Option(help="Add TypeSafe Jev to the cascade (needs TYPESAFE_API_KEY).")
|
|
301
|
+
] = False,
|
|
302
|
+
verbose: VerboseOption = False,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Run the support agent on every scenario in each mode and compare."""
|
|
305
|
+
_setup(verbose)
|
|
306
|
+
from ..bench.report import support_markdown, support_table
|
|
307
|
+
from ..bench.support import run_support_benchmark
|
|
308
|
+
from ..demo.support import MODES, SupportStack, load_scenarios
|
|
309
|
+
from ..engine import Engine
|
|
310
|
+
from ..llm import from_spec
|
|
311
|
+
from ..providers import SystemOne
|
|
312
|
+
from ..tracing import Tracer
|
|
313
|
+
from ..tracing.viewer import write_viewer
|
|
314
|
+
|
|
315
|
+
modes = mode or ["llm", "hybrid", "models"]
|
|
316
|
+
for m in modes:
|
|
317
|
+
if m not in MODES:
|
|
318
|
+
raise typer.BadParameter(f"unknown mode {m!r}; choose from {', '.join(MODES)}")
|
|
319
|
+
scenarios = load_scenarios()[:limit] if limit else load_scenarios()
|
|
320
|
+
output = out or Settings().trace_dir.parent / "bench" / time.strftime("support-%Y%m%dT%H%M%S")
|
|
321
|
+
stack = SupportStack(
|
|
322
|
+
from_spec(llm, device=device, reasoning=reasoning),
|
|
323
|
+
device=device,
|
|
324
|
+
jev=SystemOne.jev() if jev else None,
|
|
325
|
+
)
|
|
326
|
+
err.print(f"[dim]Loading models for {', '.join(modes)} ({llm})...[/]")
|
|
327
|
+
stack.warmup(tuple(modes))
|
|
328
|
+
|
|
329
|
+
total = len(scenarios) * len(modes)
|
|
330
|
+
done = 0
|
|
331
|
+
started = time.perf_counter()
|
|
332
|
+
|
|
333
|
+
def progress(result: Any) -> None:
|
|
334
|
+
nonlocal done
|
|
335
|
+
done += 1
|
|
336
|
+
mark = "[green]ok[/]" if result.correct else "[red]miss[/]"
|
|
337
|
+
err.print(
|
|
338
|
+
f"[dim]{done:>3}/{total} {time.perf_counter() - started:6.1f}s[/] {result.mode:<7} {result.ticket_id} "
|
|
339
|
+
f"{mark} {result.action} [dim]llm calls {result.summary.llm_calls}[/]"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def factory(mode_name: str) -> Callable[[Tracer], Engine]:
|
|
343
|
+
return lambda tracer: stack.engine(mode_name, tracer=tracer, threshold=threshold)
|
|
344
|
+
|
|
345
|
+
bench = run_support_benchmark(
|
|
346
|
+
{m: factory(m) for m in modes},
|
|
347
|
+
reasoning_model=stack.llm.name
|
|
348
|
+
+ ("" if reasoning == "default" else f" (reasoning {reasoning})"),
|
|
349
|
+
scenarios=scenarios,
|
|
350
|
+
output_dir=output,
|
|
351
|
+
reference_price=reference,
|
|
352
|
+
threshold=threshold,
|
|
353
|
+
on_ticket=progress,
|
|
354
|
+
)
|
|
355
|
+
(output / "report.md").write_text(support_markdown(bench), encoding="utf-8")
|
|
356
|
+
write_viewer([output / "traces"], output / "viewer.html", title="Support benchmark")
|
|
357
|
+
console.print(support_table(bench))
|
|
358
|
+
console.print(
|
|
359
|
+
f"\nResults: {output / 'results.json'}\nReport: {output / 'report.md'}\nViewer: {output / 'viewer.html'}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@bench_app.command("intents")
|
|
364
|
+
def bench_intents(
|
|
365
|
+
dataset: Annotated[str, typer.Option(help="banking77, clinc150 or emotion.")] = "banking77",
|
|
366
|
+
provider: Annotated[
|
|
367
|
+
list[str] | None, typer.Option(help="gliner, laya or llm. Repeat the option.")
|
|
368
|
+
] = None,
|
|
369
|
+
llm: LlmOption = "local",
|
|
370
|
+
device: DeviceOption = "auto",
|
|
371
|
+
reasoning: ReasoningOption = "default",
|
|
372
|
+
limit: Annotated[int, typer.Option(help="Examples to sample from the test split.")] = 500,
|
|
373
|
+
seed: Annotated[int, typer.Option(help="Sampling seed.")] = 13,
|
|
374
|
+
target: Annotated[
|
|
375
|
+
float, typer.Option(help="Target accuracy for the threshold recommendation.")
|
|
376
|
+
] = 0.95,
|
|
377
|
+
out: Annotated[Path | None, typer.Option(help="Output directory.")] = None,
|
|
378
|
+
verbose: VerboseOption = False,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Accuracy, calibration and cascade trade-offs on a public intent dataset."""
|
|
381
|
+
_setup(verbose)
|
|
382
|
+
from ..bench.intents import run_intents_benchmark
|
|
383
|
+
from ..bench.intents_report import intents_markdown, intents_table
|
|
384
|
+
|
|
385
|
+
providers = provider or ["gliner", "laya", "llm"]
|
|
386
|
+
output = out or Settings().trace_dir.parent / "bench" / time.strftime(
|
|
387
|
+
f"intents-{dataset}-%Y%m%dT%H%M%S"
|
|
388
|
+
)
|
|
389
|
+
result = run_intents_benchmark(
|
|
390
|
+
dataset,
|
|
391
|
+
providers=providers,
|
|
392
|
+
llm_spec=llm,
|
|
393
|
+
device=device,
|
|
394
|
+
reasoning=reasoning,
|
|
395
|
+
limit=limit,
|
|
396
|
+
seed=seed,
|
|
397
|
+
target_accuracy=target,
|
|
398
|
+
output_dir=output,
|
|
399
|
+
progress=lambda msg: err.print(f"[dim]{msg}[/]"),
|
|
400
|
+
)
|
|
401
|
+
(output / "report.md").write_text(intents_markdown(result), encoding="utf-8")
|
|
402
|
+
console.print(intents_table(result))
|
|
403
|
+
console.print(f"\nResults: {output / 'results.json'}\nReport: {output / 'report.md'}")
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
# -------------------------------------------------------------- calibrate
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@app.command()
|
|
410
|
+
def calibrate(
|
|
411
|
+
data: Annotated[Path, typer.Argument(help="JSONL file with one labeled example per line.")],
|
|
412
|
+
provider: Annotated[str, typer.Option(help="gliner, laya, llm or jev.")] = "gliner",
|
|
413
|
+
kind: Annotated[str, typer.Option(help="choice or yes_no.")] = "choice",
|
|
414
|
+
question: Annotated[
|
|
415
|
+
str, typer.Option(help="The question to ask.")
|
|
416
|
+
] = "Which category best fits the text?",
|
|
417
|
+
labels: Annotated[
|
|
418
|
+
str | None,
|
|
419
|
+
typer.Option(help="Choice options, comma-separated. Default: every label in the data."),
|
|
420
|
+
] = None,
|
|
421
|
+
demo_question: Annotated[
|
|
422
|
+
str | None,
|
|
423
|
+
typer.Option(help="Use a question from the support demo by name, for example injection."),
|
|
424
|
+
] = None,
|
|
425
|
+
text_field: Annotated[str, typer.Option(help="Field holding the input text.")] = "text",
|
|
426
|
+
label_field: Annotated[
|
|
427
|
+
str | None,
|
|
428
|
+
typer.Option(help="Field holding the label. Default: label, or the demo question name."),
|
|
429
|
+
] = None,
|
|
430
|
+
target: Annotated[float, typer.Option(help="Accuracy the accepted answers must reach.")] = 0.95,
|
|
431
|
+
llm: LlmOption = "local",
|
|
432
|
+
device: DeviceOption = "auto",
|
|
433
|
+
reasoning: ReasoningOption = "default",
|
|
434
|
+
verbose: VerboseOption = False,
|
|
435
|
+
) -> None:
|
|
436
|
+
"""Find the threshold that meets a target accuracy on your own labeled data.
|
|
437
|
+
|
|
438
|
+
Rows look like {"text": "...", "label": "refund"} for a choice question, or
|
|
439
|
+
{"text": "...", "label": true} for a yes/no question.
|
|
440
|
+
"""
|
|
441
|
+
_setup(verbose)
|
|
442
|
+
from ..bench.intents import build_provider, evaluate_question
|
|
443
|
+
from ..bench.metrics import expected_calibration_error, recommend_threshold, threshold_sweep
|
|
444
|
+
from ..questions import Choice, Question, YesNo
|
|
445
|
+
|
|
446
|
+
rows = [
|
|
447
|
+
json.loads(line) for line in data.read_text(encoding="utf-8").splitlines() if line.strip()
|
|
448
|
+
]
|
|
449
|
+
if not rows:
|
|
450
|
+
raise typer.BadParameter(f"{data} has no rows")
|
|
451
|
+
asked: Question
|
|
452
|
+
if demo_question:
|
|
453
|
+
from ..demo.support import questions as demo
|
|
454
|
+
|
|
455
|
+
candidates = {q.key: q for q in (*demo.TRIAGE, demo.KB_MATCH)}
|
|
456
|
+
if demo_question not in candidates:
|
|
457
|
+
raise typer.BadParameter(
|
|
458
|
+
f"unknown demo question {demo_question!r}; choose from {', '.join(candidates)}"
|
|
459
|
+
)
|
|
460
|
+
asked = candidates[demo_question]
|
|
461
|
+
field = label_field or demo_question
|
|
462
|
+
else:
|
|
463
|
+
field = label_field or "label"
|
|
464
|
+
if kind == "yes_no":
|
|
465
|
+
asked = YesNo(question, name=field)
|
|
466
|
+
elif kind == "choice":
|
|
467
|
+
options = (
|
|
468
|
+
[x.strip() for x in labels.split(",")]
|
|
469
|
+
if labels
|
|
470
|
+
else sorted({str(r[field]) for r in rows})
|
|
471
|
+
)
|
|
472
|
+
asked = Choice(question, options=options, name=field)
|
|
473
|
+
else:
|
|
474
|
+
raise typer.BadParameter("kind must be choice or yes_no")
|
|
475
|
+
if not isinstance(asked, (Choice, YesNo)):
|
|
476
|
+
raise typer.BadParameter("calibration supports choice and yes/no questions")
|
|
477
|
+
|
|
478
|
+
evaluation = evaluate_question(
|
|
479
|
+
build_provider(provider, llm_spec=llm, device=device, reasoning=reasoning),
|
|
480
|
+
asked,
|
|
481
|
+
rows,
|
|
482
|
+
text_field=text_field,
|
|
483
|
+
label_field=field,
|
|
484
|
+
)
|
|
485
|
+
if any(c is None for c in evaluation.confidences):
|
|
486
|
+
console.print(
|
|
487
|
+
f"[yellow]{provider} reports no calibrated confidence, so there is no threshold to tune.[/] "
|
|
488
|
+
f"Accuracy {evaluation.accuracy * 100:.1f}%."
|
|
489
|
+
)
|
|
490
|
+
raise typer.Exit()
|
|
491
|
+
confidences = [float(c) for c in evaluation.confidences if c is not None]
|
|
492
|
+
points = threshold_sweep(confidences, evaluation.correct)
|
|
493
|
+
best = recommend_threshold(points, target)
|
|
494
|
+
table = Table(title=f"{provider} on {data.name}: {asked.key} ({len(rows)} rows)")
|
|
495
|
+
for column in ("threshold", "answers alone", "accuracy of those answers"):
|
|
496
|
+
table.add_column(column, justify="right")
|
|
497
|
+
for point in points:
|
|
498
|
+
style = "bold green" if best and point.threshold == best.threshold else None
|
|
499
|
+
table.add_row(
|
|
500
|
+
f"{point.threshold:.2f}",
|
|
501
|
+
f"{point.coverage * 100:.1f}%",
|
|
502
|
+
"n/a" if point.accuracy is None else f"{point.accuracy * 100:.1f}%",
|
|
503
|
+
style=style,
|
|
504
|
+
)
|
|
505
|
+
console.print(table)
|
|
506
|
+
console.print(
|
|
507
|
+
f"overall accuracy {evaluation.accuracy * 100:.1f}%, "
|
|
508
|
+
f"ECE {expected_calibration_error(confidences, evaluation.correct):.3f}, "
|
|
509
|
+
f"p50 latency {evaluation.latency_p50_ms:.1f} ms"
|
|
510
|
+
)
|
|
511
|
+
if best:
|
|
512
|
+
console.print(
|
|
513
|
+
f"[bold]Recommended threshold {best.threshold:.2f}[/]: {provider} answers "
|
|
514
|
+
f"{best.coverage * 100:.1f}% of inputs on its own at {(best.accuracy or 0.0) * 100:.1f}% accuracy; "
|
|
515
|
+
"the rest go to the next provider."
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
console.print(
|
|
519
|
+
f"[yellow]No threshold reaches {target * 100:.0f}% accuracy.[/] Route this question to a "
|
|
520
|
+
f'stronger provider, for example Question(..., providers=("rules", "llm")).'
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ------------------------------------------------------------------ trace
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@trace_app.command("ls")
|
|
528
|
+
def trace_ls(
|
|
529
|
+
directory: Annotated[
|
|
530
|
+
Path | None, typer.Argument(help="Trace directory. Default: THINKLESS_TRACE_DIR.")
|
|
531
|
+
] = None,
|
|
532
|
+
limit: Annotated[int, typer.Option(help="How many to show.")] = 20,
|
|
533
|
+
) -> None:
|
|
534
|
+
"""List recent traces with their headline numbers."""
|
|
535
|
+
from ..tracing import iter_trace_files, read_trace, summarize
|
|
536
|
+
|
|
537
|
+
root = directory or Settings().trace_dir
|
|
538
|
+
table = Table(title=str(root))
|
|
539
|
+
for column in ("file", "name", "mode", "time", "LLM calls", "decisions", "cost"):
|
|
540
|
+
table.add_column(column)
|
|
541
|
+
for index, file in enumerate(iter_trace_files(root)):
|
|
542
|
+
if index >= limit:
|
|
543
|
+
break
|
|
544
|
+
spans = read_trace(file)
|
|
545
|
+
if not spans:
|
|
546
|
+
continue
|
|
547
|
+
s = summarize(spans)
|
|
548
|
+
table.add_row(
|
|
549
|
+
file.name,
|
|
550
|
+
s.name,
|
|
551
|
+
str(s.attributes.get("mode", "")),
|
|
552
|
+
f"{s.duration_ms:.0f} ms",
|
|
553
|
+
str(s.llm_calls),
|
|
554
|
+
str(s.decisions),
|
|
555
|
+
f"${s.cost_usd:.6f}",
|
|
556
|
+
)
|
|
557
|
+
console.print(table)
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
@trace_app.command("show")
|
|
561
|
+
def trace_show(path: Annotated[Path, typer.Argument(help="A .jsonl trace file.")]) -> None:
|
|
562
|
+
"""Print one trace as a tree with its summary."""
|
|
563
|
+
from ..tracing import read_trace
|
|
564
|
+
from ..tracing.console import print_trace
|
|
565
|
+
|
|
566
|
+
print_trace(read_trace(path), console=console)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@trace_app.command("view")
|
|
570
|
+
def trace_view(
|
|
571
|
+
paths: Annotated[
|
|
572
|
+
list[Path] | None,
|
|
573
|
+
typer.Argument(help="Trace files or directories. Default: THINKLESS_TRACE_DIR."),
|
|
574
|
+
] = None,
|
|
575
|
+
out: Annotated[Path | None, typer.Option(help="Where to write the HTML file.")] = None,
|
|
576
|
+
limit: Annotated[
|
|
577
|
+
int | None, typer.Option(help="At most this many traces, newest first.")
|
|
578
|
+
] = 500,
|
|
579
|
+
open_browser: Annotated[
|
|
580
|
+
bool, typer.Option("--open/--no-open", help="Open the viewer in a browser.")
|
|
581
|
+
] = True,
|
|
582
|
+
) -> None:
|
|
583
|
+
"""Write a self-contained HTML viewer for traces and open it."""
|
|
584
|
+
from ..tracing.viewer import write_viewer
|
|
585
|
+
|
|
586
|
+
sources = paths or [Settings().trace_dir]
|
|
587
|
+
output = out or Settings().trace_dir / "viewer.html"
|
|
588
|
+
write_viewer(sources, output, title="ThinkLess traces", limit=limit)
|
|
589
|
+
console.print(f"Viewer: {output}")
|
|
590
|
+
if open_browser:
|
|
591
|
+
webbrowser.open(output.resolve().as_uri())
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
if __name__ == "__main__": # pragma: no cover
|
|
595
|
+
app()
|
thinkless/confidence.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Confidence normalization.
|
|
2
|
+
|
|
3
|
+
Providers disagree about what "confidence" means. Measured on the same input,
|
|
4
|
+
Laya reports ``1 - normalized entropy`` for choices and ``max(p, 1 - p)`` for
|
|
5
|
+
yes/no questions, while TypeSafe documents ``(k * p_max - 1) / (k - 1)``. A
|
|
6
|
+
threshold of 0.8 would therefore mean something different depending on which
|
|
7
|
+
backend answered.
|
|
8
|
+
|
|
9
|
+
ThinkLess computes one confidence from each provider's probability
|
|
10
|
+
distribution and applies thresholds to that number only. The provider's own
|
|
11
|
+
fields are kept untouched in ``Decision.raw``.
|
|
12
|
+
|
|
13
|
+
The formula is the normalized maximum probability, the same one TypeSafe
|
|
14
|
+
documents for Jev::
|
|
15
|
+
|
|
16
|
+
confidence = (k * p_max - 1) / (k - 1)
|
|
17
|
+
|
|
18
|
+
It is 0 for a uniform distribution over ``k`` outcomes and 1 when all mass sits
|
|
19
|
+
on one outcome. For a yes/no question (``k = 2``) it reduces to
|
|
20
|
+
``2 * max(p, 1 - p) - 1``, so a threshold of 0.8 requires ``p >= 0.9``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
|
|
27
|
+
__all__ = ["from_distribution", "from_yes_probability", "normalize_distribution"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _clip(value: float) -> float:
|
|
31
|
+
return 0.0 if value < 0.0 else 1.0 if value > 1.0 else value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_distribution(scores: Mapping[str, float]) -> dict[str, float]:
|
|
35
|
+
"""Rescale non-negative scores so they sum to 1.
|
|
36
|
+
|
|
37
|
+
Useful for providers that emit independent per-label scores (for example
|
|
38
|
+
sigmoid outputs) rather than a softmax distribution.
|
|
39
|
+
"""
|
|
40
|
+
cleaned = {label: max(0.0, float(score)) for label, score in scores.items()}
|
|
41
|
+
total = sum(cleaned.values())
|
|
42
|
+
if total <= 0.0:
|
|
43
|
+
uniform = 1.0 / len(cleaned) if cleaned else 0.0
|
|
44
|
+
return dict.fromkeys(cleaned, uniform)
|
|
45
|
+
return {label: score / total for label, score in cleaned.items()}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def from_distribution(probabilities: Mapping[str, float]) -> float:
|
|
49
|
+
"""Normalized confidence of a categorical distribution."""
|
|
50
|
+
k = len(probabilities)
|
|
51
|
+
if k == 0:
|
|
52
|
+
return 0.0
|
|
53
|
+
if k == 1:
|
|
54
|
+
return 1.0
|
|
55
|
+
p_max = max(probabilities.values())
|
|
56
|
+
return _clip((k * p_max - 1.0) / (k - 1.0))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def from_yes_probability(p_yes: float) -> float:
|
|
60
|
+
"""Normalized confidence of a binary answer given ``P(yes)``."""
|
|
61
|
+
p = _clip(float(p_yes))
|
|
62
|
+
return _clip(2.0 * max(p, 1.0 - p) - 1.0)
|