harshrawat18 commited on
Commit
eaa829f
Β·
verified Β·
1 Parent(s): 7a56b52

Upload folder using huggingface_hub

Browse files
Files changed (2) hide show
  1. api.py +62 -24
  2. graph_rag.py +348 -0
api.py CHANGED
@@ -1,4 +1,11 @@
1
  import os
 
 
 
 
 
 
 
2
  import gc
3
  import hashlib
4
  import re
@@ -21,6 +28,7 @@ from slowapi.errors import RateLimitExceeded
21
  from bhashini import translate_text, LANGUAGE_CODES
22
  from eligibility.engine import check_eligibility
23
  from neuro_symbolic import run_neuro_symbolic_pipeline
 
24
  from whatsapp.webhook import router as whatsapp_router
25
  from config import settings
26
 
@@ -39,9 +47,17 @@ embedding_model = None
39
  cross_encoder_model = None
40
 
41
  def get_embedding_model():
 
 
 
 
42
  return embedding_model
43
 
44
  def get_reranker_model():
 
 
 
 
45
  return cross_encoder_model
46
 
47
  # --- SECURE KEYS ---
@@ -55,12 +71,7 @@ groq_client = Groq(api_key=GROQ_API_KEY)
55
 
56
  @asynccontextmanager
57
  async def lifespan(app: FastAPI):
58
- global embedding_model, cross_encoder_model
59
- logger.info("⏳ Pre-loading Nomic Embedding Model (768-dim)...")
60
- embedding_model = SentenceTransformer("nomic-ai/nomic-embed-text-v1", trust_remote_code=True)
61
- logger.info("⏳ Pre-loading Cross-Encoder Reranker Model...")
62
- cross_encoder_model = CrossEncoder("cross-encoder/ettin-reranker-68m-v1", max_length=512, device='cpu', trust_remote_code=True)
63
- logger.info("βœ… Models pre-loaded successfully.")
64
  yield
65
 
66
  app = FastAPI(lifespan=lifespan)
@@ -484,26 +495,53 @@ async def rag_query(request: Request, query: SearchQuery):
484
  if c.get('scheme_title')
485
  ]))
486
 
487
- # --- STEP 6: GENERATE RESPONSE IN ENGLISH ---
488
- # Note: We await the full block of text rather than stream it piece-by-piece,
489
- # because translation APIs require complete sentences to maintain grammar rules.
490
- response = groq_client.chat.completions.create(
491
- model="llama-3.3-70b-versatile",
492
- messages=[
493
- {"role": "system", "content":
494
- "You are GovBridge AI. Answer using ONLY the provided context. "
495
- "Respond exclusively in English. "
496
- "Keep your answer under 200 words. Be precise, not exhaustive. "
497
- "Cite the scheme name. Format benefits as bullet points where applicable."
498
- },
499
- {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {english_query}"}
500
- ],
501
- temperature=0.1,
502
- max_tokens=512,
503
- stream=False
504
  )
505
 
506
- english_response = response.choices[0].message.content or ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
 
508
  # Write to cache after successful generation
509
  try:
 
1
  import os
2
+ # Configure thread pool limits BEFORE PyTorch/numpy are imported to conserve memory in Codespace
3
+ os.environ["OMP_NUM_THREADS"] = "1"
4
+ os.environ["MKL_NUM_THREADS"] = "1"
5
+ os.environ["OPENBLAS_NUM_THREADS"] = "1"
6
+ os.environ["VECLIB_MAXIMUM_THREADS"] = "1"
7
+ os.environ["NUMEXPR_NUM_THREADS"] = "1"
8
+
9
  import gc
10
  import hashlib
11
  import re
 
28
  from bhashini import translate_text, LANGUAGE_CODES
29
  from eligibility.engine import check_eligibility
30
  from neuro_symbolic import run_neuro_symbolic_pipeline
31
+ from graph_rag import run_graph_rag
32
  from whatsapp.webhook import router as whatsapp_router
33
  from config import settings
34
 
 
47
  cross_encoder_model = None
48
 
49
  def get_embedding_model():
50
+ global embedding_model
51
+ if embedding_model is None:
52
+ logger.info("⏳ Lazily loading Nomic Embedding Model (768-dim)...")
53
+ embedding_model = SentenceTransformer("nomic-ai/nomic-embed-text-v1", trust_remote_code=True)
54
  return embedding_model
55
 
56
  def get_reranker_model():
57
+ global cross_encoder_model
58
+ if cross_encoder_model is None:
59
+ logger.info("⏳ Lazily loading Cross-Encoder Reranker Model...")
60
+ cross_encoder_model = CrossEncoder("cross-encoder/ettin-reranker-68m-v1", max_length=512, device='cpu', trust_remote_code=True)
61
  return cross_encoder_model
62
 
63
  # --- SECURE KEYS ---
 
71
 
72
  @asynccontextmanager
73
  async def lifespan(app: FastAPI):
74
+ logger.info("πŸš€ Lifespan started (lazy model loading enabled).")
 
 
 
 
 
75
  yield
76
 
77
  app = FastAPI(lifespan=lifespan)
 
495
  if c.get('scheme_title')
496
  ]))
497
 
498
+ # --- STEP 6: GENERATE RESPONSE WITH GRAPH-RAG (Sprint 35) ---
499
+ # The Graph-RAG engine wraps LLM generation with optional autonomous
500
+ # graph traversal. If the LLM determines that legal relationship context
501
+ # would help, it invokes traverse_legal_graph via tool calling.
502
+ # Max 2 traversal iterations. Falls back to standard generation if no
503
+ # tool calls are made.
504
+ graph_rag_result = run_graph_rag(
505
+ query=english_query,
506
+ context_chunks=chunks if chunks else [],
507
+ groq_client=groq_client,
508
+ supabase_client=supabase,
509
+ embedding_model=get_embedding_model(),
510
+ user_language=user_language,
 
 
 
 
511
  )
512
 
513
+ english_response = graph_rag_result.get("response", "")
514
+
515
+ # Log graph traversal metrics
516
+ if graph_rag_result.get("graph_traversals", 0) > 0:
517
+ logger.info(
518
+ f"πŸ”— Graph-RAG: {graph_rag_result['graph_traversals']} traversal(s), "
519
+ f"TOON context: {len(graph_rag_result.get('graph_context', ''))} chars"
520
+ )
521
+
522
+ # If Graph-RAG returned additional sources from graph traversal, merge them
523
+ graph_sources = graph_rag_result.get("sources", [])
524
+ if graph_sources:
525
+ source_titles = list(set(source_titles + graph_sources))
526
+
527
+ # Fallback: if Graph-RAG returned empty (e.g., LLM error), use direct generation
528
+ if not english_response:
529
+ response = groq_client.chat.completions.create(
530
+ model="llama-3.3-70b-versatile",
531
+ messages=[
532
+ {"role": "system", "content":
533
+ "You are GovBridge AI. Answer using ONLY the provided context. "
534
+ "Respond exclusively in English. "
535
+ "Keep your answer under 200 words. Be precise, not exhaustive. "
536
+ "Cite the scheme name. Format benefits as bullet points where applicable."
537
+ },
538
+ {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {english_query}"}
539
+ ],
540
+ temperature=0.1,
541
+ max_tokens=512,
542
+ stream=False
543
+ )
544
+ english_response = response.choices[0].message.content or ""
545
 
546
  # Write to cache after successful generation
547
  try:
graph_rag.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GovBridge India β€” Graph-RAG Autonomous Traversal Engine (Sprint 35)
3
+
4
+ PROJECT INDRA Phase 4.0: Autonomous Graph-RAG
5
+
6
+ This module implements a ReAct-style tool-calling loop that enables
7
+ the LLM (Groq/Llama-3.3) to autonomously traverse the tensor_edges
8
+ knowledge graph to enrich its RAG context.
9
+
10
+ Pipeline:
11
+ 1. First LLM call includes a tool definition for `traverse_legal_graph`
12
+ 2. If the LLM invokes the tool, we intercept the call and execute
13
+ the Supabase `get_graph_neighborhood` RPC
14
+ 3. We apply CatRAG semantic pruning to filter irrelevant edges
15
+ 4. We serialize the relevant subgraph as TOON (Token-Oriented Object Notation)
16
+ 5. We feed the TOON context back to the LLM for final synthesis
17
+ 6. Max 2 traversal iterations. visited_nodes set prevents re-exploration.
18
+
19
+ ARCHITECTURAL LAW:
20
+ - No LangChain, no LlamaIndex, no framework bloat.
21
+ - Pure recursive Python. Zero dependencies beyond groq + supabase.
22
+ - LLM NEVER does arithmetic. It only traverses and synthesizes prose.
23
+ """
24
+
25
+ import json
26
+ import logging
27
+ from typing import Any, Optional
28
+ from groq import Groq
29
+ from supabase import Client
30
+ from sentence_transformers import SentenceTransformer
31
+ import numpy as np
32
+
33
+ logger = logging.getLogger("govbridge.graph_rag")
34
+
35
+ # ── Tool Definition (Groq/OpenAI Tool Calling Format) ────────────
36
+ TRAVERSE_TOOL = {
37
+ "type": "function",
38
+ "function": {
39
+ "name": "traverse_legal_graph",
40
+ "description": (
41
+ "Traverse the legal knowledge graph to find related gazette documents, "
42
+ "amendments, superseding acts, and cross-references for a given document node. "
43
+ "Use this when the user's question involves legal relationships, document "
44
+ "history, amendments, or connections between government notifications."
45
+ ),
46
+ "parameters": {
47
+ "type": "object",
48
+ "properties": {
49
+ "anchor_id": {
50
+ "type": "string",
51
+ "description": (
52
+ "UUID of the gazette document node to explore. "
53
+ "This should be extracted from the context provided."
54
+ )
55
+ }
56
+ },
57
+ "required": ["anchor_id"],
58
+ },
59
+ },
60
+ }
61
+
62
+ # ── TOON Serializer (Token-Oriented Object Notation) ──────────────
63
+ def graph_to_toon(edges: list[dict[str, Any]]) -> str:
64
+ """
65
+ Flatten graph edges into a compact Markdown table.
66
+
67
+ This saves ~60% of context tokens vs raw JSON serialization.
68
+ The LLM reads tabular data efficiently.
69
+
70
+ Returns:
71
+ Markdown table string or empty string if no edges.
72
+ """
73
+ if not edges:
74
+ return ""
75
+
76
+ lines = ["| Source | Relation | Target | Hop |", "|--------|----------|--------|-----|"]
77
+
78
+ for edge in edges:
79
+ source = (edge.get("source_title") or "Unknown")[:50]
80
+ target = (edge.get("target_title") or "Unresolved")[:50]
81
+ relation = (edge.get("edge_type") or "cross_references").upper()
82
+ hop = edge.get("hop_depth", 1)
83
+ lines.append(f"| {source} | {relation} | {target} | {hop} |")
84
+
85
+ return "\n".join(lines)
86
+
87
+
88
+ # ── CatRAG Semantic Pruning ───────────────────────────────────────
89
+ def prune_edges_by_relevance(
90
+ query: str,
91
+ edges: list[dict[str, Any]],
92
+ embedding_model: SentenceTransformer,
93
+ top_k: int = 15,
94
+ ) -> list[dict[str, Any]]:
95
+ """
96
+ Semantic pruning: Compare user query to edge context and return
97
+ ONLY the top_k most relevant edges.
98
+
99
+ Uses cosine similarity between the query embedding and a
100
+ composite text of each edge (source_title + relation + target_title).
101
+
102
+ Args:
103
+ query: The user's original question.
104
+ edges: Raw edges from get_graph_neighborhood RPC.
105
+ embedding_model: The Nomic embedding model instance.
106
+ top_k: Maximum number of edges to return (default 15).
107
+
108
+ Returns:
109
+ Pruned list of the top_k most relevant edges.
110
+ """
111
+ if len(edges) <= top_k:
112
+ return edges
113
+
114
+ # Build composite text for each edge
115
+ edge_texts = []
116
+ for edge in edges:
117
+ text = (
118
+ f"{edge.get('source_title', '')} "
119
+ f"{edge.get('edge_type', '')} "
120
+ f"{edge.get('target_title', '')}"
121
+ )
122
+ edge_texts.append(text)
123
+
124
+ # Encode query and edge texts
125
+ query_emb = embedding_model.encode(
126
+ f"search_query: {query}",
127
+ normalize_embeddings=True,
128
+ )
129
+ edge_embs = embedding_model.encode(
130
+ [f"search_document: {t}" for t in edge_texts],
131
+ normalize_embeddings=True,
132
+ batch_size=32,
133
+ show_progress_bar=False,
134
+ )
135
+
136
+ # Compute cosine similarities (vectors are normalized, so dot product = cosine)
137
+ similarities = np.dot(edge_embs, query_emb)
138
+
139
+ # Get top_k indices by descending similarity
140
+ top_indices = np.argsort(similarities)[::-1][:top_k]
141
+
142
+ return [edges[int(i)] for i in top_indices]
143
+
144
+
145
+ # ── Main ReAct Loop ───────────────────────────────────────────────
146
+ def run_graph_rag(
147
+ query: str,
148
+ context_chunks: list[dict[str, Any]],
149
+ groq_client: Groq,
150
+ supabase_client: Client,
151
+ embedding_model: SentenceTransformer,
152
+ user_language: str = "english",
153
+ ) -> dict[str, Any]:
154
+ """
155
+ Execute the Graph-RAG ReAct loop.
156
+
157
+ This function wraps the standard RAG response generation with
158
+ an optional graph traversal step. If the LLM determines that
159
+ graph context would help answer the query, it invokes the
160
+ traverse_legal_graph tool, and we feed the results back.
161
+
162
+ Args:
163
+ query: The user's question (in English).
164
+ context_chunks: Pre-fetched RAG chunks from hybrid search.
165
+ groq_client: Initialized Groq client.
166
+ supabase_client: Initialized Supabase client.
167
+ embedding_model: The Nomic embedding model.
168
+ user_language: Target language for the response.
169
+
170
+ Returns:
171
+ Dict with:
172
+ - "response": str β€” The final answer text.
173
+ - "sources": list[str] β€” Source document titles.
174
+ - "graph_traversals": int β€” Number of graph hops performed.
175
+ - "graph_context": str β€” TOON-formatted graph context (if any).
176
+ """
177
+ MAX_ITERATIONS = 2
178
+ visited_nodes: set[str] = set()
179
+ graph_context_toon = ""
180
+ graph_traversals = 0
181
+
182
+ # Build initial context from RAG chunks
183
+ rag_context = "\n\n---\n\n".join([
184
+ f"[Document: {str(c.get('scheme_title', 'Unknown'))}] "
185
+ f"(ID: {c.get('id', 'N/A')})\n{str(c.get('chunk_text', ''))}"
186
+ for c in context_chunks
187
+ ])
188
+
189
+ source_titles = list(set([
190
+ str(c.get('scheme_title', 'Unknown'))
191
+ for c in context_chunks
192
+ if c.get('scheme_title')
193
+ ]))
194
+
195
+ # System prompt with graph-awareness
196
+ system_prompt = (
197
+ "You are GovBridge AI, India's premier government scheme assistant. "
198
+ "Answer using ONLY the provided context. "
199
+ "Respond exclusively in English. "
200
+ "Keep your answer under 200 words. Be precise, not exhaustive. "
201
+ "Cite the scheme or gazette name. Format benefits as bullet points.\n\n"
202
+ "You have access to a legal knowledge graph. If the user's question involves "
203
+ "relationships between gazette documents, amendments, superseding acts, or "
204
+ "cross-references, you may call the `traverse_legal_graph` tool with the "
205
+ "document UUID from the context to explore related documents. "
206
+ "Only call the tool if the graph context would genuinely help answer the question."
207
+ )
208
+
209
+ # Build messages
210
+ messages: list[dict[str, Any]] = [
211
+ {"role": "system", "content": system_prompt},
212
+ {"role": "user", "content": f"Context:\n{rag_context}\n\nQuestion: {query}"},
213
+ ]
214
+
215
+ # ── ReAct Loop ────────────────────────────────────────────
216
+ for iteration in range(MAX_ITERATIONS + 1): # +1 for final synthesis
217
+ try:
218
+ response = groq_client.chat.completions.create(
219
+ model="llama-3.3-70b-versatile",
220
+ messages=messages,
221
+ tools=[TRAVERSE_TOOL] if iteration < MAX_ITERATIONS else None,
222
+ tool_choice="auto" if iteration < MAX_ITERATIONS else None,
223
+ temperature=0.1,
224
+ max_tokens=512,
225
+ stream=False,
226
+ )
227
+ except Exception as e:
228
+ logger.error(f"Graph-RAG LLM call failed: {e}")
229
+ return {
230
+ "response": "",
231
+ "sources": source_titles,
232
+ "graph_traversals": graph_traversals,
233
+ "graph_context": graph_context_toon,
234
+ "error": str(e),
235
+ }
236
+
237
+ choice = response.choices[0]
238
+
239
+ # ── Case 1: LLM returns a direct answer (no tool call) ──
240
+ if choice.finish_reason != "tool_calls" or not choice.message.tool_calls:
241
+ return {
242
+ "response": choice.message.content or "",
243
+ "sources": source_titles,
244
+ "graph_traversals": graph_traversals,
245
+ "graph_context": graph_context_toon,
246
+ }
247
+
248
+ # ── Case 2: LLM invoked traverse_legal_graph ────────────
249
+ tool_call = choice.message.tool_calls[0]
250
+
251
+ if tool_call.function.name != "traverse_legal_graph":
252
+ # Unknown tool β€” force synthesis
253
+ logger.warning(f"Unknown tool called: {tool_call.function.name}")
254
+ break
255
+
256
+ try:
257
+ args = json.loads(tool_call.function.arguments)
258
+ anchor_id = args.get("anchor_id", "")
259
+ except (json.JSONDecodeError, AttributeError):
260
+ logger.warning("Failed to parse tool arguments")
261
+ break
262
+
263
+ # ── Loop Breaker: Check visited set ─────────────────────
264
+ if anchor_id in visited_nodes:
265
+ # Append the assistant's tool call message
266
+ messages.append(choice.message.model_dump())
267
+ messages.append({
268
+ "role": "tool",
269
+ "tool_call_id": tool_call.id,
270
+ "content": "SYSTEM OVERRIDE: Node already explored. Synthesize your answer from existing context.",
271
+ })
272
+ continue
273
+
274
+ visited_nodes.add(anchor_id)
275
+ graph_traversals += 1
276
+ logger.info(f"πŸ”— Graph-RAG traversal #{graph_traversals}: anchor={anchor_id}")
277
+
278
+ # ── Execute graph traversal via Supabase RPC ────────────
279
+ try:
280
+ result = supabase_client.rpc("get_graph_neighborhood", {
281
+ "anchor_id": anchor_id,
282
+ "max_depth": 1,
283
+ "edge_limit": 50,
284
+ }).execute()
285
+ raw_edges = result.data or []
286
+ except Exception as e:
287
+ logger.error(f"Graph RPC failed: {e}")
288
+ raw_edges = []
289
+
290
+ # ── CatRAG Semantic Pruning ─────────────────────────────
291
+ if raw_edges:
292
+ pruned_edges = prune_edges_by_relevance(
293
+ query, raw_edges, embedding_model, top_k=15
294
+ )
295
+ else:
296
+ pruned_edges = []
297
+
298
+ # ── TOON Serialization ──────────────────────────────────
299
+ toon_result = graph_to_toon(pruned_edges)
300
+ graph_context_toon = toon_result # Store for response metadata
301
+
302
+ # ── Feed graph context back to LLM ──────────────────────
303
+ if toon_result:
304
+ tool_response = (
305
+ f"Graph neighborhood for document {anchor_id}:\n\n"
306
+ f"{toon_result}\n\n"
307
+ f"({len(pruned_edges)} relevant relationships found out of "
308
+ f"{len(raw_edges)} total edges.)"
309
+ )
310
+ else:
311
+ tool_response = (
312
+ f"No graph relationships found for document {anchor_id}. "
313
+ "Synthesize your answer from the existing context."
314
+ )
315
+
316
+ # Append tool call and response to messages
317
+ messages.append(choice.message.model_dump())
318
+ messages.append({
319
+ "role": "tool",
320
+ "tool_call_id": tool_call.id,
321
+ "content": tool_response,
322
+ })
323
+
324
+ # ── Fallback: if loop exhausted without a final answer ────
325
+ # Make one last call WITHOUT tools to force synthesis
326
+ try:
327
+ final_response = groq_client.chat.completions.create(
328
+ model="llama-3.3-70b-versatile",
329
+ messages=messages,
330
+ temperature=0.1,
331
+ max_tokens=512,
332
+ stream=False,
333
+ )
334
+ return {
335
+ "response": final_response.choices[0].message.content or "",
336
+ "sources": source_titles,
337
+ "graph_traversals": graph_traversals,
338
+ "graph_context": graph_context_toon,
339
+ }
340
+ except Exception as e:
341
+ logger.error(f"Graph-RAG final synthesis failed: {e}")
342
+ return {
343
+ "response": "",
344
+ "sources": source_titles,
345
+ "graph_traversals": graph_traversals,
346
+ "graph_context": graph_context_toon,
347
+ "error": str(e),
348
+ }