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
api/routes/entries.py ADDED
@@ -0,0 +1,411 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from fastapi.responses import PlainTextResponse, RedirectResponse, Response
7
+ from pydantic import BaseModel
8
+ from sqlalchemy.orm import Session
9
+
10
+ from core import events as _events
11
+ from core.app_state import graph as _graph
12
+ from core.retrieval.retrieval import RetrievalEngine
13
+ from core.schemas.edge import EdgeRelation
14
+ from core.schemas.entry import (
15
+ Entry,
16
+ EntryType,
17
+ KNOWN_ASSET_FOLDERS,
18
+ NodeAsset,
19
+ )
20
+ from core.storage.database import get_db
21
+ from core.storage.repository import EdgeRepository, EntryRepository
22
+
23
+ router = APIRouter()
24
+
25
+
26
+ def _engine(db: Session = Depends(get_db)) -> RetrievalEngine:
27
+ return RetrievalEngine(db, _graph)
28
+
29
+
30
+ @router.get("/", response_model=list[dict])
31
+ def list_entries(
32
+ limit: int = 20,
33
+ offset: int = 0,
34
+ engine: RetrievalEngine = Depends(_engine),
35
+ ):
36
+ """List entries with pagination."""
37
+ return [e.model_dump(mode="json") for e in engine.list_entries(limit=limit, offset=offset)]
38
+
39
+
40
+ @router.get("/search", response_model=list[dict])
41
+ def search_entries(
42
+ q: Optional[str] = None,
43
+ tags: Optional[str] = None,
44
+ entry_type: Optional[EntryType] = None,
45
+ limit: int = 20,
46
+ include_scores: bool = False,
47
+ engine: RetrievalEngine = Depends(_engine),
48
+ ):
49
+ """Full-text + vector hybrid search.
50
+
51
+ Pass ``include_scores=true`` to receive a ``_score`` field (0.0–1.0) on
52
+ each result — useful for rendering relevance coloring in the frontend.
53
+ """
54
+ tag_list = [t.strip() for t in tags.split(",")] if tags else None
55
+ if include_scores and q:
56
+ results = engine.search_entries_scored(
57
+ query=q, tags=tag_list, entry_type=entry_type, limit=limit
58
+ )
59
+ return [{**e.model_dump(mode="json"), "_score": round(score, 4)} for e, score in results]
60
+ results = engine.search_entries(query=q, tags=tag_list, entry_type=entry_type, limit=limit)
61
+ return [e.model_dump(mode="json") for e in results]
62
+
63
+
64
+ @router.get("/{entry_id}", response_model=dict)
65
+ def get_entry(entry_id: str, engine: RetrievalEngine = Depends(_engine)):
66
+ """Retrieve a single entry by ID, slug, or alias."""
67
+ entry = engine.resolve_identifier(entry_id)
68
+ if not entry:
69
+ raise HTTPException(status_code=404, detail="Entry not found")
70
+ return entry.model_dump(mode="json")
71
+
72
+
73
+ @router.post("/", response_model=dict, status_code=201)
74
+ def create_entry(entry: Entry, db: Session = Depends(get_db)):
75
+ """Create a new entry."""
76
+ saved = EntryRepository(db).create(entry)
77
+ _graph.add_entry(saved)
78
+ _events.emit("node_added", {
79
+ "id": saved.id,
80
+ "title": saved.title,
81
+ "slug": saved.slug,
82
+ "entry_type": saved.entry_type.value if hasattr(saved.entry_type, "value") else saved.entry_type,
83
+ "tags": saved.tags,
84
+ })
85
+ return saved.model_dump(mode="json")
86
+
87
+
88
+ @router.put("/{entry_id}", response_model=dict)
89
+ def update_entry(entry_id: str, entry: Entry, db: Session = Depends(get_db)):
90
+ """Update an existing entry."""
91
+ entry.id = entry_id
92
+ updated = EntryRepository(db).update(entry)
93
+ if not updated:
94
+ raise HTTPException(status_code=404, detail="Entry not found")
95
+ _graph.add_entry(updated)
96
+ _events.emit("node_updated", {
97
+ "id": updated.id,
98
+ "title": updated.title,
99
+ "slug": updated.slug,
100
+ "entry_type": updated.entry_type.value if hasattr(updated.entry_type, "value") else updated.entry_type,
101
+ "tags": updated.tags,
102
+ })
103
+ return updated.model_dump(mode="json")
104
+
105
+
106
+ @router.delete("/{entry_id}", status_code=204)
107
+ def delete_entry(entry_id: str, db: Session = Depends(get_db)):
108
+ """Delete an entry and its node from the in-memory graph."""
109
+ if not EntryRepository(db).delete(entry_id):
110
+ raise HTTPException(status_code=404, detail="Entry not found")
111
+ _graph.remove_entry(entry_id)
112
+ _events.emit("node_removed", {"id": entry_id})
113
+
114
+
115
+ @router.get("/{entry_id}/related", response_model=list[dict])
116
+ def get_related(
117
+ entry_id: str,
118
+ depth: int = 1,
119
+ relation: Optional[EdgeRelation] = None,
120
+ engine: RetrievalEngine = Depends(_engine),
121
+ ):
122
+ """Return entries related to *entry_id* via the graph."""
123
+ related = engine.get_related_entries(entry_id, depth=depth, relation=relation)
124
+ return [e.model_dump(mode="json") for e in related]
125
+
126
+
127
+ @router.get("/{entry_id}/edges", response_model=list[dict])
128
+ def get_edges(entry_id: str, engine: RetrievalEngine = Depends(_engine)):
129
+ """Return all edges incident to *entry_id*."""
130
+ return [e.model_dump(mode="json") for e in engine.get_edges_for_entry(entry_id)]
131
+
132
+
133
+ @router.get("/{entry_id}/scripts")
134
+ def list_entry_scripts(entry_id: str, engine: RetrievalEngine = Depends(_engine)):
135
+ """List all scripts attached to an entry (metadata only — no code bodies)."""
136
+ entry = engine.resolve_identifier(entry_id)
137
+ if not entry:
138
+ raise HTTPException(status_code=404, detail="Entry not found")
139
+ return [
140
+ {
141
+ "filename": s.filename,
142
+ "language": s.language,
143
+ "requirements": s.requirements,
144
+ "description": s.description,
145
+ "download_url": f"/entries/{entry.id}/scripts/{s.filename}",
146
+ }
147
+ for s in entry.scripts
148
+ ]
149
+
150
+
151
+ @router.get("/{entry_id}/scripts/{filename}")
152
+ def download_entry_script(entry_id: str, filename: str, engine: RetrievalEngine = Depends(_engine)):
153
+ """Download the source code of a specific script attached to an entry."""
154
+ entry = engine.resolve_identifier(entry_id)
155
+ if not entry:
156
+ raise HTTPException(status_code=404, detail="Entry not found")
157
+ script = next((s for s in entry.scripts if s.filename == filename), None)
158
+ if not script:
159
+ raise HTTPException(status_code=404, detail=f"No script '{filename}' on entry '{entry_id}'")
160
+
161
+ safe_filename = filename.replace("/", "_").replace("\\", "_").replace('"', "")
162
+ media_type = _media_type_for_language(script.language)
163
+ return Response(
164
+ content=script.content,
165
+ media_type=media_type,
166
+ headers={"Content-Disposition": f'attachment; filename="{safe_filename}"'},
167
+ )
168
+
169
+
170
+ @router.get("/{entry_id}/download")
171
+ def download_script(entry_id: str, engine: RetrievalEngine = Depends(_engine)):
172
+ """Download the first attached script of an entry (backward-compatible endpoint).
173
+
174
+ Prefer ``GET /{entry_id}/scripts/{filename}`` when the entry has multiple scripts.
175
+ Returns 400 if the entry has no scripts attached.
176
+ """
177
+ entry = engine.resolve_identifier(entry_id)
178
+ if not entry:
179
+ raise HTTPException(status_code=404, detail="Entry not found")
180
+ if not entry.scripts:
181
+ raise HTTPException(
182
+ status_code=400,
183
+ detail=f"Entry '{entry_id}' has no attached scripts.",
184
+ )
185
+
186
+ script = entry.scripts[0]
187
+ filename = script.filename.replace("/", "_").replace("\\", "_").replace('"', "")
188
+ media_type = _media_type_for_language(script.language)
189
+ return Response(
190
+ content=script.content,
191
+ media_type=media_type,
192
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
193
+ )
194
+
195
+
196
+ def _media_type_for_language(language: str) -> str:
197
+ mapping = {
198
+ "python": "text/x-python",
199
+ "py": "text/x-python",
200
+ "bash": "text/x-sh",
201
+ "shell": "text/x-sh",
202
+ "sh": "text/x-sh",
203
+ "julia": "text/x-julia",
204
+ "javascript": "text/javascript",
205
+ "js": "text/javascript",
206
+ "typescript": "text/typescript",
207
+ "ts": "text/typescript",
208
+ "r": "text/x-r",
209
+ }
210
+ return mapping.get(language.lower(), "text/plain")
211
+
212
+
213
+ # ── Folder-style assets ─────────────────────────────────────────────────────
214
+
215
+
216
+ def _asset_meta(entry_id: str, asset: NodeAsset) -> dict:
217
+ return {
218
+ "folder": asset.folder,
219
+ "filename": asset.filename,
220
+ "path": asset.path,
221
+ "kind": asset.kind,
222
+ "language": asset.language,
223
+ "mime_type": asset.mime_type,
224
+ "description": asset.description,
225
+ "requirements": asset.requirements,
226
+ "size": len(asset.content or ""),
227
+ "download_url": f"/entries/{entry_id}/assets/{asset.folder}/{asset.filename}",
228
+ "metadata": asset.metadata,
229
+ }
230
+
231
+
232
+ @router.get("/{entry_id}/assets")
233
+ def list_entry_assets(entry_id: str, engine: RetrievalEngine = Depends(_engine)):
234
+ """List all assets attached to an entry, grouped by folder.
235
+
236
+ Returns ``{ folders: { scripts: [...], references: [...], ... } }``.
237
+ Use ``GET /entries/{id}/assets/{folder}/{filename}`` to fetch a body.
238
+ """
239
+ entry = engine.resolve_identifier(entry_id)
240
+ if not entry:
241
+ raise HTTPException(status_code=404, detail="Entry not found")
242
+ grouped: dict[str, list[dict]] = {}
243
+ for a in entry.assets:
244
+ grouped.setdefault(a.folder, []).append(_asset_meta(entry.id, a))
245
+ # Stable ordering: known folders first, then any extras alphabetically
246
+ ordered = {}
247
+ for f in KNOWN_ASSET_FOLDERS:
248
+ if f in grouped:
249
+ ordered[f] = grouped.pop(f)
250
+ for f in sorted(grouped.keys()):
251
+ ordered[f] = grouped[f]
252
+ return {
253
+ "entry_id": entry.id,
254
+ "slug": entry.slug,
255
+ "folders": ordered,
256
+ "total": sum(len(v) for v in ordered.values()),
257
+ }
258
+
259
+
260
+ @router.get("/{entry_id}/assets/{folder}")
261
+ def list_entry_assets_in_folder(
262
+ entry_id: str, folder: str, engine: RetrievalEngine = Depends(_engine)
263
+ ):
264
+ """List assets in a single folder of an entry."""
265
+ entry = engine.resolve_identifier(entry_id)
266
+ if not entry:
267
+ raise HTTPException(status_code=404, detail="Entry not found")
268
+ items = [_asset_meta(entry.id, a) for a in entry.assets if a.folder == folder.lower()]
269
+ return {"entry_id": entry.id, "folder": folder.lower(), "items": items}
270
+
271
+
272
+ @router.get("/{entry_id}/assets/{folder}/{filename:path}")
273
+ def download_entry_asset(
274
+ entry_id: str,
275
+ folder: str,
276
+ filename: str,
277
+ engine: RetrievalEngine = Depends(_engine),
278
+ ):
279
+ """Fetch the content of an asset.
280
+
281
+ - ``kind == "file"``: returns the file body with an ``attachment`` disposition.
282
+ - ``kind == "text"``: returns the body inline as ``text/markdown`` (or stored mime).
283
+ - ``kind == "link"``: 302-redirects to the URL stored in ``content``.
284
+ """
285
+ entry = engine.resolve_identifier(entry_id)
286
+ if not entry:
287
+ raise HTTPException(status_code=404, detail="Entry not found")
288
+ if ".." in filename.split("/"):
289
+ raise HTTPException(status_code=400, detail="Invalid filename")
290
+ asset = entry.find_asset(folder, filename)
291
+ if asset is None:
292
+ raise HTTPException(
293
+ status_code=404,
294
+ detail=f"No asset '{folder}/{filename}' on entry '{entry_id}'",
295
+ )
296
+
297
+ if asset.kind == "link":
298
+ url = (asset.content or "").strip()
299
+ if not url:
300
+ raise HTTPException(status_code=400, detail="Link asset has empty URL")
301
+ return RedirectResponse(url=url, status_code=302)
302
+
303
+ safe_name = asset.filename.split("/")[-1].replace('"', "")
304
+ media_type = (
305
+ asset.mime_type
306
+ or (_media_type_for_language(asset.language) if asset.language else None)
307
+ or ("text/markdown" if asset.kind == "text" else "application/octet-stream")
308
+ )
309
+ disposition = "inline" if asset.kind == "text" else "attachment"
310
+ return Response(
311
+ content=asset.content,
312
+ media_type=media_type,
313
+ headers={"Content-Disposition": f'{disposition}; filename="{safe_name}"'},
314
+ )
315
+
316
+
317
+ class AssetBody(BaseModel):
318
+ folder: str
319
+ filename: str
320
+ kind: str = "file" # file | link | text
321
+ content: str = ""
322
+ language: Optional[str] = None
323
+ mime_type: Optional[str] = None
324
+ description: str = ""
325
+ requirements: list[str] = []
326
+ metadata: dict = {}
327
+
328
+
329
+ @router.post("/{entry_id}/assets", status_code=201)
330
+ def add_entry_asset(
331
+ entry_id: str, body: AssetBody, db: Session = Depends(get_db)
332
+ ):
333
+ """Add or replace an asset on an entry.
334
+
335
+ If an asset with the same ``folder/filename`` exists it is replaced.
336
+ """
337
+ engine = RetrievalEngine(db, _graph)
338
+ entry = engine.resolve_identifier(entry_id)
339
+ if not entry:
340
+ raise HTTPException(status_code=404, detail="Entry not found")
341
+ try:
342
+ new_asset = NodeAsset(**body.model_dump())
343
+ except ValueError as e:
344
+ raise HTTPException(status_code=400, detail=str(e))
345
+ entry.assets = [
346
+ a for a in entry.assets
347
+ if not (a.folder == new_asset.folder and a.filename == new_asset.filename)
348
+ ]
349
+ entry.assets.append(new_asset)
350
+ updated = EntryRepository(db).update(entry)
351
+ if updated is None:
352
+ raise HTTPException(status_code=500, detail="Failed to update entry")
353
+ _graph.add_entry(updated)
354
+ return _asset_meta(updated.id, new_asset)
355
+
356
+
357
+ @router.delete("/{entry_id}/assets/{folder}/{filename:path}", status_code=204)
358
+ def delete_entry_asset(
359
+ entry_id: str, folder: str, filename: str, db: Session = Depends(get_db)
360
+ ):
361
+ """Delete a single asset from an entry."""
362
+ engine = RetrievalEngine(db, _graph)
363
+ entry = engine.resolve_identifier(entry_id)
364
+ if not entry:
365
+ raise HTTPException(status_code=404, detail="Entry not found")
366
+ folder_n = folder.lower()
367
+ before = len(entry.assets)
368
+ entry.assets = [
369
+ a for a in entry.assets
370
+ if not (a.folder == folder_n and a.filename == filename)
371
+ ]
372
+ if len(entry.assets) == before:
373
+ raise HTTPException(status_code=404, detail="Asset not found")
374
+ updated = EntryRepository(db).update(entry)
375
+ if updated is None:
376
+ raise HTTPException(status_code=500, detail="Failed to update entry")
377
+ _graph.add_entry(updated)
378
+
379
+
380
+ # ── Feedback / verification ──────────────────────────────────────────────────
381
+
382
+
383
+ class FeedbackBody(BaseModel):
384
+ verdict: str # works | peer_works | bugged | deprecated | unclear
385
+ note: str = ""
386
+ evidence: str = ""
387
+ agent_id: str = "external"
388
+
389
+
390
+ @router.post("/{entry_id}/feedback", status_code=201)
391
+ def post_entry_feedback(entry_id: str, body: FeedbackBody, db: Session = Depends(get_db)):
392
+ """Record correctness feedback on an entry.
393
+
394
+ Updates ``metadata.verification_status`` according to *verdict* and appends
395
+ the event (timestamp + agent_id + note + evidence) to
396
+ ``metadata.feedback_log``. This is the canonical channel for external
397
+ agents to flag a node as working or bugged.
398
+ """
399
+ from agents.graph_agent.tools import submit_feedback
400
+
401
+ result = submit_feedback(
402
+ entry_id=entry_id,
403
+ verdict=body.verdict,
404
+ note=body.note,
405
+ evidence=body.evidence,
406
+ agent_id=body.agent_id,
407
+ graph=_graph,
408
+ )
409
+ if "error" in result:
410
+ raise HTTPException(status_code=400, detail=result["error"])
411
+ return result
api/routes/graph.py ADDED
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from fastapi.responses import StreamingResponse
7
+ from starlette.types import Receive, Scope, Send
8
+
9
+ from core.app_state import graph as _graph
10
+
11
+ router = APIRouter()
12
+
13
+
14
+ class _SSEResponse(StreamingResponse):
15
+ """StreamingResponse that swallows CancelledError on server shutdown.
16
+
17
+ Starlette's listen_for_disconnect task raises CancelledError (a BaseException,
18
+ not Exception) when uvicorn force-cancels connections after the graceful-shutdown
19
+ timeout. That bypasses Starlette's internal `wrap()` handler and reaches uvicorn's
20
+ error logger, producing a spurious "Exception in ASGI application" traceback.
21
+ Catching it here keeps the log clean; all generator finally-blocks still run
22
+ because the stack unwinds normally before we get here.
23
+ """
24
+
25
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
26
+ try:
27
+ await super().__call__(scope, receive, send)
28
+ except asyncio.CancelledError:
29
+ pass
30
+
31
+
32
+ @router.get("/stats")
33
+ def graph_stats() -> dict:
34
+ """Return high-level graph statistics."""
35
+ return _graph.stats()
36
+
37
+
38
+ @router.get("/path")
39
+ def find_path(source: str, target: str, cutoff: int = 6) -> dict:
40
+ """Find all simple paths between two entry IDs."""
41
+ paths = _graph.find_paths(source, target, cutoff=cutoff)
42
+ return {"source": source, "target": target, "paths": paths}
43
+
44
+
45
+ @router.get("/subgraph/{entry_id}")
46
+ def get_subgraph(entry_id: str, depth: int = 2) -> dict:
47
+ """Return the ego-subgraph centred on *entry_id* up to *depth* hops."""
48
+ sg = _graph.get_subgraph(entry_id, depth=depth)
49
+ return {
50
+ "nodes": [{"id": n, **d} for n, d in sg.nodes(data=True)],
51
+ "edges": [
52
+ {"source": u, "target": v, **d}
53
+ for u, v, d in sg.edges(data=True)
54
+ ],
55
+ }
56
+
57
+
58
+ @router.get("/full")
59
+ def get_full_graph() -> dict:
60
+ """Return all nodes and edges in the graph."""
61
+ g = _graph._g
62
+ return {
63
+ "nodes": [{"id": n, **d} for n, d in g.nodes(data=True)],
64
+ "edges": [
65
+ {"source": u, "target": v, **d}
66
+ for u, v, d in g.edges(data=True)
67
+ ],
68
+ }
69
+
70
+
71
+ @router.post("/reload")
72
+ def reload_graph() -> dict:
73
+ """Rebuild the in-memory graph from the DB and broadcast a refresh event.
74
+
75
+ Useful after out-of-process writes (CLI extract, db merge, manual sqlite
76
+ edits) when you don't want to wait for the DB-watcher tick.
77
+ """
78
+ from core import events as _events
79
+ from core.sync.db_watcher import reload_graph_from_db
80
+
81
+ nodes, edges = reload_graph_from_db(_graph)
82
+ _events.emit("graph_changed", {"source": "reload_endpoint", "nodes": nodes, "edges": edges})
83
+ return {"reloaded": True, "nodes": nodes, "edges": edges}
84
+
85
+
86
+ @router.get("/neighbors/{entry_id}")
87
+ def get_neighbors(
88
+ entry_id: str,
89
+ direction: str = "both",
90
+ ) -> list[dict]:
91
+ """Return immediate neighbors of *entry_id*."""
92
+ return _graph.get_neighbors(entry_id, direction=direction)
93
+
94
+
95
+ @router.get("/events")
96
+ async def graph_events():
97
+ """Server-Sent Events stream — pushes graph change notifications in real time.
98
+
99
+ Events have the shape: ``{"type": "node_added"|"node_updated"|"node_removed", "data": {...}}``
100
+ A ``{"type": "ping"}`` keepalive is emitted every ~25 s.
101
+ """
102
+ from core import events as _events
103
+
104
+ async def generator():
105
+ q = _events.subscribe()
106
+ ticks_since_ping = 0
107
+ try:
108
+ while True:
109
+ try:
110
+ msg = await asyncio.wait_for(q.get(), timeout=1.0)
111
+ if msg is _events.SHUTDOWN_SENTINEL:
112
+ return
113
+ yield f"data: {msg}\n\n"
114
+ ticks_since_ping = 0
115
+ except asyncio.TimeoutError:
116
+ ticks_since_ping += 1
117
+ if ticks_since_ping >= 25:
118
+ yield 'data: {"type":"ping"}\n\n'
119
+ ticks_since_ping = 0
120
+ except asyncio.CancelledError:
121
+ raise
122
+ finally:
123
+ _events.unsubscribe(q)
124
+
125
+ return _SSEResponse(
126
+ generator(),
127
+ media_type="text/event-stream",
128
+ headers={
129
+ "Cache-Control": "no-cache",
130
+ "X-Accel-Buffering": "no",
131
+ },
132
+ )