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 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
+ ]