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
@@ -0,0 +1,472 @@
1
+ """Tools for the ReviewAgent.
2
+
3
+ The review agent uses these tools to examine the graph incrementally —
4
+ it never receives the full graph dump at once. Instead it:
5
+ - picks under-reviewed nodes (weighted toward low review_count)
6
+ - inspects a node's full details and its local neighbourhood
7
+ - updates review/modify counters after each inspection
8
+ - proposes and applies targeted fixes (title, tags, aliases)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import random
14
+ from datetime import datetime, timezone
15
+ from typing import Any
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Sampling / overview
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def sample_nodes_for_review(batch_size: int = 5, graph: Any = None) -> list[dict]:
24
+ """Return a weighted-random sample of nodes, preferring those with low review_count.
25
+
26
+ Nodes with fewer reviews are much more likely to be selected so the agent
27
+ makes forward progress on unchecked parts of the graph.
28
+
29
+ Returns id, slug, title, type, tags, aliases, review_count, modify_count.
30
+ """
31
+ from core import app_state
32
+ from core.retrieval.retrieval import RetrievalEngine
33
+ from core.storage.database import SessionLocal
34
+
35
+ g = graph or app_state.graph
36
+ with SessionLocal() as db:
37
+ engine = RetrievalEngine(db, g)
38
+ all_entries = engine.list_entries(limit=5000)
39
+
40
+ if not all_entries:
41
+ return []
42
+
43
+ # Weight = 1 / (review_count + 1) → unseen nodes are most likely
44
+ weights = [1.0 / (e.metadata.review_count + 1) for e in all_entries]
45
+ k = min(batch_size, len(all_entries))
46
+ selected = random.choices(all_entries, weights=weights, k=k)
47
+ # Deduplicate while preserving order
48
+ seen_ids: set[str] = set()
49
+ unique: list = []
50
+ for e in selected:
51
+ if e.id not in seen_ids:
52
+ seen_ids.add(e.id)
53
+ unique.append(e)
54
+
55
+ return [
56
+ {
57
+ "id": e.id,
58
+ "slug": e.slug,
59
+ "title": e.title,
60
+ "type": e.entry_type.value,
61
+ "tags": e.tags,
62
+ "aliases": e.aliases,
63
+ "review_count": e.metadata.review_count,
64
+ "modify_count": e.metadata.modify_count,
65
+ }
66
+ for e in unique
67
+ ]
68
+
69
+
70
+ def get_graph_summary(graph: Any = None) -> dict:
71
+ """Return aggregate statistics useful for high-level review.
72
+
73
+ Includes node/edge counts, type distribution, and review coverage
74
+ (how many nodes have been reviewed at least once).
75
+ """
76
+ from collections import Counter
77
+
78
+ from core import app_state
79
+ from core.retrieval.retrieval import RetrievalEngine
80
+ from core.storage.database import SessionLocal
81
+
82
+ g = graph or app_state.graph
83
+ stats = g.stats()
84
+
85
+ with SessionLocal() as db:
86
+ engine = RetrievalEngine(db, g)
87
+ all_entries = engine.list_entries(limit=5000)
88
+
89
+ type_dist = dict(Counter(e.entry_type.value for e in all_entries))
90
+ reviewed = sum(1 for e in all_entries if e.metadata.review_count > 0)
91
+ total = len(all_entries)
92
+
93
+ return {
94
+ "stats": stats,
95
+ "type_distribution": type_dist,
96
+ "total_nodes": total,
97
+ "reviewed_nodes": reviewed,
98
+ "unreviewed_nodes": total - reviewed,
99
+ "review_coverage_pct": round(100 * reviewed / total, 1) if total else 0,
100
+ }
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Node inspection
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def inspect_node(identifier: str, graph: Any = None) -> dict:
109
+ """Retrieve full details of a node including review metadata and local edges.
110
+
111
+ Returns title, type, tags, aliases, content (first 800 chars), refs,
112
+ review_count, modify_count, and a list of neighbouring node titles.
113
+ """
114
+ from core import app_state
115
+ from core.retrieval.retrieval import RetrievalEngine
116
+ from core.storage.database import SessionLocal
117
+
118
+ g = graph or app_state.graph
119
+ with SessionLocal() as db:
120
+ engine = RetrievalEngine(db, g)
121
+ entry = engine.resolve_identifier(identifier)
122
+ if entry is None:
123
+ return {"error": f"Entry '{identifier}' not found."}
124
+
125
+ neighbors_raw = g.get_neighbors(entry.id, direction="both")
126
+ neighbor_details = []
127
+ for nbr in neighbors_raw:
128
+ nbr_entry = engine.get_entry_by_id(nbr["id"])
129
+ neighbor_details.append(
130
+ {
131
+ "id": nbr["id"],
132
+ "title": nbr_entry.title if nbr_entry else "?",
133
+ "relation": nbr.get("relation"),
134
+ "direction": nbr.get("direction"),
135
+ }
136
+ )
137
+
138
+ return {
139
+ "id": entry.id,
140
+ "slug": entry.slug,
141
+ "title": entry.title,
142
+ "type": entry.entry_type.value,
143
+ "tags": entry.tags,
144
+ "aliases": entry.aliases,
145
+ "content_preview": entry.content[:800],
146
+ "refs": entry.internal_refs,
147
+ "source": entry.metadata.source_provenance,
148
+ "status": entry.metadata.refinement_status.value,
149
+ "review_count": entry.metadata.review_count,
150
+ "modify_count": entry.metadata.modify_count,
151
+ "last_reviewed_at": entry.metadata.last_reviewed_at.isoformat() if entry.metadata.last_reviewed_at else None,
152
+ "neighbors": neighbor_details,
153
+ }
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Review tracking
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ def mark_reviewed(entry_id: str, was_modified: bool = False, graph: Any = None) -> dict:
162
+ """Increment review_count (and optionally modify_count) on an entry.
163
+
164
+ Call this after inspecting a node, regardless of whether changes were made.
165
+ Pass was_modified=True if you also edited the node in this review pass.
166
+ """
167
+ from core import app_state
168
+ from core.retrieval.retrieval import RetrievalEngine
169
+ from core.storage.database import SessionLocal
170
+ from core.storage.repository import EntryRepository
171
+
172
+ g = graph or app_state.graph
173
+ with SessionLocal() as db:
174
+ engine = RetrievalEngine(db, g)
175
+ entry = engine.get_entry_by_id(entry_id)
176
+ if entry is None:
177
+ return {"error": f"Entry '{entry_id}' not found."}
178
+
179
+ entry.metadata.review_count += 1
180
+ entry.metadata.last_reviewed_at = datetime.now(timezone.utc)
181
+ if was_modified:
182
+ entry.metadata.modify_count += 1
183
+
184
+ EntryRepository(db).update(entry)
185
+
186
+ return {
187
+ "entry_id": entry_id,
188
+ "review_count": entry.metadata.review_count,
189
+ "modify_count": entry.metadata.modify_count,
190
+ }
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Cleaning helpers (re-exported from graph_agent.tools for convenience)
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def update_entry(
199
+ entry_id: str,
200
+ title: str | None = None,
201
+ content: str | None = None,
202
+ entry_type: str | None = None,
203
+ tags: list[str] | None = None,
204
+ aliases: list[str] | None = None,
205
+ graph: Any = None,
206
+ ) -> dict:
207
+ """Update fields on an existing entry and bump modify_count."""
208
+ from agents.graph_agent.tools import update_entry as _update_entry
209
+
210
+ result = _update_entry(
211
+ entry_id=entry_id,
212
+ title=title,
213
+ content=content,
214
+ entry_type=entry_type,
215
+ tags=tags,
216
+ aliases=aliases,
217
+ graph=graph,
218
+ )
219
+ if "error" not in result:
220
+ mark_reviewed(result["id"], was_modified=True, graph=graph)
221
+ return result
222
+
223
+
224
+ def merge_entries(
225
+ primary_id: str,
226
+ duplicate_id: str,
227
+ merge_aliases: bool = True,
228
+ merge_tags: bool = True,
229
+ graph: Any = None,
230
+ ) -> dict:
231
+ """Merge duplicate into primary, then mark primary as modified."""
232
+ from agents.graph_agent.tools import merge_entries as _merge_entries
233
+
234
+ result = _merge_entries(
235
+ primary_id=primary_id,
236
+ duplicate_id=duplicate_id,
237
+ merge_aliases=merge_aliases,
238
+ merge_tags=merge_tags,
239
+ graph=graph,
240
+ )
241
+ if result.get("merged"):
242
+ mark_reviewed(result["primary_id"], was_modified=True, graph=graph)
243
+ return result
244
+
245
+
246
+ def search_entries(query: str, limit: int = 10, mode: str = "hybrid", graph: Any = None) -> list[dict]:
247
+ """Hybrid semantic + keyword search — use to find duplicate or related candidates."""
248
+ from agents.graph_agent.tools import search_entries as _search_entries
249
+
250
+ return _search_entries(query=query, limit=limit, mode=mode, graph=graph)
251
+
252
+
253
+ def create_edge(
254
+ source_id: str,
255
+ target_id: str,
256
+ relation: str = "related_to",
257
+ weight: float = 1.0,
258
+ graph: Any = None,
259
+ ) -> dict:
260
+ """Create an edge between two entries."""
261
+ from agents.graph_agent.tools import create_edge as _create_edge
262
+
263
+ return _create_edge(source_id=source_id, target_id=target_id, relation=relation, weight=weight, graph=graph)
264
+
265
+
266
+ def delete_edge(edge_id: str, graph: Any = None) -> dict:
267
+ """Delete an edge by ID."""
268
+ from agents.graph_agent.tools import delete_edge as _delete_edge
269
+
270
+ return _delete_edge(edge_id=edge_id, graph=graph)
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # OpenAI tool schemas
275
+ # ---------------------------------------------------------------------------
276
+
277
+ REVIEW_TOOL_SCHEMAS: list[dict] = [
278
+ {
279
+ "type": "function",
280
+ "function": {
281
+ "name": "get_graph_summary",
282
+ "description": (
283
+ "Get high-level graph statistics including node count, type distribution, "
284
+ "and review coverage. Use at the start of each review session."
285
+ ),
286
+ "parameters": {"type": "object", "properties": {}},
287
+ },
288
+ },
289
+ {
290
+ "type": "function",
291
+ "function": {
292
+ "name": "sample_nodes_for_review",
293
+ "description": (
294
+ "Return a weighted-random batch of nodes to review. "
295
+ "Nodes with fewer prior reviews are selected more often. "
296
+ "Use this to pick the next set of nodes to inspect."
297
+ ),
298
+ "parameters": {
299
+ "type": "object",
300
+ "properties": {
301
+ "batch_size": {"type": "integer", "default": 5, "description": "Number of nodes to sample"},
302
+ },
303
+ },
304
+ },
305
+ },
306
+ {
307
+ "type": "function",
308
+ "function": {
309
+ "name": "inspect_node",
310
+ "description": (
311
+ "Get full details of a node: title, type, tags, aliases, content preview, "
312
+ "review history, and neighbouring nodes with relation types. "
313
+ "Always inspect a node before deciding to modify it."
314
+ ),
315
+ "parameters": {
316
+ "type": "object",
317
+ "properties": {
318
+ "identifier": {"type": "string", "description": "Node ID or slug"},
319
+ },
320
+ "required": ["identifier"],
321
+ },
322
+ },
323
+ },
324
+ {
325
+ "type": "function",
326
+ "function": {
327
+ "name": "mark_reviewed",
328
+ "description": (
329
+ "Record that you have reviewed a node. "
330
+ "Must be called for every node you inspect, even if no changes were made. "
331
+ "Set was_modified=True if you also edited the node."
332
+ ),
333
+ "parameters": {
334
+ "type": "object",
335
+ "properties": {
336
+ "entry_id": {"type": "string"},
337
+ "was_modified": {"type": "boolean", "default": False},
338
+ },
339
+ "required": ["entry_id"],
340
+ },
341
+ },
342
+ },
343
+ {
344
+ "type": "function",
345
+ "function": {
346
+ "name": "update_entry",
347
+ "description": (
348
+ "Update title, tags, aliases, content, or type of a node. "
349
+ "Use to: fix titles containing parenthetical acronyms (move acronym to aliases), "
350
+ "normalise tags to lowercase-hyphenated, remove redundant prefixes from titles, "
351
+ "or correct the entry_type."
352
+ ),
353
+ "parameters": {
354
+ "type": "object",
355
+ "properties": {
356
+ "entry_id": {"type": "string", "description": "Node ID or slug"},
357
+ "title": {"type": "string"},
358
+ "content": {"type": "string"},
359
+ "entry_type": {
360
+ "type": "string",
361
+ "enum": ["capability", "procedure", "workflow", "tool", "repository",
362
+ "environment", "dependency", "data", "analytical", "memory", "generic"],
363
+ },
364
+ "tags": {"type": "array", "items": {"type": "string"}},
365
+ "aliases": {"type": "array", "items": {"type": "string"}},
366
+ },
367
+ "required": ["entry_id"],
368
+ },
369
+ },
370
+ },
371
+ {
372
+ "type": "function",
373
+ "function": {
374
+ "name": "search_entries",
375
+ "description": (
376
+ "Search for nodes using hybrid semantic + keyword retrieval. "
377
+ "The default 'hybrid' mode combines embedding-based vector similarity with "
378
+ "keyword scoring (RRF fusion). Use 'semantic' to find conceptually related nodes "
379
+ "even when wording differs — ideal for surfacing near-duplicate candidates. "
380
+ "Use 'keyword' for exact title or acronym lookups. "
381
+ "If a search misses, retry with a different mode or a broader/rephrased query."
382
+ ),
383
+ "parameters": {
384
+ "type": "object",
385
+ "properties": {
386
+ "query": {"type": "string"},
387
+ "limit": {"type": "integer", "default": 10},
388
+ "mode": {
389
+ "type": "string",
390
+ "enum": ["hybrid", "semantic", "keyword"],
391
+ "default": "hybrid",
392
+ "description": (
393
+ "hybrid: keyword + embedding ANN fused (default). "
394
+ "semantic: embedding-only, best for conceptual/paraphrase matching. "
395
+ "keyword: exact text match, best for known titles or acronyms."
396
+ ),
397
+ },
398
+ },
399
+ "required": ["query"],
400
+ },
401
+ },
402
+ },
403
+ {
404
+ "type": "function",
405
+ "function": {
406
+ "name": "merge_entries",
407
+ "description": (
408
+ "Merge a duplicate node into a primary node. "
409
+ "Re-targets edges, merges aliases/tags, deletes the duplicate."
410
+ ),
411
+ "parameters": {
412
+ "type": "object",
413
+ "properties": {
414
+ "primary_id": {"type": "string"},
415
+ "duplicate_id": {"type": "string"},
416
+ "merge_aliases": {"type": "boolean", "default": True},
417
+ "merge_tags": {"type": "boolean", "default": True},
418
+ },
419
+ "required": ["primary_id", "duplicate_id"],
420
+ },
421
+ },
422
+ },
423
+ {
424
+ "type": "function",
425
+ "function": {
426
+ "name": "create_edge",
427
+ "description": "Add a typed edge between two nodes when a relationship is missing.",
428
+ "parameters": {
429
+ "type": "object",
430
+ "properties": {
431
+ "source_id": {"type": "string"},
432
+ "target_id": {"type": "string"},
433
+ "relation": {
434
+ "type": "string",
435
+ "enum": ["dependency", "compatible_with", "alternative_to", "related_workflow",
436
+ "generated_from", "memory_of", "refinement_of", "derived_from",
437
+ "warning_about", "cited_by", "wikilink", "prerequisite", "replacement",
438
+ "execution_pathway", "transformation", "provenance", "compatibility"],
439
+ },
440
+ "weight": {"type": "number", "default": 1.0},
441
+ },
442
+ "required": ["source_id", "target_id"],
443
+ },
444
+ },
445
+ },
446
+ {
447
+ "type": "function",
448
+ "function": {
449
+ "name": "delete_edge",
450
+ "description": "Delete a redundant or incorrect edge by its ID.",
451
+ "parameters": {
452
+ "type": "object",
453
+ "properties": {
454
+ "edge_id": {"type": "string"},
455
+ },
456
+ "required": ["edge_id"],
457
+ },
458
+ },
459
+ },
460
+ ]
461
+
462
+ REVIEW_TOOL_DISPATCH: dict[str, Any] = {
463
+ "get_graph_summary": get_graph_summary,
464
+ "sample_nodes_for_review": sample_nodes_for_review,
465
+ "inspect_node": inspect_node,
466
+ "mark_reviewed": mark_reviewed,
467
+ "update_entry": update_entry,
468
+ "search_entries": search_entries,
469
+ "merge_entries": merge_entries,
470
+ "create_edge": create_edge,
471
+ "delete_edge": delete_edge,
472
+ }
api/__init__.py ADDED
File without changes
api/main.py ADDED
@@ -0,0 +1,136 @@
1
+ """Know-Do Graph — FastAPI application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+
11
+ from fastapi import FastAPI
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import FileResponse, HTMLResponse
14
+ from fastapi.staticfiles import StaticFiles
15
+
16
+ from fastapi import Request
17
+ from fastapi.responses import PlainTextResponse
18
+
19
+ from api.routes import entries, graph as graph_routes, mem as mem_routes, agent as agent_routes
20
+ from api.routes import remote as remote_routes
21
+ from api.routes import remote_sync as remote_sync_routes
22
+ from api.routes import retrieve as retrieve_routes
23
+ from core.app_state import graph
24
+ from core.storage.database import SessionLocal, init_db
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @asynccontextmanager
30
+ async def lifespan(app: FastAPI):
31
+ """Initialise the database and rebuild the in-memory graph on startup."""
32
+ init_db()
33
+ from core import events as _events
34
+ _events.set_loop(asyncio.get_running_loop())
35
+ from core.sync.db_watcher import reload_graph_from_db
36
+
37
+ reload_graph_from_db(graph)
38
+
39
+ sync_task: asyncio.Task | None = None
40
+ if os.environ.get("KDG_REMOTE_SYNC_ENABLED", "").lower() in ("1", "true", "yes", "on"):
41
+ interval = int(os.environ.get("KDG_REMOTE_SYNC_INTERVAL_SECONDS", "900"))
42
+ from core.sync.remote_sync import run_periodic_sync
43
+
44
+ sync_task = asyncio.create_task(run_periodic_sync(interval))
45
+ logger.info("remote-sync background loop started (interval=%ss)", interval)
46
+
47
+ # DB-change watcher: detects mutations written by out-of-process CLI commands
48
+ # (e.g. `python main.py extract …`) and refreshes the in-memory graph + SSE.
49
+ watcher_task: asyncio.Task | None = None
50
+ watch_interval = int(os.environ.get("KDG_DB_WATCH_INTERVAL_SECONDS", "3"))
51
+ if watch_interval > 0:
52
+ from core.sync.db_watcher import run_db_watcher
53
+
54
+ watcher_task = asyncio.create_task(run_db_watcher(graph, watch_interval))
55
+ logger.info("db-watcher started (interval=%ss)", watch_interval)
56
+
57
+ try:
58
+ yield
59
+ finally:
60
+ from core import events as _events
61
+ _events.signal_shutdown()
62
+ for task in (sync_task, watcher_task):
63
+ if task is not None:
64
+ task.cancel()
65
+ try:
66
+ await task
67
+ except (asyncio.CancelledError, Exception):
68
+ pass
69
+
70
+
71
+ app = FastAPI(
72
+ title="Know-Do Graph API",
73
+ description=(
74
+ "Agent-facing interface for a wiki-native executable knowledge graph. "
75
+ "Search entries, traverse relations, and navigate operational knowledge."
76
+ ),
77
+ version="0.1.0",
78
+ lifespan=lifespan,
79
+ )
80
+
81
+ app.add_middleware(
82
+ CORSMiddleware,
83
+ allow_origins=["*"],
84
+ allow_methods=["*"],
85
+ allow_headers=["*"],
86
+ )
87
+
88
+ app.include_router(entries.router, prefix="/entries", tags=["entries"])
89
+ app.include_router(graph_routes.router, prefix="/graph", tags=["graph"])
90
+ app.include_router(mem_routes.router, prefix="/mem", tags=["mem"])
91
+ app.include_router(agent_routes.router, prefix="/agent", tags=["agent"])
92
+ app.include_router(remote_routes.router, prefix="/remote", tags=["remote"])
93
+ app.include_router(remote_sync_routes.router, prefix="/remote-sync", tags=["remote-sync"])
94
+ app.include_router(retrieve_routes.router, prefix="/retrieve", tags=["retrieve"])
95
+
96
+
97
+ @app.get("/health", tags=["meta"])
98
+ def health() -> dict:
99
+ return {"status": "ok", **graph.stats()}
100
+
101
+
102
+ @app.get("/", response_class=PlainTextResponse, include_in_schema=False)
103
+ def root_instructions(request: Request) -> PlainTextResponse:
104
+ """Return the plain-text instruction sheet for any client that hits the server root."""
105
+ from api.routes.remote import _render_instructions
106
+ return PlainTextResponse(_render_instructions(request))
107
+
108
+
109
+ # ── Frontend ──────────────────────────────────────────────────────────────────
110
+ # After `npm run build` in frontend/, Vite emits frontend/dist/ with relative
111
+ # asset URLs (vite.config.js: base: './'). We serve dist/index.html at /ui and
112
+ # the hashed bundle at /assets. In dev, prefer `npm run dev` (Vite on :5173
113
+ # with API proxy) — direct HMR, this mount unused.
114
+ _FRONTEND_ROOT = Path(__file__).parent.parent / "frontend"
115
+ _FRONTEND_DIST = _FRONTEND_ROOT / "dist"
116
+
117
+ if _FRONTEND_DIST.is_dir() and (_FRONTEND_DIST / "index.html").is_file():
118
+ if (_FRONTEND_DIST / "assets").is_dir():
119
+ app.mount("/assets", StaticFiles(directory=str(_FRONTEND_DIST / "assets")), name="ui-assets")
120
+
121
+ @app.get("/ui", include_in_schema=False)
122
+ def serve_ui() -> FileResponse:
123
+ return FileResponse(str(_FRONTEND_DIST / "index.html"))
124
+
125
+ else:
126
+ from fastapi.responses import HTMLResponse
127
+
128
+ @app.get("/ui", include_in_schema=False)
129
+ def serve_ui_not_built() -> HTMLResponse:
130
+ return HTMLResponse(
131
+ "<h1 style='font-family:sans-serif'>Frontend not built</h1>"
132
+ "<p style='font-family:sans-serif'>Run the following then restart the server:</p>"
133
+ "<pre style='background:#111;color:#0f0;padding:1em;border-radius:4px'>"
134
+ "cd frontend\nnpm install\nnpm run build</pre>",
135
+ status_code=503,
136
+ )
api/routes/__init__.py ADDED
File without changes
api/routes/agent.py ADDED
@@ -0,0 +1,81 @@
1
+ """Agent-chat routes (GraphAgent and ReviewAgent)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ from core.app_state import graph as _graph
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ class ChatRequest(BaseModel):
14
+ message: str
15
+ model: str | None = None
16
+
17
+
18
+ class ReviewRequest(BaseModel):
19
+ instructions: str = ""
20
+ batch_size: int = 5
21
+ model: str | None = None
22
+
23
+
24
+ @router.post("/graph/chat", tags=["agent"])
25
+ def graph_agent_chat(body: ChatRequest) -> dict:
26
+ """Send a message to the GraphAgent and receive its response."""
27
+ import os
28
+
29
+ if not os.environ.get("OPENAI_API_KEY"):
30
+ raise HTTPException(status_code=503, detail="OPENAI_API_KEY not configured.")
31
+
32
+ from agents.graph_agent.agent import GraphAgent
33
+
34
+ agent = GraphAgent(graph=_graph, model=body.model)
35
+ response = agent.chat(body.message)
36
+ return {"response": response}
37
+
38
+
39
+ @router.post("/review/run", tags=["agent"])
40
+ def review_agent_run(body: ReviewRequest) -> dict:
41
+ """Run one review session with the ReviewAgent."""
42
+ import os
43
+
44
+ if not os.environ.get("OPENAI_API_KEY"):
45
+ raise HTTPException(status_code=503, detail="OPENAI_API_KEY not configured.")
46
+
47
+ from agents.review_agent.agent import ReviewAgent
48
+
49
+ agent = ReviewAgent(graph=_graph, model=body.model, batch_size=body.batch_size)
50
+ response = agent.run_review(instructions=body.instructions)
51
+ return {"response": response}
52
+
53
+
54
+ @router.post("/review/chat", tags=["agent"])
55
+ def review_agent_chat(body: ChatRequest) -> dict:
56
+ """Send a single message to the ReviewAgent."""
57
+ import os
58
+
59
+ if not os.environ.get("OPENAI_API_KEY"):
60
+ raise HTTPException(status_code=503, detail="OPENAI_API_KEY not configured.")
61
+
62
+ from agents.review_agent.agent import ReviewAgent
63
+
64
+ agent = ReviewAgent(graph=_graph, model=body.model)
65
+ response = agent.chat(body.message)
66
+ return {"response": response}
67
+
68
+
69
+ @router.post("/orchestrate", tags=["agent"])
70
+ def orchestrate(body: ChatRequest) -> dict:
71
+ """Route a request through the Orchestrator to the appropriate agent(s)."""
72
+ import os
73
+
74
+ if not os.environ.get("OPENAI_API_KEY"):
75
+ raise HTTPException(status_code=503, detail="OPENAI_API_KEY not configured.")
76
+
77
+ from agents.orchestrator.agent import OrchestratorAgent
78
+
79
+ agent = OrchestratorAgent(graph=_graph, model=body.model)
80
+ response = agent.chat(body.message)
81
+ return {"response": response}