know-do-graph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. agents/__init__.py +0 -0
  2. agents/extraction_agent/__init__.py +0 -0
  3. agents/extraction_agent/agent.py +170 -0
  4. agents/graph_agent/__init__.py +5 -0
  5. agents/graph_agent/agent.py +373 -0
  6. agents/graph_agent/tools.py +2106 -0
  7. agents/maintenance_agent/__init__.py +0 -0
  8. agents/maintenance_agent/agent.py +283 -0
  9. agents/orchestrator/__init__.py +0 -0
  10. agents/orchestrator/agent.py +217 -0
  11. agents/review_agent/__init__.py +0 -0
  12. agents/review_agent/agent.py +188 -0
  13. agents/review_agent/tools.py +472 -0
  14. api/__init__.py +0 -0
  15. api/main.py +136 -0
  16. api/routes/__init__.py +0 -0
  17. api/routes/agent.py +81 -0
  18. api/routes/entries.py +411 -0
  19. api/routes/graph.py +132 -0
  20. api/routes/mem.py +179 -0
  21. api/routes/remote.py +815 -0
  22. api/routes/remote_sync.py +230 -0
  23. api/routes/retrieve.py +88 -0
  24. core/__init__.py +0 -0
  25. core/app_state.py +9 -0
  26. core/events.py +84 -0
  27. core/extraction/__init__.py +0 -0
  28. core/extraction/wikilink_parser.py +48 -0
  29. core/graph/__init__.py +0 -0
  30. core/graph/graph.py +204 -0
  31. core/memory/__init__.py +0 -0
  32. core/memory/memgraph.py +458 -0
  33. core/resources/starter.db +0 -0
  34. core/retrieval/__init__.py +0 -0
  35. core/retrieval/embedder.py +122 -0
  36. core/retrieval/fusion.py +52 -0
  37. core/retrieval/progressive.py +399 -0
  38. core/retrieval/retrieval.py +346 -0
  39. core/retrieval/vector_store.py +91 -0
  40. core/schemas/__init__.py +0 -0
  41. core/schemas/edge.py +46 -0
  42. core/schemas/entry.py +388 -0
  43. core/storage/__init__.py +0 -0
  44. core/storage/database.py +104 -0
  45. core/storage/models.py +66 -0
  46. core/storage/repository.py +243 -0
  47. core/sync/__init__.py +20 -0
  48. core/sync/autolink.py +301 -0
  49. core/sync/db_merge.py +297 -0
  50. core/sync/db_watcher.py +84 -0
  51. core/sync/remote_sync.py +345 -0
  52. examples/__init__.py +0 -0
  53. examples/example_entries.py +206 -0
  54. examples/pymatgen_interface_examples.py +811 -0
  55. frontend/dist/assets/index-BLfo7ZZu.css +1 -0
  56. frontend/dist/assets/index-G-mYbZ9R.js +83 -0
  57. frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
  58. frontend/dist/index.html +92 -0
  59. know_do_graph-0.1.0.dist-info/METADATA +765 -0
  60. know_do_graph-0.1.0.dist-info/RECORD +63 -0
  61. know_do_graph-0.1.0.dist-info/WHEEL +4 -0
  62. know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
  63. main.py +944 -0
core/sync/db_merge.py ADDED
@@ -0,0 +1,297 @@
1
+ """Database merge & deduplication utilities.
2
+
3
+ Used by the ``python main.py db merge`` and ``python main.py db dedup`` CLI
4
+ commands when consolidating snapshots from multiple environments (e.g. a
5
+ laptop dev DB and a server DB).
6
+
7
+ Design
8
+ ------
9
+ * UUID primary keys mean entry-id collisions are negligible \u2014 we treat the
10
+ ``entries`` table as a set and union it.
11
+ * Slug collisions across DBs are common (independent authoring of the same
12
+ page). ``EntryRepository.create`` already calls ``_unique_slug`` which
13
+ suffixes ``-1``, ``-2``, etc., so writes never fail \u2014 the duplicates can
14
+ be merged afterwards by ``db dedup``.
15
+ * Edges are de-duplicated by ``(source_id, target_id, relation)`` inside
16
+ ``EdgeRepository.create``.
17
+ * After import we re-run wikilink resolution so newly-arrived entries connect
18
+ to existing nodes by slug/title/alias.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from collections import defaultdict
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Optional
28
+
29
+ from sqlalchemy import create_engine
30
+ from sqlalchemy.orm import sessionmaker
31
+
32
+ from core.schemas.edge import Edge
33
+ from core.schemas.entry import Entry
34
+ from core.storage.database import SessionLocal
35
+ from core.storage.models import EdgeModel, EntryModel
36
+ from core.storage.repository import EdgeRepository, EntryRepository
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ # ── Merge ────────────────────────────────────────────────────────────────────
42
+
43
+
44
+ @dataclass
45
+ class MergeReport:
46
+ entries_inserted: int = 0
47
+ entries_updated: int = 0
48
+ entries_skipped: int = 0
49
+ edges_inserted: int = 0
50
+ edges_skipped: int = 0
51
+ wikilinks_resolved: int = 0
52
+ slug_renames: list[tuple[str, str]] = field(default_factory=list) # (incoming, final)
53
+
54
+
55
+ def _open_readonly(db_path: Path):
56
+ """Open a foreign SQLite file as a SQLAlchemy session factory.
57
+
58
+ We never write to it, so a plain connection is sufficient. (The previous
59
+ ``mode=ro&uri=true`` query-string form is mis-parsed by SQLAlchemy's URL
60
+ parser and silently opens an empty DB.)
61
+ """
62
+ if not db_path.exists():
63
+ raise FileNotFoundError(db_path)
64
+ eng = create_engine(
65
+ f"sqlite:///{db_path.resolve()}",
66
+ connect_args={"check_same_thread": False},
67
+ )
68
+ return sessionmaker(autocommit=False, autoflush=False, bind=eng)
69
+
70
+
71
+ def merge_database(
72
+ other_db_path: Path,
73
+ *,
74
+ prefer: str = "newer", # "newer" | "local" | "remote"
75
+ resolve_wikilinks: bool = True,
76
+ ) -> MergeReport:
77
+ """Additively merge entries+edges from *other_db_path* into the current DB.
78
+
79
+ ``prefer`` controls id-conflict resolution:
80
+ * ``newer`` \u2014 keep whichever side has the larger ``updated_at``.
81
+ * ``local`` \u2014 never overwrite existing local entries.
82
+ * ``remote`` \u2014 always overwrite with the incoming entry.
83
+ """
84
+ if prefer not in {"newer", "local", "remote"}:
85
+ raise ValueError(f"invalid prefer={prefer!r}")
86
+
87
+ report = MergeReport()
88
+ OtherSession = _open_readonly(Path(other_db_path))
89
+
90
+ with OtherSession() as src_db:
91
+ src_entry_models = src_db.query(EntryModel).all()
92
+ src_edge_models = src_db.query(EdgeModel).all()
93
+ # Capture raw fields up-front so we don't hold the read-only session open.
94
+ src_entries: list[tuple[dict, object, object]] = [
95
+ (m.to_dict(), m.created_at, m.updated_at) for m in src_entry_models
96
+ ]
97
+ src_edges: list[dict] = [m.to_dict() for m in src_edge_models]
98
+
99
+ logger.info(
100
+ "db merge: source has %d entries, %d edges",
101
+ len(src_entries), len(src_edges),
102
+ )
103
+
104
+ with SessionLocal() as dst_db:
105
+ dst_repo = EntryRepository(dst_db)
106
+ existing = {m.id: m for m in dst_db.query(EntryModel).all()}
107
+
108
+ for data, src_created, src_updated in src_entries:
109
+ incoming = Entry(**data)
110
+ local = existing.get(incoming.id)
111
+
112
+ if local is None:
113
+ pre_slug = incoming.slug
114
+ saved = dst_repo.create(incoming)
115
+ if saved.slug != pre_slug:
116
+ report.slug_renames.append((pre_slug, saved.slug))
117
+ report.entries_inserted += 1
118
+ continue
119
+
120
+ # id collision \u2014 apply preference policy
121
+ if prefer == "local":
122
+ report.entries_skipped += 1
123
+ continue
124
+ if prefer == "newer" and (local.updated_at and src_updated) and local.updated_at >= src_updated:
125
+ report.entries_skipped += 1
126
+ continue
127
+ dst_repo.update(incoming)
128
+ report.entries_updated += 1
129
+
130
+ with SessionLocal() as dst_db:
131
+ edge_repo = EdgeRepository(dst_db)
132
+ # ``EdgeRepository.create`` de-dups on (source, target, relation) and
133
+ # returns the existing edge if one is already present.
134
+ existing_keys = {
135
+ (m.source_id, m.target_id, m.relation)
136
+ for m in dst_db.query(EdgeModel).all()
137
+ }
138
+ for d in src_edges:
139
+ key = (d["source_id"], d["target_id"], d["relation"])
140
+ if key in existing_keys:
141
+ report.edges_skipped += 1
142
+ continue
143
+ try:
144
+ edge = Edge(**d)
145
+ except Exception as exc: # noqa: BLE001
146
+ logger.warning("skipping malformed edge %s: %s", d.get("id"), exc)
147
+ report.edges_skipped += 1
148
+ continue
149
+ edge_repo.create(edge)
150
+ existing_keys.add(key)
151
+ report.edges_inserted += 1
152
+
153
+ if resolve_wikilinks:
154
+ from agents.extraction_agent.agent import ExtractionAgent
155
+ from core import app_state
156
+
157
+ report.wikilinks_resolved = ExtractionAgent(app_state.graph).resolve_wikilinks()
158
+
159
+ # Refresh in-memory graph and notify any connected UIs.
160
+ from core import app_state, events as _events
161
+ from core.sync.db_watcher import reload_graph_from_db
162
+
163
+ nodes, edges = reload_graph_from_db(app_state.graph)
164
+ _events.emit("graph_changed", {"source": "db_merge", "nodes": nodes, "edges": edges})
165
+
166
+ return report
167
+
168
+
169
+ # ── Dedup ────────────────────────────────────────────────────────────────────
170
+
171
+
172
+ @dataclass
173
+ class DedupReport:
174
+ exact_groups: int = 0
175
+ similar_groups: int = 0
176
+ merged_pairs: int = 0
177
+ candidates: list[dict] = field(default_factory=list)
178
+
179
+
180
+ def _slug_stem(slug: str) -> str:
181
+ """Strip a trailing ``-<digits>`` collision suffix (e.g. ``mace-2`` \u2192 ``mace``)."""
182
+ import re
183
+ return re.sub(r"-\d+$", "", slug or "")
184
+
185
+
186
+ def find_exact_duplicate_groups() -> list[list[Entry]]:
187
+ """Group entries that are obviously the same node (same slug-stem or title).
188
+
189
+ Returns a list of groups, each containing 2+ entries.
190
+ """
191
+ with SessionLocal() as db:
192
+ entries = EntryRepository(db).get_all()
193
+
194
+ by_stem: dict[str, list[Entry]] = defaultdict(list)
195
+ by_title: dict[str, list[Entry]] = defaultdict(list)
196
+ for e in entries:
197
+ by_stem[_slug_stem(e.slug).lower()].append(e)
198
+ by_title[e.title.strip().lower()].append(e)
199
+
200
+ groups: list[list[Entry]] = []
201
+ seen_ids: set[str] = set()
202
+
203
+ def _emit(group: list[Entry]) -> None:
204
+ ids = tuple(sorted(e.id for e in group))
205
+ if any(i in seen_ids for i in ids):
206
+ # Already covered by a prior overlapping group.
207
+ return
208
+ seen_ids.update(ids)
209
+ groups.append(group)
210
+
211
+ for g in by_stem.values():
212
+ if len(g) > 1:
213
+ _emit(g)
214
+ for g in by_title.values():
215
+ if len(g) > 1:
216
+ _emit(g)
217
+ return groups
218
+
219
+
220
+ def _pick_primary(group: list[Entry]) -> Entry:
221
+ """Choose the survivor: prefer the entry with the most content, then earliest id."""
222
+ return max(group, key=lambda e: (len(e.content or ""), -ord(e.id[0]) if e.id else 0))
223
+
224
+
225
+ def dedup_exact(*, dry_run: bool = True) -> DedupReport:
226
+ """Merge entries that are exact duplicates (same slug-stem or normalized title)."""
227
+ from agents.graph_agent.tools import merge_entries
228
+ from core import app_state, events as _events
229
+ from core.sync.db_watcher import reload_graph_from_db
230
+
231
+ report = DedupReport()
232
+ for group in find_exact_duplicate_groups():
233
+ report.exact_groups += 1
234
+ primary = _pick_primary(group)
235
+ for dup in group:
236
+ if dup.id == primary.id:
237
+ continue
238
+ report.candidates.append({
239
+ "primary_id": primary.id,
240
+ "primary_slug": primary.slug,
241
+ "duplicate_id": dup.id,
242
+ "duplicate_slug": dup.slug,
243
+ "reason": "exact",
244
+ })
245
+ if not dry_run:
246
+ res = merge_entries(primary.id, dup.id, graph=app_state.graph)
247
+ if res.get("merged"):
248
+ report.merged_pairs += 1
249
+
250
+ if not dry_run and report.merged_pairs:
251
+ nodes, edges = reload_graph_from_db(app_state.graph)
252
+ _events.emit("graph_changed", {"source": "db_dedup", "nodes": nodes, "edges": edges})
253
+ return report
254
+
255
+
256
+ def find_similar_groups(threshold: float = 0.92, top_k: int = 20) -> list[dict]:
257
+ """Embedding-based near-duplicate candidates.
258
+
259
+ Returns a list of ``{"a_id", "b_id", "similarity"}`` dicts. Empty if the
260
+ sqlite-vec index is unavailable.
261
+ """
262
+ from sqlalchemy import text as _text
263
+ from core.retrieval.vector_store import _table_available, knn # type: ignore
264
+ import struct
265
+
266
+ with SessionLocal() as db:
267
+ if not _table_available(db):
268
+ logger.info("sqlite-vec table not available \u2014 similarity dedup skipped")
269
+ return []
270
+ rows = db.execute(_text("SELECT entry_id, embedding FROM entry_embeddings")).all()
271
+ id_to_vec: dict[str, list[float]] = {}
272
+ for entry_id, blob in rows:
273
+ if blob is None:
274
+ continue
275
+ n = len(blob) // 4
276
+ id_to_vec[entry_id] = list(struct.unpack(f"<{n}f", blob))
277
+
278
+ pairs: dict[tuple[str, str], float] = {}
279
+ for eid, vec in id_to_vec.items():
280
+ neighbors = knn(db, vec, k=top_k + 1)
281
+ for nbr_id, distance in neighbors:
282
+ if nbr_id == eid:
283
+ continue
284
+ # sqlite-vec returns L2 distance by default; convert to a
285
+ # bounded similarity. With sentence-transformers L2-normed
286
+ # embeddings, sim = 1 - d**2 / 2 is the exact cosine.
287
+ sim = max(0.0, 1.0 - (distance * distance) / 2.0)
288
+ if sim < threshold:
289
+ continue
290
+ key = tuple(sorted((eid, nbr_id)))
291
+ if sim > pairs.get(key, 0.0):
292
+ pairs[key] = sim
293
+
294
+ return [
295
+ {"a_id": a, "b_id": b, "similarity": round(s, 4)}
296
+ for (a, b), s in sorted(pairs.items(), key=lambda kv: -kv[1])
297
+ ]
@@ -0,0 +1,84 @@
1
+ """Cross-process DB-change watcher.
2
+
3
+ Polls a cheap fingerprint of the entries/edges tables every few seconds; when
4
+ it changes (e.g. a CLI command wrote to the same SQLite file) the in-memory
5
+ :class:`KnowDoGraph` is rebuilt and a ``graph_changed`` SSE event is broadcast
6
+ so connected UIs refresh automatically.
7
+
8
+ Disabled by setting ``KDG_DB_WATCH_INTERVAL_SECONDS=0``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import logging
15
+ from typing import Optional
16
+
17
+ from sqlalchemy import text
18
+
19
+ from core import events as _events
20
+ from core.storage.database import SessionLocal
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _fingerprint(db) -> tuple:
26
+ """Cheap change-detector: row counts + max(updated_at)/created_at."""
27
+ e_count = db.execute(text("SELECT COUNT(*) FROM entries")).scalar() or 0
28
+ e_max = db.execute(text("SELECT MAX(updated_at) FROM entries")).scalar() or ""
29
+ ed_count = db.execute(text("SELECT COUNT(*) FROM edges")).scalar() or 0
30
+ ed_max = db.execute(text("SELECT MAX(created_at) FROM edges")).scalar() or ""
31
+ return (int(e_count), str(e_max), int(ed_count), str(ed_max))
32
+
33
+
34
+ def reload_graph_from_db(graph) -> tuple[int, int]:
35
+ """Rebuild *graph* from the current DB contents. Returns (node_count, edge_count).
36
+
37
+ Also opportunistically deletes any dangling edges (edges whose source or
38
+ target entry no longer exists). Such rows produced "ghost" nodes in the
39
+ UI in earlier versions; pruning them here keeps the graph self-healing.
40
+ """
41
+ from core.storage.repository import EdgeRepository, EntryRepository
42
+
43
+ with SessionLocal() as db:
44
+ entry_repo = EntryRepository(db)
45
+ edge_repo = EdgeRepository(db)
46
+ entries = entry_repo.get_all()
47
+ entry_ids = {e.id for e in entries}
48
+ all_edges = edge_repo.get_all()
49
+ dangling = [e for e in all_edges if e.source_id not in entry_ids or e.target_id not in entry_ids]
50
+ if dangling:
51
+ for e in dangling:
52
+ edge_repo.delete(e.id)
53
+ logger.warning("reload_graph_from_db: pruned %d dangling edge(s)", len(dangling))
54
+ edges = [e for e in all_edges if e.source_id in entry_ids and e.target_id in entry_ids]
55
+ graph.rebuild_from_db(entries, edges)
56
+ return (len(entries), len(edges))
57
+
58
+
59
+ async def run_db_watcher(graph, interval_seconds: int) -> None:
60
+ """Poll the DB fingerprint on a fixed cadence and reload on change."""
61
+ if interval_seconds <= 0:
62
+ return
63
+
64
+ with SessionLocal() as db:
65
+ last = _fingerprint(db)
66
+
67
+ logger.info("db-watcher started (interval=%ss, initial=%s)", interval_seconds, last)
68
+ while True:
69
+ try:
70
+ await asyncio.sleep(interval_seconds)
71
+ with SessionLocal() as db:
72
+ current = _fingerprint(db)
73
+ if current != last:
74
+ logger.info("db change detected (%s \u2192 %s) \u2014 reloading graph", last, current)
75
+ nodes, edges = reload_graph_from_db(graph)
76
+ _events.emit(
77
+ "graph_changed",
78
+ {"source": "db_watcher", "nodes": nodes, "edges": edges},
79
+ )
80
+ last = current
81
+ except asyncio.CancelledError:
82
+ raise
83
+ except Exception as exc: # noqa: BLE001
84
+ logger.warning("db-watcher iteration failed: %s", exc)