harshrawat18 commited on
Commit
7a56b52
·
verified ·
1 Parent(s): 9c028b5

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. api.py +185 -35
  2. make_pdf.py +22 -0
  3. server.log +8 -0
  4. test_api_client.py +29 -0
  5. test_gazette.pdf +0 -0
api.py CHANGED
@@ -4,6 +4,10 @@ import hashlib
4
  import re
5
  from datetime import datetime
6
  from typing import Optional, Any, List, Dict, cast, AsyncGenerator
 
 
 
 
7
  from fastapi import FastAPI, Request
8
  from fastapi.responses import StreamingResponse
9
  from pydantic import BaseModel, Field, field_validator
@@ -26,6 +30,20 @@ from config import settings
26
  from indra_engine import IndraProjectionEngine
27
  indra_engine = IndraProjectionEngine(batch_size=400, dimensions=768)
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  # --- SECURE KEYS ---
30
  SUPABASE_URL = settings.SUPABASE_URL
31
  SUPABASE_KEY = settings.SUPABASE_KEY
@@ -35,7 +53,17 @@ ADMIN_SECRET = settings.ADMIN_SECRET
35
  supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
36
  groq_client = Groq(api_key=GROQ_API_KEY)
37
 
38
- app = FastAPI()
 
 
 
 
 
 
 
 
 
 
39
  app.include_router(whatsapp_router)
40
 
41
  # --- RATE LIMITER CONFIG ---
@@ -57,6 +85,7 @@ from middleware.memory_guard import MemoryGuardMiddleware
57
  app.add_middleware(MemoryGuardMiddleware)
58
 
59
 
 
60
  # --- MODELS ---
61
  class SearchQuery(BaseModel):
62
  question: str = Field(..., min_length=3, max_length=500)
@@ -128,29 +157,28 @@ class GazetteSearchQuery(BaseModel):
128
  return v.lower()
129
 
130
 
131
- # --- INITIALIZE AI (LAZY LOADED) ---
132
- embedding_model = None
133
- reranker_model = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
135
- def get_embedding_model():
136
- global embedding_model
137
- if embedding_model is None:
138
- print("⏳ Loading Nomic Embedding Model (768-dim)...")
139
- # PENDING: Run 003_upgrade_embeddings_768.sql in Supabase before this goes live
140
- embedding_model = SentenceTransformer(
141
- 'nomic-ai/nomic-embed-text-v1',
142
- trust_remote_code=True
143
- )
144
- print("✅ Nomic Model loaded")
145
- return embedding_model
146
 
147
- def get_reranker_model():
148
- global reranker_model
149
- if reranker_model is None:
150
- print("⏳ Loading Cross-Encoder Reranker Model...")
151
- reranker_model = CrossEncoder('cross-encoder/ettin-reranker-68m-v1', max_length=512, device='cpu', trust_remote_code=True)
152
- print("✅ Reranker Model loaded")
153
- return reranker_model
154
 
155
  def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
156
  paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
@@ -220,7 +248,7 @@ def rerank_gazette_chunks(query: str, chunks: list) -> list:
220
  if not chunks:
221
  return []
222
 
223
- RELEVANCE_FLOOR = 5.0 # Hard quality gate no noise passes
224
 
225
  # Gazette-specific: wider pool (30) because legislative text
226
  # has higher semantic density than scheme descriptions
@@ -554,15 +582,16 @@ async def gazette_search(request: Request, query: GazetteSearchQuery):
554
  4. Cross-encoder re-rank top 30 with Ettin
555
  5. Return page-pinned results to frontend GazetteViewer
556
  """
557
- print(f"📜 Gazette search [INDRA]: '{query.query}' | Type: {query.gazette_type} | State: {query.state}")
558
 
559
  try:
560
  # Step 1: Embed query
561
  model = get_embedding_model()
562
- query_embedding = model.encode(
563
  f"search_query: {query.query}",
564
  normalize_embeddings=True
565
- ).tolist()
 
566
 
567
  # Step 2: Call INDRA oversampling RPC
568
  rpc_params: Dict[str, Any] = {
@@ -588,15 +617,13 @@ async def gazette_search(request: Request, query: GazetteSearchQuery):
588
  }
589
 
590
  n_candidates = len(raw_results)
591
- print(f" 📊 INDRA: {n_candidates} Euclidean candidates retrieved")
592
 
593
  # Step 3: Poincaré projection & hyperbolic re-ranking
594
- import numpy as np
595
-
596
  # Extract embedding vectors from RPC results
597
  query_vec = np.array(query_embedding, dtype=np.float32)
598
  candidate_embeddings = np.array(
599
- [r["embedding"] for r in raw_results],
600
  dtype=np.float32
601
  )
602
 
@@ -619,7 +646,7 @@ async def gazette_search(request: Request, query: GazetteSearchQuery):
619
  entry.pop("embedding", None)
620
  hyperbolic_ranked.append(entry)
621
 
622
- print(f" 🔮 Poincaré: top-1 distance={hyperbolic_ranked[0]['hyperbolic_distance']:.4f}, "
623
  f"top-30 distance={hyperbolic_ranked[-1]['hyperbolic_distance']:.4f}")
624
 
625
  # Step 4: Cross-encoder re-rank (Ettin verifies semantic coherence)
@@ -646,10 +673,10 @@ async def gazette_search(request: Request, query: GazetteSearchQuery):
646
  })
647
 
648
  if results:
649
- print(f" ✅ Returning {len(results)} gazette results "
650
  f"(best CE score: {results[0]['cross_encoder_score']:.4f})")
651
  else:
652
- print(" ⛔ No results survived quality gate")
653
 
654
  return {
655
  "query": query.query,
@@ -659,12 +686,135 @@ async def gazette_search(request: Request, query: GazetteSearchQuery):
659
  }
660
 
661
  except Exception as e:
662
- print(f"❌ Gazette search [INDRA] error: {e}")
663
  return {
664
  "query": query.query,
665
  "results": [],
666
  "total": 0,
667
- "error": str(e),
668
  "pipeline": "indra_poincare + ettin_reranker"
669
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
 
 
4
  import re
5
  from datetime import datetime
6
  from typing import Optional, Any, List, Dict, cast, AsyncGenerator
7
+ import numpy as np
8
+ import json
9
+ import logging
10
+ from contextlib import asynccontextmanager
11
  from fastapi import FastAPI, Request
12
  from fastapi.responses import StreamingResponse
13
  from pydantic import BaseModel, Field, field_validator
 
30
  from indra_engine import IndraProjectionEngine
31
  indra_engine = IndraProjectionEngine(batch_size=400, dimensions=768)
32
 
33
+ # Configure logging
34
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
35
+ logger = logging.getLogger("govbridge.api")
36
+
37
+ # Variables for AI models
38
+ 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 ---
48
  SUPABASE_URL = settings.SUPABASE_URL
49
  SUPABASE_KEY = settings.SUPABASE_KEY
 
53
  supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
54
  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)
67
  app.include_router(whatsapp_router)
68
 
69
  # --- RATE LIMITER CONFIG ---
 
85
  app.add_middleware(MemoryGuardMiddleware)
86
 
87
 
88
+
89
  # --- MODELS ---
90
  class SearchQuery(BaseModel):
91
  question: str = Field(..., min_length=3, max_length=500)
 
157
  return v.lower()
158
 
159
 
160
+ class GraphNode(BaseModel):
161
+ """A node in the knowledge graph visualization."""
162
+ id: str
163
+ name: str
164
+ group: str # 'anchor' | 'hop1' | 'hop2'
165
+
166
+ class GraphLink(BaseModel):
167
+ """A directional edge in the knowledge graph visualization."""
168
+ source: str
169
+ target: str
170
+ label: str
171
+ hop: int
172
+
173
+ class GraphResponse(BaseModel):
174
+ """Force-graph-compatible response payload."""
175
+ anchor_id: str
176
+ nodes: List[GraphNode]
177
+ links: List[GraphLink]
178
+ total_nodes: int
179
+ total_links: int
180
 
 
 
 
 
 
 
 
 
 
 
 
181
 
 
 
 
 
 
 
 
182
 
183
  def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
184
  paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
 
248
  if not chunks:
249
  return []
250
 
251
+ RELEVANCE_FLOOR = 5.0 # Hard quality gate restored for production
252
 
253
  # Gazette-specific: wider pool (30) because legislative text
254
  # has higher semantic density than scheme descriptions
 
582
  4. Cross-encoder re-rank top 30 with Ettin
583
  5. Return page-pinned results to frontend GazetteViewer
584
  """
585
+ logger.info(f"📜 Gazette search [INDRA]: '{query.query}' | Type: {query.gazette_type} | State: {query.state}")
586
 
587
  try:
588
  # Step 1: Embed query
589
  model = get_embedding_model()
590
+ query_embedding_raw = model.encode(
591
  f"search_query: {query.query}",
592
  normalize_embeddings=True
593
+ )
594
+ query_embedding = np.array(query_embedding_raw).flatten().tolist()
595
 
596
  # Step 2: Call INDRA oversampling RPC
597
  rpc_params: Dict[str, Any] = {
 
617
  }
618
 
619
  n_candidates = len(raw_results)
620
+ logger.info(f" 📊 INDRA: {n_candidates} Euclidean candidates retrieved")
621
 
622
  # Step 3: Poincaré projection & hyperbolic re-ranking
 
 
623
  # Extract embedding vectors from RPC results
624
  query_vec = np.array(query_embedding, dtype=np.float32)
625
  candidate_embeddings = np.array(
626
+ [json.loads(r["embedding"]) if isinstance(r["embedding"], str) else r["embedding"] for r in raw_results],
627
  dtype=np.float32
628
  )
629
 
 
646
  entry.pop("embedding", None)
647
  hyperbolic_ranked.append(entry)
648
 
649
+ logger.info(f" 🔮 Poincaré: top-1 distance={hyperbolic_ranked[0]['hyperbolic_distance']:.4f}, "
650
  f"top-30 distance={hyperbolic_ranked[-1]['hyperbolic_distance']:.4f}")
651
 
652
  # Step 4: Cross-encoder re-rank (Ettin verifies semantic coherence)
 
673
  })
674
 
675
  if results:
676
+ logger.info(f" ✅ Returning {len(results)} gazette results "
677
  f"(best CE score: {results[0]['cross_encoder_score']:.4f})")
678
  else:
679
+ logger.info(" ⛔ No results survived quality gate")
680
 
681
  return {
682
  "query": query.query,
 
686
  }
687
 
688
  except Exception as e:
689
+ logger.error(f"❌ Gazette search [INDRA] error: {e}", exc_info=True)
690
  return {
691
  "query": query.query,
692
  "results": [],
693
  "total": 0,
694
+ "error": "An internal error occurred during the search. Please try again later.",
695
  "pipeline": "indra_poincare + ettin_reranker"
696
  }
697
+
698
+ # ═══════════════════════════════════════════════════════════════
699
+ # PROJECT INDRA Phase 3.1 — Knowledge Graph Neighborhood API
700
+ # Sprint 34
701
+ # ═══════════════════════════════════════════════════════════════
702
+
703
+ @app.get("/api/gazette/graph/{chunk_id}")
704
+ @limiter.limit("30/minute")
705
+ async def get_graph_neighborhood(request: Request, chunk_id: str, depth: int = 1, limit: int = 100):
706
+ """
707
+ Knowledge Graph Neighborhood — PROJECT INDRA Phase 3.1 (Sprint 34).
708
+
709
+ Returns the 1-hop or 2-hop graph neighborhood of a gazette document,
710
+ formatted as a node-link structure for force-directed graph rendering.
711
+
712
+ Args:
713
+ chunk_id: UUID of the anchor gazette_chunks document.
714
+ depth: Traversal depth (1 or 2). Default 1.
715
+ limit: Max edges to return. Default 100, max 500.
716
+ """
717
+ logger.info(f"🔗 Graph neighborhood: chunk_id={chunk_id} | depth={depth} | limit={limit}")
718
+
719
+ try:
720
+ # Validate UUID format
721
+ import uuid as uuid_mod
722
+ try:
723
+ uuid_mod.UUID(chunk_id, version=4)
724
+ except ValueError:
725
+ return {
726
+ "anchor_id": chunk_id,
727
+ "nodes": [],
728
+ "links": [],
729
+ "total_nodes": 0,
730
+ "total_links": 0,
731
+ "error": "Invalid UUID format"
732
+ }
733
+
734
+ # Clamp parameters
735
+ depth = max(1, min(2, depth))
736
+ limit = max(1, min(500, limit))
737
+
738
+ # Call the graph neighborhood RPC
739
+ result = supabase.rpc("get_graph_neighborhood", {
740
+ "anchor_id": chunk_id,
741
+ "max_depth": depth,
742
+ "edge_limit": limit,
743
+ }).execute()
744
+
745
+ raw_edges = cast(List[Dict[str, Any]], result.data or [])
746
+
747
+ if not raw_edges:
748
+ logger.info(f" ⛔ No graph edges found for chunk_id={chunk_id}")
749
+ return {
750
+ "anchor_id": chunk_id,
751
+ "nodes": [],
752
+ "links": [],
753
+ "total_nodes": 0,
754
+ "total_links": 0
755
+ }
756
+
757
+ # ── Transform edge-centric data to node-link format ──
758
+ nodes_map: Dict[str, GraphNode] = {}
759
+ links: List[GraphLink] = []
760
+
761
+ for edge in raw_edges:
762
+ source_id = edge.get("source_id", "")
763
+ target_id = edge.get("target_id", "")
764
+ source_title = edge.get("source_title", "Unknown Document")
765
+ target_title = edge.get("target_title", "Unresolved Entity")
766
+ edge_type = edge.get("edge_type", "cross_references")
767
+ hop_depth = edge.get("hop_depth", 1)
768
+
769
+ # Skip edges with null target (unresolved entities from Sprint 30)
770
+ if not target_id:
771
+ continue
772
+
773
+ # Deduplicate nodes — assign group based on relationship to anchor
774
+ if source_id and source_id not in nodes_map:
775
+ group = "anchor" if source_id == chunk_id else f"hop{hop_depth}"
776
+ nodes_map[source_id] = GraphNode(
777
+ id=source_id,
778
+ name=source_title[:80], # Truncate long titles for Canvas rendering
779
+ group=group,
780
+ )
781
+
782
+ if target_id and target_id not in nodes_map:
783
+ group = "anchor" if target_id == chunk_id else f"hop{hop_depth}"
784
+ nodes_map[target_id] = GraphNode(
785
+ id=target_id,
786
+ name=target_title[:80],
787
+ group=group,
788
+ )
789
+
790
+ # Create link
791
+ if source_id and target_id:
792
+ links.append(GraphLink(
793
+ source=source_id,
794
+ target=target_id,
795
+ label=edge_type,
796
+ hop=hop_depth,
797
+ ))
798
+
799
+ nodes = list(nodes_map.values())
800
+ logger.info(f" ✅ Graph: {len(nodes)} nodes, {len(links)} links (depth={depth})")
801
+
802
+ return {
803
+ "anchor_id": chunk_id,
804
+ "nodes": [n.model_dump() for n in nodes],
805
+ "links": [l.model_dump() for l in links],
806
+ "total_nodes": len(nodes),
807
+ "total_links": len(links),
808
+ }
809
+
810
+ except Exception as e:
811
+ logger.error(f"❌ Graph neighborhood error: {e}", exc_info=True)
812
+ return {
813
+ "anchor_id": chunk_id,
814
+ "nodes": [],
815
+ "links": [],
816
+ "total_nodes": 0,
817
+ "total_links": 0,
818
+ "error": "An internal error occurred retrieving the graph.",
819
+ }
820
 
make_pdf.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from playwright.async_api import async_playwright
3
+
4
+ async def make_pdf():
5
+ async with async_playwright() as p:
6
+ browser = await p.chromium.launch(headless=True, args=["--no-sandbox"])
7
+ page = await browser.new_page()
8
+ content = """
9
+ <h1>Gazette of India - Extraordinary</h1>
10
+ <h2>Part II - Section 3</h2>
11
+ <p><strong>Agricultural Subsidy Amendment 2026</strong></p>
12
+ <p>This is a real, officially ingested document to test the GovBridge system.</p>
13
+ <p>The Ministry of Agriculture has allocated additional funding for memory and compute infrastructure in rural farming cooperatives.</p>
14
+ <p>By implementing this scheme, farmers will receive unprecedented access to digital agricultural markets.</p>
15
+ <p>Clause 4: The state of Rajasthan shall be the primary testing ground for this new agricultural framework.</p>
16
+ """
17
+ await page.set_content(content)
18
+ await page.pdf(path="test_gazette.pdf")
19
+ await browser.close()
20
+
21
+ if __name__ == "__main__":
22
+ asyncio.run(make_pdf())
server.log ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ INFO: Started server process [61554]
2
+ INFO: Waiting for application startup.
3
+ INFO: Application startup complete.
4
+ INFO: Uvicorn running on http://127.0.0.1:8888 (Press CTRL+C to quit)
5
+ INFO: 127.0.0.1:43166 - "POST /api/kernels HTTP/1.1" 404 Not Found
6
+ INFO: 127.0.0.1:52406 - "POST /api/kernels HTTP/1.1" 404 Not Found
7
+ INFO: 127.0.0.1:60060 - "GET / HTTP/1.1" 404 Not Found
8
+ Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
test_api_client.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+ import json
4
+ from fastapi.testclient import TestClient
5
+
6
+ # Must run from inside gov_backend directory for imports to work
7
+ sys.path.insert(0, os.path.abspath("."))
8
+ from api import app
9
+
10
+ client = TestClient(app)
11
+
12
+ print("--- Testing /health ---")
13
+ response = client.get("/health")
14
+ print(response.json())
15
+
16
+ print("\n--- Testing /api/gazette/search ---")
17
+ payload = {
18
+ "query": "solar power tax exemption",
19
+ "limit": 5
20
+ }
21
+ response = client.post("/api/gazette/search", json=payload)
22
+ data = response.json()
23
+
24
+ print(json.dumps(data, indent=2))
25
+
26
+ if data.get("results"):
27
+ print("\nSUCCESS: Hybrid search pipeline integrated correctly with learned weights!")
28
+ else:
29
+ print("\nNo results found, but endpoint responded correctly.")
test_gazette.pdf ADDED
Binary file (21.6 kB). View file