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
|
@@ -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}
|