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.
- agents/__init__.py +0 -0
- agents/extraction_agent/__init__.py +0 -0
- agents/extraction_agent/agent.py +170 -0
- agents/graph_agent/__init__.py +5 -0
- agents/graph_agent/agent.py +373 -0
- agents/graph_agent/tools.py +2106 -0
- agents/maintenance_agent/__init__.py +0 -0
- agents/maintenance_agent/agent.py +283 -0
- agents/orchestrator/__init__.py +0 -0
- agents/orchestrator/agent.py +217 -0
- agents/review_agent/__init__.py +0 -0
- agents/review_agent/agent.py +188 -0
- agents/review_agent/tools.py +472 -0
- api/__init__.py +0 -0
- api/main.py +136 -0
- api/routes/__init__.py +0 -0
- api/routes/agent.py +81 -0
- api/routes/entries.py +411 -0
- api/routes/graph.py +132 -0
- api/routes/mem.py +179 -0
- api/routes/remote.py +815 -0
- api/routes/remote_sync.py +230 -0
- api/routes/retrieve.py +88 -0
- core/__init__.py +0 -0
- core/app_state.py +9 -0
- core/events.py +84 -0
- core/extraction/__init__.py +0 -0
- core/extraction/wikilink_parser.py +48 -0
- core/graph/__init__.py +0 -0
- core/graph/graph.py +204 -0
- core/memory/__init__.py +0 -0
- core/memory/memgraph.py +458 -0
- core/resources/starter.db +0 -0
- core/retrieval/__init__.py +0 -0
- core/retrieval/embedder.py +122 -0
- core/retrieval/fusion.py +52 -0
- core/retrieval/progressive.py +399 -0
- core/retrieval/retrieval.py +346 -0
- core/retrieval/vector_store.py +91 -0
- core/schemas/__init__.py +0 -0
- core/schemas/edge.py +46 -0
- core/schemas/entry.py +388 -0
- core/storage/__init__.py +0 -0
- core/storage/database.py +104 -0
- core/storage/models.py +66 -0
- core/storage/repository.py +243 -0
- core/sync/__init__.py +20 -0
- core/sync/autolink.py +301 -0
- core/sync/db_merge.py +297 -0
- core/sync/db_watcher.py +84 -0
- core/sync/remote_sync.py +345 -0
- examples/__init__.py +0 -0
- examples/example_entries.py +206 -0
- examples/pymatgen_interface_examples.py +811 -0
- frontend/dist/assets/index-BLfo7ZZu.css +1 -0
- frontend/dist/assets/index-G-mYbZ9R.js +83 -0
- frontend/dist/assets/index-G-mYbZ9R.js.map +1 -0
- frontend/dist/index.html +92 -0
- know_do_graph-0.1.0.dist-info/METADATA +765 -0
- know_do_graph-0.1.0.dist-info/RECORD +63 -0
- know_do_graph-0.1.0.dist-info/WHEEL +4 -0
- know_do_graph-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|
core/sync/db_watcher.py
ADDED
|
@@ -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)
|