mdgraph-ai 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.
- mdgraph/__init__.py +35 -0
- mdgraph/cli.py +411 -0
- mdgraph/compiler/__init__.py +11 -0
- mdgraph/compiler/base.py +17 -0
- mdgraph/compiler/llm_compiler.py +122 -0
- mdgraph/compiler/mock_compiler.py +99 -0
- mdgraph/compiler/prompt.py +39 -0
- mdgraph/config.py +102 -0
- mdgraph/indexer/__init__.py +12 -0
- mdgraph/indexer/base.py +83 -0
- mdgraph/indexer/postgres_cache.py +311 -0
- mdgraph/indexer/sqlite_cache.py +523 -0
- mdgraph/indexer/sync.py +88 -0
- mdgraph/ingestion/__init__.py +16 -0
- mdgraph/ingestion/base.py +30 -0
- mdgraph/ingestion/html.py +168 -0
- mdgraph/ingestion/ingestor.py +70 -0
- mdgraph/ingestion/markdown.py +39 -0
- mdgraph/ingestion/pdf.py +60 -0
- mdgraph/models.py +279 -0
- mdgraph/server/__init__.py +5 -0
- mdgraph/server/mcp_server.py +172 -0
- mdgraph/storage/__init__.py +6 -0
- mdgraph/storage/git_repo.py +211 -0
- mdgraph/storage/okf_file.py +125 -0
- mdgraph_ai-0.1.0.dist-info/METADATA +304 -0
- mdgraph_ai-0.1.0.dist-info/RECORD +30 -0
- mdgraph_ai-0.1.0.dist-info/WHEEL +4 -0
- mdgraph_ai-0.1.0.dist-info/entry_points.txt +3 -0
- mdgraph_ai-0.1.0.dist-info/licenses/LICENSE +122 -0
mdgraph/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""MdGraph: A vectorless Open Knowledge Format (OKF) context engine for agentic AI.
|
|
2
|
+
|
|
3
|
+
Kill the Vector Chunks. Store Explicit Knowledge Graphs.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
|
|
8
|
+
from mdgraph.models import OKFConcept, Edge, GraphResult, TraverseResult
|
|
9
|
+
from mdgraph.storage.git_repo import GitRepo
|
|
10
|
+
from mdgraph.storage.okf_file import OKFFileManager
|
|
11
|
+
from mdgraph.indexer.sqlite_cache import SQLiteQueryCache
|
|
12
|
+
from mdgraph.indexer.sync import IndexSyncWorker
|
|
13
|
+
from mdgraph.ingestion.ingestor import UniversalIngestor
|
|
14
|
+
from mdgraph.compiler.llm_compiler import LLMCompiler
|
|
15
|
+
from mdgraph.compiler.mock_compiler import RuleBasedCompiler
|
|
16
|
+
from mdgraph.server.mcp_server import create_mcp_server
|
|
17
|
+
from mdgraph.config import MdGraphConfig, load_config
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"__version__",
|
|
21
|
+
"OKFConcept",
|
|
22
|
+
"Edge",
|
|
23
|
+
"GraphResult",
|
|
24
|
+
"TraverseResult",
|
|
25
|
+
"GitRepo",
|
|
26
|
+
"OKFFileManager",
|
|
27
|
+
"SQLiteQueryCache",
|
|
28
|
+
"IndexSyncWorker",
|
|
29
|
+
"UniversalIngestor",
|
|
30
|
+
"LLMCompiler",
|
|
31
|
+
"RuleBasedCompiler",
|
|
32
|
+
"create_mcp_server",
|
|
33
|
+
"MdGraphConfig",
|
|
34
|
+
"load_config",
|
|
35
|
+
]
|
mdgraph/cli.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Command Line Interface (CLI) for MdGraph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.tree import Tree
|
|
14
|
+
|
|
15
|
+
from mdgraph import __version__
|
|
16
|
+
from mdgraph.compiler.llm_compiler import LLMCompiler
|
|
17
|
+
from mdgraph.compiler.mock_compiler import RuleBasedCompiler
|
|
18
|
+
from mdgraph.config import MdGraphConfig, load_config
|
|
19
|
+
from mdgraph.indexer.sqlite_cache import SQLiteQueryCache
|
|
20
|
+
from mdgraph.indexer.sync import IndexSyncWorker
|
|
21
|
+
from mdgraph.ingestion.ingestor import UniversalIngestor
|
|
22
|
+
from mdgraph.server.mcp_server import create_mcp_server
|
|
23
|
+
from mdgraph.storage.git_repo import GitRepo
|
|
24
|
+
from mdgraph.storage.okf_file import OKFFileManager
|
|
25
|
+
|
|
26
|
+
console = Console()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def print_banner():
|
|
30
|
+
banner = (
|
|
31
|
+
"[bold yellow]MdGraph[/bold yellow] [dim]v" + __version__ + "[/dim]\n"
|
|
32
|
+
"[dim]Vectorless Open Knowledge Format (OKF) Context Engine for Agentic AI[/dim]"
|
|
33
|
+
)
|
|
34
|
+
console.print(Panel(banner, border_style="yellow"))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@click.group(invoke_without_command=True)
|
|
38
|
+
@click.option("--version", is_flag=True, help="Show MdGraph version.")
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def main(ctx: click.Context, version: bool):
|
|
41
|
+
"""MdGraph: Vectorless Open Knowledge Format (OKF) storage engine."""
|
|
42
|
+
if version:
|
|
43
|
+
console.print(f"[bold yellow]MdGraph[/bold yellow] version {__version__}")
|
|
44
|
+
sys.exit(0)
|
|
45
|
+
if ctx.invoked_subcommand is None:
|
|
46
|
+
print_banner()
|
|
47
|
+
console.print(ctx.get_help())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@main.command()
|
|
51
|
+
@click.argument("path", default="./knowledge", type=click.Path())
|
|
52
|
+
def init(path: str):
|
|
53
|
+
"""Initialize a new Git-backed OKF repository and directory structure."""
|
|
54
|
+
repo_dir = Path(path).resolve()
|
|
55
|
+
console.print(f"[yellow]Initializing OKF repository at:[/yellow] {repo_dir}")
|
|
56
|
+
|
|
57
|
+
repo = GitRepo(repo_dir)
|
|
58
|
+
concepts_dir = repo_dir / "concepts"
|
|
59
|
+
concepts_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
# Create sample concept if empty
|
|
62
|
+
sample_file = concepts_dir / "example_concept.md"
|
|
63
|
+
if not sample_file.exists():
|
|
64
|
+
sample_content = (
|
|
65
|
+
"---\n"
|
|
66
|
+
"type: Concept\n"
|
|
67
|
+
"title: Example OKF Concept\n"
|
|
68
|
+
"description: An introductory node in the MdGraph Open Knowledge Format.\n"
|
|
69
|
+
"tags: [example, getting-started]\n"
|
|
70
|
+
"status: stable\n"
|
|
71
|
+
"---\n\n"
|
|
72
|
+
"# Example OKF Concept\n\n"
|
|
73
|
+
"This is a sample concept. You can link to other concepts using standard Markdown links:\n"
|
|
74
|
+
"For example, check the root [Knowledge Index](../index.md).\n"
|
|
75
|
+
)
|
|
76
|
+
sample_file.write_text(sample_content, encoding="utf-8")
|
|
77
|
+
repo.commit("Add initial example concept")
|
|
78
|
+
|
|
79
|
+
console.print(f"[bold green]✓[/bold green] OKF Git repository initialized at {repo_dir}")
|
|
80
|
+
console.print("[dim]Run 'mdgraph index' to build the initial query cache.[/dim]")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@main.command()
|
|
84
|
+
@click.argument("source", type=click.Path(exists=True))
|
|
85
|
+
@click.option("--dest", "-d", default=None, help="Destination directory for normalized markdown files.")
|
|
86
|
+
def ingest(source: str, dest: Optional[str]):
|
|
87
|
+
"""Ingest documents (PDF, HTML, text) and normalize into Markdown."""
|
|
88
|
+
src_path = Path(source).resolve()
|
|
89
|
+
ingestor = UniversalIngestor()
|
|
90
|
+
|
|
91
|
+
results = []
|
|
92
|
+
if src_path.is_file():
|
|
93
|
+
console.print(f"[yellow]Ingesting file:[/yellow] {src_path.name}")
|
|
94
|
+
results.append(ingestor.ingest_file(src_path))
|
|
95
|
+
else:
|
|
96
|
+
console.print(f"[yellow]Ingesting directory:[/yellow] {src_path}")
|
|
97
|
+
results.extend(ingestor.ingest_directory(src_path))
|
|
98
|
+
|
|
99
|
+
target_dir = Path(dest).resolve() if dest else src_path.parent / "normalized"
|
|
100
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
|
|
102
|
+
table = Table(title="Ingested Documents", border_style="yellow")
|
|
103
|
+
table.add_column("Title", style="bold")
|
|
104
|
+
table.add_column("Source Format", style="dim")
|
|
105
|
+
table.add_column("Output File", style="green")
|
|
106
|
+
|
|
107
|
+
for res in results:
|
|
108
|
+
slug = res.title.lower().replace(" ", "_")[:50]
|
|
109
|
+
out_file = target_dir / f"{slug}.md"
|
|
110
|
+
out_file.write_text(res.markdown_content, encoding="utf-8")
|
|
111
|
+
fmt = res.metadata.get("format", "unknown")
|
|
112
|
+
table.add_row(res.title, fmt, str(out_file.relative_to(Path.cwd()) if out_file.is_relative_to(Path.cwd()) else out_file))
|
|
113
|
+
|
|
114
|
+
console.print(table)
|
|
115
|
+
console.print(f"[bold green]✓[/bold green] Ingested {len(results)} document(s) to {target_dir}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@main.command()
|
|
119
|
+
@click.argument("source", type=click.Path(exists=True))
|
|
120
|
+
@click.option("--config", "-c", "config_path", help="Path to configuration file.")
|
|
121
|
+
@click.option("--model", "-m", help="LiteLLM model name (e.g. 'openai/gpt-4o-mini', 'ollama/llama3.2').")
|
|
122
|
+
@click.option("--offline", is_flag=True, help="Use deterministic rule-based compiler without calling LLM.")
|
|
123
|
+
def compile(source: str, config_path: Optional[str], model: Optional[str], offline: bool):
|
|
124
|
+
"""Compile normalized documents into explicit OKF concepts with cross-links."""
|
|
125
|
+
cfg = load_config(config_path)
|
|
126
|
+
if model:
|
|
127
|
+
cfg.llm_model = model
|
|
128
|
+
|
|
129
|
+
src_path = Path(source).resolve()
|
|
130
|
+
content = src_path.read_text(encoding="utf-8", errors="replace")
|
|
131
|
+
|
|
132
|
+
repo = GitRepo(cfg.repo_path)
|
|
133
|
+
file_manager = OKFFileManager(repo)
|
|
134
|
+
|
|
135
|
+
if offline:
|
|
136
|
+
console.print("[dim]Using deterministic rule-based compiler...[/dim]")
|
|
137
|
+
compiler = RuleBasedCompiler()
|
|
138
|
+
else:
|
|
139
|
+
console.print(f"[yellow]Compiling knowledge using model:[/yellow] [bold]{cfg.llm_model}[/bold]")
|
|
140
|
+
compiler = LLMCompiler(model=cfg.llm_model, fallback_to_rules=True)
|
|
141
|
+
|
|
142
|
+
concepts = compiler.compile(content, source_name=src_path.name)
|
|
143
|
+
if not concepts:
|
|
144
|
+
console.print("[red]No concepts extracted from source document.[/red]")
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
table = Table(title="Extracted OKF Concepts", border_style="yellow")
|
|
148
|
+
table.add_column("ID", style="bold yellow")
|
|
149
|
+
table.add_column("Type", style="cyan")
|
|
150
|
+
table.add_column("Title")
|
|
151
|
+
table.add_column("Outgoing Links", justify="right")
|
|
152
|
+
|
|
153
|
+
for concept in concepts:
|
|
154
|
+
file_manager.save_concept(concept)
|
|
155
|
+
table.add_row(
|
|
156
|
+
concept.id,
|
|
157
|
+
concept.type,
|
|
158
|
+
concept.title,
|
|
159
|
+
str(len(concept.edges)),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
console.print(table)
|
|
163
|
+
|
|
164
|
+
# Automatically reindex
|
|
165
|
+
cache = SQLiteQueryCache(cfg.cache_path)
|
|
166
|
+
sync_worker = IndexSyncWorker(repo, cache)
|
|
167
|
+
summary = sync_worker.sync()
|
|
168
|
+
|
|
169
|
+
console.print(
|
|
170
|
+
f"[bold green]✓[/bold green] Successfully saved and committed {len(concepts)} concepts to Git repo.\n"
|
|
171
|
+
f"[dim]Cache updated: {summary.indexed} indexed, {summary.updated} updated in {summary.duration_ms:.1f}ms.[/dim]"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@main.command()
|
|
176
|
+
@click.option("--config", "-c", "config_path", help="Path to config file.")
|
|
177
|
+
@click.option("--rebuild", is_flag=True, help="Drop cache and rebuild entirely from Git tree.")
|
|
178
|
+
def index(config_path: Optional[str], rebuild: bool):
|
|
179
|
+
"""Synchronize Git Markdown files into relational query cache."""
|
|
180
|
+
cfg = load_config(config_path)
|
|
181
|
+
repo = GitRepo(cfg.repo_path)
|
|
182
|
+
cache = SQLiteQueryCache(cfg.cache_path)
|
|
183
|
+
sync_worker = IndexSyncWorker(repo, cache)
|
|
184
|
+
|
|
185
|
+
action = "Rebuilding" if rebuild else "Synchronizing"
|
|
186
|
+
console.print(f"[yellow]{action} query cache from:[/yellow] {repo.repo_path}")
|
|
187
|
+
|
|
188
|
+
if rebuild:
|
|
189
|
+
summary = sync_worker.rebuild()
|
|
190
|
+
else:
|
|
191
|
+
summary = sync_worker.sync()
|
|
192
|
+
|
|
193
|
+
console.print(
|
|
194
|
+
f"[bold green]✓[/bold green] Complete: {summary.indexed} indexed, {summary.updated} updated, "
|
|
195
|
+
f"{summary.deleted} deleted, {summary.skipped} skipped in {summary.duration_ms:.1f}ms."
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@main.command()
|
|
200
|
+
@click.argument("query_str")
|
|
201
|
+
@click.option("--config", "-c", "config_path", help="Path to config file.")
|
|
202
|
+
@click.option("--tag", "-t", help="Filter by tag.")
|
|
203
|
+
@click.option("--type", "concept_type", help="Filter by concept type.")
|
|
204
|
+
@click.option("--limit", "-l", default=5, help="Result limit.")
|
|
205
|
+
@click.option("--traverse", is_flag=True, help="Traverse graph starting from top match.")
|
|
206
|
+
@click.option("--depth", default=2, help="Traversal depth if --traverse is enabled.")
|
|
207
|
+
def query(
|
|
208
|
+
query_str: str,
|
|
209
|
+
config_path: Optional[str],
|
|
210
|
+
tag: Optional[str],
|
|
211
|
+
concept_type: Optional[str],
|
|
212
|
+
limit: int,
|
|
213
|
+
traverse: bool,
|
|
214
|
+
depth: int,
|
|
215
|
+
):
|
|
216
|
+
"""Execute BM25 keyword search over knowledge concepts."""
|
|
217
|
+
cfg = load_config(config_path)
|
|
218
|
+
cache = SQLiteQueryCache(cfg.cache_path)
|
|
219
|
+
|
|
220
|
+
tags_filter = [tag] if tag else None
|
|
221
|
+
results = cache.search(query=query_str, tags=tags_filter, concept_type=concept_type, limit=limit)
|
|
222
|
+
|
|
223
|
+
if not results:
|
|
224
|
+
console.print(f"[dim]No matching concepts found for query: '{query_str}'[/dim]")
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
table = Table(title=f"Search Results for '{query_str}'", border_style="yellow")
|
|
228
|
+
table.add_column("ID", style="bold yellow")
|
|
229
|
+
table.add_column("Type", style="cyan")
|
|
230
|
+
table.add_column("Title", style="bold")
|
|
231
|
+
table.add_column("Snippet")
|
|
232
|
+
table.add_column("Rank", justify="right", style="dim")
|
|
233
|
+
|
|
234
|
+
for r in results:
|
|
235
|
+
snip = (r.snippet or r.description).replace("<b>", "[bold yellow]").replace("</b>", "[/bold yellow]")
|
|
236
|
+
table.add_row(r.id, r.type, r.title, snip, f"{r.rank:.2f}")
|
|
237
|
+
|
|
238
|
+
console.print(table)
|
|
239
|
+
|
|
240
|
+
if traverse and results:
|
|
241
|
+
top_id = results[0].id
|
|
242
|
+
console.print(f"\n[bold yellow]Multi-Hop Traversal from '{top_id}' (depth={depth}):[/bold yellow]")
|
|
243
|
+
_print_traversal_tree(cache, top_id, depth)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@main.command()
|
|
247
|
+
@click.argument("node_id")
|
|
248
|
+
@click.option("--config", "-c", "config_path", help="Path to config file.")
|
|
249
|
+
@click.option("--depth", "-d", default=2, help="Maximum traversal depth.")
|
|
250
|
+
@click.option("--direction", default="out", type=click.Choice(["out", "in", "both"]), help="Traversal direction.")
|
|
251
|
+
def traverse(node_id: str, config_path: Optional[str], depth: int, direction: str):
|
|
252
|
+
"""Traverse graph topological pathways via Recursive CTE."""
|
|
253
|
+
cfg = load_config(config_path)
|
|
254
|
+
cache = SQLiteQueryCache(cfg.cache_path)
|
|
255
|
+
_print_traversal_tree(cache, node_id, depth, direction)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _print_traversal_tree(cache: SQLiteQueryCache, root_id: str, depth: int, direction: str = "out"):
|
|
259
|
+
result = cache.traverse(root_id, max_depth=depth, direction=direction)
|
|
260
|
+
if not result.nodes:
|
|
261
|
+
console.print(f"[red]Concept '{root_id}' not found in cache.[/red]")
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
nodes_by_id = {n["id"]: n for n in result.nodes}
|
|
265
|
+
root_node = nodes_by_id.get(root_id, {"title": root_id, "type": "Root"})
|
|
266
|
+
|
|
267
|
+
tree = Tree(f"[bold yellow]{root_node.get('title', root_id)}[/bold yellow] [dim]({root_id})[/dim] [{root_node.get('type')}]")
|
|
268
|
+
|
|
269
|
+
# Map edges
|
|
270
|
+
children_map = {}
|
|
271
|
+
for edge in result.edges:
|
|
272
|
+
src = edge["source"]
|
|
273
|
+
tgt = edge["target"]
|
|
274
|
+
children_map.setdefault(src, []).append((tgt, edge["label"], edge["link_text"]))
|
|
275
|
+
|
|
276
|
+
def add_branches(current_id: str, current_tree: Tree, current_depth: int):
|
|
277
|
+
if current_depth >= depth:
|
|
278
|
+
return
|
|
279
|
+
for child_id, label, link_text in children_map.get(current_id, []):
|
|
280
|
+
child_info = nodes_by_id.get(child_id, {})
|
|
281
|
+
label_text = f" --[{label or 'references'}]--> "
|
|
282
|
+
title = child_info.get("title", child_id)
|
|
283
|
+
c_type = child_info.get("type", "Node")
|
|
284
|
+
branch = current_tree.add(
|
|
285
|
+
f"[dim]{label_text}[/dim][bold cyan]{title}[/bold cyan] [dim]({child_id})[/dim] [{c_type}]"
|
|
286
|
+
)
|
|
287
|
+
add_branches(child_id, branch, current_depth + 1)
|
|
288
|
+
|
|
289
|
+
add_branches(root_id, tree, 0)
|
|
290
|
+
console.print(tree)
|
|
291
|
+
|
|
292
|
+
console.print(
|
|
293
|
+
f"\n[dim]Topological traversal: {len(result.nodes)} node(s), {len(result.edges)} edge(s), "
|
|
294
|
+
f"{len(result.paths)} multi-hop path(s) explored via Recursive CTE.[/dim]"
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@main.command()
|
|
299
|
+
@click.argument("node_id")
|
|
300
|
+
@click.option("--config", "-c", "config_path", help="Path to config file.")
|
|
301
|
+
@click.option("--raw", is_flag=True, help="Display raw markdown file directly.")
|
|
302
|
+
def show(node_id: str, config_path: Optional[str], raw: bool):
|
|
303
|
+
"""Display concept metadata, body, and edges."""
|
|
304
|
+
cfg = load_config(config_path)
|
|
305
|
+
repo = GitRepo(cfg.repo_path)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
content = repo.read_file(node_id)
|
|
309
|
+
except FileNotFoundError:
|
|
310
|
+
console.print(f"[red]Concept '{node_id}' not found in repository.[/red]")
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
if raw:
|
|
314
|
+
console.print(content)
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
cache = SQLiteQueryCache(cfg.cache_path)
|
|
318
|
+
data = cache.get_concept(node_id)
|
|
319
|
+
if data:
|
|
320
|
+
from rich.markdown import Markdown
|
|
321
|
+
from rich.markup import escape
|
|
322
|
+
panel_title = f"{data['title']} ({data['id']})"
|
|
323
|
+
info = (
|
|
324
|
+
f"[bold]Type:[/bold] {data['type']} | [bold]Status:[/bold] {data['status']}\n"
|
|
325
|
+
f"[bold]Tags:[/bold] {', '.join(data['tags']) if data['tags'] else 'none'}\n"
|
|
326
|
+
f"[bold]Description:[/bold] {escape(data['description'])}\n"
|
|
327
|
+
f"[bold]File:[/bold] {data['file_path']} | [bold]Commit:[/bold] {data.get('commit_hash', 'uncommitted')[:8] if data.get('commit_hash') else 'none'}"
|
|
328
|
+
)
|
|
329
|
+
console.print(Panel(info, title=panel_title, border_style="yellow"))
|
|
330
|
+
|
|
331
|
+
if data.get("outgoing_edges"):
|
|
332
|
+
console.print("\n[bold]Outgoing Connections:[/bold]")
|
|
333
|
+
for e in data["outgoing_edges"]:
|
|
334
|
+
console.print(f" → [yellow]{e['target_id']}[/yellow] ([dim]{e['label']}[/dim])")
|
|
335
|
+
|
|
336
|
+
if data.get("incoming_edges"):
|
|
337
|
+
console.print("\n[bold]Incoming Backlinks:[/bold]")
|
|
338
|
+
for e in data["incoming_edges"]:
|
|
339
|
+
console.print(f" ← [cyan]{e['source_id']}[/cyan] ([dim]{e['label']}[/dim])")
|
|
340
|
+
|
|
341
|
+
console.print("\n[bold]Body Content:[/bold]")
|
|
342
|
+
console.print(Panel(Markdown(data["body"]), border_style="dim"))
|
|
343
|
+
else:
|
|
344
|
+
console.print(content)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@main.command()
|
|
348
|
+
@click.option("--config", "-c", "config_path", help="Path to config file.")
|
|
349
|
+
def stats(config_path: Optional[str]):
|
|
350
|
+
"""Show global statistics of the knowledge graph."""
|
|
351
|
+
cfg = load_config(config_path)
|
|
352
|
+
repo = GitRepo(cfg.repo_path)
|
|
353
|
+
cache = SQLiteQueryCache(cfg.cache_path)
|
|
354
|
+
|
|
355
|
+
st = cache.get_stats()
|
|
356
|
+
head = repo.get_head_commit()
|
|
357
|
+
|
|
358
|
+
table = Table(title="Knowledge Graph Overview", border_style="yellow")
|
|
359
|
+
table.add_column("Metric", style="bold")
|
|
360
|
+
table.add_column("Value", style="cyan")
|
|
361
|
+
|
|
362
|
+
table.add_row("Total Concepts", str(st.total_concepts))
|
|
363
|
+
table.add_row("Total Explicit Edges", str(st.total_edges))
|
|
364
|
+
table.add_row("Git HEAD Commit", (head[:10] + "...") if head else "none")
|
|
365
|
+
table.add_row("Repository Path", cfg.repo_path)
|
|
366
|
+
table.add_row("Cache Path", cfg.cache_path)
|
|
367
|
+
|
|
368
|
+
console.print(table)
|
|
369
|
+
|
|
370
|
+
if st.concept_types:
|
|
371
|
+
type_table = Table(title="Concepts by Type", border_style="dim")
|
|
372
|
+
type_table.add_column("Type")
|
|
373
|
+
type_table.add_column("Count", justify="right")
|
|
374
|
+
for ctype, count in st.concept_types.items():
|
|
375
|
+
type_table.add_row(ctype, str(count))
|
|
376
|
+
console.print(type_table)
|
|
377
|
+
|
|
378
|
+
if st.top_tags:
|
|
379
|
+
tags_str = ", ".join(f"{t} ({cnt})" for t, cnt in st.top_tags.items())
|
|
380
|
+
console.print(f"[bold]Top Tags:[/bold] {tags_str}")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@main.command()
|
|
384
|
+
@click.option("--config", "-c", "config_path", help="Path to config file.")
|
|
385
|
+
@click.option("--host", "-h", default=None, help="Host to bind for SSE server.")
|
|
386
|
+
@click.option("--port", "-p", default=None, type=int, help="Port to bind for SSE server.")
|
|
387
|
+
@click.option("--transport", "-t", default="sse", type=click.Choice(["sse", "stdio", "http"]), help="MCP transport protocol.")
|
|
388
|
+
def serve(config_path: Optional[str], host: Optional[str], port: Optional[int], transport: str):
|
|
389
|
+
"""Start the FastMCP server for autonomous agent fleets."""
|
|
390
|
+
cfg = load_config(config_path)
|
|
391
|
+
bind_host = host or cfg.mcp_host
|
|
392
|
+
bind_port = port or cfg.mcp_port
|
|
393
|
+
|
|
394
|
+
repo = GitRepo(cfg.repo_path)
|
|
395
|
+
cache = SQLiteQueryCache(cfg.cache_path)
|
|
396
|
+
mcp_app = create_mcp_server(config=cfg, repo=repo, cache=cache)
|
|
397
|
+
|
|
398
|
+
print_banner()
|
|
399
|
+
|
|
400
|
+
if transport == "stdio":
|
|
401
|
+
console.print("[dim]Starting FastMCP server with stdio transport...[/dim]")
|
|
402
|
+
mcp_app.run(transport="stdio")
|
|
403
|
+
else:
|
|
404
|
+
endpoint = f"http://{bind_host}:{bind_port}/sse"
|
|
405
|
+
console.print(f"[bold green]Starting FastMCP server on:[/bold green] [bold yellow]{endpoint}[/bold yellow]")
|
|
406
|
+
console.print("[dim]Connect autonomous agents (Google ADK, LangGraph, Cursor, Claude) to this endpoint.[/dim]")
|
|
407
|
+
mcp_app.run(transport=transport, host=bind_host, port=bind_port)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
if __name__ == "__main__":
|
|
411
|
+
main()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Compiler package for extracting OKF knowledge graphs using LLMs or rule engines."""
|
|
2
|
+
|
|
3
|
+
from mdgraph.compiler.base import BaseCompiler
|
|
4
|
+
from mdgraph.compiler.llm_compiler import LLMCompiler
|
|
5
|
+
from mdgraph.compiler.mock_compiler import RuleBasedCompiler
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BaseCompiler",
|
|
9
|
+
"LLMCompiler",
|
|
10
|
+
"RuleBasedCompiler",
|
|
11
|
+
]
|
mdgraph/compiler/base.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Abstract base interface for knowledge graph compilers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from mdgraph.models import OKFConcept
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BaseCompiler(ABC):
|
|
12
|
+
"""Abstract interface for compiling text into OKF concepts."""
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def compile(self, text: str, source_name: Optional[str] = None) -> List[OKFConcept]:
|
|
16
|
+
"""Compile source document into an interconnected list of OKF concepts."""
|
|
17
|
+
pass
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Pluggable LLM-driven compiler powered by LiteLLM."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
import litellm
|
|
10
|
+
|
|
11
|
+
from mdgraph.compiler.base import BaseCompiler
|
|
12
|
+
from mdgraph.compiler.mock_compiler import RuleBasedCompiler
|
|
13
|
+
from mdgraph.compiler.prompt import COMPILER_SYSTEM_PROMPT, JSON_SCHEMA_INSTRUCTION
|
|
14
|
+
from mdgraph.models import ConceptDraft, ExtractedGraph, OKFConcept, normalize_node_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LLMCompiler(BaseCompiler):
|
|
18
|
+
"""Compiles unstructured/normalized documents into OKF concepts using LiteLLM."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
model: str = "openai/gpt-4o-mini",
|
|
23
|
+
temperature: float = 0.1,
|
|
24
|
+
api_key: Optional[str] = None,
|
|
25
|
+
fallback_to_rules: bool = True,
|
|
26
|
+
):
|
|
27
|
+
self.model = model
|
|
28
|
+
self.temperature = temperature
|
|
29
|
+
self.api_key = api_key
|
|
30
|
+
self.fallback_to_rules = fallback_to_rules
|
|
31
|
+
self._rule_compiler = RuleBasedCompiler()
|
|
32
|
+
|
|
33
|
+
def compile(self, text: str, source_name: Optional[str] = None) -> List[OKFConcept]:
|
|
34
|
+
"""Compile document into OKF concepts via LiteLLM."""
|
|
35
|
+
if not text.strip():
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
# If no relevant API keys are set and local model not configured, fallback gracefully if enabled
|
|
39
|
+
has_key = bool(
|
|
40
|
+
self.api_key
|
|
41
|
+
or os.getenv("OPENAI_API_KEY")
|
|
42
|
+
or os.getenv("ANTHROPIC_API_KEY")
|
|
43
|
+
or os.getenv("GEMINI_API_KEY")
|
|
44
|
+
or "ollama" in self.model.lower()
|
|
45
|
+
or "local" in self.model.lower()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if not has_key and self.fallback_to_rules:
|
|
49
|
+
return self._rule_compiler.compile(text, source_name=source_name)
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
return self._call_llm(text, source_name)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
if self.fallback_to_rules:
|
|
55
|
+
return self._rule_compiler.compile(text, source_name=source_name)
|
|
56
|
+
raise RuntimeError(f"LLM compilation failed: {e}")
|
|
57
|
+
|
|
58
|
+
def _call_llm(self, text: str, source_name: Optional[str]) -> List[OKFConcept]:
|
|
59
|
+
user_prompt = f"Source Document: {source_name or 'Unspecified'}\n\n{text}\n\n{JSON_SCHEMA_INSTRUCTION}"
|
|
60
|
+
|
|
61
|
+
messages = [
|
|
62
|
+
{"role": "system", "content": COMPILER_SYSTEM_PROMPT},
|
|
63
|
+
{"role": "user", "content": user_prompt},
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
kwargs = {
|
|
67
|
+
"model": self.model,
|
|
68
|
+
"messages": messages,
|
|
69
|
+
"temperature": self.temperature,
|
|
70
|
+
}
|
|
71
|
+
if self.api_key:
|
|
72
|
+
kwargs["api_key"] = self.api_key
|
|
73
|
+
|
|
74
|
+
# Try structured json output
|
|
75
|
+
try:
|
|
76
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
77
|
+
response = litellm.completion(**kwargs)
|
|
78
|
+
except Exception:
|
|
79
|
+
# Retry without response_format if provider doesn't support json_object mode
|
|
80
|
+
kwargs.pop("response_format", None)
|
|
81
|
+
response = litellm.completion(**kwargs)
|
|
82
|
+
|
|
83
|
+
raw_output = response.choices[0].message.content or ""
|
|
84
|
+
return self._parse_llm_json(raw_output, source_name)
|
|
85
|
+
|
|
86
|
+
def _parse_llm_json(self, raw_output: str, source_name: Optional[str]) -> List[OKFConcept]:
|
|
87
|
+
# Extract json substring if wrapped in markdown ```json
|
|
88
|
+
content = raw_output.strip()
|
|
89
|
+
if "```json" in content:
|
|
90
|
+
content = content.split("```json", 1)[1].split("```", 1)[0].strip()
|
|
91
|
+
elif "```" in content:
|
|
92
|
+
content = content.split("```", 1)[1].split("```", 1)[0].strip()
|
|
93
|
+
|
|
94
|
+
data = json.loads(content)
|
|
95
|
+
concepts_data = data.get("concepts", [])
|
|
96
|
+
if not concepts_data and isinstance(data, list):
|
|
97
|
+
concepts_data = data
|
|
98
|
+
|
|
99
|
+
results: List[OKFConcept] = []
|
|
100
|
+
for item in concepts_data:
|
|
101
|
+
node_id = normalize_node_id(item.get("id") or item.get("title") or "concept")
|
|
102
|
+
body = item.get("body", "").strip()
|
|
103
|
+
concept_type = item.get("type", "Concept")
|
|
104
|
+
title = item.get("title", node_id.replace("_", " ").title())
|
|
105
|
+
desc = item.get("description", "")
|
|
106
|
+
tags = item.get("tags", [])
|
|
107
|
+
status = item.get("status", "stable")
|
|
108
|
+
|
|
109
|
+
concept = OKFConcept(
|
|
110
|
+
id=node_id,
|
|
111
|
+
type=concept_type,
|
|
112
|
+
title=title,
|
|
113
|
+
description=desc,
|
|
114
|
+
tags=tags,
|
|
115
|
+
status=status,
|
|
116
|
+
body=body,
|
|
117
|
+
file_path=f"concepts/{node_id}.md",
|
|
118
|
+
)
|
|
119
|
+
concept.edges = OKFConcept.extract_links(concept.id, concept.body)
|
|
120
|
+
results.append(concept)
|
|
121
|
+
|
|
122
|
+
return results
|