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
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()
@@ -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)