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.
- topicsleuth/add_topic.py +238 -0
- topicsleuth/audit.py +123 -0
- topicsleuth/backends/__init__.py +5 -0
- topicsleuth/backends/anthropic.py +28 -0
- topicsleuth/backends/fake.py +126 -0
- topicsleuth/backends/jev.py +95 -0
- topicsleuth/backends/memory.py +346 -0
- topicsleuth/backends/mongo.py +390 -0
- topicsleuth/backends/voyage.py +41 -0
- topicsleuth/cli.py +639 -0
- topicsleuth/config/settings.yaml +52 -0
- topicsleuth/embeddings.py +189 -0
- topicsleuth/env.py +88 -0
- topicsleuth/history.py +63 -0
- topicsleuth/interfaces.py +301 -0
- topicsleuth/merge.py +288 -0
- topicsleuth/py.typed +0 -0
- topicsleuth/record.py +835 -0
- topicsleuth/reprocess.py +160 -0
- topicsleuth/rubric.py +719 -0
- topicsleuth/settings.py +209 -0
- topicsleuth/split.py +346 -0
- topicsleuth/synthesize.py +1252 -0
- topicsleuth/tune.py +421 -0
- topicsleuth-0.1.0.dist-info/METADATA +618 -0
- topicsleuth-0.1.0.dist-info/RECORD +30 -0
- topicsleuth-0.1.0.dist-info/WHEEL +5 -0
- topicsleuth-0.1.0.dist-info/entry_points.txt +2 -0
- topicsleuth-0.1.0.dist-info/licenses/LICENSE +21 -0
- topicsleuth-0.1.0.dist-info/top_level.txt +1 -0
topicsleuth/add_topic.py
ADDED
|
@@ -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,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")
|