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.
- thread_archive/__init__.py +34 -0
- thread_archive/_api.py +2235 -0
- thread_archive/_config.py +126 -0
- thread_archive/_importers/__init__.py +81 -0
- thread_archive/_importers/_continuation.py +136 -0
- thread_archive/_importers/_cursor.py +103 -0
- thread_archive/_importers/_events.py +244 -0
- thread_archive/_importers/_line_stream.py +193 -0
- thread_archive/_importers/_read.py +82 -0
- thread_archive/_importers/_result.py +17 -0
- thread_archive/_importers/_sidecar.py +113 -0
- thread_archive/_importers/_state.py +240 -0
- thread_archive/_importers/_titles.py +179 -0
- thread_archive/_importers/antigravity.py +254 -0
- thread_archive/_importers/claude_code.py +293 -0
- thread_archive/_importers/claude_science.py +330 -0
- thread_archive/_importers/cloth.py +20 -0
- thread_archive/_importers/codex.py +324 -0
- thread_archive/_importers/cowork.py +107 -0
- thread_archive/_importers/cursor.py +432 -0
- thread_archive/_importers/exports.py +389 -0
- thread_archive/_importers/grok.py +600 -0
- thread_archive/_importers/opencode.py +538 -0
- thread_archive/_knowledge/__init__.py +67 -0
- thread_archive/_knowledge/_claims.py +116 -0
- thread_archive/_knowledge/_community.py +75 -0
- thread_archive/_knowledge/graph.py +208 -0
- thread_archive/_knowledge/materialize.py +212 -0
- thread_archive/_knowledge/write.py +438 -0
- thread_archive/_launchd.py +167 -0
- thread_archive/_mcp/__init__.py +1 -0
- thread_archive/_mcp/librarian.py +151 -0
- thread_archive/_mcp/server.py +307 -0
- thread_archive/_retrieval/__init__.py +280 -0
- thread_archive/_retrieval/_classify.py +80 -0
- thread_archive/_retrieval/_codex.py +157 -0
- thread_archive/_retrieval/_context.py +123 -0
- thread_archive/_retrieval/_extract.py +184 -0
- thread_archive/_retrieval/embed.py +186 -0
- thread_archive/_retrieval/format.py +149 -0
- thread_archive/_retrieval/fts.py +538 -0
- thread_archive/_retrieval/rank.py +228 -0
- thread_archive/_retrieval/read.py +908 -0
- thread_archive/_retrieval/rerank.py +143 -0
- thread_archive/_retrieval/vectors.py +611 -0
- thread_archive/_scripts/__init__.py +1 -0
- thread_archive/_scripts/backfill_recompute.py +328 -0
- thread_archive/_scripts/backfill_reconcile.py +296 -0
- thread_archive/_scripts/denamespace_dedup_keys.py +120 -0
- thread_archive/_scripts/recover_dropped_events.py +190 -0
- thread_archive/_scripts/repair_grok_tool_names.py +118 -0
- thread_archive/_scripts/repair_grok_tool_names_backup_20260704T155625Z.json +275 -0
- thread_archive/_scripts/repair_grok_tool_names_plan_20260704.json +435 -0
- thread_archive/_setup/__init__.py +12 -0
- thread_archive/_setup/clients.py +89 -0
- thread_archive/_setup/wizard.py +474 -0
- thread_archive/_store/__init__.py +45 -0
- thread_archive/_store/_base.py +173 -0
- thread_archive/_store/_defaults.py +57 -0
- thread_archive/_store/_types.py +38 -0
- thread_archive/_store/models.py +370 -0
- thread_archive/_store/schema.py +84 -0
- thread_archive/_thread_import/__init__.py +40 -0
- thread_archive/_thread_import/api.py +78 -0
- thread_archive/_thread_import/event_builder.py +810 -0
- thread_archive/_thread_import/exporters/__init__.py +9 -0
- thread_archive/_thread_import/exporters/_cursor_kv_mixin.py +166 -0
- thread_archive/_thread_import/exporters/_cursor_parse_mixin.py +149 -0
- thread_archive/_thread_import/exporters/cursor.py +582 -0
- thread_archive/_thread_import/exporters/cursor_parse.py +145 -0
- thread_archive/_thread_import/parsers/__init__.py +191 -0
- thread_archive/_thread_import/parsers/base.py +698 -0
- thread_archive/_thread_import/parsers/chatgpt.py +722 -0
- thread_archive/_thread_import/parsers/chatgpt_content.py +332 -0
- thread_archive/_thread_import/parsers/claude.py +443 -0
- thread_archive/_thread_import/parsers/claude_code.py +1035 -0
- thread_archive/_thread_import/parsers/claude_code_blocks.py +415 -0
- thread_archive/_thread_import/parsers/claude_code_ide.py +122 -0
- thread_archive/_thread_import/parsers/claude_code_sessions.py +90 -0
- thread_archive/_thread_import/parsers/config/__init__.py +26 -0
- thread_archive/_thread_import/parsers/config/base.py +220 -0
- thread_archive/_thread_import/parsers/cursor.py +538 -0
- thread_archive/_thread_import/parsers/cursor_blocks.py +365 -0
- thread_archive/_thread_import/parsers/pipeline/__init__.py +29 -0
- thread_archive/_thread_import/parsers/pipeline/base.py +251 -0
- thread_archive/_thread_import/parsers/pipeline/interfaces.py +191 -0
- thread_archive/_thread_import/parsers/transformers/__init__.py +22 -0
- thread_archive/_thread_import/parsers/transformers/active_path.py +87 -0
- thread_archive/_thread_import/parsers/transformers/coalescing.py +389 -0
- thread_archive/_thread_import/parsers/transformers/ide_context.py +158 -0
- thread_archive/_thread_import/parsers/transformers/thinking_merge.py +68 -0
- thread_archive/_thread_import/parsers/types/__init__.py +56 -0
- thread_archive/_thread_import/parsers/types/chatgpt.py +99 -0
- thread_archive/_thread_import/parsers/types/claude.py +120 -0
- thread_archive/_thread_import/parsers/types/claude_code.py +139 -0
- thread_archive/_thread_import/parsers/types/cursor.py +109 -0
- thread_archive/_thread_import/parsers/validators/__init__.py +83 -0
- thread_archive/_thread_import/parsers/validators/base.py +168 -0
- thread_archive/_thread_import/parsers/validators/content.py +80 -0
- thread_archive/_thread_import/parsers/validators/referential.py +57 -0
- thread_archive/_thread_import/parsers/validators/thinking.py +143 -0
- thread_archive/_thread_import/parsers/validators/types.py +87 -0
- thread_archive/_thread_import/schemas/__init__.py +231 -0
- thread_archive/_thread_import/schemas/chatgpt/v1.json +197 -0
- thread_archive/_thread_import/schemas/chatgpt/v2.json +203 -0
- thread_archive/_thread_import/schemas/claude/v1.json +188 -0
- thread_archive/_thread_import/schemas/claude_code/v1.json +183 -0
- thread_archive/_thread_import/schemas/cursor/v1.json +143 -0
- thread_archive/_thread_import/schemas/cursor/v2.json +200 -0
- thread_archive/_thread_import/timestamps.py +57 -0
- thread_archive/_thread_import/tool_names.py +48 -0
- thread_archive/_truth/__init__.py +40 -0
- thread_archive/_truth/jsonl_log.py +2340 -0
- thread_archive/_truth/repair.py +322 -0
- thread_archive/_watcher/__init__.py +57 -0
- thread_archive/_watcher/base.py +65 -0
- thread_archive/_watcher/daemon.py +289 -0
- thread_archive/_watcher/export_drop.py +203 -0
- thread_archive/_watcher/exthost.py +316 -0
- thread_archive/_watcher/lazy.py +117 -0
- thread_archive/_watcher/sources.py +616 -0
- thread_archive/_web/__init__.py +15 -0
- thread_archive/_web/server.py +299 -0
- thread_archive/_web/static/assets/index-DECSH1Mz.css +10 -0
- thread_archive/_web/static/assets/index-Djq0FK0y.js +101 -0
- thread_archive/_web/static/index.html +13 -0
- thread_archive/cli.py +746 -0
- thread_archive-0.0.2.dist-info/METADATA +296 -0
- thread_archive-0.0.2.dist-info/RECORD +132 -0
- thread_archive-0.0.2.dist-info/WHEEL +4 -0
- thread_archive-0.0.2.dist-info/entry_points.txt +6 -0
- 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
|
+
}
|