muthuk1 commited on
Commit
3101051
Β·
verified Β·
1 Parent(s): 79a8e0b

Fix #1: Add TigerGraph GraphRAG integration layer wrapping official repo REST APIs

Browse files
Files changed (1) hide show
  1. graphrag/layers/tg_graphrag_client.py +532 -0
graphrag/layers/tg_graphrag_client.py ADDED
@@ -0,0 +1,532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ TigerGraph GraphRAG Client β€” Integration with the Official tigergraph/graphrag Repo
3
+ ====================================================================================
4
+ This module integrates with the official TigerGraph GraphRAG service
5
+ (https://github.com/tigergraph/graphrag) deployed via Docker.
6
+
7
+ The official repo exposes REST APIs for graph-powered Q&A with three retrievers:
8
+ - Hybrid Search: vector similarity + graph traversal combined
9
+ - Community: hierarchical community summaries (Leiden algorithm)
10
+ - Sibling: sibling/neighbor node traversal from seed entities
11
+
12
+ This client calls those APIs. When the official service is not available,
13
+ it falls back to our custom pyTigerGraph-based GraphLayer implementation.
14
+
15
+ Usage:
16
+ client = TGGraphRAGClient(service_url="http://localhost:8000", ...)
17
+ if client.connect():
18
+ result = client.retrieve(query, retriever="hybrid", top_k=5, num_hops=2)
19
+ answer = client.query(question, retriever="hybrid")
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ import time
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @dataclass
33
+ class RetrievalResult:
34
+ """Result from a TG GraphRAG retrieval call."""
35
+ content: str = ""
36
+ chunks: List[Dict[str, Any]] = field(default_factory=list)
37
+ entities: List[Dict[str, Any]] = field(default_factory=list)
38
+ relations: List[str] = field(default_factory=list)
39
+ community_summaries: List[str] = field(default_factory=list)
40
+ retriever_used: str = ""
41
+ score: float = 0.0
42
+ latency_ms: float = 0.0
43
+ metadata: Dict[str, Any] = field(default_factory=dict)
44
+
45
+
46
+ @dataclass
47
+ class GraphRAGAnswer:
48
+ """Full answer from the TG GraphRAG service."""
49
+ answer: str = ""
50
+ retrieval: RetrievalResult = field(default_factory=RetrievalResult)
51
+ total_tokens: int = 0
52
+ input_tokens: int = 0
53
+ output_tokens: int = 0
54
+ latency_ms: float = 0.0
55
+ cost_usd: float = 0.0
56
+
57
+
58
+ class TGGraphRAGClient:
59
+ """
60
+ Client for the official TigerGraph GraphRAG service.
61
+
62
+ Supports two modes:
63
+ 1. REST API mode: calls the deployed tigergraph/graphrag Docker service
64
+ 2. Direct mode: uses pyTigerGraph SDK with our custom GSQL queries (fallback)
65
+
66
+ The hackathon allows both Path A (use as-is) and Path B (customize).
67
+ This client implements Path A (REST API) with Path B fallback (direct GSQL).
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ service_url: str = "",
73
+ tg_host: str = "",
74
+ tg_graph: str = "GraphRAG",
75
+ tg_username: str = "tigergraph",
76
+ tg_password: str = "",
77
+ tg_token: str = "",
78
+ ):
79
+ self.service_url = (
80
+ service_url
81
+ or os.getenv("GRAPHRAG_SERVICE_URL", "")
82
+ or os.getenv("TG_GRAPHRAG_URL", "")
83
+ ).rstrip("/")
84
+ self.tg_host = tg_host or os.getenv("TG_HOST", "")
85
+ self.tg_graph = tg_graph or os.getenv("TG_GRAPH", "GraphRAG")
86
+ self.tg_username = tg_username or os.getenv("TG_USERNAME", "tigergraph")
87
+ self.tg_password = tg_password or os.getenv("TG_PASSWORD", "")
88
+ self.tg_token = tg_token or os.getenv("TG_TOKEN", "")
89
+
90
+ self._service_available = False
91
+ self._direct_available = False
92
+ self._conn = None
93
+ self._api_token = ""
94
+ self._openapi_spec: Dict = {}
95
+
96
+ # ── Connection ────────────────────────────────────────
97
+
98
+ def connect(self) -> bool:
99
+ """
100
+ Connect to the TG GraphRAG service.
101
+ Tries REST API first, then falls back to direct pyTigerGraph.
102
+ """
103
+ # Try REST API service first
104
+ if self.service_url:
105
+ self._service_available = self._check_service()
106
+ if self._service_available:
107
+ logger.info(f"Connected to TG GraphRAG service at {self.service_url}")
108
+ self._discover_endpoints()
109
+ return True
110
+
111
+ # Fall back to direct pyTigerGraph connection
112
+ if self.tg_host:
113
+ self._direct_available = self._connect_direct()
114
+ if self._direct_available:
115
+ logger.info(f"Connected to TigerGraph directly at {self.tg_host}")
116
+ return True
117
+
118
+ logger.warning("No TG GraphRAG connection available. Running in offline mode.")
119
+ return False
120
+
121
+ def _check_service(self) -> bool:
122
+ """Check if the TG GraphRAG REST service is healthy."""
123
+ import urllib.request
124
+ import urllib.error
125
+
126
+ # Try common health endpoints
127
+ for path in ["/health", "/api/health", "/", "/docs", "/openapi.json"]:
128
+ try:
129
+ url = f"{self.service_url}{path}"
130
+ req = urllib.request.Request(url, method="GET")
131
+ if self._api_token:
132
+ req.add_header("Authorization", f"Bearer {self._api_token}")
133
+ with urllib.request.urlopen(req, timeout=5) as resp:
134
+ if resp.status == 200:
135
+ logger.info(f"TG GraphRAG service healthy at {url}")
136
+ return True
137
+ except (urllib.error.URLError, OSError):
138
+ continue
139
+ return False
140
+
141
+ def _discover_endpoints(self):
142
+ """Discover available API endpoints from OpenAPI spec."""
143
+ import urllib.request
144
+ try:
145
+ url = f"{self.service_url}/openapi.json"
146
+ req = urllib.request.Request(url, method="GET")
147
+ with urllib.request.urlopen(req, timeout=5) as resp:
148
+ self._openapi_spec = json.loads(resp.read())
149
+ paths = list(self._openapi_spec.get("paths", {}).keys())
150
+ logger.info(f"Discovered {len(paths)} API endpoints: {paths[:10]}")
151
+ except Exception as e:
152
+ logger.debug(f"Could not discover endpoints: {e}")
153
+
154
+ def _connect_direct(self) -> bool:
155
+ """Connect directly to TigerGraph via pyTigerGraph."""
156
+ try:
157
+ import pyTigerGraph as tg
158
+ self._conn = tg.TigerGraphConnection(
159
+ host=self.tg_host,
160
+ graphname=self.tg_graph,
161
+ username=self.tg_username,
162
+ password=self.tg_password,
163
+ )
164
+ if self.tg_token:
165
+ self._conn.apiToken = self.tg_token
166
+ else:
167
+ secret = self._conn.createSecret()
168
+ self._conn.getToken(secret)
169
+ return True
170
+ except Exception as e:
171
+ logger.error(f"Direct TigerGraph connection failed: {e}")
172
+ return False
173
+
174
+ @property
175
+ def is_connected(self) -> bool:
176
+ return self._service_available or self._direct_available
177
+
178
+ @property
179
+ def mode(self) -> str:
180
+ if self._service_available:
181
+ return "rest_api"
182
+ elif self._direct_available:
183
+ return "direct"
184
+ return "offline"
185
+
186
+ # ── Retrieval (Core API) ──────────────────────────────
187
+
188
+ def retrieve(
189
+ self,
190
+ query: str,
191
+ retriever: str = "hybrid",
192
+ top_k: int = 5,
193
+ num_hops: int = 2,
194
+ community_level: int = 1,
195
+ ) -> RetrievalResult:
196
+ """
197
+ Retrieve context for a query using the specified retriever.
198
+
199
+ Args:
200
+ query: The question to retrieve context for
201
+ retriever: One of "hybrid", "community", "sibling"
202
+ top_k: Number of top results to return
203
+ num_hops: Graph traversal depth (for hybrid/sibling)
204
+ community_level: Leiden hierarchy level (for community)
205
+
206
+ Returns:
207
+ RetrievalResult with chunks, entities, and metadata
208
+ """
209
+ start = time.perf_counter()
210
+
211
+ if self._service_available:
212
+ result = self._retrieve_via_api(query, retriever, top_k, num_hops, community_level)
213
+ elif self._direct_available:
214
+ result = self._retrieve_via_direct(query, retriever, top_k, num_hops, community_level)
215
+ else:
216
+ result = RetrievalResult(
217
+ content="[No TG GraphRAG connection β€” offline mode]",
218
+ retriever_used=retriever,
219
+ )
220
+
221
+ result.latency_ms = (time.perf_counter() - start) * 1000
222
+ return result
223
+
224
+ def _retrieve_via_api(
225
+ self, query: str, retriever: str, top_k: int, num_hops: int, community_level: int
226
+ ) -> RetrievalResult:
227
+ """Call the official TG GraphRAG REST API for retrieval."""
228
+ import urllib.request
229
+ import urllib.error
230
+
231
+ payload = {
232
+ "query": query,
233
+ "top_k": top_k,
234
+ }
235
+ if retriever in ("hybrid", "sibling"):
236
+ payload["num_hops"] = num_hops
237
+ if retriever == "community":
238
+ payload["community_level"] = community_level
239
+
240
+ # Try multiple endpoint patterns (official repo may use different paths)
241
+ endpoint_patterns = [
242
+ f"/retrieve/{retriever}",
243
+ f"/api/retrieve/{retriever}",
244
+ f"/graphrag/retrieve/{retriever}",
245
+ f"/api/v1/retrieve/{retriever}",
246
+ f"/retrieve", # with retriever in body
247
+ f"/api/retrieve", # with retriever in body
248
+ f"/query", # generic query endpoint
249
+ f"/api/query",
250
+ ]
251
+
252
+ # For generic endpoints, include retriever type in payload
253
+ payload_with_type = {**payload, "retriever": retriever, "retriever_type": retriever}
254
+
255
+ for path in endpoint_patterns:
256
+ try:
257
+ url = f"{self.service_url}{path}"
258
+ body = json.dumps(payload_with_type if "/retrieve/" not in path else payload)
259
+ req = urllib.request.Request(
260
+ url, data=body.encode("utf-8"), method="POST",
261
+ headers={"Content-Type": "application/json"}
262
+ )
263
+ if self._api_token:
264
+ req.add_header("Authorization", f"Bearer {self._api_token}")
265
+
266
+ with urllib.request.urlopen(req, timeout=30) as resp:
267
+ data = json.loads(resp.read())
268
+ return self._parse_api_response(data, retriever)
269
+ except urllib.error.HTTPError as e:
270
+ if e.code == 404:
271
+ continue # try next endpoint pattern
272
+ logger.error(f"API error on {path}: {e.code} {e.reason}")
273
+ continue
274
+ except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
275
+ logger.debug(f"Endpoint {path} failed: {e}")
276
+ continue
277
+
278
+ logger.warning("All REST API endpoint patterns failed. Falling back to direct mode.")
279
+ if self._direct_available:
280
+ return self._retrieve_via_direct(query, retriever, top_k, num_hops, community_level)
281
+ return RetrievalResult(content="[API retrieval failed]", retriever_used=retriever)
282
+
283
+ def _parse_api_response(self, data: Dict, retriever: str) -> RetrievalResult:
284
+ """Parse the response from the TG GraphRAG API into a RetrievalResult."""
285
+ result = RetrievalResult(retriever_used=retriever)
286
+
287
+ # Handle various response formats the API might return
288
+ if isinstance(data, dict):
289
+ # Standard format: {"results": [...], "answer": "..."}
290
+ results = data.get("results", data.get("chunks", data.get("documents", [])))
291
+ if isinstance(results, list):
292
+ for item in results:
293
+ if isinstance(item, dict):
294
+ result.chunks.append({
295
+ "text": item.get("content", item.get("text", item.get("chunk_text", ""))),
296
+ "score": item.get("score", item.get("similarity", 0.0)),
297
+ "source": item.get("source", item.get("doc_id", "")),
298
+ "chunk_id": item.get("chunk_id", item.get("id", "")),
299
+ })
300
+ elif isinstance(item, str):
301
+ result.chunks.append({"text": item, "score": 0.0})
302
+
303
+ # Extract entities if present
304
+ entities = data.get("entities", data.get("nodes", []))
305
+ if isinstance(entities, list):
306
+ result.entities = entities
307
+
308
+ # Extract relations if present
309
+ relations = data.get("relations", data.get("edges", data.get("relationships", [])))
310
+ if isinstance(relations, list):
311
+ result.relations = [str(r) for r in relations]
312
+
313
+ # Extract community summaries if present
314
+ summaries = data.get("community_summaries", data.get("summaries", []))
315
+ if isinstance(summaries, list):
316
+ result.community_summaries = [str(s) for s in summaries]
317
+
318
+ # Build combined content
319
+ texts = [c.get("text", "") for c in result.chunks if c.get("text")]
320
+ if result.community_summaries:
321
+ texts = result.community_summaries + texts
322
+ result.content = "\n\n".join(texts)
323
+
324
+ # Answer if provided
325
+ if "answer" in data:
326
+ result.metadata["service_answer"] = data["answer"]
327
+
328
+ result.metadata["raw_response_keys"] = list(data.keys())
329
+
330
+ elif isinstance(data, list):
331
+ for item in data:
332
+ text = item.get("text", item.get("content", str(item))) if isinstance(item, dict) else str(item)
333
+ result.chunks.append({"text": text, "score": 0.0})
334
+ result.content = "\n\n".join(c["text"] for c in result.chunks)
335
+
336
+ return result
337
+
338
+ def _retrieve_via_direct(
339
+ self, query: str, retriever: str, top_k: int, num_hops: int, community_level: int
340
+ ) -> RetrievalResult:
341
+ """
342
+ Fallback: use pyTigerGraph direct GSQL queries.
343
+ Maps official retriever names to our custom GSQL queries.
344
+ """
345
+ result = RetrievalResult(retriever_used=f"{retriever}_direct")
346
+
347
+ if not self._conn:
348
+ return result
349
+
350
+ try:
351
+ # Get query embedding for vector search
352
+ from .orchestration_layer import EmbeddingManager
353
+ embedder = EmbeddingManager()
354
+ embedder.initialize()
355
+ query_emb = embedder.embed_single(query)
356
+
357
+ if retriever == "hybrid":
358
+ # Hybrid = vector search chunks + entity traversal
359
+ chunks = self._run_query("vectorSearchChunks",
360
+ {"queryVec": query_emb, "topK": top_k})
361
+ entity_results = self._run_query("vectorSearchEntities",
362
+ {"queryVec": query_emb, "topK": top_k})
363
+ seed_ids = [e.get("entity_id", "") for e in
364
+ (entity_results[0].get("@@topEntities", []) if entity_results else [])]
365
+ if seed_ids:
366
+ traversal = self._run_query("graphRAGTraverse",
367
+ {"seedEntityIds": seed_ids, "hops": num_hops})
368
+ if traversal:
369
+ for r in traversal:
370
+ if "@@chunkTexts" in r:
371
+ for text in r["@@chunkTexts"]:
372
+ result.chunks.append({"text": text, "score": 0.0})
373
+ if "@@relationDescriptions" in r:
374
+ result.relations = list(r["@@relationDescriptions"])
375
+
376
+ # Also add vector search results
377
+ if chunks:
378
+ for c in chunks[0].get("@@topChunks", []):
379
+ result.chunks.append({
380
+ "text": c.get("text", c.get("chunk_id", "")),
381
+ "score": c.get("score", 0.0),
382
+ })
383
+
384
+ result.content = "\n\n".join(c["text"] for c in result.chunks[:top_k] if c.get("text"))
385
+
386
+ elif retriever == "community":
387
+ # Community retriever β€” use community summaries
388
+ chunks = self._run_query("vectorSearchChunks",
389
+ {"queryVec": query_emb, "topK": top_k})
390
+ if chunks:
391
+ for c in chunks[0].get("@@topChunks", []):
392
+ result.chunks.append({"text": c.get("text", ""), "score": c.get("score", 0.0)})
393
+ result.content = "\n\n".join(c["text"] for c in result.chunks if c.get("text"))
394
+
395
+ elif retriever == "sibling":
396
+ # Sibling retriever β€” entity neighbors
397
+ entity_results = self._run_query("vectorSearchEntities",
398
+ {"queryVec": query_emb, "topK": top_k})
399
+ seed_ids = [e.get("entity_id", "") for e in
400
+ (entity_results[0].get("@@topEntities", []) if entity_results else [])]
401
+ if seed_ids:
402
+ traversal = self._run_query("graphRAGTraverse",
403
+ {"seedEntityIds": seed_ids, "hops": num_hops})
404
+ if traversal:
405
+ for r in traversal:
406
+ if "@@chunkTexts" in r:
407
+ for text in r["@@chunkTexts"]:
408
+ result.chunks.append({"text": text, "score": 0.0})
409
+ if "@@relationDescriptions" in r:
410
+ result.relations = list(r["@@relationDescriptions"])
411
+ result.content = "\n\n".join(c["text"] for c in result.chunks[:top_k] if c.get("text"))
412
+
413
+ except Exception as e:
414
+ logger.error(f"Direct retrieval failed: {e}")
415
+ result.content = f"[Retrieval error: {e}]"
416
+
417
+ return result
418
+
419
+ def _run_query(self, query_name: str, params: Dict) -> List[Dict]:
420
+ """Run an installed GSQL query."""
421
+ try:
422
+ return self._conn.runInstalledQuery(query_name, params=params)
423
+ except Exception as e:
424
+ logger.error(f"GSQL query {query_name} failed: {e}")
425
+ return []
426
+
427
+ # ── Full Q&A (Retrieval + Generation) ─────────────────
428
+
429
+ def query(
430
+ self,
431
+ question: str,
432
+ retriever: str = "hybrid",
433
+ top_k: int = 5,
434
+ num_hops: int = 2,
435
+ community_level: int = 1,
436
+ llm_layer=None,
437
+ ) -> GraphRAGAnswer:
438
+ """
439
+ Full GraphRAG Q&A: retrieve context β†’ generate answer.
440
+
441
+ If the TG GraphRAG service provides its own answer, use that.
442
+ Otherwise, retrieve context and pass to our LLM layer for generation.
443
+ """
444
+ start = time.perf_counter()
445
+ retrieval = self.retrieve(query=question, retriever=retriever,
446
+ top_k=top_k, num_hops=num_hops,
447
+ community_level=community_level)
448
+ answer_obj = GraphRAGAnswer(retrieval=retrieval)
449
+
450
+ # If the service already returned an answer, use it
451
+ service_answer = retrieval.metadata.get("service_answer", "")
452
+ if service_answer:
453
+ answer_obj.answer = service_answer
454
+ elif llm_layer and retrieval.content:
455
+ # Generate answer using our LLM layer with retrieved context
456
+ resp = llm_layer.generate_answer(question, retrieval.content,
457
+ system_prompt=(
458
+ "You are a knowledgeable assistant with access to a knowledge graph. "
459
+ "Use the structured context including entities, relationships, and passages "
460
+ "to answer accurately. Follow relationship chains for multi-hop reasoning. "
461
+ "Be concise and precise."
462
+ ))
463
+ answer_obj.answer = resp.content
464
+ answer_obj.input_tokens = resp.input_tokens
465
+ answer_obj.output_tokens = resp.output_tokens
466
+ answer_obj.total_tokens = resp.total_tokens
467
+ answer_obj.cost_usd = resp.cost_usd
468
+ else:
469
+ answer_obj.answer = "[No context retrieved and no LLM available]"
470
+
471
+ answer_obj.latency_ms = (time.perf_counter() - start) * 1000
472
+ return answer_obj
473
+
474
+ # ── Document Ingestion via Service ────────────────────
475
+
476
+ def ingest_document(
477
+ self,
478
+ doc_id: str,
479
+ title: str,
480
+ content: str,
481
+ source: str = "",
482
+ ) -> Dict[str, Any]:
483
+ """
484
+ Ingest a document via the TG GraphRAG service API.
485
+ Falls back to direct pyTigerGraph if service is unavailable.
486
+ """
487
+ if self._service_available:
488
+ return self._ingest_via_api(doc_id, title, content, source)
489
+ elif self._direct_available:
490
+ return self._ingest_via_direct(doc_id, title, content, source)
491
+ return {"status": "error", "message": "No connection available"}
492
+
493
+ def _ingest_via_api(self, doc_id, title, content, source) -> Dict:
494
+ import urllib.request
495
+ payload = json.dumps({
496
+ "doc_id": doc_id, "title": title,
497
+ "content": content, "source": source,
498
+ })
499
+ for path in ["/ingest", "/api/ingest", "/documents", "/api/documents"]:
500
+ try:
501
+ url = f"{self.service_url}{path}"
502
+ req = urllib.request.Request(
503
+ url, data=payload.encode(), method="POST",
504
+ headers={"Content-Type": "application/json"})
505
+ with urllib.request.urlopen(req, timeout=60) as resp:
506
+ return json.loads(resp.read())
507
+ except Exception:
508
+ continue
509
+ return {"status": "error", "message": "All ingest endpoints failed"}
510
+
511
+ def _ingest_via_direct(self, doc_id, title, content, source) -> Dict:
512
+ try:
513
+ self._conn.upsertVertex("Document", doc_id, {
514
+ "title": title, "content": content, "source": source})
515
+ return {"status": "ok", "doc_id": doc_id}
516
+ except Exception as e:
517
+ return {"status": "error", "message": str(e)}
518
+
519
+ # ── Status / Debug ────────────────────────────────────
520
+
521
+ def status(self) -> Dict[str, Any]:
522
+ """Return connection status and available features."""
523
+ return {
524
+ "mode": self.mode,
525
+ "service_url": self.service_url if self._service_available else None,
526
+ "tg_host": self.tg_host if self._direct_available else None,
527
+ "tg_graph": self.tg_graph,
528
+ "service_available": self._service_available,
529
+ "direct_available": self._direct_available,
530
+ "available_retrievers": ["hybrid", "community", "sibling"],
531
+ "openapi_endpoints": list(self._openapi_spec.get("paths", {}).keys())[:20],
532
+ }