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 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
+ ]
@@ -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