jevfilter 0.1.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.
- jevfilter/__init__.py +50 -0
- jevfilter/content.py +46 -0
- jevfilter/defaults.py +52 -0
- jevfilter/engine.py +285 -0
- jevfilter/errors.py +19 -0
- jevfilter/facets/__init__.py +214 -0
- jevfilter/helpers.py +75 -0
- jevfilter/judges/__init__.py +25 -0
- jevfilter/judges/base.py +98 -0
- jevfilter/judges/fake.py +106 -0
- jevfilter/judges/jev.py +90 -0
- jevfilter/policy.py +71 -0
- jevfilter/py.typed +0 -0
- jevfilter/registry.py +68 -0
- jevfilter/result.py +197 -0
- jevfilter/topic.py +598 -0
- jevfilter/version.py +6 -0
- jevfilter/wording.py +129 -0
- jevfilter-0.1.0.dist-info/METADATA +124 -0
- jevfilter-0.1.0.dist-info/RECORD +22 -0
- jevfilter-0.1.0.dist-info/WHEEL +4 -0
- jevfilter-0.1.0.dist-info/licenses/LICENSE +21 -0
jevfilter/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""jevfilter: judge content against plain-English definitions with Jev.
|
|
2
|
+
|
|
3
|
+
```python
|
|
4
|
+
import jevfilter as jf
|
|
5
|
+
|
|
6
|
+
jf.choose("I was charged twice", ["billing", "bug", "feature request"])
|
|
7
|
+
|
|
8
|
+
f = jf.Filter(jf.Topic.load("topics/"))
|
|
9
|
+
r = f.judge("Your application to Acme was received")
|
|
10
|
+
```
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .content import Content
|
|
14
|
+
from .defaults import configure
|
|
15
|
+
from .engine import Filter, Plan
|
|
16
|
+
from .errors import JevFilterError, JudgeError, TopicError
|
|
17
|
+
from .helpers import check, choose, rate
|
|
18
|
+
from .policy import Decision, Policy, ThresholdPolicy, TopicAnswers
|
|
19
|
+
from .registry import facet
|
|
20
|
+
from .result import Choice, Field, Result, Score, TopicResult
|
|
21
|
+
from .topic import Topic, Topics
|
|
22
|
+
from .version import __version__
|
|
23
|
+
from .wording import WORDING_VERSION
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"WORDING_VERSION",
|
|
27
|
+
"Choice",
|
|
28
|
+
"Content",
|
|
29
|
+
"Decision",
|
|
30
|
+
"Field",
|
|
31
|
+
"Filter",
|
|
32
|
+
"JevFilterError",
|
|
33
|
+
"JudgeError",
|
|
34
|
+
"Plan",
|
|
35
|
+
"Policy",
|
|
36
|
+
"Result",
|
|
37
|
+
"Score",
|
|
38
|
+
"ThresholdPolicy",
|
|
39
|
+
"Topic",
|
|
40
|
+
"TopicAnswers",
|
|
41
|
+
"TopicError",
|
|
42
|
+
"TopicResult",
|
|
43
|
+
"Topics",
|
|
44
|
+
"__version__",
|
|
45
|
+
"check",
|
|
46
|
+
"configure",
|
|
47
|
+
"choose",
|
|
48
|
+
"facet",
|
|
49
|
+
"rate",
|
|
50
|
+
]
|
jevfilter/content.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""What gets judged."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Content:
|
|
12
|
+
"""Content to judge, plus optional field candidates and context.
|
|
13
|
+
|
|
14
|
+
`state` is text or a JSON-able dict. `candidates` maps topic name → field
|
|
15
|
+
name → candidate values (use `"*"` as the topic for every topic).
|
|
16
|
+
`context` is extra JSON-able state the model can read (e.g. who the
|
|
17
|
+
reader is); it is sent alongside `content`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
state: Any
|
|
21
|
+
candidates: Mapping[str, Mapping[str, Sequence[str]]] | None = None
|
|
22
|
+
context: Any = None
|
|
23
|
+
|
|
24
|
+
def as_state(self) -> dict[str, Any]:
|
|
25
|
+
state: dict[str, Any] = {"content": self.state}
|
|
26
|
+
if self.context is not None:
|
|
27
|
+
state["context"] = self.context
|
|
28
|
+
return state
|
|
29
|
+
|
|
30
|
+
def candidates_for(self, topic: str, field: str) -> list[str]:
|
|
31
|
+
if not self.candidates:
|
|
32
|
+
return []
|
|
33
|
+
values = [
|
|
34
|
+
*self.candidates.get(topic, {}).get(field, ()),
|
|
35
|
+
*self.candidates.get("*", {}).get(field, ()),
|
|
36
|
+
]
|
|
37
|
+
out: list[str] = []
|
|
38
|
+
for v in values:
|
|
39
|
+
v = v.strip() if isinstance(v, str) else v
|
|
40
|
+
if isinstance(v, str) and v and v not in out:
|
|
41
|
+
out.append(v)
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def as_content(content: Any) -> Content:
|
|
46
|
+
return content if isinstance(content, Content) else Content(content)
|
jevfilter/defaults.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The default judge used by helpers and by `Filter` when none is given."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .judges.base import Judge
|
|
8
|
+
|
|
9
|
+
_default: Judge | None = None
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configure(
|
|
13
|
+
*,
|
|
14
|
+
api_key: str | None = None,
|
|
15
|
+
model: str | None = None,
|
|
16
|
+
judge: Judge | None = None,
|
|
17
|
+
**client_kwargs: Any,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Set the default judge for helpers and filters created without `judge=`.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
jf.configure(api_key="...", model="jev-1.13.0") # a JevJudge
|
|
23
|
+
jf.configure(judge=FakeJudge({...})) # any Judge
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Without this, a `JevJudge` reading `TYPESAFE_API_KEY` from the
|
|
27
|
+
environment is created on first use.
|
|
28
|
+
"""
|
|
29
|
+
global _default
|
|
30
|
+
if judge is not None:
|
|
31
|
+
if api_key is not None or model is not None or client_kwargs:
|
|
32
|
+
raise ValueError("pass either judge= or JevJudge options, not both")
|
|
33
|
+
_default = judge
|
|
34
|
+
return
|
|
35
|
+
from .judges.jev import JevJudge
|
|
36
|
+
|
|
37
|
+
_default = JevJudge(model=model, api_key=api_key, **client_kwargs)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def default_judge() -> Judge:
|
|
41
|
+
global _default
|
|
42
|
+
if _default is None:
|
|
43
|
+
from .judges.jev import JevJudge
|
|
44
|
+
|
|
45
|
+
_default = JevJudge()
|
|
46
|
+
return _default
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def reset() -> None:
|
|
50
|
+
"""Forget the configured default (mainly for tests)."""
|
|
51
|
+
global _default
|
|
52
|
+
_default = None
|
jevfilter/engine.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""The judge pipeline: compile → plan → execute → interpret → decide → report.
|
|
2
|
+
|
|
3
|
+
This version sends every question for one piece of content in a single
|
|
4
|
+
request. Packing / splitting across Jev's context limits comes later.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
from . import registry, wording
|
|
15
|
+
from .content import Content, as_content
|
|
16
|
+
from .defaults import default_judge
|
|
17
|
+
from .errors import JevFilterError, JudgeError
|
|
18
|
+
from .facets import BUILTIN, Facet
|
|
19
|
+
from .judges.base import Answer, Judge, NoulAnswer, Question, Response, answer_to_dict
|
|
20
|
+
from .policy import Policy, ThresholdPolicy, TopicAnswers
|
|
21
|
+
from .result import Result, Score, TopicResult
|
|
22
|
+
from .topic import Topic, Topics, TopicSource, as_topics
|
|
23
|
+
from .version import __version__
|
|
24
|
+
|
|
25
|
+
DEFAULT_PRICE_PER_MTOK = 0.042
|
|
26
|
+
"""Jev's published input price (USD per million tokens) when this was written."""
|
|
27
|
+
|
|
28
|
+
OnError = Literal["raise", "review"] | Judge
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class _Route:
|
|
33
|
+
topic: str
|
|
34
|
+
facet: str # "membership", a built-in facet key or a custom facet key
|
|
35
|
+
local: str # the facet's own question name ("" for a single question)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _Compiled:
|
|
40
|
+
state: dict[str, Any]
|
|
41
|
+
questions: dict[str, Question]
|
|
42
|
+
routes: dict[str, _Route]
|
|
43
|
+
warnings: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Plan:
|
|
48
|
+
"""What `judge()` would send, without sending it."""
|
|
49
|
+
|
|
50
|
+
requests: list[dict[str, Any]]
|
|
51
|
+
estimated_input_tokens: int
|
|
52
|
+
cost_usd: float
|
|
53
|
+
warnings: tuple[str, ...] = ()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Filter:
|
|
57
|
+
"""Topics plus engine configuration. `judge(content)` → `Result`.
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
f = Filter(Topic.load("topics/"))
|
|
61
|
+
r = f.judge({"from": "...", "subject": "...", "body": "..."})
|
|
62
|
+
```
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
topics: TopicSource | Topics,
|
|
68
|
+
*,
|
|
69
|
+
judge: Judge | None = None,
|
|
70
|
+
policy: Policy | None = None,
|
|
71
|
+
on_error: OnError = "raise",
|
|
72
|
+
price_per_mtok: float = DEFAULT_PRICE_PER_MTOK,
|
|
73
|
+
include_requests: bool = False,
|
|
74
|
+
):
|
|
75
|
+
self.topics = as_topics(topics)
|
|
76
|
+
if not self.topics:
|
|
77
|
+
raise ValueError("Filter needs at least one topic")
|
|
78
|
+
if isinstance(on_error, str) and on_error not in ("raise", "review"):
|
|
79
|
+
raise ValueError("on_error must be 'raise', 'review' or a Judge")
|
|
80
|
+
self._backend = judge
|
|
81
|
+
self.policy: Policy = policy or ThresholdPolicy()
|
|
82
|
+
self.on_error = on_error
|
|
83
|
+
self.price_per_mtok = price_per_mtok
|
|
84
|
+
self.include_requests = include_requests
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def backend(self) -> Judge:
|
|
88
|
+
"""The judge backend: the one given, else the `configure()`d default."""
|
|
89
|
+
return self._backend or default_judge()
|
|
90
|
+
|
|
91
|
+
# -- public -------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
def judge(self, content: Any) -> Result:
|
|
94
|
+
"""Judge one piece of content (text, a dict, or `Content`) against every topic."""
|
|
95
|
+
c = as_content(content)
|
|
96
|
+
compiled = self._compile(c)
|
|
97
|
+
response, degraded, error = self._execute(compiled)
|
|
98
|
+
return self._report(compiled, response, degraded, error)
|
|
99
|
+
|
|
100
|
+
def explain(self, content: Any) -> Plan:
|
|
101
|
+
"""The exact request payloads and an estimated cost, without calling the backend."""
|
|
102
|
+
compiled = self._compile(as_content(content))
|
|
103
|
+
request = _payload(compiled)
|
|
104
|
+
tokens = estimate_tokens(request)
|
|
105
|
+
return Plan(
|
|
106
|
+
requests=[request],
|
|
107
|
+
estimated_input_tokens=tokens,
|
|
108
|
+
cost_usd=tokens * self.price_per_mtok / 1e6,
|
|
109
|
+
warnings=tuple(compiled.warnings),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# -- 1. compile ---------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def _compile(self, content: Content) -> _Compiled:
|
|
115
|
+
compiled = _Compiled(content.as_state(), {}, {})
|
|
116
|
+
for t in self.topics.values():
|
|
117
|
+
qid = f"{t.name}/membership"
|
|
118
|
+
compiled.questions[qid] = wording.membership(t)
|
|
119
|
+
compiled.routes[qid] = _Route(t.name, "membership", "")
|
|
120
|
+
for key, facet, config in _facets_of(t):
|
|
121
|
+
for local, q in facet.questions(t, config, content).items():
|
|
122
|
+
qid = f"{t.name}/{key}/{local}" if local else f"{t.name}/{key}"
|
|
123
|
+
compiled.questions[qid] = q
|
|
124
|
+
compiled.routes[qid] = _Route(t.name, key, local)
|
|
125
|
+
warn = getattr(facet, "warnings", None)
|
|
126
|
+
if warn is not None:
|
|
127
|
+
compiled.warnings.extend(warn(t, content))
|
|
128
|
+
return compiled
|
|
129
|
+
|
|
130
|
+
# -- 3/4. execute -------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def _execute(self, compiled: _Compiled) -> tuple[Response | None, bool, str | None]:
|
|
133
|
+
try:
|
|
134
|
+
return self.backend.ask(compiled.state, compiled.questions), False, None
|
|
135
|
+
except JudgeError as e:
|
|
136
|
+
if self.on_error == "raise":
|
|
137
|
+
raise
|
|
138
|
+
if self.on_error == "review":
|
|
139
|
+
return None, True, str(e)
|
|
140
|
+
fallback = self.on_error
|
|
141
|
+
return fallback.ask(compiled.state, compiled.questions), True, str(e)
|
|
142
|
+
|
|
143
|
+
# -- 5/6/7. interpret, decide, report -----------------------------------
|
|
144
|
+
|
|
145
|
+
def _report(
|
|
146
|
+
self,
|
|
147
|
+
compiled: _Compiled,
|
|
148
|
+
response: Response | None,
|
|
149
|
+
degraded: bool,
|
|
150
|
+
error: str | None,
|
|
151
|
+
) -> Result:
|
|
152
|
+
warnings = list(compiled.warnings)
|
|
153
|
+
if error:
|
|
154
|
+
warnings.append(f"backend_error: {error}")
|
|
155
|
+
topics: dict[str, TopicResult] = {}
|
|
156
|
+
if response is None:
|
|
157
|
+
for t in self.topics.values():
|
|
158
|
+
topics[t.name] = TopicResult(
|
|
159
|
+
t.name, "review", 0.0, ("backend_error",), topic_version=t.version
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
grouped = _group(compiled.routes, response.answers)
|
|
163
|
+
for t in self.topics.values():
|
|
164
|
+
topics[t.name] = self._topic_result(t, grouped.get(t.name, {}))
|
|
165
|
+
|
|
166
|
+
tokens = response.input_tokens if response else None
|
|
167
|
+
return Result(
|
|
168
|
+
topics=topics,
|
|
169
|
+
model=response.model if response else None,
|
|
170
|
+
request_ids=tuple(r for r in [response.request_id if response else None] if r),
|
|
171
|
+
input_tokens=tokens,
|
|
172
|
+
cost_usd=tokens * self.price_per_mtok / 1e6 if tokens is not None else None,
|
|
173
|
+
wording_version=wording.WORDING_VERSION,
|
|
174
|
+
jevfilter_version=__version__,
|
|
175
|
+
degraded=degraded,
|
|
176
|
+
warnings=tuple(warnings),
|
|
177
|
+
raw={qid: answer_to_dict(a) for qid, a in response.answers.items()} if response else {},
|
|
178
|
+
requests=[_payload(compiled)] if self.include_requests else None,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def _topic_result(self, t: Topic, answers: dict[str, dict[str, Answer]]) -> TopicResult:
|
|
182
|
+
membership = answers.get("membership", {}).get("")
|
|
183
|
+
if not isinstance(membership, NoulAnswer):
|
|
184
|
+
raise JudgeError(f"no membership answer for topic {t.name!r}")
|
|
185
|
+
|
|
186
|
+
values: dict[str, Any] = {}
|
|
187
|
+
for key, facet, config in _facets_of(t):
|
|
188
|
+
if key in answers or key == "fields":
|
|
189
|
+
values[key] = facet.interpret(t, config, answers.get(key, {}))
|
|
190
|
+
|
|
191
|
+
# `when: {category: [...]}` is independent of the outcome, so apply it first.
|
|
192
|
+
category = values.get("categories")
|
|
193
|
+
_filter(t, values, lambda w: w.mode != "category" or _in(category, w.categories))
|
|
194
|
+
|
|
195
|
+
custom = {k: v for k, v in values.items() if k not in BUILTIN}
|
|
196
|
+
draft = TopicAnswers(
|
|
197
|
+
p=membership.p,
|
|
198
|
+
category=values.get("categories"),
|
|
199
|
+
fields=values.get("fields", {}),
|
|
200
|
+
scores=values.get("scores", {}),
|
|
201
|
+
flags=values.get("flags", {}),
|
|
202
|
+
facets=custom,
|
|
203
|
+
)
|
|
204
|
+
decision = self.policy.decide(t, draft)
|
|
205
|
+
|
|
206
|
+
# Everything but `always` facets is only used when content belongs.
|
|
207
|
+
if decision.outcome == "no":
|
|
208
|
+
_filter(t, values, lambda w: w.mode == "always")
|
|
209
|
+
|
|
210
|
+
scores: dict[str, Score] = values.get("scores", {})
|
|
211
|
+
return TopicResult(
|
|
212
|
+
topic=t.name,
|
|
213
|
+
outcome=decision.outcome,
|
|
214
|
+
p=membership.p,
|
|
215
|
+
reasons=decision.reasons,
|
|
216
|
+
category=values.get("categories"),
|
|
217
|
+
fields=values.get("fields", {}),
|
|
218
|
+
scores=scores,
|
|
219
|
+
flags=values.get("flags", {}),
|
|
220
|
+
composites=_composites(t, scores),
|
|
221
|
+
facets={k: v for k, v in values.items() if k not in BUILTIN},
|
|
222
|
+
topic_version=t.version,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# -- helpers ----------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _facets_of(t: Topic) -> list[tuple[str, Facet, Any]]:
|
|
230
|
+
out: list[tuple[str, Facet, Any]] = []
|
|
231
|
+
for key in BUILTIN:
|
|
232
|
+
config = getattr(t, key)
|
|
233
|
+
if config:
|
|
234
|
+
out.append((key, BUILTIN[key], config))
|
|
235
|
+
for key, config in t.custom.items():
|
|
236
|
+
facet = registry.get_facet(key)
|
|
237
|
+
if facet is None:
|
|
238
|
+
raise JevFilterError(f"Topic {t.name!r}: custom facet {key!r} is no longer registered")
|
|
239
|
+
out.append((key, facet, config))
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _group(
|
|
244
|
+
routes: Mapping[str, _Route], answers: Mapping[str, Answer]
|
|
245
|
+
) -> dict[str, dict[str, dict[str, Answer]]]:
|
|
246
|
+
grouped: dict[str, dict[str, dict[str, Answer]]] = {}
|
|
247
|
+
for qid, a in answers.items():
|
|
248
|
+
route = routes.get(qid)
|
|
249
|
+
if route is not None:
|
|
250
|
+
grouped.setdefault(route.topic, {}).setdefault(route.facet, {})[route.local] = a
|
|
251
|
+
return grouped
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _filter(t: Topic, values: dict[str, Any], keep: Any) -> None:
|
|
255
|
+
"""Drop facet answers (or items within them) whose `when` fails `keep`."""
|
|
256
|
+
for key in list(values):
|
|
257
|
+
value = values[key]
|
|
258
|
+
if key in ("fields", "scores", "flags"):
|
|
259
|
+
values[key] = {n: v for n, v in value.items() if keep(t.when_for(key, n))}
|
|
260
|
+
elif not keep(t.when_for(key)):
|
|
261
|
+
del values[key]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _in(category: Any, names: tuple[str, ...]) -> bool:
|
|
265
|
+
return category is not None and category.value in names
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _composites(t: Topic, scores: Mapping[str, Score]) -> dict[str, float]:
|
|
269
|
+
out = {}
|
|
270
|
+
for name, weights in t.composites.items():
|
|
271
|
+
if all(s in scores for s in weights):
|
|
272
|
+
out[name] = sum(w * scores[s].normalized for s, w in weights.items())
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _payload(compiled: _Compiled) -> dict[str, Any]:
|
|
277
|
+
return {
|
|
278
|
+
"state": compiled.state,
|
|
279
|
+
"questions": {qid: q.to_dict() for qid, q in compiled.questions.items()},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def estimate_tokens(payload: Any) -> int:
|
|
284
|
+
"""Rough input-token estimate: characters ÷ 4."""
|
|
285
|
+
return len(json.dumps(payload, ensure_ascii=False)) // 4 + 1
|
jevfilter/errors.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Typed errors raised by jevfilter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class JevFilterError(Exception):
|
|
7
|
+
"""Base class for every jevfilter error."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TopicError(JevFilterError, ValueError):
|
|
11
|
+
"""A topic definition is invalid. `problems` lists every issue found."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, problems: list[str]):
|
|
14
|
+
self.problems = problems
|
|
15
|
+
super().__init__("\n".join(problems) if len(problems) > 1 else problems[0])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class JudgeError(JevFilterError):
|
|
19
|
+
"""The judge backend failed to answer."""
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Facets: the kinds of judgment a topic asks for.
|
|
2
|
+
|
|
3
|
+
A facet turns a topic's config into questions and reads its answers back.
|
|
4
|
+
Built-ins cover categories, fields, scores and flags; register your own with
|
|
5
|
+
`@jevfilter.facet("name")` and use it as a topic key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any, ClassVar, Protocol
|
|
12
|
+
|
|
13
|
+
from .. import wording
|
|
14
|
+
from ..content import Content
|
|
15
|
+
from ..errors import JevFilterError
|
|
16
|
+
from ..judges.base import Answer, ChoiceAnswer, NoulAnswer, Question, ScoreAnswer
|
|
17
|
+
from ..result import Choice, Field, Score
|
|
18
|
+
from ..topic import Topic
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Facet(Protocol):
|
|
22
|
+
"""Questions are keyed by a local name ("" for a single question); the
|
|
23
|
+
engine prefixes them into question IDs: `<topic>/<facet>[/<name>]`."""
|
|
24
|
+
|
|
25
|
+
def questions(self, topic: Topic, config: Any, content: Content) -> dict[str, Question]: ...
|
|
26
|
+
|
|
27
|
+
def interpret(self, topic: Topic, config: Any, answers: Mapping[str, Answer]) -> Any: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _speculative(topic: Topic, facet: str, item: str | None = None) -> bool:
|
|
31
|
+
return topic.when_for(facet, item).mode != "always"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def to_choice(a: ChoiceAnswer) -> Choice:
|
|
35
|
+
return Choice(a.choice, a.confidence, dict(a.probabilities))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def to_score(a: ScoreAnswer, levels: tuple[Any, ...]) -> Score:
|
|
39
|
+
def label(i: int) -> str:
|
|
40
|
+
return levels[i] if isinstance(levels[i], str) else str(i)
|
|
41
|
+
|
|
42
|
+
best = max(a.probabilities, key=a.probabilities.__getitem__)
|
|
43
|
+
return Score(
|
|
44
|
+
value=a.score,
|
|
45
|
+
level=label(best),
|
|
46
|
+
confidence=a.confidence,
|
|
47
|
+
probabilities={label(i): p for i, p in sorted(a.probabilities.items())},
|
|
48
|
+
max_level=len(levels) - 1,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# -- built-ins --------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class CategoriesFacet:
|
|
56
|
+
def questions(self, topic: Topic, config: Any, content: Content) -> dict[str, Question]:
|
|
57
|
+
if any(not c.is_leaf for c in topic.categories.values()):
|
|
58
|
+
raise JevFilterError(
|
|
59
|
+
f"Topic {topic.name!r}: nested categories aren't supported by this "
|
|
60
|
+
"version of jevfilter yet; use a flat list"
|
|
61
|
+
)
|
|
62
|
+
return {"": wording.categories(topic, topic.categories, _speculative(topic, "categories"))}
|
|
63
|
+
|
|
64
|
+
def interpret(self, topic: Topic, config: Any, answers: Mapping[str, Answer]) -> Choice:
|
|
65
|
+
a = answers[""]
|
|
66
|
+
assert isinstance(a, ChoiceAnswer)
|
|
67
|
+
return to_choice(a)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FieldsFacet:
|
|
71
|
+
def questions(self, topic: Topic, config: Any, content: Content) -> dict[str, Question]:
|
|
72
|
+
out = {}
|
|
73
|
+
for name, spec in topic.fields.items():
|
|
74
|
+
candidates = content.candidates_for(topic.name, name)
|
|
75
|
+
candidates = [c for c in candidates if c != wording.NONE_OF_THESE]
|
|
76
|
+
if candidates:
|
|
77
|
+
out[name] = wording.field(
|
|
78
|
+
topic, spec, candidates, _speculative(topic, "fields", name)
|
|
79
|
+
)
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
def warnings(self, topic: Topic, content: Content) -> list[str]:
|
|
83
|
+
return [
|
|
84
|
+
f"{topic.name}/fields/{name}: no candidates, so it wasn't asked"
|
|
85
|
+
for name in topic.fields
|
|
86
|
+
if not content.candidates_for(topic.name, name)
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
def interpret(
|
|
90
|
+
self, topic: Topic, config: Any, answers: Mapping[str, Answer]
|
|
91
|
+
) -> dict[str, Field]:
|
|
92
|
+
out = {}
|
|
93
|
+
for name in topic.fields:
|
|
94
|
+
a = answers.get(name)
|
|
95
|
+
if isinstance(a, ChoiceAnswer):
|
|
96
|
+
value = None if a.choice == wording.NONE_OF_THESE else a.choice
|
|
97
|
+
out[name] = Field(value, a.confidence, dict(a.probabilities))
|
|
98
|
+
else:
|
|
99
|
+
out[name] = Field(None, 0.0, {})
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class ScoresFacet:
|
|
104
|
+
def questions(self, topic: Topic, config: Any, content: Content) -> dict[str, Question]:
|
|
105
|
+
return {
|
|
106
|
+
name: wording.score(topic, spec, _speculative(topic, "scores", name))
|
|
107
|
+
for name, spec in topic.scores.items()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def interpret(
|
|
111
|
+
self, topic: Topic, config: Any, answers: Mapping[str, Answer]
|
|
112
|
+
) -> dict[str, Score]:
|
|
113
|
+
out = {}
|
|
114
|
+
for name, spec in topic.scores.items():
|
|
115
|
+
a = answers[name]
|
|
116
|
+
assert isinstance(a, ScoreAnswer)
|
|
117
|
+
out[name] = to_score(a, spec.levels)
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class FlagsFacet:
|
|
122
|
+
def questions(self, topic: Topic, config: Any, content: Content) -> dict[str, Question]:
|
|
123
|
+
return {
|
|
124
|
+
name: wording.flag(topic, cond, _speculative(topic, "flags", name))
|
|
125
|
+
for name, cond in topic.flags.items()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
def interpret(
|
|
129
|
+
self, topic: Topic, config: Any, answers: Mapping[str, Answer]
|
|
130
|
+
) -> dict[str, float]:
|
|
131
|
+
out = {}
|
|
132
|
+
for name in topic.flags:
|
|
133
|
+
a = answers[name]
|
|
134
|
+
assert isinstance(a, NoulAnswer)
|
|
135
|
+
out[name] = a.p
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
BUILTIN: dict[str, Facet] = {
|
|
140
|
+
"categories": CategoriesFacet(),
|
|
141
|
+
"fields": FieldsFacet(),
|
|
142
|
+
"scores": ScoresFacet(),
|
|
143
|
+
"flags": FlagsFacet(),
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# -- bases for custom facets ------------------------------------------------
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class _SingleQuestionFacet:
|
|
151
|
+
"""Custom facet asking one question. Set `instructions` (and criteria/levels)."""
|
|
152
|
+
|
|
153
|
+
key: str = "" # set by @jevfilter.facet(...)
|
|
154
|
+
instructions: ClassVar[Any] = None
|
|
155
|
+
|
|
156
|
+
def _instructions(self, topic: Topic) -> Any:
|
|
157
|
+
if self.instructions is None:
|
|
158
|
+
raise JevFilterError(f"{type(self).__name__} needs `instructions`")
|
|
159
|
+
return wording.custom(topic, self.instructions, _speculative(topic, self.key))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class NoulFacet(_SingleQuestionFacet):
|
|
163
|
+
"""A yes/no custom facet; its result is the probability of yes."""
|
|
164
|
+
|
|
165
|
+
criteria: ClassVar[Mapping[str, Any] | None] = None
|
|
166
|
+
|
|
167
|
+
def questions(self, topic: Topic, config: Any, content: Content) -> dict[str, Question]:
|
|
168
|
+
return {"": Question("noul", self._instructions(topic), self.criteria)}
|
|
169
|
+
|
|
170
|
+
def interpret(self, topic: Topic, config: Any, answers: Mapping[str, Answer]) -> float:
|
|
171
|
+
a = answers[""]
|
|
172
|
+
assert isinstance(a, NoulAnswer)
|
|
173
|
+
return a.p
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ChoiceFacet(_SingleQuestionFacet):
|
|
177
|
+
"""A custom facet picking one of `criteria` (label → description)."""
|
|
178
|
+
|
|
179
|
+
criteria: ClassVar[Mapping[str, Any]] = {}
|
|
180
|
+
|
|
181
|
+
def questions(self, topic: Topic, config: Any, content: Content) -> dict[str, Question]:
|
|
182
|
+
if not self.criteria:
|
|
183
|
+
raise JevFilterError(f"{type(self).__name__} needs `criteria`")
|
|
184
|
+
return {"": Question("choice", self._instructions(topic), dict(self.criteria))}
|
|
185
|
+
|
|
186
|
+
def interpret(self, topic: Topic, config: Any, answers: Mapping[str, Answer]) -> Choice:
|
|
187
|
+
a = answers[""]
|
|
188
|
+
assert isinstance(a, ChoiceAnswer)
|
|
189
|
+
return to_choice(a)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class ScoreFacet(_SingleQuestionFacet):
|
|
193
|
+
"""A custom facet rating content on ordered `levels` (low → high)."""
|
|
194
|
+
|
|
195
|
+
levels: ClassVar[tuple[Any, ...]] = ()
|
|
196
|
+
|
|
197
|
+
def questions(self, topic: Topic, config: Any, content: Content) -> dict[str, Question]:
|
|
198
|
+
if len(self.levels) < 2:
|
|
199
|
+
raise JevFilterError(f"{type(self).__name__} needs at least two `levels`")
|
|
200
|
+
return {"": Question("score", self._instructions(topic), list(self.levels))}
|
|
201
|
+
|
|
202
|
+
def interpret(self, topic: Topic, config: Any, answers: Mapping[str, Answer]) -> Score:
|
|
203
|
+
a = answers[""]
|
|
204
|
+
assert isinstance(a, ScoreAnswer)
|
|
205
|
+
return to_score(a, tuple(self.levels))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__ = [
|
|
209
|
+
"BUILTIN",
|
|
210
|
+
"ChoiceFacet",
|
|
211
|
+
"Facet",
|
|
212
|
+
"NoulFacet",
|
|
213
|
+
"ScoreFacet",
|
|
214
|
+
]
|