thread-archive 0.0.2__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.
Files changed (132) hide show
  1. thread_archive/__init__.py +34 -0
  2. thread_archive/_api.py +2235 -0
  3. thread_archive/_config.py +126 -0
  4. thread_archive/_importers/__init__.py +81 -0
  5. thread_archive/_importers/_continuation.py +136 -0
  6. thread_archive/_importers/_cursor.py +103 -0
  7. thread_archive/_importers/_events.py +244 -0
  8. thread_archive/_importers/_line_stream.py +193 -0
  9. thread_archive/_importers/_read.py +82 -0
  10. thread_archive/_importers/_result.py +17 -0
  11. thread_archive/_importers/_sidecar.py +113 -0
  12. thread_archive/_importers/_state.py +240 -0
  13. thread_archive/_importers/_titles.py +179 -0
  14. thread_archive/_importers/antigravity.py +254 -0
  15. thread_archive/_importers/claude_code.py +293 -0
  16. thread_archive/_importers/claude_science.py +330 -0
  17. thread_archive/_importers/cloth.py +20 -0
  18. thread_archive/_importers/codex.py +324 -0
  19. thread_archive/_importers/cowork.py +107 -0
  20. thread_archive/_importers/cursor.py +432 -0
  21. thread_archive/_importers/exports.py +389 -0
  22. thread_archive/_importers/grok.py +600 -0
  23. thread_archive/_importers/opencode.py +538 -0
  24. thread_archive/_knowledge/__init__.py +67 -0
  25. thread_archive/_knowledge/_claims.py +116 -0
  26. thread_archive/_knowledge/_community.py +75 -0
  27. thread_archive/_knowledge/graph.py +208 -0
  28. thread_archive/_knowledge/materialize.py +212 -0
  29. thread_archive/_knowledge/write.py +438 -0
  30. thread_archive/_launchd.py +167 -0
  31. thread_archive/_mcp/__init__.py +1 -0
  32. thread_archive/_mcp/librarian.py +151 -0
  33. thread_archive/_mcp/server.py +307 -0
  34. thread_archive/_retrieval/__init__.py +280 -0
  35. thread_archive/_retrieval/_classify.py +80 -0
  36. thread_archive/_retrieval/_codex.py +157 -0
  37. thread_archive/_retrieval/_context.py +123 -0
  38. thread_archive/_retrieval/_extract.py +184 -0
  39. thread_archive/_retrieval/embed.py +186 -0
  40. thread_archive/_retrieval/format.py +149 -0
  41. thread_archive/_retrieval/fts.py +538 -0
  42. thread_archive/_retrieval/rank.py +228 -0
  43. thread_archive/_retrieval/read.py +908 -0
  44. thread_archive/_retrieval/rerank.py +143 -0
  45. thread_archive/_retrieval/vectors.py +611 -0
  46. thread_archive/_scripts/__init__.py +1 -0
  47. thread_archive/_scripts/backfill_recompute.py +328 -0
  48. thread_archive/_scripts/backfill_reconcile.py +296 -0
  49. thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
  50. thread_archive/_scripts/recover_dropped_events.py +190 -0
  51. thread_archive/_scripts/repair_grok_tool_names.py +118 -0
  52. thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
  53. thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
  54. thread_archive/_setup/__init__.py +12 -0
  55. thread_archive/_setup/clients.py +89 -0
  56. thread_archive/_setup/wizard.py +474 -0
  57. thread_archive/_store/__init__.py +45 -0
  58. thread_archive/_store/_base.py +173 -0
  59. thread_archive/_store/_defaults.py +57 -0
  60. thread_archive/_store/_types.py +38 -0
  61. thread_archive/_store/models.py +370 -0
  62. thread_archive/_store/schema.py +84 -0
  63. thread_archive/_thread_import/__init__.py +40 -0
  64. thread_archive/_thread_import/api.py +78 -0
  65. thread_archive/_thread_import/event_builder.py +810 -0
  66. thread_archive/_thread_import/exporters/__init__.py +9 -0
  67. thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
  68. thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
  69. thread_archive/_thread_import/exporters/cursor.py +582 -0
  70. thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
  71. thread_archive/_thread_import/parsers/__init__.py +191 -0
  72. thread_archive/_thread_import/parsers/base.py +698 -0
  73. thread_archive/_thread_import/parsers/chatgpt.py +722 -0
  74. thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
  75. thread_archive/_thread_import/parsers/claude.py +443 -0
  76. thread_archive/_thread_import/parsers/claude_code.py +1035 -0
  77. thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
  78. thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
  79. thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
  80. thread_archive/_thread_import/parsers/config/__init__.py +26 -0
  81. thread_archive/_thread_import/parsers/config/base.py +220 -0
  82. thread_archive/_thread_import/parsers/cursor.py +538 -0
  83. thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
  84. thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
  85. thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
  86. thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
  87. thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
  88. thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
  89. thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
  90. thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
  91. thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
  92. thread_archive/_thread_import/parsers/types/__init__.py +56 -0
  93. thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
  94. thread_archive/_thread_import/parsers/types/claude.py +120 -0
  95. thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
  96. thread_archive/_thread_import/parsers/types/cursor.py +109 -0
  97. thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
  98. thread_archive/_thread_import/parsers/validators/base.py +168 -0
  99. thread_archive/_thread_import/parsers/validators/content.py +80 -0
  100. thread_archive/_thread_import/parsers/validators/referential.py +57 -0
  101. thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
  102. thread_archive/_thread_import/parsers/validators/types.py +87 -0
  103. thread_archive/_thread_import/schemas/__init__.py +231 -0
  104. thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
  105. thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
  106. thread_archive/_thread_import/schemas/claude/v1.json +188 -0
  107. thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
  108. thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
  109. thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
  110. thread_archive/_thread_import/timestamps.py +57 -0
  111. thread_archive/_thread_import/tool_names.py +48 -0
  112. thread_archive/_truth/__init__.py +40 -0
  113. thread_archive/_truth/jsonl_log.py +2340 -0
  114. thread_archive/_truth/repair.py +322 -0
  115. thread_archive/_watcher/__init__.py +57 -0
  116. thread_archive/_watcher/base.py +65 -0
  117. thread_archive/_watcher/daemon.py +289 -0
  118. thread_archive/_watcher/export_drop.py +203 -0
  119. thread_archive/_watcher/exthost.py +316 -0
  120. thread_archive/_watcher/lazy.py +117 -0
  121. thread_archive/_watcher/sources.py +616 -0
  122. thread_archive/_web/__init__.py +15 -0
  123. thread_archive/_web/server.py +299 -0
  124. thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
  125. thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
  126. thread_archive/_web/static/index.html +13 -0
  127. thread_archive/cli.py +746 -0
  128. thread_archive-0.0.2.dist-info/METADATA +296 -0
  129. thread_archive-0.0.2.dist-info/RECORD +132 -0
  130. thread_archive-0.0.2.dist-info/WHEEL +4 -0
  131. thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
  132. thread_archive-0.0.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,116 @@
1
+ """Lease-based work claims for parallel librarian backfill.
2
+
3
+ Static `id % N` sharding is rigid: you fix the worker count up front, a dead shard
4
+ stalls, and the modulo isn't index-sargable. Instead, workers **claim** a batch of
5
+ thread ids — stamped with a timestamp — into a shared state file, and a claim older than
6
+ the lease (default 1h) is treated as a dead worker's and reclaimed. So workers
7
+ self-balance (a fast one claims more), you can launch any number independently with no
8
+ coordinator, and a crash forfeits nothing but a one-hour wait on those few threads.
9
+
10
+ The claim file (`<home>/.librarian-claims.json`) is **ephemeral operator coordination**,
11
+ not truth — like a lock dir. Deleting it only drops in-flight leases; the archive (JSONL
12
+ truth + the topic citations/links that actually mark a thread done) is untouched. Concurrency
13
+ is an `flock`'d read-modify-write held only for the brief claim, never during the work.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import fcntl
19
+ import json
20
+ import os
21
+ import time
22
+ from contextlib import contextmanager
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ DEFAULT_LEASE_SECONDS = 3600 # a claim older than this is a presumed-dead worker's
27
+
28
+
29
+ def claims_path() -> Path:
30
+ from .._config import resolve_paths
31
+
32
+ return resolve_paths().home / ".librarian-claims.json"
33
+
34
+
35
+ def _now() -> float:
36
+ return time.time()
37
+
38
+
39
+ @contextmanager
40
+ def _locked(path: Path):
41
+ """Yield the claims dict under an exclusive lock; persist it on clean exit.
42
+
43
+ The lock is the claim file's own fd (``flock`` mutexes across processes *and*
44
+ threads on separate descriptions). A corrupt file reads as empty. If the body
45
+ raises, the file is left unchanged — a failed claim never half-writes."""
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o644)
48
+ try:
49
+ fcntl.flock(fd, fcntl.LOCK_EX)
50
+ chunks = []
51
+ while True:
52
+ block = os.read(fd, 65536)
53
+ if not block:
54
+ break
55
+ chunks.append(block)
56
+ raw = b"".join(chunks)
57
+ try:
58
+ claims = json.loads(raw) if raw.strip() else {}
59
+ except ValueError:
60
+ claims = {}
61
+ yield claims
62
+ os.lseek(fd, 0, 0)
63
+ os.ftruncate(fd, 0)
64
+ os.write(fd, json.dumps(claims).encode("utf-8"))
65
+ finally:
66
+ fcntl.flock(fd, fcntl.LOCK_UN)
67
+ os.close(fd)
68
+
69
+
70
+ def _prune_expired(claims: dict, lease: int, now: float) -> None:
71
+ for tid in list(claims):
72
+ if now - float(claims[tid].get("at", 0)) >= lease:
73
+ del claims[tid]
74
+
75
+
76
+ def claim_review_batch(
77
+ worker: str, batch: int, *, lease: int = DEFAULT_LEASE_SECONDS,
78
+ exclude_source_id: Optional[str] = None,
79
+ ) -> list[dict]:
80
+ """Claim (and return) up to ``batch`` unreviewed conversations for ``worker``.
81
+
82
+ Under the lock: expire stale claims, ask the queue for eligible threads not claimed
83
+ by *other* live workers (resuming this worker's own still-unreviewed claims first),
84
+ stamp them to now, and drop this worker's claims that have since left the queue
85
+ (reviewed). Returns the queue rows the caller should process."""
86
+ from .write import review_queue
87
+
88
+ now = _now()
89
+ with _locked(claims_path()) as claims:
90
+ _prune_expired(claims, lease, now)
91
+ others = [int(t) for t, c in claims.items() if c.get("worker") != worker]
92
+ rows = review_queue(
93
+ limit=batch, exclude_source_id=exclude_source_id, exclude_ids=others
94
+ )
95
+ returned = {r["id"] for r in rows}
96
+ for r in rows:
97
+ claims[str(r["id"])] = {"worker": worker, "at": now}
98
+ # forget my claims for threads no longer eligible (reviewed, or bumped out of
99
+ # the window) so stale leases don't accumulate under my name
100
+ for tid in [t for t, c in claims.items() if c.get("worker") == worker and int(t) not in returned]:
101
+ del claims[tid]
102
+ return rows
103
+
104
+
105
+ def has_claimable_work(
106
+ *, lease: int = DEFAULT_LEASE_SECONDS, exclude_source_id: Optional[str] = None
107
+ ) -> bool:
108
+ """Whether any unreviewed conversation is free to claim (not held by a live lease).
109
+ The driver's respawn condition."""
110
+ from .write import review_queue
111
+
112
+ now = _now()
113
+ with _locked(claims_path()) as claims:
114
+ _prune_expired(claims, lease, now)
115
+ active = [int(t) for t in claims]
116
+ return bool(review_queue(limit=1, exclude_source_id=exclude_source_id, exclude_ids=active))
@@ -0,0 +1,75 @@
1
+ """Community detection for the topic graph — Leiden, with a Louvain fallback.
2
+
3
+ Leiden (Traag et al. 2019) is the algorithm Neo4j GDS ran on the original graph; it
4
+ strictly dominates Louvain — it guarantees well-connected communities (Louvain can
5
+ leave a community internally disconnected) and converges to a better partition. We run
6
+ it locally via ``leidenalg`` + ``python-igraph`` (base dependencies), with no graph
7
+ server — Leiden is the community engine for the topic graph.
8
+
9
+ :func:`detect_communities` still falls back to networkx's Louvain (free with the base
10
+ ``networkx`` dependency) if those C extensions ever fail to import, so a read path can
11
+ never crash on community detection. Both paths are **seeded** for a deterministic
12
+ partition across runs.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+
19
+ import networkx as nx
20
+ from networkx.algorithms.community import louvain_communities
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ # Seeded so a given graph yields a stable partition across calls/runs (both engines).
25
+ SEED = 42
26
+
27
+ _LEIDEN_AVAILABLE: bool | None = None
28
+
29
+
30
+ def leiden_available() -> bool:
31
+ """Whether the Leiden engine (``leidenalg`` + ``igraph``) can be imported. They are
32
+ base dependencies, so this is normally true; it guards the fail-soft fallback for
33
+ the rare environment where the C extensions can't load. Memoized — probes once."""
34
+ global _LEIDEN_AVAILABLE
35
+ if _LEIDEN_AVAILABLE is None:
36
+ try:
37
+ import igraph # noqa: F401
38
+ import leidenalg # noqa: F401
39
+
40
+ _LEIDEN_AVAILABLE = True
41
+ except Exception:
42
+ _LEIDEN_AVAILABLE = False
43
+ return _LEIDEN_AVAILABLE
44
+
45
+
46
+ def detect_communities(graph: "nx.Graph") -> list[list[int]]:
47
+ """Partition ``graph`` into communities (lists of node ids), maximizing modularity.
48
+
49
+ Uses Leiden when available, else networkx Louvain. Edge ``weight`` attributes are
50
+ honored by both. Returns ``[]`` for an empty graph. Each community is sorted, and
51
+ the list is order-stable, so the partition is reproducible."""
52
+ if graph.number_of_nodes() == 0:
53
+ return []
54
+ if leiden_available():
55
+ try:
56
+ return _leiden(graph)
57
+ except Exception: # pragma: no cover — fall back rather than fail the read path
58
+ logger.exception("leiden community detection failed — falling back to louvain")
59
+ return [sorted(c) for c in louvain_communities(graph, weight="weight", seed=SEED)]
60
+
61
+
62
+ def _leiden(graph: "nx.Graph") -> list[list[int]]:
63
+ import igraph as ig
64
+ import leidenalg as la
65
+
66
+ nodes = list(graph.nodes())
67
+ index = {n: i for i, n in enumerate(nodes)}
68
+ edges = [(index[u], index[v]) for u, v in graph.edges()]
69
+ weights = [float(graph[u][v].get("weight", 1.0)) for u, v in graph.edges()]
70
+
71
+ g = ig.Graph(n=len(nodes), edges=edges, directed=False)
72
+ partition = la.find_partition(
73
+ g, la.ModularityVertexPartition, weights=weights, seed=SEED
74
+ )
75
+ return [sorted(nodes[i] for i in community) for community in partition]
@@ -0,0 +1,208 @@
1
+ """In-process topic-graph projection (networkx spine, optional Leiden community engine).
2
+
3
+ A pure-graph topic projection. The graph is rebuildable from ``thread_links`` + topic
4
+ ``threads``, so there is no graph server: build a networkx graph from SQLite and run
5
+ the algorithms in process.
6
+
7
+ Two projections:
8
+ * **PageRank** — every link, undirected, weight = ``strength``; ``pagerank(alpha=0.85)``.
9
+ * **Community** — drop oppositional (contrast/supersedes) + operational
10
+ (works_on/investigates/benchmark) edges, ×0.7 the instantiation edges
11
+ (implements/example-of); then :func:`thread_archive._knowledge._community.detect_communities`
12
+ — Leiden (the algorithm Neo4j GDS used; a base dependency), with networkx Louvain
13
+ as a seeded, deterministic fail-soft fallback.
14
+
15
+ Topics are threads with ``thread_type='topic'``; the graph is populated as the
16
+ librarian curates (or by a migration of existing topics + links). The graph is empty
17
+ (and all queries return empty) until then — by design (the core archive works without
18
+ this layer).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Optional
25
+
26
+ import networkx as nx
27
+ from sqlalchemy import text as sa_text
28
+
29
+ from .._store import get_engine, get_session
30
+ from ._community import detect_communities
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Edge-type families. The community projection excludes oppositional + operational
35
+ # edges and down-weights instantiation.
36
+ _OPPOSITIONAL = frozenset({"contrast", "supersedes"})
37
+ _OPERATIONAL = frozenset({"works_on", "investigates", "benchmark"})
38
+ _INSTANTIATION = frozenset({"implements", "example-of"})
39
+
40
+ # Lazy per-engine projection cache. {id(engine): _Projection}. Reset on rebuild.
41
+ _CACHE: dict = {}
42
+
43
+
44
+ class _Projection:
45
+ """The computed topic-graph state for one engine snapshot."""
46
+
47
+ __slots__ = ("pagerank", "community", "degree", "component", "members", "titles",
48
+ "_pr_graph", "_kn_graph")
49
+
50
+ def __init__(self, pr_graph: "nx.Graph", kn_graph: "nx.Graph", titles: dict[int, str]):
51
+ self._pr_graph = pr_graph
52
+ self._kn_graph = kn_graph
53
+ self.titles = titles
54
+ self.pagerank: dict[int, float] = (
55
+ nx.pagerank(pr_graph, alpha=0.85, weight="weight") if pr_graph.number_of_nodes() else {}
56
+ )
57
+ self.degree: dict[int, int] = dict(pr_graph.degree())
58
+ self.component: dict[int, int] = {}
59
+ for cid, comp in enumerate(nx.connected_components(pr_graph)):
60
+ for n in comp:
61
+ self.component[n] = cid
62
+ self.community: dict[int, int] = {}
63
+ self.members: dict[int, list[int]] = {}
64
+ for cid, comm in enumerate(detect_communities(kn_graph)):
65
+ self.members[cid] = sorted(comm)
66
+ for n in comm:
67
+ self.community[n] = cid
68
+
69
+ def betweenness(self) -> dict[int, float]:
70
+ if not self._pr_graph.number_of_nodes():
71
+ return {}
72
+ return nx.betweenness_centrality(self._pr_graph, weight="weight")
73
+
74
+
75
+ def is_available() -> bool:
76
+ try:
77
+ return get_engine().dialect.name == "sqlite"
78
+ except Exception:
79
+ return False
80
+
81
+
82
+ def reset_cache() -> None:
83
+ """Drop the cached projection (call after the KG mutates / on rebuild)."""
84
+ _CACHE.clear()
85
+
86
+
87
+ def _build_projection() -> _Projection:
88
+ pr_graph = nx.Graph()
89
+ kn_graph = nx.Graph()
90
+ with get_session() as s:
91
+ titles = {
92
+ int(r[0]): r[1] for r in s.execute(sa_text(
93
+ "SELECT id, title FROM threads WHERE thread_type = 'topic' "
94
+ "AND (archived IS NULL OR archived = 0)"
95
+ ))
96
+ }
97
+ links = s.execute(sa_text(
98
+ "SELECT source_thread_id, target_thread_id, link_type, "
99
+ "COALESCE(strength, 1.0) AS w FROM thread_links"
100
+ )).all()
101
+
102
+ topic_ids = set(titles)
103
+ pr_graph.add_nodes_from(topic_ids)
104
+ kn_graph.add_nodes_from(topic_ids)
105
+ for src, tgt, link_type, w in links:
106
+ if src == tgt or src not in topic_ids or tgt not in topic_ids:
107
+ continue
108
+ weight = float(w)
109
+ _add_weighted(pr_graph, src, tgt, weight)
110
+ if link_type not in _OPPOSITIONAL and link_type not in _OPERATIONAL:
111
+ _add_weighted(kn_graph, src, tgt, weight * (0.7 if link_type in _INSTANTIATION else 1.0))
112
+ return _Projection(pr_graph, kn_graph, titles)
113
+
114
+
115
+ def _add_weighted(g: "nx.Graph", a: int, b: int, w: float) -> None:
116
+ if g.has_edge(a, b):
117
+ g[a][b]["weight"] += w
118
+ else:
119
+ g.add_edge(a, b, weight=w)
120
+
121
+
122
+ def _projection() -> Optional[_Projection]:
123
+ if not is_available():
124
+ return None
125
+ key = id(get_engine())
126
+ proj = _CACHE.get(key)
127
+ if proj is None:
128
+ proj = _build_projection()
129
+ _CACHE[key] = proj
130
+ return proj
131
+
132
+
133
+ def get_topic_graph_metadata(thread_ids: list[int]) -> dict[int, dict]:
134
+ """Batch graph properties (pagerank / community / degree / link_count) for topics."""
135
+ proj = _projection()
136
+ if proj is None or not thread_ids:
137
+ return {}
138
+ out: dict[int, dict] = {}
139
+ for tid in thread_ids:
140
+ if tid not in proj.degree and tid not in proj.pagerank:
141
+ continue
142
+ deg = proj.degree.get(tid, 0)
143
+ out[tid] = {
144
+ "pagerank": proj.pagerank.get(tid, 0.0),
145
+ "community": proj.community.get(tid),
146
+ "degree": deg,
147
+ "link_count": deg,
148
+ }
149
+ return out
150
+
151
+
152
+ def get_topic_graph_meta(thread_id: int) -> dict | None:
153
+ return get_topic_graph_metadata([thread_id]).get(thread_id)
154
+
155
+
156
+ def get_community_topic_ids(community_id: int) -> list[int]:
157
+ proj = _projection()
158
+ if proj is None:
159
+ return []
160
+ return list(proj.members.get(community_id, []))
161
+
162
+
163
+ def get_community_peers(thread_id: int, limit: int = 5) -> list[dict]:
164
+ """Other topics in the same community, highest-pagerank first."""
165
+ proj = _projection()
166
+ if proj is None:
167
+ return []
168
+ cid = proj.community.get(thread_id)
169
+ if cid is None:
170
+ return []
171
+ peers = [t for t in proj.members.get(cid, []) if t != thread_id]
172
+ peers.sort(key=lambda t: (-proj.pagerank.get(t, 0.0), t))
173
+ return [{"thread_id": t, "title": proj.titles.get(t), "pagerank": proj.pagerank.get(t, 0.0)}
174
+ for t in peers[:limit]]
175
+
176
+
177
+ def get_bridge_topics(limit: int = 20) -> list[dict]:
178
+ """Highest-betweenness topics — structural bridges between communities."""
179
+ proj = _projection()
180
+ if proj is None:
181
+ return []
182
+ bc = proj.betweenness()
183
+ ranked = [(t, sc) for t, sc in sorted(bc.items(), key=lambda kv: (-kv[1], kv[0])) if sc > 0][:limit]
184
+ return [{"topic_id": t, "title": proj.titles.get(t), "score": score} for t, score in ranked]
185
+
186
+
187
+ def get_unconnected_topics(limit: int = 50) -> list[dict]:
188
+ """Topics with no link — the knowledge islands."""
189
+ proj = _projection()
190
+ if proj is None:
191
+ return []
192
+ isolated = [t for t, d in proj.degree.items() if d == 0][:limit]
193
+ return [{"thread_id": t, "link_count": 0} for t in isolated]
194
+
195
+
196
+ def get_status() -> dict:
197
+ proj = _projection()
198
+ if proj is None:
199
+ return {"available": False, "error": "store is not sqlite"}
200
+ from ._community import leiden_available
201
+
202
+ return {
203
+ "available": True,
204
+ "nodes": len(proj.degree),
205
+ "communities": len(proj.members),
206
+ "components": len(set(proj.component.values())),
207
+ "community_engine": "leiden" if leiden_available() else "louvain",
208
+ }
@@ -0,0 +1,212 @@
1
+ """The materializer: fold curatorial events into the knowledge projection.
2
+
3
+ A ``KgEvent`` is the unit of curation truth; this module is the **fold** that turns
4
+ the append-only event log into the ``thread_links`` / ``topic_messages`` SQLite
5
+ tables the topic graph reads. It is the serverless analogue of a streaming
6
+ materializer — but in-process, folding to SQLite rather than to a graph server.
7
+
8
+ :func:`apply_event` dispatches one event to its projection mutation. It is called
9
+ in two places, both with the session it must mutate:
10
+
11
+ * **live write** — :mod:`thread_archive._knowledge.write` appends an event *and*
12
+ applies it in the same transaction, so one call yields a durable log line plus an
13
+ updated projection;
14
+ * **reindex replay** — ``truth.jsonl_log`` replays the whole log in ``id`` order to
15
+ rebuild the projection from scratch (on top of any legacy snapshot seed).
16
+
17
+ Every mutation is **upsert + tombstone**, never blind insert: ``link.created`` on an
18
+ existing edge updates it, ``link.deleted`` removes it whether it came from the log or
19
+ a seed snapshot, ``evidence.archived`` stamps rather than deletes. That is what makes
20
+ the replay idempotent and order-stable — applying the same log twice, or folding a
21
+ delta onto a seed, lands on the same projection.
22
+
23
+ Timestamps come from the event (``occurred_at``), not the moment of replay, so a
24
+ rebuild is deterministic: a link's ``created_at`` is when it was *made*, every time.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from typing import Callable
31
+
32
+ from sqlalchemy import delete, select, update
33
+
34
+ from .._store import Event, Thread, ThreadLink, TopicMessage
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ def apply_event(session, ev) -> None:
40
+ """Fold one ``KgEvent`` into the projection on ``session`` (no commit).
41
+
42
+ ``ev`` is any object exposing ``event_type`` / ``payload`` / ``actor`` /
43
+ ``actor_thread_id`` / ``occurred_at`` (a live ``KgEvent`` or a transient one
44
+ rebuilt from a log row). Unknown event types are logged and skipped — a forward-
45
+ compatible reader never aborts a replay on an event it doesn't understand."""
46
+ fn = _DISPATCH.get(ev.event_type)
47
+ if fn is None:
48
+ logger.warning("kg materialize: unknown event_type %r — skipped", ev.event_type)
49
+ return
50
+ fn(session, ev.payload or {}, ev)
51
+
52
+
53
+ # ── topics (topics are threads; the event log carries audit + lifecycle) ──────
54
+ def _topic_created(session, p: dict, ev) -> None:
55
+ # The topic thread itself is created + recorded by the write path (it has its own
56
+ # per-thread truth file); this event is the audit record. Nothing to project.
57
+ return
58
+
59
+
60
+ def _topic_renamed(session, p: dict, ev) -> None:
61
+ fields = {k: p[k] for k in ("title", "description") if k in p}
62
+ if not fields:
63
+ return
64
+ _update_thread(session, _entity_int(ev), fields)
65
+
66
+
67
+ def _topic_updated(session, p: dict, ev) -> None:
68
+ fields = p.get("fields", p)
69
+ allowed = {k: v for k, v in fields.items()
70
+ if k in ("title", "description", "topic_kind", "epistemological_type", "search_description")}
71
+ if allowed:
72
+ _update_thread(session, _entity_int(ev), allowed)
73
+
74
+
75
+ def _topic_archived(session, p: dict, ev) -> None:
76
+ _update_thread(session, _entity_int(ev), {"archived": True})
77
+
78
+
79
+ def _topic_merged(session, p: dict, ev) -> None:
80
+ """Repoint every link + evidence from ``from_id`` onto ``into_id``, then archive
81
+ ``from_id``. Repointing can collide with an existing edge/evidence (unique keys),
82
+ so it is done row-by-row with a duplicate guard rather than a blind UPDATE."""
83
+ from_id, into_id = int(p["from_id"]), int(p["into_id"])
84
+ if from_id == into_id:
85
+ return
86
+ _repoint_links(session, from_id, into_id)
87
+ _repoint_evidence(session, from_id, into_id)
88
+ _update_thread(session, from_id, {"archived": True})
89
+
90
+
91
+ # ── links (the topic-graph edge set) ──────────────────────────────────────────
92
+ def _link_created(session, p: dict, ev) -> None:
93
+ src, tgt, lt = int(p["source_thread_id"]), int(p["target_thread_id"]), p.get("link_type", "related")
94
+ row = _find_link(session, src, tgt, lt)
95
+ if row is None:
96
+ row = ThreadLink(source_thread_id=src, target_thread_id=tgt, link_type=lt, created_at=ev.occurred_at)
97
+ session.add(row)
98
+ row.strength = float(p.get("strength", 1.0))
99
+ row.evidence = p.get("evidence")
100
+ row.created_by = p.get("created_by") or getattr(ev, "actor", "librarian")
101
+ row.created_by_thread_id = getattr(ev, "actor_thread_id", None)
102
+ row.updated_at = ev.occurred_at
103
+
104
+
105
+ def _link_updated(session, p: dict, ev) -> None:
106
+ row = _find_link(session, int(p["source_thread_id"]), int(p["target_thread_id"]),
107
+ p.get("link_type", "related"))
108
+ if row is None:
109
+ return
110
+ for k, v in (p.get("fields") or {}).items():
111
+ if k in ("strength", "evidence", "link_type"):
112
+ setattr(row, k, v)
113
+ row.updated_at = ev.occurred_at
114
+
115
+
116
+ def _link_deleted(session, p: dict, ev) -> None:
117
+ session.execute(delete(ThreadLink).where(
118
+ ThreadLink.source_thread_id == int(p["source_thread_id"]),
119
+ ThreadLink.target_thread_id == int(p["target_thread_id"]),
120
+ ThreadLink.link_type == p.get("link_type", "related"),
121
+ ))
122
+
123
+
124
+ # ── evidence (message → topic citations) ──────────────────────────────────────
125
+ def _evidence_added(session, p: dict, ev) -> None:
126
+ topic_id, event_id = int(p["topic_id"]), int(p["event_id"])
127
+ # The cited event's own row is authoritative for the thread; the payload
128
+ # value is the fallback for a citation whose event the store doesn't hold
129
+ # (a dangling reference replayed from an older, unvalidated write). Looked
130
+ # up before the row is added — a query would autoflush the incomplete row.
131
+ cited = session.get(Event, event_id)
132
+ row = _find_evidence(session, topic_id, event_id)
133
+ if row is None:
134
+ row = TopicMessage(topic_id=topic_id, event_id=event_id, created_at=ev.occurred_at)
135
+ session.add(row)
136
+ row.thread_id = int(cited.thread_id) if cited is not None else int(p["thread_id"])
137
+ row.quote = p.get("quote", "")
138
+ row.actor = p.get("actor") or getattr(ev, "actor", "librarian")
139
+ row.created_by_thread_id = getattr(ev, "actor_thread_id", None)
140
+ row.archived_at = None
141
+
142
+
143
+ def _evidence_archived(session, p: dict, ev) -> None:
144
+ session.execute(update(TopicMessage).where(
145
+ TopicMessage.topic_id == int(p["topic_id"]),
146
+ TopicMessage.event_id == int(p["event_id"]),
147
+ ).values(archived_at=ev.occurred_at))
148
+
149
+
150
+ # ── helpers ───────────────────────────────────────────────────────────────────
151
+ def _entity_int(ev) -> int:
152
+ return int(ev.entity_id)
153
+
154
+
155
+ def _update_thread(session, thread_id: int, fields: dict) -> None:
156
+ session.execute(update(Thread).where(Thread.id == thread_id).values(**fields))
157
+
158
+
159
+ def _find_link(session, src: int, tgt: int, link_type: str):
160
+ return session.execute(select(ThreadLink).where(
161
+ ThreadLink.source_thread_id == src,
162
+ ThreadLink.target_thread_id == tgt,
163
+ ThreadLink.link_type == link_type,
164
+ )).scalars().first()
165
+
166
+
167
+ def _find_evidence(session, topic_id: int, event_id: int):
168
+ return session.execute(select(TopicMessage).where(
169
+ TopicMessage.topic_id == topic_id,
170
+ TopicMessage.event_id == event_id,
171
+ )).scalars().first()
172
+
173
+
174
+ def _repoint_links(session, from_id: int, into_id: int) -> None:
175
+ links = session.execute(select(ThreadLink).where(
176
+ (ThreadLink.source_thread_id == from_id) | (ThreadLink.target_thread_id == from_id)
177
+ )).scalars().all()
178
+ for link in links:
179
+ new_src = into_id if link.source_thread_id == from_id else link.source_thread_id
180
+ new_tgt = into_id if link.target_thread_id == from_id else link.target_thread_id
181
+ if new_src == new_tgt: # a self-loop after merge — drop it
182
+ session.delete(link)
183
+ continue
184
+ if _find_link(session, new_src, new_tgt, link.link_type) is not None:
185
+ session.delete(link) # the merged-into edge already exists — collapse
186
+ continue
187
+ link.source_thread_id, link.target_thread_id = new_src, new_tgt
188
+
189
+
190
+ def _repoint_evidence(session, from_id: int, into_id: int) -> None:
191
+ rows = session.execute(
192
+ select(TopicMessage).where(TopicMessage.topic_id == from_id)
193
+ ).scalars().all()
194
+ for row in rows:
195
+ if _find_evidence(session, into_id, row.event_id) is not None:
196
+ session.delete(row) # citation already exists on the merged-into topic
197
+ continue
198
+ row.topic_id = into_id
199
+
200
+
201
+ _DISPATCH: dict[str, Callable] = {
202
+ "topic.created": _topic_created,
203
+ "topic.renamed": _topic_renamed,
204
+ "topic.updated": _topic_updated,
205
+ "topic.archived": _topic_archived,
206
+ "topic.merged": _topic_merged,
207
+ "link.created": _link_created,
208
+ "link.updated": _link_updated,
209
+ "link.deleted": _link_deleted,
210
+ "evidence.added": _evidence_added,
211
+ "evidence.archived": _evidence_archived,
212
+ }