topicsleuth 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- topicsleuth-0.1.0/PKG-INFO +618 -0
- topicsleuth-0.1.0/README.md +581 -0
- topicsleuth-0.1.0/pyproject.toml +61 -0
- topicsleuth-0.1.0/setup.cfg +4 -0
- topicsleuth-0.1.0/tests/test_add_topic.py +161 -0
- topicsleuth-0.1.0/tests/test_audit.py +58 -0
- topicsleuth-0.1.0/tests/test_backend_adapters.py +172 -0
- topicsleuth-0.1.0/tests/test_cli.py +192 -0
- topicsleuth-0.1.0/tests/test_embeddings.py +111 -0
- topicsleuth-0.1.0/tests/test_end_to_end.py +392 -0
- topicsleuth-0.1.0/tests/test_memory_store.py +81 -0
- topicsleuth-0.1.0/tests/test_record.py +293 -0
- topicsleuth-0.1.0/tests/test_rubric.py +167 -0
- topicsleuth-0.1.0/tests/test_store_contract.py +380 -0
- topicsleuth-0.1.0/tests/test_synthesize.py +605 -0
- topicsleuth-0.1.0/tests/test_write_path.py +2247 -0
- topicsleuth-0.1.0/topicsleuth/add_topic.py +238 -0
- topicsleuth-0.1.0/topicsleuth/audit.py +123 -0
- topicsleuth-0.1.0/topicsleuth/backends/__init__.py +5 -0
- topicsleuth-0.1.0/topicsleuth/backends/anthropic.py +28 -0
- topicsleuth-0.1.0/topicsleuth/backends/fake.py +126 -0
- topicsleuth-0.1.0/topicsleuth/backends/jev.py +95 -0
- topicsleuth-0.1.0/topicsleuth/backends/memory.py +346 -0
- topicsleuth-0.1.0/topicsleuth/backends/mongo.py +390 -0
- topicsleuth-0.1.0/topicsleuth/backends/voyage.py +41 -0
- topicsleuth-0.1.0/topicsleuth/cli.py +639 -0
- topicsleuth-0.1.0/topicsleuth/config/settings.yaml +52 -0
- topicsleuth-0.1.0/topicsleuth/embeddings.py +189 -0
- topicsleuth-0.1.0/topicsleuth/env.py +88 -0
- topicsleuth-0.1.0/topicsleuth/history.py +63 -0
- topicsleuth-0.1.0/topicsleuth/interfaces.py +301 -0
- topicsleuth-0.1.0/topicsleuth/merge.py +288 -0
- topicsleuth-0.1.0/topicsleuth/py.typed +0 -0
- topicsleuth-0.1.0/topicsleuth/record.py +835 -0
- topicsleuth-0.1.0/topicsleuth/reprocess.py +160 -0
- topicsleuth-0.1.0/topicsleuth/rubric.py +719 -0
- topicsleuth-0.1.0/topicsleuth/settings.py +209 -0
- topicsleuth-0.1.0/topicsleuth/split.py +346 -0
- topicsleuth-0.1.0/topicsleuth/synthesize.py +1252 -0
- topicsleuth-0.1.0/topicsleuth/tune.py +421 -0
- topicsleuth-0.1.0/topicsleuth.egg-info/PKG-INFO +618 -0
- topicsleuth-0.1.0/topicsleuth.egg-info/SOURCES.txt +45 -0
- topicsleuth-0.1.0/topicsleuth.egg-info/dependency_links.txt +1 -0
- topicsleuth-0.1.0/topicsleuth.egg-info/entry_points.txt +2 -0
- topicsleuth-0.1.0/topicsleuth.egg-info/requires.txt +20 -0
- topicsleuth-0.1.0/topicsleuth.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dachary Carey
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: topicsleuth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Topic detection and tracking over a stream of documents: link each item to a topic or open a new one, keep the evidence, and measure how topics move.
|
|
5
|
+
Author-email: Dachary Carey <dc@dacharycarey.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://topicsleuth.dev
|
|
8
|
+
Project-URL: Repository, https://github.com/agent-ecosystem/topicsleuth
|
|
9
|
+
Project-URL: Issues, https://github.com/agent-ecosystem/topicsleuth/issues
|
|
10
|
+
Keywords: topic detection,topic tracking,topic detection and tracking,first-story detection,llm,vector search,mongodb
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
17
|
+
Classifier: Topic :: Text Processing :: Indexing
|
|
18
|
+
Requires-Python: >=3.12
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: pyyaml
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Requires-Dist: python-dotenv
|
|
24
|
+
Provides-Extra: mongo
|
|
25
|
+
Requires-Dist: pymongo>=4.6; extra == "mongo"
|
|
26
|
+
Provides-Extra: voyage
|
|
27
|
+
Requires-Dist: voyageai; extra == "voyage"
|
|
28
|
+
Provides-Extra: anthropic
|
|
29
|
+
Requires-Dist: anthropic; extra == "anthropic"
|
|
30
|
+
Provides-Extra: all
|
|
31
|
+
Requires-Dist: pymongo>=4.6; extra == "all"
|
|
32
|
+
Requires-Dist: voyageai; extra == "all"
|
|
33
|
+
Requires-Dist: anthropic; extra == "all"
|
|
34
|
+
Provides-Extra: test
|
|
35
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# topicsleuth
|
|
39
|
+
|
|
40
|
+
You're interested in something specific. New content arrives every day:
|
|
41
|
+
papers, release notes, posts, discussion threads. topicsleuth remembers
|
|
42
|
+
them for you. It works out which ongoing question each new document
|
|
43
|
+
belongs to, keeps the content related to every question in one place,
|
|
44
|
+
and tells you which questions are gathering momentum, settling, or going
|
|
45
|
+
quiet. It is the memory behind the topics you care about, so
|
|
46
|
+
"what happened to that thing from two weeks ago?" has an answer.
|
|
47
|
+
|
|
48
|
+
If that is the problem you have, the next section gets you running. The
|
|
49
|
+
rest of this page explains what the library does and how, so you can
|
|
50
|
+
decide whether it fits before you commit to it.
|
|
51
|
+
|
|
52
|
+
## Try it in five minutes
|
|
53
|
+
|
|
54
|
+
To use topicsleuth with the default backends, you need:
|
|
55
|
+
|
|
56
|
+
- Python 3.12 or higher
|
|
57
|
+
- A MongoDB Atlas cluster
|
|
58
|
+
- A vector search index on the `topics` collection with:
|
|
59
|
+
- path `embedding`
|
|
60
|
+
- cosine similarity
|
|
61
|
+
- number of dimensions matching your embedding model
|
|
62
|
+
- index name `topic_embedding_index` (or set `TOPICSLEUTH_VECTOR_INDEX`)
|
|
63
|
+
- API keys for:
|
|
64
|
+
- Voyage embeddings
|
|
65
|
+
- Anthropic
|
|
66
|
+
|
|
67
|
+
Other databases and models work too; see "Bring your own models and
|
|
68
|
+
database" below.
|
|
69
|
+
|
|
70
|
+
### Install topicsleuth
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
pip install "topicsleuth[all]"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Tell it what you're interested in
|
|
77
|
+
|
|
78
|
+
The judge works from prompts that describe the field it is working in.
|
|
79
|
+
The defaults describe a feed of papers, release notes and posts about
|
|
80
|
+
a technical field. If that is not the content you're interested in,
|
|
81
|
+
write a settings file before the first run, so the first topics it
|
|
82
|
+
opens are the right shape. Two things go in it: the domain, as a
|
|
83
|
+
noun phrase, and a `rubric` block with the prose the judge reads about
|
|
84
|
+
your field.
|
|
85
|
+
|
|
86
|
+
```yaml
|
|
87
|
+
domain: municipal housing policy
|
|
88
|
+
rubric:
|
|
89
|
+
source_kinds: council minutes, local news, planning filings, advocacy reports
|
|
90
|
+
reading_by_kind: a filing bears on a question by proposing a change, a minute by recording what was decided, a news story by reporting an outcome, an advocacy report by arguing a position
|
|
91
|
+
topic_size: 10-50 observations over a year
|
|
92
|
+
question_examples:
|
|
93
|
+
- question: Are cities loosening parking minimums near transit?
|
|
94
|
+
subject: parking
|
|
95
|
+
- question: Is inclusionary zoning producing units where it is adopted?
|
|
96
|
+
subject: zoning
|
|
97
|
+
subset_example: The candidate asks whether cities are loosening parking minimums near transit; the item is one city's ordinance doing so. The item is a subset, not a different question.
|
|
98
|
+
counter_examples:
|
|
99
|
+
- A council agenda that lists every item of the month, with candidates all rated `adjacent_question`: skip, not a "city council activity" meta-topic.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Every key is optional, and the full list with what each one does is on
|
|
103
|
+
`RubricConfig` in `topicsleuth/rubric.py`. Only the wording about your
|
|
104
|
+
field is yours to set. The labels the judge applies, the rules it follows
|
|
105
|
+
and the questions it answers are fixed, because the code depends on them.
|
|
106
|
+
`topicsleuth show-prompts` prints the prompts exactly as the judge will
|
|
107
|
+
see them, which is the quickest way to check that the result reads well.
|
|
108
|
+
|
|
109
|
+
### Use the library
|
|
110
|
+
|
|
111
|
+
To use the default clients, import from `backends`. Otherwise,
|
|
112
|
+
import `record`, `settings`, and `synthesize` to make and store
|
|
113
|
+
observations related to your topics:
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from datetime import date
|
|
117
|
+
|
|
118
|
+
from topicsleuth.backends.anthropic import AnthropicJudge
|
|
119
|
+
from topicsleuth.backends.mongo import MongoStore
|
|
120
|
+
from topicsleuth.backends.voyage import VoyageEmbedder
|
|
121
|
+
from topicsleuth.record import record
|
|
122
|
+
from topicsleuth.settings import load_settings
|
|
123
|
+
from topicsleuth.synthesize import synthesize_items
|
|
124
|
+
|
|
125
|
+
settings = load_settings("settings.yaml")
|
|
126
|
+
store = MongoStore.from_uri(MONGODB_URI, database="briefing")
|
|
127
|
+
embedder = VoyageEmbedder(model="voyage-4", dimensions=settings.embedding.dimensions)
|
|
128
|
+
judge = AnthropicJudge(model="claude-opus-4-8")
|
|
129
|
+
|
|
130
|
+
items = fetch_new_documents() # your dicts: url, title, summary, source_type, tags ...
|
|
131
|
+
|
|
132
|
+
class Eval: # what the library needs per document
|
|
133
|
+
def __init__(self, url, embedding, admitted):
|
|
134
|
+
self.url, self.embedding, self.above_threshold = url, embedding, admitted
|
|
135
|
+
|
|
136
|
+
vectors = embedder.embed_documents([f"{d['title']}\n\n{d.get('summary', '')}" for d in items])
|
|
137
|
+
evals = [Eval(d["url"], v, True) for d, v in zip(items, vectors)]
|
|
138
|
+
|
|
139
|
+
decisions = synthesize_items(items, evals, store, settings, judge, embedder)
|
|
140
|
+
result = record(items, evals, decisions, store, settings=settings, embedder=embedder,
|
|
141
|
+
run_id=f"my-feed-{date.today()}") # so `clean --run-id` can remove this run
|
|
142
|
+
|
|
143
|
+
for obs in result.observations_created:
|
|
144
|
+
print(f"{obs['item_title']} -> {obs['topic_id']} ({obs['connection_type']})")
|
|
145
|
+
for obs in result.already_recorded: # held from an earlier run; not judged or written again
|
|
146
|
+
print(f"{obs['item_title']} was already on {obs['topic_id']}")
|
|
147
|
+
for failure in result.failures: # retry these next run; do not mark them done
|
|
148
|
+
print(f"{failure.item_url} failed at {failure.stage}: {failure.error}")
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Recording is safe to retry. A document is evidence for a topic once: the
|
|
152
|
+
store holds one observation per document per topic, so running the same
|
|
153
|
+
batch twice, or two programs admitting the same document, writes it once,
|
|
154
|
+
and a document the store already holds is not judged again; the result
|
|
155
|
+
lists it under `already_recorded`. A document that could not be
|
|
156
|
+
finished is listed in `result.failures` with the stage it stopped at.
|
|
157
|
+
Keep it in your queue and hand it over again next run: every stage is
|
|
158
|
+
idempotent, so the retry completes what the failed run left undone (a
|
|
159
|
+
snapshot, a status, an embedding) without repeating what it did. Pass a
|
|
160
|
+
`run_id` and everything the run writes is stamped with it, which is what
|
|
161
|
+
lets `clean --run-id` remove one run and nothing else.
|
|
162
|
+
|
|
163
|
+
## Keep track of content across days, not just today
|
|
164
|
+
|
|
165
|
+
Anyone who reads a feed and writes a summary ends up with the same thing:
|
|
166
|
+
a good account of today, and no connection to yesterday. The paper from
|
|
167
|
+
February, the product launch in June and the tutorial this morning are
|
|
168
|
+
one story; an idea becomes a product which begets a practice. Your daily
|
|
169
|
+
summary can't tell that story because it has no memory.
|
|
170
|
+
|
|
171
|
+
Think of topicsleuth as the tool that helps you keep a notebook with one
|
|
172
|
+
page per question. A subject like "security" is too broad. Instead, the
|
|
173
|
+
topic is a question the field keeps working on, such as "do coding
|
|
174
|
+
assistants make developers write less secure code?" A question is the
|
|
175
|
+
right size for a page: specific enough that a document clearly belongs
|
|
176
|
+
on it or not, but also broad enough to gather evidence for months.
|
|
177
|
+
|
|
178
|
+
When you let topicsleuth keep that notebook, each piece of content that
|
|
179
|
+
bears on a question becomes an **observation** on that topic's page. A
|
|
180
|
+
document that bears on two questions, say a paper that is both about
|
|
181
|
+
evaluation design and about how a coding harness runs commands, gets an
|
|
182
|
+
entry on both pages, each judged on its own. An observation records which
|
|
183
|
+
document it was, how strongly it bears on the question, and a label that
|
|
184
|
+
says how it connects to what's already in the topic. The label is there so you can see at a glance when you open a topic
|
|
185
|
+
which entries agree with each other and which ones pushed the question
|
|
186
|
+
somewhere new. An observation can relate to its topic in one of four ways:
|
|
187
|
+
|
|
188
|
+
- **supports**: adds evidence consistent with what the topic already says.
|
|
189
|
+
Most observations are this.
|
|
190
|
+
- **extends**: broadens the question by bringing in a facet, method, or
|
|
191
|
+
kind of source the page had not covered.
|
|
192
|
+
- **contradicts**: offers evidence against the topic's claim.
|
|
193
|
+
- **shifts**: marks a change in the topic's trajectory, such as the first
|
|
194
|
+
vendor to turn a practice on by default.
|
|
195
|
+
|
|
196
|
+
When a piece of content arrives that raises a question with no page yet,
|
|
197
|
+
it starts a new one. And because the entries on each page are dated, it can
|
|
198
|
+
measure what a summary never could: how fast a question is gathering
|
|
199
|
+
evidence, from how many different sources, and where the question is in
|
|
200
|
+
its life. Every topic carries a status that moves as evidence arrives or
|
|
201
|
+
stops arriving:
|
|
202
|
+
|
|
203
|
+
- **emerging**: a new page with fewer than five observations. It may be
|
|
204
|
+
the start of something, or a one-off.
|
|
205
|
+
- **active**: five or more observations. The question has drawn repeated
|
|
206
|
+
evidence.
|
|
207
|
+
- **mature**: twenty or more observations, a well-established thread. From
|
|
208
|
+
here on, a weak match (strength below 0.7) is still filed but marked
|
|
209
|
+
minor, so a mature topic does not drift on tangential links. Both numbers
|
|
210
|
+
are settings, under `record`.
|
|
211
|
+
- **fading**: an active or mature topic that has gone a week with no new
|
|
212
|
+
evidence. Fresh evidence sends it back to active.
|
|
213
|
+
- **dormant**: a fading topic whose last evidence is more than thirty days
|
|
214
|
+
old. Fresh evidence sends it back to emerging, to earn its place again.
|
|
215
|
+
|
|
216
|
+
Topics you merge or split are marked **retired** and keep a pointer to
|
|
217
|
+
what replaced them, so old links still resolve.
|
|
218
|
+
|
|
219
|
+
## Know whether it fits before you build on it
|
|
220
|
+
|
|
221
|
+
You might find topicsleuth useful when:
|
|
222
|
+
|
|
223
|
+
- You have a steady stream of content about one area of interest.
|
|
224
|
+
- You want continuity across days, not only a digest of each day.
|
|
225
|
+
- You are willing to pay for at least one call to a strong language model
|
|
226
|
+
per document you choose to track. The judgment of where a document belongs
|
|
227
|
+
is made by a model, and that is where the cost goes.
|
|
228
|
+
|
|
229
|
+
You will also need a database with vector search; MongoDB Atlas works out
|
|
230
|
+
of the box, and anything else needs an adapter (see "Bring your own
|
|
231
|
+
models and database" for what one has to provide).
|
|
232
|
+
|
|
233
|
+
It is the wrong tool if you want to discover clusters in a corpus you
|
|
234
|
+
already have (that is topic modeling), if you want a dashboard or alerts
|
|
235
|
+
(this produces the record a dashboard would read), or if you were
|
|
236
|
+
searching for something to do with Kafka topics.
|
|
237
|
+
|
|
238
|
+
## How a piece of content finds its topic
|
|
239
|
+
|
|
240
|
+
Let's follow one document through the library: a changelog entry announcing
|
|
241
|
+
that a coding tool now runs commands in a sandbox.
|
|
242
|
+
|
|
243
|
+
**First, the library looks nearby.** Every topic and every document has an
|
|
244
|
+
embedding, a vector that places its meaning so that similar things sit
|
|
245
|
+
close together. The library pulls the topics nearest to the document,
|
|
246
|
+
around thirty, and adds any that share a tag with it. This is quick and
|
|
247
|
+
cheap, and it only finds the neighborhood; it does not decide anything.
|
|
248
|
+
|
|
249
|
+
Tags are the one piece of your own vocabulary the library uses. A
|
|
250
|
+
document arrives with whatever tags your program gave it, free-form
|
|
251
|
+
strings such as `security` or `benchmarks`, and the judge sees them with
|
|
252
|
+
the document. When the judge opens a topic it gives the page tags of its
|
|
253
|
+
own, a merge or a split rewrites them, and topics you seed or add by hand
|
|
254
|
+
carry the tags you wrote. From then on the tags are a second way to find
|
|
255
|
+
the neighborhood: a topic that shares a tag with the document joins the
|
|
256
|
+
candidates even when its embedding sits far away. Each entry also keeps a
|
|
257
|
+
copy of its document's tags, so you can see later how a page's evidence
|
|
258
|
+
breaks down. A tag every document carries, such as the name of the feed,
|
|
259
|
+
would match every page; list it under `ignored_tags` in settings and the
|
|
260
|
+
library drops it from matching and from the tags of new topics.
|
|
261
|
+
|
|
262
|
+
**Then a language model decides.** Being near a topic is not the same as
|
|
263
|
+
belonging to it. A document can sit right beside a topic and be about a
|
|
264
|
+
different question. So the library shows a language model, which it calls
|
|
265
|
+
the **judge**, the document and the similar topics, each with its
|
|
266
|
+
one-sentence summary and a few of the entries already on it. The lib asks
|
|
267
|
+
the judge if the document's question is the same as the existing topic,
|
|
268
|
+
a narrower case of it, a neighbor of it, or does it only share vocabulary?
|
|
269
|
+
The document goes on the page of every similar topic that is the same
|
|
270
|
+
question or a narrower case, as an observation on each, scored for each.
|
|
271
|
+
If there is none, the judge may propose a new topic, or decide the
|
|
272
|
+
document is interesting but not the start of anything.
|
|
273
|
+
|
|
274
|
+
The sandbox changelog lands on "are coding tools isolating what agents
|
|
275
|
+
execute by default?", even though every other entry on that page is a
|
|
276
|
+
paper and this is a release note. That is the point of the whole design:
|
|
277
|
+
a topic gathers evidence of every kind related to the shared question.
|
|
278
|
+
|
|
279
|
+
**New topics get a second opinion.** Putting a document on a slightly
|
|
280
|
+
wrong page costs one noisy entry, easily moved later. Opening a page that
|
|
281
|
+
should have been part of an existing one is worse: from then on, evidence
|
|
282
|
+
splits between the two and nothing repairs it on its own. Because the two
|
|
283
|
+
mistakes are not equal, every proposed topic goes to a second, independent
|
|
284
|
+
model call that sees only the closest existing topics and the proposal,
|
|
285
|
+
and can confirm it, fold the document into an existing topic instead, or
|
|
286
|
+
drop it.
|
|
287
|
+
|
|
288
|
+
**Finally, it is written down.** The entry is recorded with a strength
|
|
289
|
+
that comes from three yes-or-no questions the judge answers about fit,
|
|
290
|
+
never a number the model invents. The page's statistics are refreshed,
|
|
291
|
+
and if the topic has crossed a threshold, its status changes.
|
|
292
|
+
|
|
293
|
+
## Keep the judge honest
|
|
294
|
+
|
|
295
|
+
The decision at the heart of the library is a judgment, and judgments
|
|
296
|
+
drift. Edit the prompt, upgrade the model, change how many candidates the
|
|
297
|
+
judge sees, and the topic assignment results can change without any
|
|
298
|
+
noise letting you know something is different.
|
|
299
|
+
|
|
300
|
+
In real-world use, changing the judge model from one Opus version to a
|
|
301
|
+
newer one resulted in far more new topics being created versus observations
|
|
302
|
+
linking to existing topics. Nothing in a daily briefing hints that
|
|
303
|
+
evidence is spreading across an increasing percentage of one-item topics
|
|
304
|
+
until you notice the topic library has grown by hundreds.
|
|
305
|
+
|
|
306
|
+
Treat the judge the way you would treat any classifier: take a baseline,
|
|
307
|
+
change one thing, and measure. The library provides a tool for this; the
|
|
308
|
+
ability to replay the decision on documents the store already holds,
|
|
309
|
+
without writing anything:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
# A baseline on 30 documents already in the store, drawn twice.
|
|
313
|
+
topicsleuth tune synthesize --sample-query '{"source": "my-feed"}' --n 30 --seed 42 --label baseline
|
|
314
|
+
topicsleuth tune synthesize --sample-query '{"source": "my-feed"}' --n 30 --seed 7 --label baseline
|
|
315
|
+
|
|
316
|
+
# Each baseline writes tuning_runs/synthesize-<label>-n<n>-seed<seed>.json,
|
|
317
|
+
# so the two runs above land in two files.
|
|
318
|
+
|
|
319
|
+
# After a change, use `--replay` with the baseline report, then the diff.
|
|
320
|
+
topicsleuth tune synthesize --replay tuning_runs/synthesize-baseline-n30-seed42.json --label after-42
|
|
321
|
+
python scripts/diff_tuning_runs.py tuning_runs/synthesize-baseline-n30-seed42.json tuning_runs/synthesize-after-42-replay.json
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Generate a baseline report over a sample of documents that you can
|
|
325
|
+
use to to check how the judge assesses documents before and after
|
|
326
|
+
a change. Run at least two sample sets before assessing whether a
|
|
327
|
+
change has had the desired result. A fair share of documents often
|
|
328
|
+
sit right on the boundary between "link" and "new topic" and can
|
|
329
|
+
flip on their own from run to run. Two samples of thirty can tell
|
|
330
|
+
you whether a shift is real or just those documents.
|
|
331
|
+
|
|
332
|
+
The topicsleuth `tune synthesize` command can take a `--seed` flag
|
|
333
|
+
that accepts a random integer to create a sample of `--n` documents
|
|
334
|
+
from your data store. Any two integers can create random seeds, but
|
|
335
|
+
keep using the *same* two integers so every experiment is judged on
|
|
336
|
+
the same two samples. On every later run, `--replay` the report, so
|
|
337
|
+
the comparison is always one to one even after the store has grown.
|
|
338
|
+
|
|
339
|
+
Drawing a sample needs a store that can sample. The default store is
|
|
340
|
+
MongoDB, which supports sampling. If you're using another store,
|
|
341
|
+
write the first report by hand, as a list of document URLs in the shape
|
|
342
|
+
of `data/examples/replay.example.json`, and `--replay` from it.
|
|
343
|
+
|
|
344
|
+
The report tells you how many documents linked, proposed or skipped, how
|
|
345
|
+
strong the links were, and for each call, where the chosen topic came from
|
|
346
|
+
and how large the prompt was. The diff tells you which documents changed
|
|
347
|
+
their minds. The `tune` command reads your settings file through
|
|
348
|
+
`TOPICSLEUTH_SETTINGS`, so a change to the rubric block is replayed by
|
|
349
|
+
editing the file.
|
|
350
|
+
|
|
351
|
+
### What you can change to tune library performance
|
|
352
|
+
|
|
353
|
+
Five things move the judge, listed here in order from the largest
|
|
354
|
+
effect to the smallest. All of them live in the settings file. The
|
|
355
|
+
first three can be overridden for a single replay from the command line,
|
|
356
|
+
so you can try a value before you keep it.
|
|
357
|
+
|
|
358
|
+
- **The model.** The judge is an object you construct, and `--model`
|
|
359
|
+
swaps it for one replay. Expect the boundary to move: a different model
|
|
360
|
+
draws the line between "link" and "new topic" somewhere else, which is
|
|
361
|
+
why a model change gets the full two-seed treatment.
|
|
362
|
+
- **Which topics the judge sees.** `candidates.vector_limit` is how many
|
|
363
|
+
nearest topics go in the prompt, `candidates.tag_overlap` adds every
|
|
364
|
+
topic sharing a tag with the document, and `candidates.ignored_tags`
|
|
365
|
+
keeps a routing tag from matching everything. A judge cannot link to a
|
|
366
|
+
topic it was not shown. Flags: `--limit`, `--no-tag-overlap`,
|
|
367
|
+
`--ignore-tags`.
|
|
368
|
+
- **The second opinion.** `synthesize.confirm_proposals` turns the
|
|
369
|
+
reviewer on, `confirm_candidates` is how many nearest topics it sees,
|
|
370
|
+
and `confirm_backend` picks the judge's second call or a decision model
|
|
371
|
+
with `confirm_link_threshold` as its bar. These change how many
|
|
372
|
+
proposals survive without touching the rubric. Flags: `--no-confirm`,
|
|
373
|
+
`--confirm-backend`, `--confirm-threshold`. In the same group,
|
|
374
|
+
`secondary_link_floor` drops a document's second or later link when its
|
|
375
|
+
strength is below the floor, and `--secondary-link-floor` tries one.
|
|
376
|
+
- **The prose about your field.** `domain` and the `rubric` block from
|
|
377
|
+
"Tell it what you're interested in". Counter-examples are the working lever: a
|
|
378
|
+
replay shows you the decisions the judge gets wrong, and one
|
|
379
|
+
well-chosen counter-example states that case and its right answer.
|
|
380
|
+
- **The rubric itself.** The labels, rules and questions in
|
|
381
|
+
`topicsleuth/rubric.py`. Change them in a fork, replay both seeds, and
|
|
382
|
+
expect every baseline you have to need re-taking.
|
|
383
|
+
|
|
384
|
+
The problems below are the ones a store develops in practice. Each names
|
|
385
|
+
the report line that shows it and the lever that moves it.
|
|
386
|
+
|
|
387
|
+
### Too many new topics
|
|
388
|
+
|
|
389
|
+
Evidence spreads across near-duplicate pages, each looks slow, and
|
|
390
|
+
`suggest-merges` keeps finding pairs. In the report, the `propose_new`
|
|
391
|
+
share is high and `confirmation outcomes` shows most proposals confirmed.
|
|
392
|
+
Read the proposed topics and the diff first. If the right page existed
|
|
393
|
+
and the judge was shown it, the fix is in the prose: write the case up
|
|
394
|
+
as a counter-example in your `rubric` block. If the reviewer is waving
|
|
395
|
+
proposals through, give it more to compare against with
|
|
396
|
+
`confirm_candidates`, or switch `confirm_backend` to `decision` and lower
|
|
397
|
+
`confirm_link_threshold` a step at a time. If the right page was not
|
|
398
|
+
among the candidates at all, that is the next problem.
|
|
399
|
+
|
|
400
|
+
### The right topic was never shown
|
|
401
|
+
|
|
402
|
+
The report's `linked topic vector rank` says where each chosen topic sat
|
|
403
|
+
in the nearest-neighbor list. When links cluster near the bottom of the
|
|
404
|
+
list, or the diff shows documents finding a better page only after you
|
|
405
|
+
raised `--limit`, the judge is being starved of candidates. Raise
|
|
406
|
+
`vector_limit` in steps of ten and watch `mean input tokens`, which is
|
|
407
|
+
the cost of each step. If `linked topic channel` shows the tag channel
|
|
408
|
+
doing real work, check that `ignored_tags` is not dropping a tag that
|
|
409
|
+
matters. The reverse also happens: a tag every document carries turns the
|
|
410
|
+
channel into "every topic", and the fix is to list it.
|
|
411
|
+
|
|
412
|
+
### One topic swallows everything
|
|
413
|
+
|
|
414
|
+
A page with hundreds of entries whose summary could cover half the feed.
|
|
415
|
+
In the report, `gate overrides` counts the times the judge wanted a new
|
|
416
|
+
topic and the rules forced a link, and the strength buckets lean toward
|
|
417
|
+
the low bands. The rules are doing what they are for, so the fix is in
|
|
418
|
+
the store and the prose. Split the page with `split-topic`, and make
|
|
419
|
+
`topic_size` and the `question_examples` say what narrow looks like in
|
|
420
|
+
your field. A counter-example of the form "an item that only mentions X,
|
|
421
|
+
judged against the X page, rated `surface_overlap`: do not link" closes
|
|
422
|
+
the door.
|
|
423
|
+
|
|
424
|
+
### The judge reasons about the wrong kind of document
|
|
425
|
+
|
|
426
|
+
The reasoning traces talk about papers and releases when your feed is
|
|
427
|
+
filings and minutes. The judge is working from the default prose. Set
|
|
428
|
+
the `rubric` block, check it with `show-prompts`, and take a fresh
|
|
429
|
+
baseline, because every comparison from before was made by a different
|
|
430
|
+
judge.
|
|
431
|
+
|
|
432
|
+
### Weak secondary links
|
|
433
|
+
|
|
434
|
+
A document that bears on two questions is filed on both pages, and the
|
|
435
|
+
second link is often the weaker one: the judge said the question is the
|
|
436
|
+
same but answered the fit questions "no". On a well-evidenced topic that
|
|
437
|
+
entry is marked minor, which is correct, but a page can fill with minor
|
|
438
|
+
entries from documents whose main home is elsewhere. The report's
|
|
439
|
+
`LINKS` line shows how many links were secondary, their strength
|
|
440
|
+
buckets, and how many a floor dropped. Set `synthesize.secondary_link_floor`
|
|
441
|
+
a step at a time (0.5 drops the "all three no" and "not citable" bands)
|
|
442
|
+
and replay both seeds; a replayed report shows what each value would
|
|
443
|
+
have dropped without a new model call. The first link is never dropped.
|
|
444
|
+
|
|
445
|
+
### It costs too much
|
|
446
|
+
|
|
447
|
+
`mean input tokens per call` in the report is the bill. The candidate
|
|
448
|
+
list is most of it, so `vector_limit` is the first thing to lower and
|
|
449
|
+
`confirm_candidates` the second. A cheaper model is worth a replay to see
|
|
450
|
+
what it gives up, and `--model` tries it without committing to it.
|
|
451
|
+
|
|
452
|
+
When the question is whether the judge's rubric is right at all, rather
|
|
453
|
+
than whether a change moved it, build a small labeled set: pairs of a
|
|
454
|
+
document and a candidate topic, labeled by a person or an independent
|
|
455
|
+
model without seeing what the judge said. The `scripts/` folder can
|
|
456
|
+
produce such labels from a model and compare raters against each other
|
|
457
|
+
and against the judge. Keep your baselines and label sets with the code
|
|
458
|
+
that owns your store, since they describe its content; `data/examples/`
|
|
459
|
+
shows the shape of every file.
|
|
460
|
+
|
|
461
|
+
## Keep the store tidy
|
|
462
|
+
|
|
463
|
+
No judge declines to open a page for a document that is genuinely a
|
|
464
|
+
one-off, and a notebook that has grown for months needs editing. The
|
|
465
|
+
`topicsleuth` command does that against a store configured through the
|
|
466
|
+
environment (`MONGODB_URI`, `TOPICSLEUTH_DATABASE`, `TOPICSLEUTH_SETTINGS`
|
|
467
|
+
for your settings file, and optionally the model names; a `.env` file in
|
|
468
|
+
the working directory is read). Every
|
|
469
|
+
command that writes takes `--dry-run` and shows the plan first.
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
topicsleuth audit # how big the store is and how evidence is spread
|
|
473
|
+
topicsleuth suggest-merges # pairs of topics that look like the same question
|
|
474
|
+
topicsleuth merge-topics <source> <target> # fold one into the other; the judge rewrites the survivor
|
|
475
|
+
topicsleuth split-topic <id> # the judge proposes sub-topics and reassigns the entries
|
|
476
|
+
topicsleuth add-topic topic.yaml --link <url> # open a page by hand and move named entries onto it
|
|
477
|
+
topicsleuth move-observation <url> <topic_id> # move one document's entry to another page
|
|
478
|
+
topicsleuth reprocess-tail --max-obs 1 --older-than-days 60 # clear pages that never got a second entry
|
|
479
|
+
topicsleuth seed topics.yaml # start an empty store from a list of topics
|
|
480
|
+
topicsleuth backfill-embeddings # embed topics that have none; refresh well-evidenced ones
|
|
481
|
+
topicsleuth clean --hours 28 # remove what the last run wrote
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
`reprocess-tail` clears the pages but does not re-judge their documents:
|
|
485
|
+
it writes their URLs to a file (under `tuning_runs/` by default, or
|
|
486
|
+
`--urls-out`), and your feeder hands those documents over again on its
|
|
487
|
+
next run so they find a home among the topics that exist now.
|
|
488
|
+
`backfill-embeddings` is for after an import or a change of embedding
|
|
489
|
+
model: it embeds every topic that has no vector and recomputes the
|
|
490
|
+
vector of every topic with at least five entries from the entries'
|
|
491
|
+
own embeddings.
|
|
492
|
+
|
|
493
|
+
Nothing here re-judges a document, except that a split asks the judge to
|
|
494
|
+
re-assess each entry's strength and connection type against its narrower
|
|
495
|
+
sub-topic. Merging and moving keep both. All three keep each entry's
|
|
496
|
+
identity, date and metadata and note where it came from, and a retired
|
|
497
|
+
topic keeps a pointer to what replaced it, so old links still resolve. A
|
|
498
|
+
document that was evidence on both sides of a merge or move keeps the
|
|
499
|
+
target's entry, with the other folded into it and noted. A
|
|
500
|
+
split is checked in full before anything is written: every entry assigned
|
|
501
|
+
exactly once, to a sub-topic that exists, with a known connection type.
|
|
502
|
+
|
|
503
|
+
These commands may run while a feeder is writing. A document caught
|
|
504
|
+
between a command and the feeder fails that run and is retried on the
|
|
505
|
+
next, and a merge or split carries along anything that arrived on its
|
|
506
|
+
source while it ran. If a command dies part-way through deleting a
|
|
507
|
+
topic, the topic stays marked for up to fifteen minutes, during which
|
|
508
|
+
recording and the other commands skip it and report it as busy; the next
|
|
509
|
+
`clean` or `reprocess-tail` then finishes or undoes the interrupted
|
|
510
|
+
deletion on its own.
|
|
511
|
+
|
|
512
|
+
`clean` removes the observations, topics and snapshots written in a
|
|
513
|
+
window, by when they were written rather than when their evidence
|
|
514
|
+
happened, and with `--run-id` only what one run wrote (pass `run_id` to
|
|
515
|
+
`record` and the run stamps everything it writes). It is not a full undo.
|
|
516
|
+
A topic written in the window that still holds older evidence is kept,
|
|
517
|
+
and a status, embedding or description a run changed is not restored.
|
|
518
|
+
A topic another command is working on is left alone and reported as
|
|
519
|
+
busy; run `clean` again once that command has finished.
|
|
520
|
+
|
|
521
|
+
## Bring your own content
|
|
522
|
+
|
|
523
|
+
topicsleuth never fetches documents and never decides which ones matter.
|
|
524
|
+
That is the job of your own program, which this page calls the **feeder**:
|
|
525
|
+
it gathers documents, chooses which to track, embeds them, and hands them
|
|
526
|
+
over, then does what it likes with the outcome. The library owns the
|
|
527
|
+
notebook, and it is the only code that writes to it, so two feeders
|
|
528
|
+
sharing one store cannot disagree about where things belong.
|
|
529
|
+
|
|
530
|
+
## Bring your own models and database
|
|
531
|
+
|
|
532
|
+
Every service the library talks to is an object you construct and pass
|
|
533
|
+
in: the embedding model, the language model that judges, the database,
|
|
534
|
+
and optionally a decision-only model that reviews proposals in place of
|
|
535
|
+
the judge's second call. Each is a `typing.Protocol` in
|
|
536
|
+
`topicsleuth.interfaces`, with the methods listed there; anything that has
|
|
537
|
+
them works, with no base class to inherit. The model protocols are a
|
|
538
|
+
method or two. The store protocol is larger, and a few of its operations
|
|
539
|
+
must be atomic: a conditional update for the lease a maintenance command
|
|
540
|
+
takes on a topic, for publishing a topic's embedding and for registering
|
|
541
|
+
a refresh it still owes, and a transaction wherever one operation writes
|
|
542
|
+
two documents (deleting a leased topic's entries, moving or folding an
|
|
543
|
+
entry onto a topic that must still be live). The docstrings on the
|
|
544
|
+
protocol say which, and the store contract test in the source tree is the
|
|
545
|
+
specification: an adapter that passes it will behave under retries and
|
|
546
|
+
concurrent writers the way the defaults do. Defaults for Voyage, Anthropic,
|
|
547
|
+
MongoDB Atlas and TypeSafe Jev are included as extras (`voyage`,
|
|
548
|
+
`anthropic`, `mongo`), and the core package itself depends only on pyyaml,
|
|
549
|
+
numpy and python-dotenv.
|
|
550
|
+
|
|
551
|
+
A few decisions have to be shared by everyone writing to one store, and
|
|
552
|
+
those live in settings rather than code: the domain and the prose the
|
|
553
|
+
prompts are written with, the embedding dimensionality the index was
|
|
554
|
+
built with, how candidates are assembled, and whether and how proposals
|
|
555
|
+
are reviewed. The defaults ship with the package and
|
|
556
|
+
`load_settings(path, overrides)` layers yours on top:
|
|
557
|
+
|
|
558
|
+
```yaml
|
|
559
|
+
domain: the field this store follows # a noun phrase; every prompt is rendered with it
|
|
560
|
+
rubric: {} # the prose about your field; see "Tell it what you're interested in"
|
|
561
|
+
embedding:
|
|
562
|
+
dimensions: 1024 # match your embedding model and your index
|
|
563
|
+
candidates:
|
|
564
|
+
tag_overlap: true # topics sharing a tag with the document join the candidates
|
|
565
|
+
ignored_tags: [] # a tag every document carries would match everything; list it here
|
|
566
|
+
vector_limit: 30 # how many nearest topics the judge sees
|
|
567
|
+
synthesize:
|
|
568
|
+
confirm_proposals: true # the second opinion on every new topic
|
|
569
|
+
confirm_candidates: 5 # how many nearest topics the reviewer sees
|
|
570
|
+
confirm_backend: judge # judge | decision
|
|
571
|
+
confirm_link_threshold: 0.7 # decision backend: how sure it must be to fold a proposal in
|
|
572
|
+
secondary_link_floor: 0.0 # drop a document's second or later link below this strength
|
|
573
|
+
record:
|
|
574
|
+
minor_floor_after: 20 # from this many observations on a topic...
|
|
575
|
+
minor_floor_strength: 0.7 # ...a new one below this strength is marked minor
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Loading history rather than today's documents? Pass `date_from_item=True`
|
|
579
|
+
to `record` so entries are dated by publication rather than by the run,
|
|
580
|
+
and load oldest first. A batch dated before a topic's latest snapshot is
|
|
581
|
+
inserted into its history: the snapshot counts only evidence up to its
|
|
582
|
+
date, and the topic's current status is left to the newer evidence. Pass
|
|
583
|
+
`backfill=True` as well and every entry the batch writes is stamped
|
|
584
|
+
`backfill`, so you can tell re-homed history from evidence that arrived
|
|
585
|
+
fresh.
|
|
586
|
+
|
|
587
|
+
## Test without spending anything
|
|
588
|
+
|
|
589
|
+
The package ships stand-ins for every service: an embedder that hashes
|
|
590
|
+
text to a stable vector, a judge that returns scripted answers, a decision
|
|
591
|
+
model driven by a table, and an in-memory store. Together they run the
|
|
592
|
+
whole loop with no network, so a feeder's tests are ordinary unit tests.
|
|
593
|
+
|
|
594
|
+
```python
|
|
595
|
+
from topicsleuth.backends.fake import HashEmbedder, ScriptedJudge
|
|
596
|
+
from topicsleuth.backends.memory import MemoryStore
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
The `topicsleuth` command uses the same stand-ins when
|
|
600
|
+
`TOPICSLEUTH_STORE=memory`, `TOPICSLEUTH_JUDGE=scripted`,
|
|
601
|
+
`TOPICSLEUTH_EMBEDDER=hash` and `TOPICSLEUTH_DECISION=scripted` are set,
|
|
602
|
+
which is how its own tests run. If
|
|
603
|
+
you write a store adapter, the store contract test in the source tree's
|
|
604
|
+
`tests/` folder (not in the wheel) runs against it.
|
|
605
|
+
|
|
606
|
+
## Where things stand
|
|
607
|
+
|
|
608
|
+
Early. The matching, recording, curation and tuning code has run daily
|
|
609
|
+
against a live store for months, but the public interface is new and may
|
|
610
|
+
move between minor versions, so pin a release. Releases are published to
|
|
611
|
+
PyPI from version tags, and `DESIGN.md` explains the reasoning behind the
|
|
612
|
+
interfaces and the decision for anyone who wants to go deeper.
|
|
613
|
+
|
|
614
|
+
The name follows the topic detection and tracking literature, where the
|
|
615
|
+
tracked thing is a topic, a document is a story, noticing a new topic is
|
|
616
|
+
first-story detection, and attaching a document to one is tracking.
|
|
617
|
+
|
|
618
|
+
MIT licensed.
|