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.
Files changed (47) hide show
  1. topicsleuth-0.1.0/LICENSE +21 -0
  2. topicsleuth-0.1.0/PKG-INFO +618 -0
  3. topicsleuth-0.1.0/README.md +581 -0
  4. topicsleuth-0.1.0/pyproject.toml +61 -0
  5. topicsleuth-0.1.0/setup.cfg +4 -0
  6. topicsleuth-0.1.0/tests/test_add_topic.py +161 -0
  7. topicsleuth-0.1.0/tests/test_audit.py +58 -0
  8. topicsleuth-0.1.0/tests/test_backend_adapters.py +172 -0
  9. topicsleuth-0.1.0/tests/test_cli.py +192 -0
  10. topicsleuth-0.1.0/tests/test_embeddings.py +111 -0
  11. topicsleuth-0.1.0/tests/test_end_to_end.py +392 -0
  12. topicsleuth-0.1.0/tests/test_memory_store.py +81 -0
  13. topicsleuth-0.1.0/tests/test_record.py +293 -0
  14. topicsleuth-0.1.0/tests/test_rubric.py +167 -0
  15. topicsleuth-0.1.0/tests/test_store_contract.py +380 -0
  16. topicsleuth-0.1.0/tests/test_synthesize.py +605 -0
  17. topicsleuth-0.1.0/tests/test_write_path.py +2247 -0
  18. topicsleuth-0.1.0/topicsleuth/add_topic.py +238 -0
  19. topicsleuth-0.1.0/topicsleuth/audit.py +123 -0
  20. topicsleuth-0.1.0/topicsleuth/backends/__init__.py +5 -0
  21. topicsleuth-0.1.0/topicsleuth/backends/anthropic.py +28 -0
  22. topicsleuth-0.1.0/topicsleuth/backends/fake.py +126 -0
  23. topicsleuth-0.1.0/topicsleuth/backends/jev.py +95 -0
  24. topicsleuth-0.1.0/topicsleuth/backends/memory.py +346 -0
  25. topicsleuth-0.1.0/topicsleuth/backends/mongo.py +390 -0
  26. topicsleuth-0.1.0/topicsleuth/backends/voyage.py +41 -0
  27. topicsleuth-0.1.0/topicsleuth/cli.py +639 -0
  28. topicsleuth-0.1.0/topicsleuth/config/settings.yaml +52 -0
  29. topicsleuth-0.1.0/topicsleuth/embeddings.py +189 -0
  30. topicsleuth-0.1.0/topicsleuth/env.py +88 -0
  31. topicsleuth-0.1.0/topicsleuth/history.py +63 -0
  32. topicsleuth-0.1.0/topicsleuth/interfaces.py +301 -0
  33. topicsleuth-0.1.0/topicsleuth/merge.py +288 -0
  34. topicsleuth-0.1.0/topicsleuth/py.typed +0 -0
  35. topicsleuth-0.1.0/topicsleuth/record.py +835 -0
  36. topicsleuth-0.1.0/topicsleuth/reprocess.py +160 -0
  37. topicsleuth-0.1.0/topicsleuth/rubric.py +719 -0
  38. topicsleuth-0.1.0/topicsleuth/settings.py +209 -0
  39. topicsleuth-0.1.0/topicsleuth/split.py +346 -0
  40. topicsleuth-0.1.0/topicsleuth/synthesize.py +1252 -0
  41. topicsleuth-0.1.0/topicsleuth/tune.py +421 -0
  42. topicsleuth-0.1.0/topicsleuth.egg-info/PKG-INFO +618 -0
  43. topicsleuth-0.1.0/topicsleuth.egg-info/SOURCES.txt +45 -0
  44. topicsleuth-0.1.0/topicsleuth.egg-info/dependency_links.txt +1 -0
  45. topicsleuth-0.1.0/topicsleuth.egg-info/entry_points.txt +2 -0
  46. topicsleuth-0.1.0/topicsleuth.egg-info/requires.txt +20 -0
  47. 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.