topicsleuth 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.
@@ -0,0 +1,238 @@
1
+ """Add a single topic to a populated corpus.
2
+
3
+ `seed` only runs on an empty topics collection and `split-topic` retires a
4
+ parent to extract children, so until now there was no supported way to add a
5
+ topic the pipeline had not proposed on its own. This takes a topic definition
6
+ (the seed_topics.yaml entry shape, plus an optional topic_id and an optional
7
+ list of item URLs whose observations should move onto the new topic as its
8
+ founding evidence), generates the embedding so vector search can match
9
+ against it, inserts the document, and records snapshots on every topic touched.
10
+
11
+ Moved observations keep their strength and connection type; their reasoning
12
+ trace gains a line saying where they came from, so the audit trail survives.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+
23
+ from topicsleuth.history import removed_history
24
+ from topicsleuth.interfaces import Embedder, Store
25
+ from topicsleuth.synthesize import _sanitize_topic_id
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ @dataclass
31
+ class TopicDefinition:
32
+ title: str
33
+ summary: str
34
+ topic_id: str
35
+ tags: list[str] = field(default_factory=list)
36
+ suggested_directions: list[str] = field(default_factory=list)
37
+ link: list[str] = field(default_factory=list) # item URLs to move onto the topic
38
+
39
+
40
+ def load_topic_definition(path: Path) -> TopicDefinition:
41
+ raw = yaml.safe_load(Path(path).read_text()) or {}
42
+ if "topics" in raw:
43
+ raise ValueError(
44
+ "add-topic takes one topic definition, not a seed_topics.yaml file "
45
+ "with a `topics:` list"
46
+ )
47
+ title = (raw.get("title") or "").strip()
48
+ summary = (raw.get("summary") or "").strip()
49
+ if not title or not summary:
50
+ raise ValueError("topic definition needs a title and a summary")
51
+ topic_id = _sanitize_topic_id(raw.get("topic_id") or title)
52
+ if not topic_id:
53
+ raise ValueError("could not derive a topic_id from the title")
54
+ return TopicDefinition(
55
+ title=title,
56
+ summary=summary,
57
+ topic_id=topic_id,
58
+ tags=[str(t) for t in raw.get("tags") or []],
59
+ suggested_directions=[str(d) for d in raw.get("suggested_directions") or []],
60
+ link=[str(u) for u in raw.get("link") or []],
61
+ )
62
+
63
+
64
+ def plan_moves(store: Store, urls: list[str], target_topic_id: str) -> tuple[list[dict], list[str]]:
65
+ """Find the observations for the given item URLs. Returns (observations,
66
+ missing_urls). An item with no observation cannot be moved; it would have
67
+ to go back through the pipeline."""
68
+ found: list[dict] = []
69
+ missing: list[str] = []
70
+ for url in urls:
71
+ obs = store.get_observations_for_item(url)
72
+ obs = [o for o in obs if o.get("topic_id") != target_topic_id]
73
+ if not obs:
74
+ missing.append(url)
75
+ found.extend(obs)
76
+ return found, missing
77
+
78
+
79
+ def add_topic(
80
+ definition: TopicDefinition,
81
+ store: Store,
82
+ embedder: Embedder,
83
+ dry_run: bool = False,
84
+ ) -> dict:
85
+ if store.get_topic(definition.topic_id):
86
+ raise ValueError(f"topic already exists: {definition.topic_id}")
87
+
88
+ moves, missing = plan_moves(store, definition.link, definition.topic_id)
89
+ source_counts: dict[str, int] = {}
90
+ for o in moves:
91
+ source_counts[o["topic_id"]] = source_counts.get(o["topic_id"], 0) + 1
92
+
93
+ report = {
94
+ "dry_run": dry_run,
95
+ "topic_id": definition.topic_id,
96
+ "title": definition.title,
97
+ "observations_to_move": [
98
+ {"item_url": o["item_url"], "item_title": o.get("item_title", ""),
99
+ "from_topic": o["topic_id"], "strength": o.get("strength"),
100
+ "connection_type": o.get("connection_type")}
101
+ for o in moves
102
+ ],
103
+ "urls_without_observation": missing,
104
+ "source_topics_affected": source_counts,
105
+ }
106
+ if dry_run:
107
+ return report
108
+
109
+ embedding = embedder.embed_documents([f"{definition.title}\n\n{definition.summary}"])[0]
110
+ now = datetime.now(timezone.utc)
111
+ store.create_topic({
112
+ "topic_id": definition.topic_id,
113
+ "title": definition.title,
114
+ "summary": definition.summary,
115
+ "tags": definition.tags,
116
+ "suggested_directions": definition.suggested_directions,
117
+ "embedding": embedding,
118
+ "added_manually": True,
119
+ })
120
+ logger.info("Created topic %s", definition.topic_id)
121
+
122
+ retired = move_observations(
123
+ store, moves, definition.topic_id, now, reason="add-topic", retire_empty_sources=False
124
+ )
125
+ report["observations_moved"] = len(moves)
126
+ report["sources_retired"] = retired
127
+ return report
128
+
129
+
130
+ def move_observations(
131
+ store: Store,
132
+ observations: list[dict],
133
+ target_topic_id: str,
134
+ now: datetime,
135
+ reason: str,
136
+ retire_empty_sources: bool = True,
137
+ ) -> list[str]:
138
+ """Re-point observations at target_topic_id, annotate their trace, and
139
+ re-snapshot every topic touched. With retire_empty_sources, a source
140
+ topic left with no observations is retired with merged_into=target so
141
+ the provenance survives (a topic with no evidence is not a topic).
142
+ Returns the ids of retired source topics."""
143
+ from topicsleuth.record import _compute_snapshot
144
+
145
+ sources: set[str] = set()
146
+ for o in observations:
147
+ src = o["topic_id"]
148
+ if src == target_topic_id:
149
+ continue
150
+ sources.add(src)
151
+ # An item may already be evidence on the target (it bore on both
152
+ # questions). One observation per item per topic: the target's
153
+ # survives and the moving one is folded into it, noted in the
154
+ # trace. The fold is one store operation: the target must be live
155
+ # when it commits, and the survivor's update and the source's
156
+ # deletion land together or not at all. The store gives the
157
+ # survivor the folded observation's plan and history, read at the
158
+ # moment of the write, so a link that only it carried, approved
159
+ # and not yet written, survives the fold, and so does whatever
160
+ # another fold into the same survivor added meanwhile.
161
+ holder = next((h for h in store.get_observations_for_item(o["item_url"])
162
+ if h.get("topic_id") == target_topic_id), None)
163
+ if holder is not None:
164
+ note = (
165
+ f"\n\nFOLDED by {reason} on {now.date().isoformat()}: this item's "
166
+ f"observation on '{src}' (strength {o.get('strength')}, {o.get('connection_type')}) "
167
+ f"was merged into this one."
168
+ )
169
+ store.fold_observation(o["_id"], holder["_id"], note)
170
+ logger.info("Folded duplicate observation of %s from %s into %s", o["item_url"], src, target_topic_id)
171
+ continue
172
+ note = (
173
+ f"\n\nMOVED by {reason} on {now.date().isoformat()}: from "
174
+ f"'{src}' to '{target_topic_id}'."
175
+ )
176
+ store.update_observation(o["_id"], {
177
+ "topic_id": target_topic_id,
178
+ "reasoning_trace": (o.get("reasoning_trace") or "") + note,
179
+ "moved_from": src,
180
+ "removed_from": removed_history(o, src),
181
+ })
182
+
183
+ retired: list[str] = []
184
+ for tid in [target_topic_id, *sorted(sources)]:
185
+ store.append_snapshot(tid, _compute_snapshot(store, tid, now))
186
+ if (
187
+ retire_empty_sources
188
+ and tid != target_topic_id
189
+ and store.count_observations_for_topic(tid) == 0
190
+ ):
191
+ topic = store.get_topic(tid)
192
+ if topic and topic.get("status") != "retired":
193
+ store.update_topic(tid, {
194
+ "status": "retired", "retired_at": now, "merged_into": target_topic_id,
195
+ })
196
+ retired.append(tid)
197
+ logger.info("Retired emptied topic %s (merged_into %s)", tid, target_topic_id)
198
+ return retired
199
+
200
+
201
+ def move_observation(
202
+ item_url: str,
203
+ target_topic_id: str,
204
+ store: Store,
205
+ dry_run: bool = False,
206
+ ) -> dict:
207
+ """Move one item's observation(s) onto an existing topic."""
208
+ target = store.get_topic(target_topic_id)
209
+ if not target:
210
+ raise ValueError(f"target topic not found: {target_topic_id}")
211
+ if target.get("status") in ("retired", "deleting"):
212
+ raise ValueError(f"target topic is {target['status']}: {target_topic_id}")
213
+ moves, missing = plan_moves(store, [item_url], target_topic_id)
214
+ if not moves:
215
+ raise ValueError(
216
+ f"no observation to move for {item_url}"
217
+ + (" (already on the target topic or never recorded)" if missing else "")
218
+ )
219
+ will_empty = [
220
+ o["topic_id"] for o in moves
221
+ if store.count_observations_for_topic(o["topic_id"]) == 1
222
+ ]
223
+ report = {
224
+ "dry_run": dry_run,
225
+ "target_topic_id": target_topic_id,
226
+ "moves": [
227
+ {"item_title": o.get("item_title", ""), "from_topic": o["topic_id"],
228
+ "strength": o.get("strength"), "connection_type": o.get("connection_type")}
229
+ for o in moves
230
+ ],
231
+ "sources_that_would_empty": sorted(set(will_empty)),
232
+ }
233
+ if dry_run:
234
+ return report
235
+ report["sources_retired"] = move_observations(
236
+ store, moves, target_topic_id, datetime.now(timezone.utc), reason="move-observation"
237
+ )
238
+ return report
topicsleuth/audit.py ADDED
@@ -0,0 +1,123 @@
1
+ """Snapshot the current store state for before/after comparison.
2
+
3
+ Produces a markdown report covering topic counts, observation distribution,
4
+ strength buckets and connection types. Useful for
5
+ diffing across major operations (reprocess-tail, merge-topics, prompt
6
+ changes, etc.).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections import Counter
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from topicsleuth.interfaces import Store
15
+
16
+ DEFAULT_OUTPUT_DIR = Path("tuning_runs") # relative to the working directory
17
+
18
+
19
+ def _strength_bucket(s: float) -> str:
20
+ if s < 0.3: return "<0.3"
21
+ if s < 0.5: return "0.3-0.5"
22
+ if s < 0.7: return "0.5-0.7"
23
+ if s < 0.8: return "0.7-0.8"
24
+ if s < 0.9: return "0.8-0.9"
25
+ return "0.9+"
26
+
27
+
28
+ def _evidence_bucket(n: int) -> str:
29
+ if n <= 1: return "0-1"
30
+ if n <= 4: return "2-4"
31
+ if n <= 9: return "5-9"
32
+ if n <= 19: return "10-19"
33
+ if n <= 29: return "20-29"
34
+ return "30+"
35
+
36
+
37
+ def build_audit_report(store: Store) -> str:
38
+ """Compute the audit and return it as a markdown string."""
39
+ topics = store.list_topics()
40
+ observations = store.list_observations()
41
+
42
+ lines: list[str] = []
43
+ lines.append(f"# Store state — {datetime.now(timezone.utc).isoformat()}")
44
+ lines.append("")
45
+
46
+ lines.append("## Topics")
47
+ status_counts = Counter(t.get("status", "?") for t in topics)
48
+ lines.append(f"Total: {len(topics)}")
49
+ for status, n in status_counts.most_common():
50
+ lines.append(f" {status:<12} {n}")
51
+ lines.append("")
52
+
53
+ obs_by_topic: Counter = Counter()
54
+ for o in observations:
55
+ obs_by_topic[o["topic_id"]] += 1
56
+
57
+ lines.append("## Topics by observation count (non-retired)")
58
+ bucket_counts: Counter = Counter()
59
+ for topic in topics:
60
+ if topic.get("status") == "retired":
61
+ continue
62
+ n = obs_by_topic.get(topic["topic_id"], 0)
63
+ bucket_counts[_evidence_bucket(n)] += 1
64
+ for bucket in ("0-1", "2-4", "5-9", "10-19", "20-29", "30+"):
65
+ lines.append(f" {bucket:<6} {bucket_counts.get(bucket, 0)}")
66
+ lines.append("")
67
+
68
+ lines.append("## Top 15 topics by observation count")
69
+ topic_lookup = {t["topic_id"]: t for t in topics}
70
+ for tid, n in obs_by_topic.most_common(15):
71
+ t = topic_lookup.get(tid)
72
+ title = t["title"] if t else tid
73
+ status = t.get("status", "?") if t else "?"
74
+ lines.append(f" {n:>4} [{status:<8}] {title[:60]}")
75
+ lines.append("")
76
+
77
+ lines.append("## Observations")
78
+ minor = sum(1 for o in observations if o.get("minor"))
79
+ lines.append(f"Total: {len(observations)}")
80
+ lines.append(f" primary: {len(observations) - minor}")
81
+ lines.append(f" minor: {minor}")
82
+ lines.append("")
83
+
84
+ sb: Counter = Counter()
85
+ for o in observations:
86
+ sb[_strength_bucket(o.get("strength") or 0)] += 1
87
+ lines.append("## Strength distribution")
88
+ for b in ("<0.3", "0.3-0.5", "0.5-0.7", "0.7-0.8", "0.8-0.9", "0.9+"):
89
+ lines.append(f" {b:<10} {sb.get(b, 0)}")
90
+ lines.append("")
91
+
92
+ lines.append("## Connection types")
93
+ ct = Counter(o.get("connection_type", "?") for o in observations)
94
+ for k, n in ct.most_common():
95
+ lines.append(f" {k:<14} {n}")
96
+ lines.append("")
97
+
98
+ return "\n".join(lines)
99
+
100
+
101
+ def write_audit_report(
102
+ store: Store,
103
+ output_path: Path | None = None,
104
+ label: str | None = None,
105
+ ) -> Path:
106
+ """Run the audit and write it to disk. Returns the output path.
107
+
108
+ If output_path is None, writes to tuning_runs/audit-<label or
109
+ timestamp>.md.
110
+ """
111
+ report = build_audit_report(store)
112
+ if output_path is None:
113
+ DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
114
+ if label:
115
+ output_path = DEFAULT_OUTPUT_DIR / f"audit-{label}.md"
116
+ else:
117
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
118
+ output_path = DEFAULT_OUTPUT_DIR / f"audit-{ts}.md"
119
+ else:
120
+ output_path = Path(output_path)
121
+ output_path.parent.mkdir(parents=True, exist_ok=True)
122
+ output_path.write_text(report)
123
+ return output_path
@@ -0,0 +1,5 @@
1
+ """Default implementations of the interfaces, one module per service, each
2
+ importing its SDK lazily so the core package depends on none of them.
3
+
4
+ pip install "topicsleuth[mongo,voyage,anthropic]"
5
+ """
@@ -0,0 +1,28 @@
1
+ """Claude as the judge. ``pip install "topicsleuth[anthropic]"``."""
2
+ from __future__ import annotations
3
+
4
+ from topicsleuth.interfaces import Completion
5
+
6
+
7
+ class AnthropicJudge:
8
+ def __init__(self, model: str = "claude-opus-4-8", client=None, api_key: str | None = None):
9
+ try:
10
+ import anthropic
11
+ except ImportError as e: # pragma: no cover
12
+ raise ImportError('AnthropicJudge needs the anthropic package: pip install "topicsleuth[anthropic]"') from e
13
+ self.model = model
14
+ self._client = client or anthropic.Anthropic(api_key=api_key)
15
+
16
+ def complete(self, system: str, user: str, max_tokens: int = 2048) -> Completion:
17
+ response = self._client.messages.create(
18
+ model=self.model,
19
+ max_tokens=max_tokens,
20
+ system=system,
21
+ messages=[{"role": "user", "content": user}],
22
+ )
23
+ usage = getattr(response, "usage", None)
24
+ return Completion(
25
+ text=response.content[0].text,
26
+ input_tokens=getattr(usage, "input_tokens", None),
27
+ output_tokens=getattr(usage, "output_tokens", None),
28
+ )
@@ -0,0 +1,126 @@
1
+ """Deterministic backends for tests, yours and ours.
2
+
3
+ ``HashEmbedder`` maps text to a stable unit vector, so similar strings do
4
+ not embed similarly but the same string always does and nothing needs a
5
+ network. ``ScriptedJudge`` returns canned completions in order, or whatever a
6
+ callable decides from the prompt; ``ScriptedDecisionModel`` answers the pair
7
+ questions from a table of link probabilities. Together with
8
+ ``topicsleuth.backends.memory.MemoryStore`` they run the whole decision and
9
+ record path offline.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import json
15
+ from collections.abc import Callable, Iterable
16
+
17
+ from topicsleuth.interfaces import Completion, PairScore
18
+
19
+
20
+ class HashEmbedder:
21
+ """Stable pseudo-embeddings. Pass ``vectors`` to pin specific texts to
22
+ specific vectors when a test needs two items to be neighbours."""
23
+
24
+ def __init__(self, dimensions: int = 8, vectors: dict[str, list[float]] | None = None):
25
+ self.model = "hash"
26
+ self.dimensions = dimensions
27
+ self.vectors = dict(vectors or {})
28
+ self.calls: list[list[str]] = []
29
+
30
+ def _one(self, text: str) -> list[float]:
31
+ if text in self.vectors:
32
+ return list(self.vectors[text])
33
+ raw: list[float] = []
34
+ block = 0
35
+ while len(raw) < self.dimensions: # a digest is 32 bytes; chain as many as needed
36
+ digest = hashlib.sha256(f"{block}:{text}".encode()).digest()
37
+ raw.extend((b / 255.0) * 2 - 1 for b in digest)
38
+ block += 1
39
+ raw = raw[: self.dimensions]
40
+ norm = sum(x * x for x in raw) ** 0.5 or 1.0
41
+ return [x / norm for x in raw]
42
+
43
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
44
+ self.calls.append(list(texts))
45
+ return [self._one(t) for t in texts]
46
+
47
+ def embed_queries(self, texts: list[str]) -> list[list[float]]:
48
+ return self.embed_documents(texts)
49
+
50
+
51
+ class ScriptedJudge:
52
+ """Returns completions from a script: a list consumed in order, or a
53
+ callable ``(system, user) -> str | dict``. Dicts are JSON-encoded. Every
54
+ call is recorded in ``calls`` as ``(system, user)``."""
55
+
56
+ def __init__(self, script: Iterable[str | dict] | Callable[[str, str], str | dict] = (), model: str = "scripted"):
57
+ self.model = model
58
+ self._fn = script if callable(script) else None
59
+ self._queue = list(script) if not callable(script) else []
60
+ self.calls: list[tuple[str, str]] = []
61
+
62
+ def queue(self, *items: str | dict) -> "ScriptedJudge":
63
+ """Append completions to the script. Returns self for chaining."""
64
+ self._queue.extend(items)
65
+ return self
66
+
67
+ def complete(self, system: str, user: str, max_tokens: int = 2048) -> Completion:
68
+ self.calls.append((system, user))
69
+ if self._fn is not None:
70
+ out = self._fn(system, user)
71
+ elif self._queue:
72
+ out = self._queue.pop(0)
73
+ else:
74
+ raise AssertionError(f"ScriptedJudge ran out of script at call {len(self.calls)}")
75
+ text = json.dumps(out) if isinstance(out, (dict, list)) else str(out)
76
+ return Completion(text=text, input_tokens=len(system) + len(user), output_tokens=len(text))
77
+
78
+
79
+ class ScriptedDecisionModel:
80
+ """Scores item-topic pairs from ``link_probabilities``, a map of
81
+ candidate topic title to p(belongs). Unknown titles score 0.1. Each
82
+ call is recorded as ``{item, topic, representative_titles}``."""
83
+
84
+ def __init__(self, link_probabilities: dict[str, float] | None = None, model: str = "scripted-decision"):
85
+ self.model = model
86
+ self.link_probabilities = dict(link_probabilities or {})
87
+ self.calls: list[dict] = []
88
+
89
+ def score_pair(self, item: dict, topic: dict, representative_titles: list[str]) -> PairScore:
90
+ self.calls.append({"item": item, "topic": topic, "representative_titles": list(representative_titles)})
91
+ p = self.link_probabilities.get(topic.get("title", ""), 0.1)
92
+ label = "same_question" if p >= 0.7 else "adjacent_question"
93
+ return PairScore(
94
+ label=label, link_probability=p, confidence=0.9,
95
+ probabilities={label: p, "surface_overlap": 1 - p},
96
+ input_tokens=100, model=self.model,
97
+ )
98
+
99
+
100
+ # Process-wide instances the CLI picks up when TOPICSLEUTH_STORE=memory,
101
+ # TOPICSLEUTH_JUDGE=scripted or TOPICSLEUTH_EMBEDDER=hash, so a test can seed
102
+ # a store and queue completions, then run a command in-process.
103
+ _shared: dict[str, object] = {}
104
+
105
+
106
+ def shared(kind: str):
107
+ """``shared("store")``, ``shared("judge")``, ``shared("embedder")``,
108
+ ``shared("decision_model")``: the process-wide fake of that kind,
109
+ created on first use."""
110
+ if kind not in _shared:
111
+ if kind == "store":
112
+ from topicsleuth.backends.memory import MemoryStore
113
+ _shared[kind] = MemoryStore()
114
+ elif kind == "judge":
115
+ _shared[kind] = ScriptedJudge()
116
+ elif kind == "embedder":
117
+ _shared[kind] = HashEmbedder()
118
+ elif kind == "decision_model":
119
+ _shared[kind] = ScriptedDecisionModel()
120
+ else:
121
+ raise KeyError(kind)
122
+ return _shared[kind]
123
+
124
+
125
+ def reset_shared() -> None:
126
+ _shared.clear()
@@ -0,0 +1,95 @@
1
+ """TypeSafe Jev as the decision model.
2
+
3
+ Jev scores a fixed option list in one forward pass and returns a probability
4
+ distribution plus a confidence, with no text. There is no system prompt; the
5
+ rubric from ``topicsleuth.rubric`` is carried in the option descriptions,
6
+ which Jev reads field by field. No SDK: plain HTTPS.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import time
13
+ import urllib.error
14
+ import urllib.request
15
+
16
+ from topicsleuth.interfaces import PairScore
17
+ from topicsleuth.rubric import (
18
+ LINK_CRITERION,
19
+ RELATIONSHIP_CRITERIA,
20
+ RELATIONSHIP_INSTRUCTIONS,
21
+ pair_view,
22
+ )
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ API_URL = "https://api.typesafe.ai/v1/systemone"
27
+ DEFAULT_MODEL = "jev-latest"
28
+
29
+ # The library's rubric in Jev's question format: a choice over the
30
+ # relationship labels and a yes/no ("noul") on whether the item belongs.
31
+ PAIR_QUESTIONS = {
32
+ "relationship": {
33
+ "type": "choice",
34
+ "instructions": RELATIONSHIP_INSTRUCTIONS,
35
+ "criteria": RELATIONSHIP_CRITERIA,
36
+ },
37
+ "link": {
38
+ "type": "noul",
39
+ "instructions": LINK_CRITERION,
40
+ },
41
+ }
42
+
43
+
44
+ class JevError(RuntimeError):
45
+ pass
46
+
47
+
48
+ class JevDecisionModel:
49
+ """Implements the DecisionModel protocol over the TypeSafe API."""
50
+
51
+ def __init__(self, api_key: str, model: str = DEFAULT_MODEL, retries: int = 3, timeout: float = 30.0):
52
+ if not api_key:
53
+ raise ValueError("JevDecisionModel needs an API key")
54
+ self.api_key = api_key
55
+ self.model = model
56
+ self.retries = retries
57
+ self.timeout = timeout
58
+
59
+ def score_pair(self, item: dict, topic: dict, representative_titles: list[str]) -> PairScore:
60
+ resp = self.decide(pair_view(item, topic, representative_titles), PAIR_QUESTIONS)
61
+ answers = resp.get("answers") or {}
62
+ rel = answers.get("relationship") or {}
63
+ link = answers.get("link") or {}
64
+ return PairScore(
65
+ label=rel.get("choice"),
66
+ link_probability=link.get("noul"),
67
+ confidence=rel.get("confidence"),
68
+ probabilities=rel.get("probabilities"),
69
+ input_tokens=(resp.get("usage") or {}).get("input_tokens"),
70
+ model=resp.get("model", self.model),
71
+ )
72
+
73
+ def decide(self, state: dict, questions: dict) -> dict:
74
+ """One raw call: Jev's own request and reply shapes."""
75
+ body = json.dumps({"model": self.model, "state": state, "questions": questions}).encode()
76
+ req = urllib.request.Request(
77
+ API_URL, data=body, method="POST",
78
+ headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
79
+ )
80
+ for attempt in range(self.retries):
81
+ try:
82
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
83
+ return json.loads(r.read())
84
+ except urllib.error.HTTPError as e:
85
+ detail = e.read().decode(errors="replace")[:300]
86
+ if e.code in (429, 500, 502, 503, 504) and attempt < self.retries - 1:
87
+ time.sleep(2 ** attempt)
88
+ continue
89
+ raise JevError(f"HTTP {e.code}: {detail}") from None
90
+ except urllib.error.URLError as e:
91
+ if attempt < self.retries - 1:
92
+ time.sleep(2 ** attempt)
93
+ continue
94
+ raise JevError(str(e)) from None
95
+ raise JevError("unreachable")