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,161 @@
|
|
|
1
|
+
"""Any Hugging Face text-classification model as a decision provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from .._hub import ensure_downloaded
|
|
11
|
+
from ..confidence import normalize_distribution
|
|
12
|
+
from ..decision import Answer, Plane
|
|
13
|
+
from ..logs import get_logger
|
|
14
|
+
from ..questions import Choice, Kind, Question, YesNo
|
|
15
|
+
from ..settings import resolve_device
|
|
16
|
+
from .base import DecisionProvider, ProviderResult, State, render_state
|
|
17
|
+
|
|
18
|
+
__all__ = ["HFClassifier"]
|
|
19
|
+
|
|
20
|
+
logger = get_logger("providers.hf")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HFClassifier(DecisionProvider):
|
|
24
|
+
"""A fine-tuned sequence classifier answering the questions it was trained for.
|
|
25
|
+
|
|
26
|
+
General decision models answer anything reasonably; a classifier trained
|
|
27
|
+
for one narrow question (prompt injection, toxicity, your own intent
|
|
28
|
+
taxonomy) usually answers that question much better, and fast. This
|
|
29
|
+
provider maps a classifier's labels onto ThinkLess questions, so it drops
|
|
30
|
+
into a cascade for those questions only.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
model: Hugging Face model id or local path of a
|
|
34
|
+
``text-classification`` model.
|
|
35
|
+
answers: Question name to label mapping. For a ``YesNo`` question,
|
|
36
|
+
``{"yes": "<model label>"}`` names the label that means yes; every
|
|
37
|
+
other label counts as no. For a ``Choice`` question, map each
|
|
38
|
+
option to a model label, ``{"<option>": "<model label>", ...}``;
|
|
39
|
+
options without a label get probability 0.
|
|
40
|
+
name: Provider name in traces. Defaults to the model's last path part.
|
|
41
|
+
field: Read this field of a mapping state instead of the rendered
|
|
42
|
+
state, for example ``"message"``.
|
|
43
|
+
device: ``auto``, ``cpu``, ``cuda`` or ``mps``.
|
|
44
|
+
max_length: Token limit; longer inputs are truncated.
|
|
45
|
+
pipeline: A ready ``transformers`` pipeline (tests, custom loading).
|
|
46
|
+
|
|
47
|
+
Example:
|
|
48
|
+
>>> HFClassifier(
|
|
49
|
+
... "protectai/deberta-v3-base-prompt-injection-v2",
|
|
50
|
+
... answers={"injection": {"yes": "INJECTION"}},
|
|
51
|
+
... field="message",
|
|
52
|
+
... )
|
|
53
|
+
|
|
54
|
+
Requires ``transformers`` and ``torch`` (the ``local-llm`` extra covers both).
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
plane: ClassVar[Plane] = Plane.MODEL
|
|
58
|
+
kinds: ClassVar[frozenset[Kind]] = frozenset({Kind.CHOICE, Kind.YES_NO})
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
model: str,
|
|
63
|
+
*,
|
|
64
|
+
answers: Mapping[str, Mapping[str, str]],
|
|
65
|
+
name: str | None = None,
|
|
66
|
+
field: str | None = None,
|
|
67
|
+
device: str = "auto",
|
|
68
|
+
max_length: int = 512,
|
|
69
|
+
pipeline: Any | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
if not answers:
|
|
72
|
+
raise ValueError("HFClassifier needs at least one question in answers")
|
|
73
|
+
self.model = model
|
|
74
|
+
self.name = name or model.rstrip("/").split("/")[-1]
|
|
75
|
+
self.answers = {key: dict(mapping) for key, mapping in answers.items()}
|
|
76
|
+
self._field = field
|
|
77
|
+
self._device_request = device
|
|
78
|
+
self._max_length = max_length
|
|
79
|
+
self._pipeline = pipeline
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
self.device: str | None = None
|
|
82
|
+
|
|
83
|
+
def supports(self, question: Question) -> bool:
|
|
84
|
+
return question.key in self.answers and super().supports(question)
|
|
85
|
+
|
|
86
|
+
def warmup(self) -> None:
|
|
87
|
+
pipe = self._load()
|
|
88
|
+
with self._lock:
|
|
89
|
+
for _ in range(2):
|
|
90
|
+
pipe("A short message to warm up the classifier.", top_k=None, truncation=True)
|
|
91
|
+
|
|
92
|
+
def _load(self) -> Any:
|
|
93
|
+
if self._pipeline is not None:
|
|
94
|
+
return self._pipeline
|
|
95
|
+
with self._lock:
|
|
96
|
+
if self._pipeline is None:
|
|
97
|
+
try:
|
|
98
|
+
from transformers import pipeline
|
|
99
|
+
except ImportError as exc: # pragma: no cover - depends on the extra
|
|
100
|
+
raise ImportError(
|
|
101
|
+
'HFClassifier needs transformers: pip install "thinkless[local-llm]"'
|
|
102
|
+
) from exc
|
|
103
|
+
device = resolve_device(self._device_request)
|
|
104
|
+
started = time.perf_counter()
|
|
105
|
+
ensure_downloaded(self.model)
|
|
106
|
+
self._pipeline = pipeline(
|
|
107
|
+
"text-classification",
|
|
108
|
+
model=self.model,
|
|
109
|
+
device=device,
|
|
110
|
+
max_length=self._max_length,
|
|
111
|
+
)
|
|
112
|
+
self.device = device
|
|
113
|
+
logger.info(
|
|
114
|
+
"loaded %s on %s in %.1f s", self.model, device, time.perf_counter() - started
|
|
115
|
+
)
|
|
116
|
+
return self._pipeline
|
|
117
|
+
|
|
118
|
+
def _text(self, state: State) -> str:
|
|
119
|
+
if self._field is not None and isinstance(state, Mapping):
|
|
120
|
+
return str(state.get(self._field) or "")
|
|
121
|
+
return render_state(state)
|
|
122
|
+
|
|
123
|
+
def answer(self, state: State, questions: Mapping[str, Question]) -> ProviderResult:
|
|
124
|
+
pipe = self._load()
|
|
125
|
+
with self._lock:
|
|
126
|
+
raw = pipe(self._text(state), top_k=None, truncation=True)
|
|
127
|
+
# Pipelines return a list of {"label", "score"}, sometimes nested once.
|
|
128
|
+
items = raw[0] if raw and isinstance(raw[0], list) else raw
|
|
129
|
+
scores = {str(item["label"]): float(item["score"]) for item in items}
|
|
130
|
+
answers: dict[str, Answer | None] = {}
|
|
131
|
+
for key, question in questions.items():
|
|
132
|
+
mapping = self.answers.get(question.key, {})
|
|
133
|
+
answers[key] = self._answer(question, mapping, scores)
|
|
134
|
+
return ProviderResult(answers=answers, model=self.model, meta={"labels": scores})
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _answer(
|
|
138
|
+
question: Question, mapping: Mapping[str, str], scores: Mapping[str, float]
|
|
139
|
+
) -> Answer | None:
|
|
140
|
+
if isinstance(question, YesNo):
|
|
141
|
+
yes_label = mapping.get("yes")
|
|
142
|
+
if yes_label is None or yes_label not in scores:
|
|
143
|
+
return None
|
|
144
|
+
total = sum(scores.values()) or 1.0
|
|
145
|
+
p_yes = scores[yes_label] / total
|
|
146
|
+
return Answer(value=p_yes >= 0.5, probabilities={"yes": p_yes, "no": 1.0 - p_yes})
|
|
147
|
+
if isinstance(question, Choice):
|
|
148
|
+
mapped = {
|
|
149
|
+
option: scores.get(label, 0.0)
|
|
150
|
+
for option, label in mapping.items()
|
|
151
|
+
if option in question.options
|
|
152
|
+
}
|
|
153
|
+
if not any(mapped.values()):
|
|
154
|
+
return None
|
|
155
|
+
probabilities = normalize_distribution({o: mapped.get(o, 0.0) for o in question.labels})
|
|
156
|
+
best = max(probabilities, key=probabilities.__getitem__)
|
|
157
|
+
return Answer(value=best, probabilities=probabilities)
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def close(self) -> None:
|
|
161
|
+
self._pipeline = None
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Laya: an open-weight System One decision model that runs locally."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from .._hub import ensure_downloaded
|
|
11
|
+
from ..decision import Plane, Usage
|
|
12
|
+
from ..logs import get_logger
|
|
13
|
+
from ..questions import Kind, Question
|
|
14
|
+
from ..settings import resolve_device
|
|
15
|
+
from .base import DecisionProvider, ProviderResult, State
|
|
16
|
+
from .wire import from_wire, to_wire
|
|
17
|
+
|
|
18
|
+
__all__ = ["Laya"]
|
|
19
|
+
|
|
20
|
+
logger = get_logger("providers.laya")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Laya(DecisionProvider):
|
|
24
|
+
"""Answers choice, score and yes/no questions with Laya.
|
|
25
|
+
|
|
26
|
+
Laya is a non-autoregressive encoder (about 421M parameters for the
|
|
27
|
+
English checkpoint) that answers every question of a call in one forward
|
|
28
|
+
pass. Measured on an RTX 5060 laptop GPU: about 30 ms for three questions;
|
|
29
|
+
about 650 ms on CPU.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
model: Hugging Face repo of the checkpoint. ``convaiinnovations/laya``
|
|
33
|
+
is the English model; see the Laya project for the multilingual
|
|
34
|
+
and fine-tuned checkpoints.
|
|
35
|
+
device: ``auto``, ``cpu``, ``cuda`` or ``mps``.
|
|
36
|
+
name: Provider name in traces.
|
|
37
|
+
max_len: Token budget for the state. Longer inputs are truncated by
|
|
38
|
+
the model.
|
|
39
|
+
|
|
40
|
+
Requires the ``laya`` extra. Weights download on first use.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
plane: ClassVar[Plane] = Plane.MODEL
|
|
44
|
+
kinds: ClassVar[frozenset[Kind]] = frozenset({Kind.CHOICE, Kind.SCORE, Kind.YES_NO})
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
model: str = "convaiinnovations/laya",
|
|
49
|
+
*,
|
|
50
|
+
device: str = "auto",
|
|
51
|
+
name: str = "laya",
|
|
52
|
+
max_len: int | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.name = name
|
|
55
|
+
self.model = model
|
|
56
|
+
self._device_request = device
|
|
57
|
+
self._max_len = max_len
|
|
58
|
+
self._agent: Any = None
|
|
59
|
+
self._lock = threading.Lock()
|
|
60
|
+
self.device: str | None = None
|
|
61
|
+
|
|
62
|
+
def warmup(self) -> None:
|
|
63
|
+
"""Load the weights and run one tiny inference, so the first request is not slow."""
|
|
64
|
+
agent = self._load()
|
|
65
|
+
with self._lock:
|
|
66
|
+
agent.predict(
|
|
67
|
+
"Hello, I placed order 4471 last Tuesday and the tracking page has not changed since. "
|
|
68
|
+
"Could you tell me when it will arrive, or refund it if it is lost?",
|
|
69
|
+
{
|
|
70
|
+
"a": {
|
|
71
|
+
"type": "choice",
|
|
72
|
+
"instructions": "Topic?",
|
|
73
|
+
"criteria": {f"t{i}": None for i in range(7)},
|
|
74
|
+
},
|
|
75
|
+
"b": {
|
|
76
|
+
"type": "score",
|
|
77
|
+
"instructions": "Urgency?",
|
|
78
|
+
"criteria": ["low", "medium", "high"],
|
|
79
|
+
},
|
|
80
|
+
"c": {"type": "noul", "instructions": "Is this a test?"},
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def _load(self) -> Any:
|
|
85
|
+
if self._agent is not None:
|
|
86
|
+
return self._agent
|
|
87
|
+
with self._lock:
|
|
88
|
+
if self._agent is None:
|
|
89
|
+
try:
|
|
90
|
+
import laya
|
|
91
|
+
except ImportError as exc: # pragma: no cover - depends on the extra
|
|
92
|
+
raise ImportError(
|
|
93
|
+
'The Laya provider needs the laya extra: pip install "thinkless[laya]"'
|
|
94
|
+
) from exc
|
|
95
|
+
device = resolve_device(self._device_request)
|
|
96
|
+
started = time.perf_counter()
|
|
97
|
+
ensure_downloaded(self.model)
|
|
98
|
+
self._agent = laya.load(self.model, device=device)
|
|
99
|
+
self.device = device
|
|
100
|
+
logger.info(
|
|
101
|
+
"loaded %s on %s in %.1f s", self.model, device, time.perf_counter() - started
|
|
102
|
+
)
|
|
103
|
+
return self._agent
|
|
104
|
+
|
|
105
|
+
def answer(self, state: State, questions: Mapping[str, Question]) -> ProviderResult:
|
|
106
|
+
agent = self._load()
|
|
107
|
+
kwargs: dict[str, Any] = {}
|
|
108
|
+
if self._max_len is not None:
|
|
109
|
+
kwargs["max_len"] = self._max_len
|
|
110
|
+
with self._lock:
|
|
111
|
+
result = agent.predict(state, to_wire(questions), **kwargs)
|
|
112
|
+
usage = result.get("usage") or {}
|
|
113
|
+
return ProviderResult(
|
|
114
|
+
answers=from_wire(result.get("answers") or {}, questions),
|
|
115
|
+
model=result.get("model") or self.model,
|
|
116
|
+
usage=Usage(input_tokens=int(usage.get("input_tokens") or 0)),
|
|
117
|
+
meta={"device": self.device, "checkpoint": self.model},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def close(self) -> None:
|
|
121
|
+
self._agent = None
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""A generative model as a decision provider.
|
|
2
|
+
|
|
3
|
+
This is the usual way agents make decisions today: describe the options in a
|
|
4
|
+
prompt and parse the reply. ThinkLess uses it as the last step of a cascade and
|
|
5
|
+
as the whole decision plane in LLM-only baselines, so benchmarks compare the
|
|
6
|
+
same questions answered two ways.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from collections.abc import Mapping
|
|
14
|
+
from typing import Any, ClassVar
|
|
15
|
+
|
|
16
|
+
from ..decision import Answer, Decision, Plane
|
|
17
|
+
from ..llm.base import LLM
|
|
18
|
+
from ..logs import get_logger
|
|
19
|
+
from ..questions import Choice, Extract, Kind, Question, Score, YesNo
|
|
20
|
+
from .base import DecisionProvider, ProviderResult, State, render_state
|
|
21
|
+
|
|
22
|
+
__all__ = ["LLMDecider", "build_prompt", "parse_reply"]
|
|
23
|
+
|
|
24
|
+
logger = get_logger("providers.llm")
|
|
25
|
+
|
|
26
|
+
# Stop reasons that mean the reply hit the token limit, across backends.
|
|
27
|
+
TRUNCATED = frozenset({"length", "max_tokens"})
|
|
28
|
+
|
|
29
|
+
SYSTEM_PROMPT = (
|
|
30
|
+
"You answer typed questions about an input. Follow the allowed answers exactly. "
|
|
31
|
+
"Reply with a single JSON object and nothing else."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_YES = {"yes", "true", "y", "1"}
|
|
35
|
+
_NO = {"no", "false", "n", "0"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _describe(key: str, question: Question, number: int) -> tuple[str, str]:
|
|
39
|
+
"""Prompt lines for one question and its JSON placeholder."""
|
|
40
|
+
lines = []
|
|
41
|
+
if isinstance(question, Choice):
|
|
42
|
+
lines.append(f'{number}. "{key}": {question.instructions}')
|
|
43
|
+
lines.append(" Answer with exactly one of these labels:")
|
|
44
|
+
for label, description in question.options.items():
|
|
45
|
+
lines.append(f" - {label}" + (f": {description}" if description else ""))
|
|
46
|
+
placeholder = '"<label>"'
|
|
47
|
+
elif isinstance(question, Score):
|
|
48
|
+
lines.append(f'{number}. "{key}": {question.instructions}')
|
|
49
|
+
lines.append(
|
|
50
|
+
" Answer with one level, from lowest to highest: " + ", ".join(question.levels)
|
|
51
|
+
)
|
|
52
|
+
placeholder = '"<level>"'
|
|
53
|
+
elif isinstance(question, YesNo):
|
|
54
|
+
lines.append(f'{number}. "{key}": {question.instructions}')
|
|
55
|
+
if question.yes_means:
|
|
56
|
+
lines.append(f" Yes means: {question.yes_means}")
|
|
57
|
+
if question.no_means:
|
|
58
|
+
lines.append(f" No means: {question.no_means}")
|
|
59
|
+
lines.append(" Answer true or false.")
|
|
60
|
+
placeholder = "<true or false>"
|
|
61
|
+
elif isinstance(question, Extract):
|
|
62
|
+
lines.append(f'{number}. "{key}": {question.instructions}')
|
|
63
|
+
for name, description in question.fields.items():
|
|
64
|
+
lines.append(f" - {name}: {description}")
|
|
65
|
+
lines.append(" Copy values from the input. Use null for a field that is not present.")
|
|
66
|
+
placeholder = (
|
|
67
|
+
"{" + ", ".join(f'"{name}": <string or null>' for name in question.fields) + "}"
|
|
68
|
+
)
|
|
69
|
+
else: # pragma: no cover - guarded by kinds
|
|
70
|
+
raise TypeError(type(question).__name__)
|
|
71
|
+
return "\n".join(lines), f'"{key}": {placeholder}'
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _settled_value(question: Question, decision: Decision) -> str:
|
|
75
|
+
if isinstance(question, YesNo):
|
|
76
|
+
return "yes" if decision.value else "no"
|
|
77
|
+
if isinstance(question, Score):
|
|
78
|
+
return str(decision.level)
|
|
79
|
+
if isinstance(question, Extract):
|
|
80
|
+
return json.dumps(decision.value, ensure_ascii=False)
|
|
81
|
+
return str(decision.value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_prompt(
|
|
85
|
+
state: State,
|
|
86
|
+
questions: Mapping[str, Question],
|
|
87
|
+
context: Mapping[str, tuple[Question, Decision]] | None = None,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""The user prompt for a batch of questions.
|
|
90
|
+
|
|
91
|
+
``context`` lists decisions already settled for the same input by other
|
|
92
|
+
providers. They are shown as established facts so the model reads each
|
|
93
|
+
open question with the same information it would have had in one batch.
|
|
94
|
+
"""
|
|
95
|
+
blocks = []
|
|
96
|
+
shapes = []
|
|
97
|
+
for number, (key, question) in enumerate(questions.items(), start=1):
|
|
98
|
+
block, shape = _describe(key, question, number)
|
|
99
|
+
blocks.append(block)
|
|
100
|
+
shapes.append(shape)
|
|
101
|
+
known = ""
|
|
102
|
+
if context:
|
|
103
|
+
lines = [
|
|
104
|
+
f'- "{key}" ({question.instructions}): {_settled_value(question, decision)}'
|
|
105
|
+
for key, (question, decision) in context.items()
|
|
106
|
+
]
|
|
107
|
+
known = "Already established by other checks:\n" + "\n".join(lines) + "\n\n"
|
|
108
|
+
return (
|
|
109
|
+
"Input:\n<<<\n"
|
|
110
|
+
+ render_state(state)
|
|
111
|
+
+ "\n>>>\n\n"
|
|
112
|
+
+ known
|
|
113
|
+
+ "Questions:\n"
|
|
114
|
+
+ "\n".join(blocks)
|
|
115
|
+
+ "\n\nReply with JSON in this shape:\n{"
|
|
116
|
+
+ ", ".join(shapes)
|
|
117
|
+
+ "}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _json_object(text: str) -> dict[str, Any] | None:
|
|
122
|
+
"""Find the first top-level JSON object in a reply."""
|
|
123
|
+
text = re.sub(r"```(?:json)?", "", text)
|
|
124
|
+
start = text.find("{")
|
|
125
|
+
while start != -1:
|
|
126
|
+
depth = 0
|
|
127
|
+
in_string = False
|
|
128
|
+
escaped = False
|
|
129
|
+
for index in range(start, len(text)):
|
|
130
|
+
char = text[index]
|
|
131
|
+
if in_string:
|
|
132
|
+
if escaped:
|
|
133
|
+
escaped = False
|
|
134
|
+
elif char == "\\":
|
|
135
|
+
escaped = True
|
|
136
|
+
elif char == '"':
|
|
137
|
+
in_string = False
|
|
138
|
+
elif char == '"':
|
|
139
|
+
in_string = True
|
|
140
|
+
elif char == "{":
|
|
141
|
+
depth += 1
|
|
142
|
+
elif char == "}":
|
|
143
|
+
depth -= 1
|
|
144
|
+
if depth == 0:
|
|
145
|
+
candidate = text[start : index + 1]
|
|
146
|
+
try:
|
|
147
|
+
parsed = json.loads(candidate)
|
|
148
|
+
except json.JSONDecodeError:
|
|
149
|
+
try:
|
|
150
|
+
parsed = json.loads(re.sub(r",\s*([}\]])", r"\1", candidate))
|
|
151
|
+
except json.JSONDecodeError:
|
|
152
|
+
break
|
|
153
|
+
return parsed if isinstance(parsed, dict) else None
|
|
154
|
+
start = text.find("{", start + 1)
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _normalize(text: str) -> str:
|
|
159
|
+
return re.sub(r"[\s\-]+", "_", text.strip().lower())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _coerce(question: Question, value: Any) -> Answer | None:
|
|
163
|
+
if value is None:
|
|
164
|
+
return None
|
|
165
|
+
if isinstance(question, Choice):
|
|
166
|
+
wanted = _normalize(str(value))
|
|
167
|
+
for label in question.options:
|
|
168
|
+
if _normalize(label) == wanted:
|
|
169
|
+
return Answer(value=label)
|
|
170
|
+
return None
|
|
171
|
+
if isinstance(question, Score):
|
|
172
|
+
levels = list(question.levels)
|
|
173
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
174
|
+
index = round(float(value))
|
|
175
|
+
return (
|
|
176
|
+
Answer(value=float(index), raw={"level": levels[index]})
|
|
177
|
+
if 0 <= index < len(levels)
|
|
178
|
+
else None
|
|
179
|
+
)
|
|
180
|
+
wanted = _normalize(str(value))
|
|
181
|
+
for index, level in enumerate(levels):
|
|
182
|
+
if _normalize(level) == wanted:
|
|
183
|
+
return Answer(value=float(index), raw={"level": level})
|
|
184
|
+
return None
|
|
185
|
+
if isinstance(question, YesNo):
|
|
186
|
+
if isinstance(value, bool):
|
|
187
|
+
return Answer(value=value)
|
|
188
|
+
word = str(value).strip().lower()
|
|
189
|
+
if word in _YES:
|
|
190
|
+
return Answer(value=True)
|
|
191
|
+
if word in _NO:
|
|
192
|
+
return Answer(value=False)
|
|
193
|
+
return None
|
|
194
|
+
if isinstance(question, Extract):
|
|
195
|
+
if not isinstance(value, Mapping):
|
|
196
|
+
return None
|
|
197
|
+
values = {}
|
|
198
|
+
for name in question.fields:
|
|
199
|
+
item = value.get(name)
|
|
200
|
+
values[name] = None if item in (None, "", "null") else str(item)
|
|
201
|
+
return Answer(value=values)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def parse_reply(text: str, questions: Mapping[str, Question]) -> dict[str, Answer | None]:
|
|
206
|
+
"""Map a model reply onto the questions. Invalid answers become ``None``."""
|
|
207
|
+
payload = _json_object(text) or {}
|
|
208
|
+
lowered = {str(k).lower(): v for k, v in payload.items()}
|
|
209
|
+
return {
|
|
210
|
+
key: _coerce(q, payload.get(key, lowered.get(key.lower()))) for key, q in questions.items()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class LLMDecider(DecisionProvider):
|
|
215
|
+
"""Answers any question kind by prompting a generative model.
|
|
216
|
+
|
|
217
|
+
Answers carry no probabilities, so the engine treats them as uncalibrated:
|
|
218
|
+
by default they are accepted as the final word of a cascade (see
|
|
219
|
+
``Engine(trust_uncalibrated=...)``).
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
llm: The model to prompt.
|
|
223
|
+
name: Provider name. Defaults to ``llm``.
|
|
224
|
+
max_tokens: Reply budget per batch, plus 48 tokens per question.
|
|
225
|
+
retry_on_truncation: When a reply is cut off at the token limit and
|
|
226
|
+
answers are missing, retry once with three times the budget.
|
|
227
|
+
Reasoning models are the usual cause: hidden reasoning tokens
|
|
228
|
+
count against the limit. The retry is recorded in the trace.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
plane: ClassVar[Plane] = Plane.LLM
|
|
232
|
+
kinds: ClassVar[frozenset[Kind]] = frozenset(Kind)
|
|
233
|
+
calibrated: ClassVar[bool] = False
|
|
234
|
+
accepts_context: ClassVar[bool] = True
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
llm: LLM,
|
|
239
|
+
*,
|
|
240
|
+
name: str = "llm",
|
|
241
|
+
max_tokens: int = 256,
|
|
242
|
+
retry_on_truncation: bool = True,
|
|
243
|
+
) -> None:
|
|
244
|
+
self.llm = llm
|
|
245
|
+
self.name = name
|
|
246
|
+
self.price_key = llm.provider
|
|
247
|
+
self._max_tokens = max_tokens
|
|
248
|
+
self._retry_on_truncation = retry_on_truncation
|
|
249
|
+
|
|
250
|
+
def warmup(self) -> None:
|
|
251
|
+
self.llm.warmup()
|
|
252
|
+
|
|
253
|
+
def answer(
|
|
254
|
+
self,
|
|
255
|
+
state: State,
|
|
256
|
+
questions: Mapping[str, Question],
|
|
257
|
+
*,
|
|
258
|
+
context: Mapping[str, tuple[Question, Decision]] | None = None,
|
|
259
|
+
) -> ProviderResult:
|
|
260
|
+
prompt = build_prompt(state, questions, context)
|
|
261
|
+
budget = self._max_tokens + 48 * len(questions)
|
|
262
|
+
messages = [{"role": "user", "content": prompt}]
|
|
263
|
+
completion = self.llm.complete(
|
|
264
|
+
messages, system=SYSTEM_PROMPT, max_tokens=budget, json_mode=True
|
|
265
|
+
)
|
|
266
|
+
answers = parse_reply(completion.text, questions)
|
|
267
|
+
invalid = [key for key, answer in answers.items() if answer is None]
|
|
268
|
+
usage = completion.usage
|
|
269
|
+
cost = completion.cost_usd
|
|
270
|
+
truncated = completion.stop_reason in TRUNCATED and bool(invalid)
|
|
271
|
+
retried = False
|
|
272
|
+
if truncated:
|
|
273
|
+
logger.warning(
|
|
274
|
+
"%s stopped at the %d token limit with %d of %d answers missing; if the model "
|
|
275
|
+
"reasons, lower its reasoning effort (for example --reasoning off)",
|
|
276
|
+
completion.model,
|
|
277
|
+
budget,
|
|
278
|
+
len(invalid),
|
|
279
|
+
len(questions),
|
|
280
|
+
)
|
|
281
|
+
if self._retry_on_truncation:
|
|
282
|
+
retried = True
|
|
283
|
+
completion = self.llm.complete(
|
|
284
|
+
messages, system=SYSTEM_PROMPT, max_tokens=budget * 3, json_mode=True
|
|
285
|
+
)
|
|
286
|
+
answers = parse_reply(completion.text, questions)
|
|
287
|
+
invalid = [key for key, answer in answers.items() if answer is None]
|
|
288
|
+
usage = usage + completion.usage
|
|
289
|
+
if cost is not None and completion.cost_usd is not None:
|
|
290
|
+
cost += completion.cost_usd
|
|
291
|
+
else:
|
|
292
|
+
cost = None
|
|
293
|
+
return ProviderResult(
|
|
294
|
+
answers=answers,
|
|
295
|
+
model=completion.model,
|
|
296
|
+
usage=usage,
|
|
297
|
+
cost_usd=cost,
|
|
298
|
+
meta={
|
|
299
|
+
"invalid": invalid,
|
|
300
|
+
"stop_reason": completion.stop_reason,
|
|
301
|
+
"truncated": truncated,
|
|
302
|
+
"retried": retried,
|
|
303
|
+
},
|
|
304
|
+
content={"system": SYSTEM_PROMPT, "prompt": prompt, "completion": completion.text},
|
|
305
|
+
)
|