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
main.py
ADDED
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
"""Know-Do Graph — CLI entry point.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
python main.py --help
|
|
6
|
+
python main.py entry add "My Entry" --content "..." --type tool
|
|
7
|
+
python main.py entry list
|
|
8
|
+
python main.py entry show <id-or-slug>
|
|
9
|
+
python main.py entry search <query>
|
|
10
|
+
python main.py extract file path/to/notes/
|
|
11
|
+
python main.py graph stats
|
|
12
|
+
python main.py graph neighbors <entry-id>
|
|
13
|
+
python main.py mem add "remembered something"
|
|
14
|
+
python main.py agent chat # interactive REPL
|
|
15
|
+
python main.py agent run "task" # one-shot task
|
|
16
|
+
python main.py serve
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import typer
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
|
|
26
|
+
# Load .env so OPENAI_API_KEY / OPENAI_API_BASE are available without manual export
|
|
27
|
+
try:
|
|
28
|
+
from dotenv import load_dotenv
|
|
29
|
+
load_dotenv()
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass # python-dotenv not installed; rely on shell environment
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(
|
|
34
|
+
name="know-do-graph",
|
|
35
|
+
help="Wiki-native executable knowledge graph — CLI",
|
|
36
|
+
no_args_is_help=True,
|
|
37
|
+
)
|
|
38
|
+
console = Console()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _init() -> None:
|
|
42
|
+
from core.storage.database import init_db
|
|
43
|
+
|
|
44
|
+
init_db()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command("init")
|
|
48
|
+
def initialize(
|
|
49
|
+
starter: bool = typer.Option(
|
|
50
|
+
False,
|
|
51
|
+
"--starter",
|
|
52
|
+
help="Initialize from the packaged starter database instead of an empty database.",
|
|
53
|
+
),
|
|
54
|
+
force: bool = typer.Option(
|
|
55
|
+
False,
|
|
56
|
+
"--force",
|
|
57
|
+
help="Overwrite an existing database. Only valid with --starter.",
|
|
58
|
+
),
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Initialize the configured working database."""
|
|
61
|
+
from core.storage.database import DB_PATH, init_db, install_starter_database
|
|
62
|
+
|
|
63
|
+
if force and not starter:
|
|
64
|
+
raise typer.BadParameter("--force can only be used with --starter")
|
|
65
|
+
|
|
66
|
+
if starter:
|
|
67
|
+
try:
|
|
68
|
+
install_starter_database(force=force)
|
|
69
|
+
except FileExistsError:
|
|
70
|
+
console.print(
|
|
71
|
+
f"[yellow]Database already exists:[/yellow] {DB_PATH}\n"
|
|
72
|
+
"Use [cyan]--force[/cyan] to replace it with the starter database."
|
|
73
|
+
)
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
except FileNotFoundError as exc:
|
|
76
|
+
console.print(f"[red]Starter database unavailable:[/red] {exc}")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
console.print(f"[green]Starter database installed:[/green] {DB_PATH}")
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
existed = DB_PATH.exists()
|
|
82
|
+
init_db()
|
|
83
|
+
if existed:
|
|
84
|
+
console.print(f"[yellow]Database already exists:[/yellow] {DB_PATH}")
|
|
85
|
+
else:
|
|
86
|
+
console.print(f"[green]Empty database created:[/green] {DB_PATH}")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rebuild_graph() -> None:
|
|
90
|
+
from core import app_state
|
|
91
|
+
from core.sync.db_watcher import reload_graph_from_db
|
|
92
|
+
|
|
93
|
+
reload_graph_from_db(app_state.graph)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ===========================================================================
|
|
97
|
+
# entry sub-commands
|
|
98
|
+
# ===========================================================================
|
|
99
|
+
|
|
100
|
+
entry_app = typer.Typer(help="Manage knowledge entries", no_args_is_help=True)
|
|
101
|
+
app.add_typer(entry_app, name="entry")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@entry_app.command("add")
|
|
105
|
+
def entry_add(
|
|
106
|
+
title: str = typer.Argument(..., help="Entry title"),
|
|
107
|
+
content: str = typer.Option("", "--content", "-c", help="Entry body (wiki text)"),
|
|
108
|
+
entry_type: str = typer.Option("generic", "--type", "-t", help="Entry type"),
|
|
109
|
+
tags: str = typer.Option("", "--tags", help="Comma-separated tags"),
|
|
110
|
+
source: str = typer.Option(None, "--source", "-s", help="Source provenance URL/path"),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Add a new knowledge entry."""
|
|
113
|
+
_init()
|
|
114
|
+
from core import app_state
|
|
115
|
+
from core.schemas.entry import Entry, EntryMetadata, EntryType
|
|
116
|
+
from core.storage.database import SessionLocal
|
|
117
|
+
from core.storage.repository import EntryRepository
|
|
118
|
+
|
|
119
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
120
|
+
entry = Entry(
|
|
121
|
+
title=title,
|
|
122
|
+
content=content,
|
|
123
|
+
entry_type=EntryType(entry_type),
|
|
124
|
+
tags=tag_list,
|
|
125
|
+
metadata=EntryMetadata(source_provenance=source),
|
|
126
|
+
)
|
|
127
|
+
with SessionLocal() as db:
|
|
128
|
+
saved = EntryRepository(db).create(entry)
|
|
129
|
+
app_state.graph.add_entry(saved)
|
|
130
|
+
console.print(f"[green]Created:[/green] {saved.title} [dim]({saved.id})[/dim]")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@entry_app.command("list")
|
|
134
|
+
def entry_list(
|
|
135
|
+
limit: int = typer.Option(20, "--limit", "-n"),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""List knowledge entries."""
|
|
138
|
+
_init()
|
|
139
|
+
from core.storage.database import SessionLocal
|
|
140
|
+
from core.storage.repository import EntryRepository
|
|
141
|
+
|
|
142
|
+
with SessionLocal() as db:
|
|
143
|
+
entries = EntryRepository(db).get_all()
|
|
144
|
+
|
|
145
|
+
table = Table("Short ID", "Title", "Type", "Tags", "Refs", show_header=True)
|
|
146
|
+
for e in entries[:limit]:
|
|
147
|
+
table.add_row(
|
|
148
|
+
e.id[:8],
|
|
149
|
+
e.title,
|
|
150
|
+
e.entry_type.value,
|
|
151
|
+
", ".join(e.tags),
|
|
152
|
+
str(len(e.internal_refs)),
|
|
153
|
+
)
|
|
154
|
+
console.print(table)
|
|
155
|
+
if len(entries) > limit:
|
|
156
|
+
console.print(f"[dim]… {len(entries) - limit} more entries not shown[/dim]")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@entry_app.command("show")
|
|
160
|
+
def entry_show(
|
|
161
|
+
identifier: str = typer.Argument(..., help="Entry ID or slug"),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Show full details of an entry."""
|
|
164
|
+
_init()
|
|
165
|
+
from core import app_state
|
|
166
|
+
from core.retrieval.retrieval import RetrievalEngine
|
|
167
|
+
from core.storage.database import SessionLocal
|
|
168
|
+
|
|
169
|
+
with SessionLocal() as db:
|
|
170
|
+
engine = RetrievalEngine(db, app_state.graph)
|
|
171
|
+
entry = engine.get_entry_by_id(identifier) or engine.get_entry_by_slug(identifier)
|
|
172
|
+
|
|
173
|
+
if not entry:
|
|
174
|
+
console.print(f"[red]Not found:[/red] {identifier}")
|
|
175
|
+
raise typer.Exit(1)
|
|
176
|
+
|
|
177
|
+
console.rule(f"[bold]{entry.title}[/bold]")
|
|
178
|
+
console.print(f"[dim]ID :[/dim] {entry.id}")
|
|
179
|
+
console.print(f"[dim]Slug :[/dim] {entry.slug}")
|
|
180
|
+
console.print(f"[dim]Type :[/dim] {entry.entry_type.value}")
|
|
181
|
+
console.print(f"[dim]Tags :[/dim] {', '.join(entry.tags) or '—'}")
|
|
182
|
+
console.print(f"[dim]Refs :[/dim] {', '.join(entry.internal_refs) or '—'}")
|
|
183
|
+
console.print(
|
|
184
|
+
f"[dim]Source :[/dim] {entry.metadata.source_provenance or '—'}"
|
|
185
|
+
)
|
|
186
|
+
console.print(
|
|
187
|
+
f"[dim]Status :[/dim] {entry.metadata.refinement_status.value}"
|
|
188
|
+
)
|
|
189
|
+
console.rule()
|
|
190
|
+
console.print(entry.content)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@entry_app.command("search")
|
|
194
|
+
def entry_search(
|
|
195
|
+
query: str = typer.Argument(...),
|
|
196
|
+
limit: int = typer.Option(10, "--limit", "-n"),
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Full-text search across entries."""
|
|
199
|
+
_init()
|
|
200
|
+
from core import app_state
|
|
201
|
+
from core.retrieval.retrieval import RetrievalEngine
|
|
202
|
+
from core.storage.database import SessionLocal
|
|
203
|
+
|
|
204
|
+
with SessionLocal() as db:
|
|
205
|
+
engine = RetrievalEngine(db, app_state.graph)
|
|
206
|
+
results = engine.search_entries(query=query, limit=limit)
|
|
207
|
+
|
|
208
|
+
table = Table("Short ID", "Title", "Type", "Tags")
|
|
209
|
+
for e in results:
|
|
210
|
+
table.add_row(e.id[:8], e.title, e.entry_type.value, ", ".join(e.tags))
|
|
211
|
+
console.print(table)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@entry_app.command("delete")
|
|
215
|
+
def entry_delete(
|
|
216
|
+
entry_id: str = typer.Argument(..., help="Entry ID"),
|
|
217
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Delete an entry."""
|
|
220
|
+
if not yes:
|
|
221
|
+
typer.confirm(f"Delete entry {entry_id}?", abort=True)
|
|
222
|
+
_init()
|
|
223
|
+
from core import app_state
|
|
224
|
+
from core.storage.database import SessionLocal
|
|
225
|
+
from core.storage.repository import EntryRepository
|
|
226
|
+
|
|
227
|
+
with SessionLocal() as db:
|
|
228
|
+
deleted = EntryRepository(db).delete(entry_id)
|
|
229
|
+
if deleted:
|
|
230
|
+
app_state.graph.remove_entry(entry_id)
|
|
231
|
+
console.print(f"[green]Deleted:[/green] {entry_id}")
|
|
232
|
+
else:
|
|
233
|
+
console.print(f"[red]Not found:[/red] {entry_id}")
|
|
234
|
+
raise typer.Exit(1)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ===========================================================================
|
|
238
|
+
# extract sub-commands
|
|
239
|
+
# ===========================================================================
|
|
240
|
+
|
|
241
|
+
extract_app = typer.Typer(help="Extract entries from files or directories", no_args_is_help=True)
|
|
242
|
+
app.add_typer(extract_app, name="extract")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@extract_app.command("file")
|
|
246
|
+
def extract_file(
|
|
247
|
+
path: str = typer.Argument(..., help="File or directory to extract from"),
|
|
248
|
+
entry_type: str = typer.Option("generic", "--type", "-t"),
|
|
249
|
+
tags: str = typer.Option("", "--tags"),
|
|
250
|
+
resolve: bool = typer.Option(
|
|
251
|
+
True, "--resolve/--no-resolve", help="Resolve [[wikilinks]] after extraction"
|
|
252
|
+
),
|
|
253
|
+
) -> None:
|
|
254
|
+
"""Extract entries from a file or directory of text/markdown files."""
|
|
255
|
+
_init()
|
|
256
|
+
from pathlib import Path as P
|
|
257
|
+
|
|
258
|
+
from core import app_state
|
|
259
|
+
from core.schemas.entry import EntryType
|
|
260
|
+
from agents.extraction_agent.agent import ExtractionAgent
|
|
261
|
+
|
|
262
|
+
agent = ExtractionAgent(app_state.graph)
|
|
263
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
264
|
+
target = P(path)
|
|
265
|
+
|
|
266
|
+
with console.status(f"Extracting from [bold]{target}[/bold]…"):
|
|
267
|
+
if target.is_dir():
|
|
268
|
+
extracted = agent.extract_from_directory(
|
|
269
|
+
target, entry_type=EntryType(entry_type), tags=tag_list
|
|
270
|
+
)
|
|
271
|
+
else:
|
|
272
|
+
extracted = [
|
|
273
|
+
agent.extract_from_file(target, EntryType(entry_type), tags=tag_list)
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
for e in extracted:
|
|
277
|
+
console.print(f" [green]+[/green] {e.title}")
|
|
278
|
+
|
|
279
|
+
if resolve:
|
|
280
|
+
count = agent.resolve_wikilinks()
|
|
281
|
+
console.print(f"[blue]Resolved {count} wikilink edge(s)[/blue]")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ===========================================================================
|
|
285
|
+
# graph sub-commands
|
|
286
|
+
# ===========================================================================
|
|
287
|
+
|
|
288
|
+
graph_app = typer.Typer(help="Graph inspection and traversal", no_args_is_help=True)
|
|
289
|
+
app.add_typer(graph_app, name="graph")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@graph_app.command("stats")
|
|
293
|
+
def graph_stats() -> None:
|
|
294
|
+
"""Print graph statistics."""
|
|
295
|
+
_init()
|
|
296
|
+
_rebuild_graph()
|
|
297
|
+
from core import app_state
|
|
298
|
+
|
|
299
|
+
s = app_state.graph.stats()
|
|
300
|
+
console.print(f"Nodes : [bold]{s['nodes']}[/bold]")
|
|
301
|
+
console.print(f"Edges : [bold]{s['edges']}[/bold]")
|
|
302
|
+
console.print(f"Is DAG: [bold]{s['is_dag']}[/bold]")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@graph_app.command("neighbors")
|
|
306
|
+
def graph_neighbors(
|
|
307
|
+
entry_id: str = typer.Argument(...),
|
|
308
|
+
depth: int = typer.Option(1, "--depth", "-d"),
|
|
309
|
+
) -> None:
|
|
310
|
+
"""Show entries related to *entry_id*."""
|
|
311
|
+
_init()
|
|
312
|
+
_rebuild_graph()
|
|
313
|
+
from core import app_state
|
|
314
|
+
from core.retrieval.retrieval import RetrievalEngine
|
|
315
|
+
from core.storage.database import SessionLocal
|
|
316
|
+
|
|
317
|
+
with SessionLocal() as db:
|
|
318
|
+
engine = RetrievalEngine(db, app_state.graph)
|
|
319
|
+
related = engine.get_related_entries(entry_id, depth=depth)
|
|
320
|
+
|
|
321
|
+
table = Table("Short ID", "Title", "Type")
|
|
322
|
+
for e in related:
|
|
323
|
+
table.add_row(e.id[:8], e.title, e.entry_type.value)
|
|
324
|
+
console.print(table)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@graph_app.command("export")
|
|
328
|
+
def graph_export(
|
|
329
|
+
output: str = typer.Option("data/nodes", "--output", "-o", help="Output directory"),
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Export all entries to YAML files."""
|
|
332
|
+
_init()
|
|
333
|
+
from pathlib import Path as P
|
|
334
|
+
|
|
335
|
+
from core import app_state
|
|
336
|
+
from agents.maintenance_agent.agent import MaintenanceAgent
|
|
337
|
+
|
|
338
|
+
agent = MaintenanceAgent(app_state.graph)
|
|
339
|
+
count = agent.export_to_yaml(P(output))
|
|
340
|
+
console.print(f"[green]Exported {count} entries to {output}[/green]")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ===========================================================================
|
|
344
|
+
# mem sub-commands
|
|
345
|
+
# ===========================================================================
|
|
346
|
+
|
|
347
|
+
mem_app = typer.Typer(help="Manage Mem-Graph session traces", no_args_is_help=True)
|
|
348
|
+
app.add_typer(mem_app, name="mem")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
@mem_app.command("add")
|
|
352
|
+
def mem_add(
|
|
353
|
+
content: str = typer.Argument(...),
|
|
354
|
+
session: str = typer.Option("default", "--session"),
|
|
355
|
+
tags: str = typer.Option("", "--tags"),
|
|
356
|
+
success: bool = typer.Option(None, "--success/--failure"),
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Record a memory trace."""
|
|
359
|
+
from core.memory.memgraph import MemGraph
|
|
360
|
+
|
|
361
|
+
mg = MemGraph(session)
|
|
362
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
363
|
+
entry = mg.add(content, tags=tag_list, success=success)
|
|
364
|
+
console.print(f"[green]Recorded:[/green] {entry.id[:8]}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@mem_app.command("list")
|
|
368
|
+
def mem_list(
|
|
369
|
+
session: str = typer.Option("default", "--session"),
|
|
370
|
+
) -> None:
|
|
371
|
+
"""List memory traces for a session."""
|
|
372
|
+
from core.memory.memgraph import MemGraph
|
|
373
|
+
|
|
374
|
+
mg = MemGraph(session)
|
|
375
|
+
table = Table("Short ID", "Content", "Tags", "Success", "Promoted")
|
|
376
|
+
for e in mg.list():
|
|
377
|
+
table.add_row(
|
|
378
|
+
e.id[:8],
|
|
379
|
+
e.content[:60] + ("…" if len(e.content) > 60 else ""),
|
|
380
|
+
", ".join(e.tags),
|
|
381
|
+
str(e.success) if e.success is not None else "—",
|
|
382
|
+
"yes" if e.promoted else "no",
|
|
383
|
+
)
|
|
384
|
+
console.print(table)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@mem_app.command("promote")
|
|
388
|
+
def mem_promote(
|
|
389
|
+
mem_id: str = typer.Argument(..., help="Memory trace ID"),
|
|
390
|
+
session: str = typer.Option("default", "--session"),
|
|
391
|
+
entry_type: str = typer.Option("memory", "--type", "-t"),
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Promote a Mem-Graph trace into a full Know-Do Graph entry."""
|
|
394
|
+
_init()
|
|
395
|
+
from core import app_state
|
|
396
|
+
from core.schemas.entry import EntryType
|
|
397
|
+
from agents.maintenance_agent.agent import MaintenanceAgent
|
|
398
|
+
|
|
399
|
+
agent = MaintenanceAgent(app_state.graph)
|
|
400
|
+
entry = agent.promote_mem_entry(mem_id, session_id=session, entry_type=EntryType(entry_type))
|
|
401
|
+
if entry:
|
|
402
|
+
console.print(f"[green]Promoted to entry:[/green] {entry.title} [dim]({entry.id})[/dim]")
|
|
403
|
+
else:
|
|
404
|
+
console.print(f"[red]Memory trace not found:[/red] {mem_id}")
|
|
405
|
+
raise typer.Exit(1)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# ===========================================================================
|
|
409
|
+
# agent sub-commands
|
|
410
|
+
# ===========================================================================
|
|
411
|
+
|
|
412
|
+
agent_app = typer.Typer(help="LLM-driven graph management agent", no_args_is_help=True)
|
|
413
|
+
app.add_typer(agent_app, name="agent")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@agent_app.command("chat")
|
|
417
|
+
def agent_chat(
|
|
418
|
+
message: str = typer.Argument(None, help="Single message (omit for interactive REPL)"),
|
|
419
|
+
model: str = typer.Option(None, "--model", "-m", help="Override model (e.g. openai/glm-5.1)"),
|
|
420
|
+
session: bool = typer.Option(True, "--session/--no-session", help="Keep conversation history"),
|
|
421
|
+
) -> None:
|
|
422
|
+
"""Chat with the graph agent. Omit MESSAGE to start an interactive session."""
|
|
423
|
+
import os
|
|
424
|
+
|
|
425
|
+
_init()
|
|
426
|
+
_rebuild_graph()
|
|
427
|
+
|
|
428
|
+
if "OPENAI_API_KEY" not in os.environ:
|
|
429
|
+
console.print("[red]OPENAI_API_KEY is not set.[/red] Add it to your .env or environment.")
|
|
430
|
+
raise typer.Exit(1)
|
|
431
|
+
|
|
432
|
+
import json as _json
|
|
433
|
+
|
|
434
|
+
from core import app_state
|
|
435
|
+
from agents.graph_agent.agent import GraphAgent
|
|
436
|
+
|
|
437
|
+
def _step_handler(event: str, data: dict) -> None:
|
|
438
|
+
if event == "thinking":
|
|
439
|
+
console.print(f"[dim]↻ iteration {data['iteration']} — calling model…[/dim]")
|
|
440
|
+
elif event == "tool_call":
|
|
441
|
+
args_preview = _json.dumps(data["args"], default=str, ensure_ascii=False)
|
|
442
|
+
if len(args_preview) > 160:
|
|
443
|
+
args_preview = args_preview[:160] + "…"
|
|
444
|
+
console.print(f" [cyan]→ {data['name']}[/cyan] [dim]{args_preview}[/dim]")
|
|
445
|
+
elif event == "tool_result":
|
|
446
|
+
result_preview = _json.dumps(data["result"], default=str, ensure_ascii=False)
|
|
447
|
+
if len(result_preview) > 240:
|
|
448
|
+
result_preview = result_preview[:240] + "…"
|
|
449
|
+
console.print(f" [yellow]← {data['name']}[/yellow] [dim]{result_preview}[/dim]")
|
|
450
|
+
|
|
451
|
+
graph_agent = GraphAgent(app_state.graph, model=model, on_step=_step_handler)
|
|
452
|
+
|
|
453
|
+
def _run(msg: str) -> None:
|
|
454
|
+
reply = graph_agent.chat(msg)
|
|
455
|
+
console.rule("[dim]Agent[/dim]")
|
|
456
|
+
console.print(reply)
|
|
457
|
+
console.rule()
|
|
458
|
+
|
|
459
|
+
if message:
|
|
460
|
+
_run(message)
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
# Interactive REPL
|
|
464
|
+
from prompt_toolkit import PromptSession as _PS
|
|
465
|
+
_session = _PS()
|
|
466
|
+
console.print("[bold]Know-Do Graph Agent[/bold] (type [dim]exit[/dim] or [dim]quit[/dim] to stop, [dim]reset[/dim] to clear history)")
|
|
467
|
+
while True:
|
|
468
|
+
try:
|
|
469
|
+
user_input = _session.prompt("You: ")
|
|
470
|
+
except (EOFError, KeyboardInterrupt):
|
|
471
|
+
console.print("\n[dim]Goodbye.[/dim]")
|
|
472
|
+
break
|
|
473
|
+
if user_input.strip().lower() in {"exit", "quit"}:
|
|
474
|
+
console.print("[dim]Goodbye.[/dim]")
|
|
475
|
+
break
|
|
476
|
+
if user_input.strip().lower() == "reset":
|
|
477
|
+
graph_agent.reset()
|
|
478
|
+
console.print("[dim]Conversation history cleared.[/dim]")
|
|
479
|
+
continue
|
|
480
|
+
if not user_input.strip():
|
|
481
|
+
continue
|
|
482
|
+
_run(user_input)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@agent_app.command("run")
|
|
486
|
+
def agent_run(
|
|
487
|
+
task: str = typer.Argument(..., help="Natural-language task for the agent to perform"),
|
|
488
|
+
model: str = typer.Option(None, "--model", "-m"),
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Run a one-shot agentic task and print the result."""
|
|
491
|
+
import os
|
|
492
|
+
|
|
493
|
+
_init()
|
|
494
|
+
_rebuild_graph()
|
|
495
|
+
|
|
496
|
+
if "OPENAI_API_KEY" not in os.environ:
|
|
497
|
+
console.print("[red]OPENAI_API_KEY is not set.[/red]")
|
|
498
|
+
raise typer.Exit(1)
|
|
499
|
+
|
|
500
|
+
from core import app_state
|
|
501
|
+
from agents.graph_agent.agent import GraphAgent
|
|
502
|
+
|
|
503
|
+
graph_agent = GraphAgent(app_state.graph, model=model)
|
|
504
|
+
with console.status("[bold green]Agent running…[/bold green]"):
|
|
505
|
+
reply = graph_agent.chat(task)
|
|
506
|
+
console.rule("[dim]Agent result[/dim]")
|
|
507
|
+
console.print(reply)
|
|
508
|
+
console.rule()
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
# ===========================================================================
|
|
512
|
+
# review sub-commands
|
|
513
|
+
# ===========================================================================
|
|
514
|
+
|
|
515
|
+
review_app = typer.Typer(help="ReviewAgent — audit and clean the graph", no_args_is_help=True)
|
|
516
|
+
app.add_typer(review_app, name="review")
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _make_step_handler():
|
|
520
|
+
import json as _json
|
|
521
|
+
|
|
522
|
+
def _step_handler(event: str, data: dict) -> None:
|
|
523
|
+
if event == "thinking":
|
|
524
|
+
console.print(f"[dim]↻ iteration {data['iteration']}…[/dim]")
|
|
525
|
+
elif event == "orchestrator_thinking":
|
|
526
|
+
console.print(f"[dim bold magenta]◈ orchestrator iteration {data['iteration']}…[/dim bold magenta]")
|
|
527
|
+
elif event == "route":
|
|
528
|
+
console.print(f"[bold magenta]→ routing to:[/bold magenta] [cyan]{data['agent']}[/cyan] [dim]{data.get('args', {})}[/dim]")
|
|
529
|
+
elif event == "tool_call":
|
|
530
|
+
args_preview = _json.dumps(data["args"], default=str, ensure_ascii=False)
|
|
531
|
+
if len(args_preview) > 160:
|
|
532
|
+
args_preview = args_preview[:160] + "…"
|
|
533
|
+
console.print(f" [cyan]→ {data['name']}[/cyan] [dim]{args_preview}[/dim]")
|
|
534
|
+
elif event == "tool_result":
|
|
535
|
+
result_preview = _json.dumps(data["result"], default=str, ensure_ascii=False)
|
|
536
|
+
if len(result_preview) > 240:
|
|
537
|
+
result_preview = result_preview[:240] + "…"
|
|
538
|
+
console.print(f" [yellow]← {data['name']}[/yellow] [dim]{result_preview}[/dim]")
|
|
539
|
+
|
|
540
|
+
return _step_handler
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@review_app.command("run")
|
|
544
|
+
def review_run(
|
|
545
|
+
instructions: str = typer.Argument("", help="Optional focus for this review session"),
|
|
546
|
+
batch_size: int = typer.Option(5, "--batch", "-b", help="Nodes to review per session"),
|
|
547
|
+
model: str = typer.Option(None, "--model", "-m"),
|
|
548
|
+
) -> None:
|
|
549
|
+
"""Run one review session (sample nodes, inspect, fix, mark reviewed)."""
|
|
550
|
+
import os
|
|
551
|
+
|
|
552
|
+
_init()
|
|
553
|
+
_rebuild_graph()
|
|
554
|
+
|
|
555
|
+
if "OPENAI_API_KEY" not in os.environ:
|
|
556
|
+
console.print("[red]OPENAI_API_KEY is not set.[/red]")
|
|
557
|
+
raise typer.Exit(1)
|
|
558
|
+
|
|
559
|
+
from core import app_state
|
|
560
|
+
from agents.review_agent.agent import ReviewAgent
|
|
561
|
+
|
|
562
|
+
agent = ReviewAgent(app_state.graph, model=model, batch_size=batch_size, on_step=_make_step_handler())
|
|
563
|
+
console.print(f"[bold]ReviewAgent[/bold] batch={batch_size}" + (f" focus: {instructions}" if instructions else ""))
|
|
564
|
+
reply = agent.run_review(instructions=instructions)
|
|
565
|
+
console.rule("[dim]Review summary[/dim]")
|
|
566
|
+
console.print(reply)
|
|
567
|
+
console.rule()
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@review_app.command("chat")
|
|
571
|
+
def review_chat(
|
|
572
|
+
message: str = typer.Argument(None, help="Single message (omit for interactive REPL)"),
|
|
573
|
+
model: str = typer.Option(None, "--model", "-m"),
|
|
574
|
+
) -> None:
|
|
575
|
+
"""Chat interactively with the ReviewAgent."""
|
|
576
|
+
import os
|
|
577
|
+
|
|
578
|
+
_init()
|
|
579
|
+
_rebuild_graph()
|
|
580
|
+
|
|
581
|
+
if "OPENAI_API_KEY" not in os.environ:
|
|
582
|
+
console.print("[red]OPENAI_API_KEY is not set.[/red]")
|
|
583
|
+
raise typer.Exit(1)
|
|
584
|
+
|
|
585
|
+
from core import app_state
|
|
586
|
+
from agents.review_agent.agent import ReviewAgent
|
|
587
|
+
|
|
588
|
+
agent = ReviewAgent(app_state.graph, model=model, on_step=_make_step_handler())
|
|
589
|
+
|
|
590
|
+
def _run(msg: str) -> None:
|
|
591
|
+
reply = agent.chat(msg)
|
|
592
|
+
console.rule("[dim]ReviewAgent[/dim]")
|
|
593
|
+
console.print(reply)
|
|
594
|
+
console.rule()
|
|
595
|
+
|
|
596
|
+
if message:
|
|
597
|
+
_run(message)
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
from prompt_toolkit import PromptSession as _PS
|
|
601
|
+
_session = _PS()
|
|
602
|
+
console.print("[bold]Know-Do Graph ReviewAgent[/bold] (type [dim]exit[/dim] to stop)")
|
|
603
|
+
while True:
|
|
604
|
+
try:
|
|
605
|
+
user_input = _session.prompt("You: ")
|
|
606
|
+
except (EOFError, KeyboardInterrupt):
|
|
607
|
+
console.print("\n[dim]Goodbye.[/dim]")
|
|
608
|
+
break
|
|
609
|
+
if user_input.strip().lower() in {"exit", "quit"}:
|
|
610
|
+
console.print("[dim]Goodbye.[/dim]")
|
|
611
|
+
break
|
|
612
|
+
if not user_input.strip():
|
|
613
|
+
continue
|
|
614
|
+
_run(user_input)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@review_app.command("stats")
|
|
618
|
+
def review_stats() -> None:
|
|
619
|
+
"""Show review coverage statistics across all nodes."""
|
|
620
|
+
_init()
|
|
621
|
+
from core.storage.database import SessionLocal
|
|
622
|
+
from core.storage.models import EntryModel
|
|
623
|
+
import json as _json
|
|
624
|
+
|
|
625
|
+
with SessionLocal() as db:
|
|
626
|
+
rows = db.query(EntryModel).all()
|
|
627
|
+
|
|
628
|
+
total = len(rows)
|
|
629
|
+
reviewed = 0
|
|
630
|
+
unreviewed_titles = []
|
|
631
|
+
table = Table("Title", "Type", "Reviews", "Modifications", "Last Reviewed")
|
|
632
|
+
for row in rows:
|
|
633
|
+
meta = _json.loads(row.metadata_json or "{}")
|
|
634
|
+
rc = meta.get("review_count", 0)
|
|
635
|
+
mc = meta.get("modify_count", 0)
|
|
636
|
+
lr = meta.get("last_reviewed_at") or "—"
|
|
637
|
+
if isinstance(lr, str) and len(lr) > 19:
|
|
638
|
+
lr = lr[:19]
|
|
639
|
+
if rc > 0:
|
|
640
|
+
reviewed += 1
|
|
641
|
+
else:
|
|
642
|
+
unreviewed_titles.append(row.title)
|
|
643
|
+
table.add_row(row.title, row.entry_type, str(rc), str(mc), lr)
|
|
644
|
+
|
|
645
|
+
console.print(table)
|
|
646
|
+
console.print(f"\n[bold]Coverage:[/bold] {reviewed}/{total} nodes reviewed "
|
|
647
|
+
f"([green]{100*reviewed//total if total else 0}%[/green])")
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
# ===========================================================================
|
|
651
|
+
# orchestrate command
|
|
652
|
+
# ===========================================================================
|
|
653
|
+
|
|
654
|
+
orchestrate_app = typer.Typer(help="Orchestrator — routes tasks to the right agent", no_args_is_help=True)
|
|
655
|
+
app.add_typer(orchestrate_app, name="orchestrate")
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
@orchestrate_app.command("chat")
|
|
659
|
+
def orchestrate_chat(
|
|
660
|
+
message: str = typer.Argument(None, help="Single message (omit for interactive REPL)"),
|
|
661
|
+
model: str = typer.Option(None, "--model", "-m"),
|
|
662
|
+
) -> None:
|
|
663
|
+
"""Route a request through the orchestrator to the appropriate agent(s)."""
|
|
664
|
+
import os
|
|
665
|
+
|
|
666
|
+
_init()
|
|
667
|
+
_rebuild_graph()
|
|
668
|
+
|
|
669
|
+
if "OPENAI_API_KEY" not in os.environ:
|
|
670
|
+
console.print("[red]OPENAI_API_KEY is not set.[/red]")
|
|
671
|
+
raise typer.Exit(1)
|
|
672
|
+
|
|
673
|
+
from core import app_state
|
|
674
|
+
from agents.orchestrator.agent import OrchestratorAgent
|
|
675
|
+
|
|
676
|
+
orchestrator = OrchestratorAgent(app_state.graph, model=model, on_step=_make_step_handler())
|
|
677
|
+
|
|
678
|
+
def _run(msg: str) -> None:
|
|
679
|
+
reply = orchestrator.chat(msg)
|
|
680
|
+
console.rule("[dim]Result[/dim]")
|
|
681
|
+
console.print(reply)
|
|
682
|
+
console.rule()
|
|
683
|
+
|
|
684
|
+
if message:
|
|
685
|
+
_run(message)
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
from prompt_toolkit import PromptSession as _PS
|
|
689
|
+
_session = _PS()
|
|
690
|
+
console.print(
|
|
691
|
+
"[bold]Know-Do Graph Orchestrator[/bold] "
|
|
692
|
+
"(type [dim]exit[/dim] to stop, [dim]reset[/dim] to clear history)"
|
|
693
|
+
)
|
|
694
|
+
while True:
|
|
695
|
+
try:
|
|
696
|
+
user_input = _session.prompt("You: ")
|
|
697
|
+
except (EOFError, KeyboardInterrupt):
|
|
698
|
+
console.print("\n[dim]Goodbye.[/dim]")
|
|
699
|
+
break
|
|
700
|
+
if user_input.strip().lower() in {"exit", "quit"}:
|
|
701
|
+
console.print("[dim]Goodbye.[/dim]")
|
|
702
|
+
break
|
|
703
|
+
if user_input.strip().lower() == "reset":
|
|
704
|
+
orchestrator.reset()
|
|
705
|
+
console.print("[dim]History cleared.[/dim]")
|
|
706
|
+
continue
|
|
707
|
+
if not user_input.strip():
|
|
708
|
+
continue
|
|
709
|
+
_run(user_input)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@orchestrate_app.command("run")
|
|
713
|
+
def orchestrate_run(
|
|
714
|
+
task: str = typer.Argument(..., help="Natural-language task"),
|
|
715
|
+
model: str = typer.Option(None, "--model", "-m"),
|
|
716
|
+
) -> None:
|
|
717
|
+
"""Run a one-shot task through the orchestrator."""
|
|
718
|
+
import os
|
|
719
|
+
|
|
720
|
+
_init()
|
|
721
|
+
_rebuild_graph()
|
|
722
|
+
|
|
723
|
+
if "OPENAI_API_KEY" not in os.environ:
|
|
724
|
+
console.print("[red]OPENAI_API_KEY is not set.[/red]")
|
|
725
|
+
raise typer.Exit(1)
|
|
726
|
+
|
|
727
|
+
from core import app_state
|
|
728
|
+
from agents.orchestrator.agent import OrchestratorAgent
|
|
729
|
+
|
|
730
|
+
orchestrator = OrchestratorAgent(app_state.graph, model=model, on_step=_make_step_handler())
|
|
731
|
+
reply = orchestrator.chat(task)
|
|
732
|
+
console.rule("[dim]Result[/dim]")
|
|
733
|
+
console.print(reply)
|
|
734
|
+
console.rule()
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
# ===========================================================================
|
|
738
|
+
# embeddings sub-commands
|
|
739
|
+
# ===========================================================================
|
|
740
|
+
|
|
741
|
+
embeddings_app = typer.Typer(help="Manage vector embeddings for hybrid retrieval", no_args_is_help=True)
|
|
742
|
+
app.add_typer(embeddings_app, name="embeddings")
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@embeddings_app.command("backfill")
|
|
746
|
+
def embeddings_backfill(
|
|
747
|
+
batch: int = typer.Option(32, "--batch", "-b", help="Embedding batch size"),
|
|
748
|
+
force: bool = typer.Option(False, "--force", help="Re-embed even if hash matches"),
|
|
749
|
+
) -> None:
|
|
750
|
+
"""Compute and store embeddings for every entry that lacks one (or all, with --force)."""
|
|
751
|
+
_init()
|
|
752
|
+
from core.retrieval import vector_store
|
|
753
|
+
from core.retrieval.embedder import build_embedding_text, get_default_embedder, text_hash
|
|
754
|
+
from core.storage.database import SessionLocal
|
|
755
|
+
from core.storage.models import EntryModel
|
|
756
|
+
|
|
757
|
+
embedder = get_default_embedder()
|
|
758
|
+
if not embedder.available:
|
|
759
|
+
console.print(
|
|
760
|
+
"[red]No embedder available.[/red] Install with: "
|
|
761
|
+
"[cyan]pip install 'know-do-graph[embeddings]'[/cyan]"
|
|
762
|
+
)
|
|
763
|
+
raise typer.Exit(1)
|
|
764
|
+
|
|
765
|
+
with SessionLocal() as db:
|
|
766
|
+
rows = db.query(EntryModel).all()
|
|
767
|
+
pending: list[tuple[EntryModel, str, str]] = []
|
|
768
|
+
for row in rows:
|
|
769
|
+
d = row.to_dict()
|
|
770
|
+
text_in = build_embedding_text(
|
|
771
|
+
title=d["title"], aliases=d["aliases"], tags=d["tags"], content=d["content"]
|
|
772
|
+
)
|
|
773
|
+
new_hash = text_hash(text_in)
|
|
774
|
+
if not force and row.embedding_hash == new_hash:
|
|
775
|
+
continue
|
|
776
|
+
pending.append((row, text_in, new_hash))
|
|
777
|
+
|
|
778
|
+
total = len(pending)
|
|
779
|
+
console.print(f"[bold]{total}[/bold] entries to embed (of {len(rows)} total).")
|
|
780
|
+
if total == 0:
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
done = 0
|
|
784
|
+
for i in range(0, total, batch):
|
|
785
|
+
chunk = pending[i:i + batch]
|
|
786
|
+
vecs = embedder.embed([t for _, t, _ in chunk])
|
|
787
|
+
for (row, _t, h), vec in zip(chunk, vecs):
|
|
788
|
+
if not vec:
|
|
789
|
+
continue
|
|
790
|
+
if vector_store.upsert(db, row.id, vec):
|
|
791
|
+
row.embedding_hash = h
|
|
792
|
+
done += 1
|
|
793
|
+
db.commit()
|
|
794
|
+
console.print(f" [dim]…{min(i + len(chunk), total)}/{total}[/dim]")
|
|
795
|
+
|
|
796
|
+
index_count = vector_store.count(db)
|
|
797
|
+
console.print(
|
|
798
|
+
f"[green]Embedded {done} entries.[/green] "
|
|
799
|
+
f"Vector index now holds {index_count if index_count is not None else '?'} rows."
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
# ===========================================================================
|
|
804
|
+
# db (merge / dedup across multiple SQLite snapshots)
|
|
805
|
+
# ===========================================================================
|
|
806
|
+
|
|
807
|
+
db_app = typer.Typer(help="Cross-environment DB maintenance (merge, dedup)", no_args_is_help=True)
|
|
808
|
+
app.add_typer(db_app, name="db")
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
@db_app.command("merge")
|
|
812
|
+
def db_merge(
|
|
813
|
+
other: Path = typer.Argument(..., help="Path to the other SQLite DB to merge into this one"),
|
|
814
|
+
prefer: str = typer.Option(
|
|
815
|
+
"newer", "--prefer",
|
|
816
|
+
help="Conflict policy on id collision: newer | local | remote",
|
|
817
|
+
),
|
|
818
|
+
no_resolve: bool = typer.Option(False, "--no-resolve", help="Skip wikilink re-resolution"),
|
|
819
|
+
notify: bool = typer.Option(
|
|
820
|
+
False, "--notify",
|
|
821
|
+
help="POST /graph/reload to a running server after merge (KDG_API_URL or --api-url)",
|
|
822
|
+
),
|
|
823
|
+
api_url: str = typer.Option(
|
|
824
|
+
None, "--api-url", help="Base URL of the running API (default: $KDG_API_URL)",
|
|
825
|
+
),
|
|
826
|
+
) -> None:
|
|
827
|
+
"""Additively merge entries+edges from another know-do-graph SQLite file.
|
|
828
|
+
|
|
829
|
+
UUID ids make union safe; slug collisions are auto-suffixed by the writer.
|
|
830
|
+
Run [bold]python main.py db dedup[/bold] afterwards to consolidate duplicates.
|
|
831
|
+
"""
|
|
832
|
+
_init()
|
|
833
|
+
from core.sync.db_merge import merge_database
|
|
834
|
+
|
|
835
|
+
report = merge_database(other, prefer=prefer, resolve_wikilinks=not no_resolve)
|
|
836
|
+
|
|
837
|
+
console.print(f"\n[bold green]Merge complete[/bold green] (from [cyan]{other}[/cyan])")
|
|
838
|
+
console.print(f" entries inserted={report.entries_inserted} updated={report.entries_updated} skipped={report.entries_skipped}")
|
|
839
|
+
console.print(f" edges inserted={report.edges_inserted} skipped={report.edges_skipped}")
|
|
840
|
+
console.print(f" wikilinks_resolved={report.wikilinks_resolved}")
|
|
841
|
+
if report.slug_renames:
|
|
842
|
+
console.print(f" [yellow]slug renames ({len(report.slug_renames)}):[/yellow]")
|
|
843
|
+
for old, new in report.slug_renames[:10]:
|
|
844
|
+
console.print(f" {old} \u2192 {new}")
|
|
845
|
+
if len(report.slug_renames) > 10:
|
|
846
|
+
console.print(f" \u2026 and {len(report.slug_renames) - 10} more")
|
|
847
|
+
|
|
848
|
+
if notify:
|
|
849
|
+
import os, httpx
|
|
850
|
+
url = (api_url or os.environ.get("KDG_API_URL") or "http://127.0.0.1:8000").rstrip("/")
|
|
851
|
+
try:
|
|
852
|
+
r = httpx.post(f"{url}/graph/reload", timeout=10.0)
|
|
853
|
+
r.raise_for_status()
|
|
854
|
+
console.print(f" [dim]reloaded running server at {url}: {r.json()}[/dim]")
|
|
855
|
+
except Exception as exc:
|
|
856
|
+
console.print(f" [red]server reload failed:[/red] {exc}")
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@db_app.command("dedup")
|
|
860
|
+
def db_dedup(
|
|
861
|
+
apply: bool = typer.Option(False, "--apply", help="Actually merge (default: dry-run report)"),
|
|
862
|
+
similar: float = typer.Option(
|
|
863
|
+
0.0, "--similar",
|
|
864
|
+
help="Also list embedding-similar pairs with cosine \u2265 THRESHOLD (e.g. 0.92). "
|
|
865
|
+
"Similar pairs are reported only \u2014 review before merging.",
|
|
866
|
+
),
|
|
867
|
+
) -> None:
|
|
868
|
+
"""Consolidate exact-duplicate entries; report near-duplicates by embedding similarity."""
|
|
869
|
+
_init()
|
|
870
|
+
from core.sync.db_merge import dedup_exact, find_similar_groups
|
|
871
|
+
|
|
872
|
+
report = dedup_exact(dry_run=not apply)
|
|
873
|
+
mode = "APPLIED" if apply else "DRY-RUN"
|
|
874
|
+
console.print(f"\n[bold]{mode} exact dedup:[/bold] {report.exact_groups} duplicate groups, {len(report.candidates)} pairs")
|
|
875
|
+
for c in report.candidates[:20]:
|
|
876
|
+
marker = "[green]merged[/green]" if apply else "[yellow]would merge[/yellow]"
|
|
877
|
+
console.print(f" {marker} {c['duplicate_slug']} \u2192 {c['primary_slug']} ({c['reason']})")
|
|
878
|
+
if len(report.candidates) > 20:
|
|
879
|
+
console.print(f" \u2026 and {len(report.candidates) - 20} more")
|
|
880
|
+
if apply:
|
|
881
|
+
console.print(f" [bold green]merged_pairs={report.merged_pairs}[/bold green]")
|
|
882
|
+
|
|
883
|
+
if similar > 0:
|
|
884
|
+
sims = find_similar_groups(threshold=similar)
|
|
885
|
+
console.print(f"\n[bold]Near-duplicate candidates[/bold] (cosine \u2265 {similar}): {len(sims)} pairs")
|
|
886
|
+
for s in sims[:30]:
|
|
887
|
+
console.print(f" sim={s['similarity']:.3f} {s['a_id'][:8]}\u2026 \u2194 {s['b_id'][:8]}\u2026")
|
|
888
|
+
if len(sims) > 30:
|
|
889
|
+
console.print(f" \u2026 and {len(sims) - 30} more")
|
|
890
|
+
console.print(
|
|
891
|
+
"[dim]Review and merge with[/dim] "
|
|
892
|
+
"[cyan]python main.py agent run \"merge_entries(primary_id=\u2026, duplicate_id=\u2026)\"[/cyan]"
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
@db_app.command("reload")
|
|
897
|
+
def db_reload(
|
|
898
|
+
api_url: str = typer.Option(None, "--api-url", help="Default: $KDG_API_URL or http://127.0.0.1:8000"),
|
|
899
|
+
) -> None:
|
|
900
|
+
"""Tell a running API server to rebuild its in-memory graph from the DB."""
|
|
901
|
+
import os, httpx
|
|
902
|
+
url = (api_url or os.environ.get("KDG_API_URL") or "http://127.0.0.1:8000").rstrip("/")
|
|
903
|
+
try:
|
|
904
|
+
r = httpx.post(f"{url}/graph/reload", timeout=10.0)
|
|
905
|
+
r.raise_for_status()
|
|
906
|
+
console.print(f"[green]reloaded:[/green] {r.json()}")
|
|
907
|
+
except Exception as exc:
|
|
908
|
+
console.print(f"[red]reload failed:[/red] {exc}")
|
|
909
|
+
raise typer.Exit(1)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
# ===========================================================================
|
|
913
|
+
# serve
|
|
914
|
+
# ===========================================================================
|
|
915
|
+
|
|
916
|
+
@app.command("serve")
|
|
917
|
+
def serve(
|
|
918
|
+
host: str = typer.Option("127.0.0.1", "--host"),
|
|
919
|
+
port: int = typer.Option(8000, "--port"),
|
|
920
|
+
reload: bool = typer.Option(False, "--reload"),
|
|
921
|
+
) -> None:
|
|
922
|
+
"""Start the FastAPI HTTP server."""
|
|
923
|
+
import uvicorn
|
|
924
|
+
|
|
925
|
+
base = f"http://{host}:{port}"
|
|
926
|
+
console.print(f"\n[bold green]Know-Do Graph API[/bold green] → [link={base}]{base}[/link]")
|
|
927
|
+
console.print(f" [cyan]Graph UI [/cyan] → [link={base}/ui]{base}/ui[/link]")
|
|
928
|
+
console.print(f" [cyan]Swagger [/cyan] → [link={base}/docs]{base}/docs[/link]\n")
|
|
929
|
+
|
|
930
|
+
# `timeout_graceful_shutdown` ensures Ctrl+C doesn't hang on long-lived
|
|
931
|
+
# SSE connections (Windows in particular). Combined with the per-second
|
|
932
|
+
# disconnect-check in /graph/events, shutdown completes in <2 s.
|
|
933
|
+
config = uvicorn.Config(
|
|
934
|
+
"api.main:app",
|
|
935
|
+
host=host,
|
|
936
|
+
port=port,
|
|
937
|
+
reload=reload,
|
|
938
|
+
timeout_graceful_shutdown=2,
|
|
939
|
+
)
|
|
940
|
+
uvicorn.Server(config).run()
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
if __name__ == "__main__":
|
|
944
|
+
app()
|