harshrawat18 commited on
Commit
2cc288e
Β·
verified Β·
1 Parent(s): 69ce8c7

Upload folder using huggingface_hub

Browse files
api.py CHANGED
@@ -95,6 +95,33 @@ class EligibilityRequest(BaseModel):
95
  occupation: str = "Unknown"
96
 
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  # --- INITIALIZE AI (LAZY LOADED) ---
99
  embedding_model = None
100
  reranker_model = None
@@ -159,6 +186,34 @@ def rerank_chunks(query: str, chunks: list) -> list:
159
  candidates.sort(key=lambda x: x['rerank_score'], reverse=True)
160
  return candidates[:5]
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  # --- ENDPOINTS ---
163
  @app.get("/health")
164
  async def health_check():
@@ -452,4 +507,93 @@ async def get_coverage_gaps(request: Request, limit: int = 20):
452
  "total_gaps": len(result.data),
453
  "queries": result.data
454
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
455
 
 
95
  occupation: str = "Unknown"
96
 
97
 
98
+ class GazetteSearchQuery(BaseModel):
99
+ """Search query for the Gazette Vault hybrid search pipeline."""
100
+ query: str = Field(..., min_length=2, max_length=500)
101
+ gazette_type: Optional[str] = Field(default=None, description="Filter: central, state, extraordinary")
102
+ state: Optional[str] = Field(default=None, description="Filter by state applicability")
103
+ limit: int = Field(default=10, ge=1, le=50)
104
+
105
+ @field_validator('query')
106
+ @classmethod
107
+ def clean_gazette_query(cls, v: str) -> str:
108
+ v = re.sub(r'<[^>]+>', '', v)
109
+ v = re.sub(r'\s+', ' ', v).strip()
110
+ if not v:
111
+ raise ValueError('Query is empty after cleaning')
112
+ return v
113
+
114
+ @field_validator('gazette_type')
115
+ @classmethod
116
+ def validate_gazette_type(cls, v: Optional[str]) -> Optional[str]:
117
+ if v is None:
118
+ return None
119
+ valid = ["central", "state", "extraordinary"]
120
+ if v.lower() not in valid:
121
+ return None
122
+ return v.lower()
123
+
124
+
125
  # --- INITIALIZE AI (LAZY LOADED) ---
126
  embedding_model = None
127
  reranker_model = None
 
186
  candidates.sort(key=lambda x: x['rerank_score'], reverse=True)
187
  return candidates[:5]
188
 
189
+
190
+ def rerank_gazette_chunks(query: str, chunks: list) -> list:
191
+ """
192
+ Cross-encoder re-ranking for gazette search results.
193
+ Takes top 30 RRF candidates (gazette chunks are denser than scheme chunks),
194
+ runs them through Ettin reranker, returns top results.
195
+
196
+ Unlike rerank_chunks() which caps at 20, gazette documents
197
+ require a wider initial pool due to dense legislative language.
198
+ """
199
+ if not chunks:
200
+ return []
201
+
202
+ # Gazette-specific: wider pool (30) because legislative text
203
+ # has higher semantic density than scheme descriptions
204
+ candidates = chunks[:30]
205
+
206
+ reranker = get_reranker_model()
207
+ pairs = [[query, c.get('chunk_text', '')] for c in candidates]
208
+
209
+ scores = reranker.predict(pairs, batch_size=32, show_progress_bar=False)
210
+
211
+ for i, score in enumerate(scores):
212
+ candidates[i]['cross_encoder_score'] = float(score)
213
+
214
+ candidates.sort(key=lambda x: x['cross_encoder_score'], reverse=True)
215
+ return candidates[:10] # Return top 10 for gazette (more results than scheme search)
216
+
217
  # --- ENDPOINTS ---
218
  @app.get("/health")
219
  async def health_check():
 
507
  "total_gaps": len(result.data),
508
  "queries": result.data
509
  }
510
+
511
+
512
+ @app.post("/api/gazette/search")
513
+ @limiter.limit("15/minute")
514
+ async def gazette_search(request: Request, query: GazetteSearchQuery):
515
+ """
516
+ Gazette Vault Hybrid Search Endpoint (Sprint 28).
517
+
518
+ Pipeline:
519
+ 1. Embed query with Nomic (768-dim) using search_query: prefix
520
+ 2. Call gazette_hybrid_search RPC (BM25 + Vector + RRF)
521
+ 3. Cross-encoder re-rank with Ettin (top 30 β†’ top 10)
522
+ 4. Return page-pinned results to frontend GazetteViewer
523
+ """
524
+ print(f"πŸ“œ Gazette search: '{query.query}' | Type: {query.gazette_type} | State: {query.state}")
525
+
526
+ try:
527
+ # Step 1: Embed query
528
+ model = get_embedding_model()
529
+ query_embedding = model.encode(
530
+ f"search_query: {query.query}",
531
+ normalize_embeddings=True
532
+ ).tolist()
533
+
534
+ # Step 2: Call Supabase RPC
535
+ rpc_params: Dict[str, Any] = {
536
+ "query_text": query.query,
537
+ "query_embedding": query_embedding,
538
+ "match_count": 50,
539
+ "k_smoothing": 60,
540
+ }
541
+
542
+ # Add optional filters (pass None to disable filter in SQL)
543
+ if query.gazette_type:
544
+ rpc_params["filter_gazette_type"] = query.gazette_type
545
+ if query.state:
546
+ rpc_params["filter_state"] = query.state
547
+
548
+ result = supabase.rpc("gazette_hybrid_search", rpc_params).execute()
549
+ raw_results = cast(List[Dict[str, Any]], result.data or [])
550
+
551
+ if not raw_results:
552
+ return {
553
+ "query": query.query,
554
+ "results": [],
555
+ "total": 0,
556
+ "pipeline": "gazette_hybrid_search + ettin_reranker"
557
+ }
558
+
559
+ # Step 3: Cross-encoder re-rank
560
+ reranked = rerank_gazette_chunks(query.query, raw_results)
561
+
562
+ # Step 4: Trim to requested limit
563
+ reranked = reranked[:query.limit]
564
+
565
+ # Step 5: Format response with page-pinned results
566
+ results = []
567
+ for chunk in reranked:
568
+ results.append({
569
+ "id": chunk.get("id"),
570
+ "document_title": chunk.get("document_title", "Unknown Gazette"),
571
+ "source_url": chunk.get("source_url"),
572
+ "page_number": chunk.get("page_number", 1),
573
+ "snippet_text": chunk.get("chunk_text", ""),
574
+ "gazette_type": chunk.get("gazette_type", "central"),
575
+ "issuing_authority": chunk.get("issuing_authority"),
576
+ "notification_date": str(chunk.get("notification_date", "")),
577
+ "cross_encoder_score": round(chunk.get("cross_encoder_score", 0.0), 4),
578
+ "rrf_score": float(chunk.get("rrf_score", 0.0)),
579
+ })
580
+
581
+ print(f" βœ… Returning {len(results)} gazette results (best score: {results[0]['cross_encoder_score']:.4f})")
582
+
583
+ return {
584
+ "query": query.query,
585
+ "results": results,
586
+ "total": len(results),
587
+ "pipeline": "gazette_hybrid_search + ettin_reranker"
588
+ }
589
+
590
+ except Exception as e:
591
+ print(f"❌ Gazette search error: {e}")
592
+ return {
593
+ "query": query.query,
594
+ "results": [],
595
+ "total": 0,
596
+ "error": str(e),
597
+ "pipeline": "gazette_hybrid_search + ettin_reranker"
598
+ }
599
 
ingestion/gazette_ingester.py ADDED
@@ -0,0 +1,344 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ GovBridge India β€” Gazette Document Ingestion Pipeline
3
+ Sprint 28: Semantic Boundary Chunking with Page-Level Granularity
4
+
5
+ Usage:
6
+ python -m ingestion.gazette_ingester \
7
+ --url "https://example.gov.in/gazette.pdf" \
8
+ --title "Gazette of India Extraordinary Part II" \
9
+ --gazette-type central \
10
+ --authority "Ministry of Finance" \
11
+ --state National
12
+ """
13
+ import os
14
+ import sys
15
+ import hashlib
16
+ import argparse
17
+ import tempfile
18
+ from typing import Optional
19
+ from datetime import date
20
+
21
+ import httpx
22
+
23
+ # Ensure parent is on path for config import
24
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
25
+ from config import settings
26
+
27
+ # Lazy imports β€” only loaded when main() runs
28
+ fitz = None # PyMuPDF
29
+ SentenceTransformer = None
30
+ supabase_client = None
31
+
32
+
33
+ def _get_fitz():
34
+ """Lazy import PyMuPDF."""
35
+ global fitz
36
+ if fitz is None:
37
+ import fitz as _fitz
38
+ fitz = _fitz
39
+ return fitz
40
+
41
+
42
+ def _get_supabase():
43
+ """Lazy Supabase client singleton."""
44
+ global supabase_client
45
+ if supabase_client is None:
46
+ from supabase import create_client
47
+ supabase_client = create_client(settings.SUPABASE_URL, settings.SUPABASE_KEY)
48
+ return supabase_client
49
+
50
+
51
+ def _get_embedding_model():
52
+ """Lazy load Nomic embedding model (768-dim)."""
53
+ global SentenceTransformer
54
+ if SentenceTransformer is None:
55
+ from sentence_transformers import SentenceTransformer as _ST
56
+ SentenceTransformer = _ST
57
+ # Use a module-level cache variable
58
+ if not hasattr(_get_embedding_model, '_model'):
59
+ print("⏳ Loading Nomic Embedding Model (768-dim)...")
60
+ _get_embedding_model._model = SentenceTransformer(
61
+ 'nomic-ai/nomic-embed-text-v1',
62
+ trust_remote_code=True
63
+ )
64
+ print("βœ… Nomic Model loaded")
65
+ return _get_embedding_model._model
66
+
67
+
68
+ # ═══════════════════════════════════════════════════════════════
69
+ # CORE: Page-Aware Text Extraction
70
+ # ═══════════════════════════════════════════════════════════════
71
+
72
+ def extract_pages_from_pdf(pdf_path: str) -> list[dict]:
73
+ """
74
+ Extract text from each page of a PDF using PyMuPDF.
75
+ Returns a list of dicts: [{"page": 1, "text": "..."}, ...]
76
+ Pages with no extractable text are skipped (image-only scans).
77
+ """
78
+ pdf = _get_fitz()
79
+ doc = pdf.open(pdf_path)
80
+ pages = []
81
+ for i, page in enumerate(doc):
82
+ text = page.get_text("text").strip()
83
+ if text:
84
+ pages.append({"page": i + 1, "text": text})
85
+ doc.close()
86
+ return pages
87
+
88
+
89
+ # ═══════════════════════════════════════════════════════════════
90
+ # CORE: Semantic Boundary Chunking (Page-Aware)
91
+ # ═══════════════════════════════════════════════════════════════
92
+
93
+ def chunk_gazette_pages(
94
+ pages: list[dict],
95
+ chunk_size: int = 500,
96
+ overlap: int = 50
97
+ ) -> list[dict]:
98
+ """
99
+ Chunk gazette text using paragraph-boundary splitting with overlap.
100
+ Each chunk retains its source page_number for the frontend viewer
101
+ to scroll to the exact location.
102
+
103
+ Args:
104
+ pages: List of {"page": int, "text": str} from extract_pages_from_pdf
105
+ chunk_size: Target character count per chunk
106
+ overlap: Character overlap between consecutive chunks
107
+
108
+ Returns:
109
+ List of {"page_number": int, "chunk_index": int, "chunk_text": str}
110
+ """
111
+ all_chunks = []
112
+ global_index = 0
113
+
114
+ for page_data in pages:
115
+ page_num = page_data["page"]
116
+ text = page_data["text"]
117
+
118
+ # Split by paragraph boundaries
119
+ paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
120
+
121
+ current = ""
122
+ page_chunks = []
123
+
124
+ for para in paragraphs:
125
+ if len(current) + len(para) < chunk_size:
126
+ current += " " + para if current else para
127
+ else:
128
+ if current.strip():
129
+ page_chunks.append(current.strip())
130
+ current = para
131
+
132
+ if current.strip():
133
+ page_chunks.append(current.strip())
134
+
135
+ # Apply overlap between consecutive chunks on the same page
136
+ if len(page_chunks) > 1:
137
+ overlapped = [page_chunks[0]]
138
+ for i in range(1, len(page_chunks)):
139
+ tail = page_chunks[i - 1][-overlap:] if len(page_chunks[i - 1]) > overlap else page_chunks[i - 1]
140
+ overlapped.append(tail + " " + page_chunks[i])
141
+ page_chunks = overlapped
142
+
143
+ for chunk_text in page_chunks:
144
+ all_chunks.append({
145
+ "page_number": page_num,
146
+ "chunk_index": global_index,
147
+ "chunk_text": chunk_text,
148
+ })
149
+ global_index += 1
150
+
151
+ return all_chunks
152
+
153
+
154
+ # ═══════════════════════════════════════════════════════════════
155
+ # CORE: Embedding Generation
156
+ # ═══════════════════════════════════════════════════════════════
157
+
158
+ def generate_embeddings(chunks: list[dict]) -> list[dict]:
159
+ """
160
+ Generate 768-dim Nomic embeddings for each chunk.
161
+ Uses the `search_document:` prefix required by Nomic models.
162
+
163
+ Modifies chunks in-place by adding 'embedding' key.
164
+ Processes in batches of 32 to control memory on 16GB HF containers.
165
+ """
166
+ model = _get_embedding_model()
167
+ texts = [f"search_document: {c['chunk_text']}" for c in chunks]
168
+
169
+ batch_size = 32
170
+ all_embeddings = []
171
+
172
+ for i in range(0, len(texts), batch_size):
173
+ batch = texts[i:i + batch_size]
174
+ embeddings = model.encode(
175
+ batch,
176
+ normalize_embeddings=True,
177
+ show_progress_bar=False
178
+ ).tolist()
179
+ all_embeddings.extend(embeddings)
180
+
181
+ for i, emb in enumerate(all_embeddings):
182
+ chunks[i]["embedding"] = emb
183
+
184
+ return chunks
185
+
186
+
187
+ # ═══════════════════════════════════════════════════════════════
188
+ # CORE: Supabase Upsert
189
+ # ═══════════════════════════════════════════════════════════════
190
+
191
+ def upsert_gazette_chunks(
192
+ chunks: list[dict],
193
+ metadata: dict
194
+ ) -> int:
195
+ """
196
+ Upsert gazette chunks to the gazette_chunks table.
197
+ Uses content_hash + chunk_index as the conflict resolution key.
198
+
199
+ Args:
200
+ chunks: List of dicts with page_number, chunk_index, chunk_text, embedding
201
+ metadata: Dict with document_title, source_url, gazette_type,
202
+ issuing_authority, notification_date, state
203
+
204
+ Returns:
205
+ Number of chunks upserted
206
+ """
207
+ sb = _get_supabase()
208
+ source_url = metadata.get("source_url", "")
209
+ content_hash_base = hashlib.sha256(source_url.encode()).hexdigest()[:16]
210
+
211
+ rows = []
212
+ for chunk in chunks:
213
+ row = {
214
+ "document_title": metadata["document_title"],
215
+ "source_url": source_url,
216
+ "gazette_type": metadata.get("gazette_type", "central"),
217
+ "issuing_authority": metadata.get("issuing_authority"),
218
+ "notification_date": metadata.get("notification_date"),
219
+ "state": metadata.get("state", "National"),
220
+ "page_number": chunk["page_number"],
221
+ "chunk_index": chunk["chunk_index"],
222
+ "chunk_text": chunk["chunk_text"],
223
+ "embedding": chunk["embedding"],
224
+ "content_hash": content_hash_base,
225
+ "is_active": True,
226
+ }
227
+ rows.append(row)
228
+
229
+ # Batch upsert in groups of 50 to avoid payload size limits
230
+ upserted = 0
231
+ for i in range(0, len(rows), 50):
232
+ batch = rows[i:i + 50]
233
+ sb.table("gazette_chunks").upsert(
234
+ batch,
235
+ on_conflict="content_hash,chunk_index"
236
+ ).execute()
237
+ upserted += len(batch)
238
+ print(f" πŸ“¦ Upserted batch {i // 50 + 1}: {len(batch)} chunks")
239
+
240
+ return upserted
241
+
242
+
243
+ # ═══════════════════════════════════════════════════════════════
244
+ # MAIN: CLI Entry Point
245
+ # ═══════════════════════════════════════════════════════════════
246
+
247
+ HEADERS = {
248
+ "User-Agent": "GovBridge-Gazette-Ingester/2.0 (+https://govbridge-india.pages.dev)",
249
+ "Accept": "application/pdf,*/*;q=0.8"
250
+ }
251
+
252
+
253
+ def download_pdf(url: str) -> str:
254
+ """Download PDF to a temporary file. Returns path."""
255
+ with tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) as tmp:
256
+ with httpx.stream(
257
+ "GET", url,
258
+ headers=HEADERS,
259
+ timeout=120.0,
260
+ follow_redirects=True
261
+ ) as r:
262
+ r.raise_for_status()
263
+ for data in r.iter_bytes():
264
+ tmp.write(data)
265
+ return tmp.name
266
+
267
+
268
+ def main():
269
+ parser = argparse.ArgumentParser(
270
+ description="GovBridge Gazette Ingestion Pipeline (Sprint 28)"
271
+ )
272
+ parser.add_argument("--url", required=True, help="URL of the gazette PDF")
273
+ parser.add_argument("--title", required=True, help="Document title")
274
+ parser.add_argument("--gazette-type", default="central",
275
+ choices=["central", "state", "extraordinary"],
276
+ help="Type of gazette")
277
+ parser.add_argument("--authority", default=None,
278
+ help="Issuing authority (e.g., 'Ministry of Finance')")
279
+ parser.add_argument("--state", default="National",
280
+ help="State applicability")
281
+ parser.add_argument("--date", default=None,
282
+ help="Notification date (YYYY-MM-DD)")
283
+ args = parser.parse_args()
284
+
285
+ notification_date = args.date
286
+ if notification_date:
287
+ # Validate date format
288
+ try:
289
+ date.fromisoformat(notification_date)
290
+ except ValueError:
291
+ print(f"❌ Invalid date format: {notification_date}. Use YYYY-MM-DD.")
292
+ sys.exit(1)
293
+
294
+ print(f"πŸš€ Starting Gazette Ingestion: {args.url}")
295
+ print(f" Title: {args.title}")
296
+ print(f" Type: {args.gazette_type}")
297
+
298
+ # Step 1: Download
299
+ print("πŸ“₯ Downloading PDF...")
300
+ pdf_path = download_pdf(args.url)
301
+
302
+ try:
303
+ # Step 2: Extract pages
304
+ print("πŸ“„ Extracting pages...")
305
+ pages = extract_pages_from_pdf(pdf_path)
306
+ print(f" βœ… Extracted text from {len(pages)} pages")
307
+
308
+ if not pages:
309
+ print("❌ No text extracted β€” PDF may be a pure image scan.")
310
+ print(" Image-only PDFs require PaddleOCR (see ocr_processor_runner.py)")
311
+ sys.exit(1)
312
+
313
+ # Step 3: Chunk with page awareness
314
+ print("βœ‚οΈ Chunking with semantic boundaries...")
315
+ chunks = chunk_gazette_pages(pages, chunk_size=500, overlap=50)
316
+ print(f" βœ… Generated {len(chunks)} chunks across {len(pages)} pages")
317
+
318
+ # Step 4: Generate embeddings
319
+ print("🧠 Generating 768-dim Nomic embeddings...")
320
+ chunks = generate_embeddings(chunks)
321
+ print(f" βœ… All {len(chunks)} chunks embedded")
322
+
323
+ # Step 5: Upsert to Supabase
324
+ print("πŸ’Ύ Upserting to gazette_chunks table...")
325
+ metadata = {
326
+ "document_title": args.title,
327
+ "source_url": args.url,
328
+ "gazette_type": args.gazette_type,
329
+ "issuing_authority": args.authority,
330
+ "notification_date": notification_date,
331
+ "state": args.state,
332
+ }
333
+ count = upsert_gazette_chunks(chunks, metadata)
334
+ print(f" βœ… {count} chunks upserted successfully")
335
+
336
+ print(f"\nπŸŽ‰ Gazette ingestion complete: {args.title}")
337
+ print(f" Chunks: {count} | Pages: {len(pages)} | Embedding dim: 768")
338
+
339
+ finally:
340
+ os.unlink(pdf_path)
341
+
342
+
343
+ if __name__ == "__main__":
344
+ main()
requirements.txt CHANGED
@@ -40,4 +40,7 @@ pydantic-settings
40
  psutil
41
 
42
  # --- Deterministic Rules Engine (Sprint 19) ---
43
- openfisca-core>=44.0.0
 
 
 
 
40
  psutil
41
 
42
  # --- Deterministic Rules Engine (Sprint 19) ---
43
+ openfisca-core>=44.0.0
44
+
45
+ # --- PDF Processing (Sprint 28) ---
46
+ pymupdf
whatsapp/__pycache__/__init__.cpython-312.pyc CHANGED
Binary files a/whatsapp/__pycache__/__init__.cpython-312.pyc and b/whatsapp/__pycache__/__init__.cpython-312.pyc differ
 
whatsapp/__pycache__/webhook.cpython-312.pyc CHANGED
Binary files a/whatsapp/__pycache__/webhook.cpython-312.pyc and b/whatsapp/__pycache__/webhook.cpython-312.pyc differ