Spaces:
Sleeping
Sleeping
Commit ·
60d1026
1
Parent(s): 99cc0f4
feat(sprint-18-19): bidirectional translation + neuro-symbolic eligibility bridge
Browse files- Dockerfile +20 -7
- api.py +63 -2
- bhashini.py +110 -57
- eligibility/__pycache__/__init__.cpython-312.pyc +0 -0
- eligibility/__pycache__/engine.cpython-312.pyc +0 -0
- eligibility/__pycache__/entities.cpython-312.pyc +0 -0
- eligibility/__pycache__/variables.cpython-312.pyc +0 -0
- eligibility/engine.py +12 -1
- eligibility/variables.py +113 -1
- middleware/__init__.py +1 -0
- middleware/memory_guard.py +57 -0
- neuro_symbolic.py +293 -0
- requirements.txt +8 -1
- tests/test_core.py +100 -0
Dockerfile
CHANGED
|
@@ -40,17 +40,30 @@ print('✓ Nomic Embedding model (768-dim) cached'); \
|
|
| 40 |
CrossEncoder('cross-encoder/ettin-reranker-68m-v1', max_length=512); \
|
| 41 |
print('✓ Ettin Reranker cached')"
|
| 42 |
|
| 43 |
-
# ── Layer
|
| 44 |
-
#
|
| 45 |
-
#
|
| 46 |
RUN python -c "\
|
| 47 |
try: \
|
| 48 |
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM; \
|
| 49 |
-
AutoTokenizer.from_pretrained('ai4bharat/indictrans2-en-indic-dist-200M', trust_remote_code=True, cache_dir='/app/models/indictrans2'); \
|
| 50 |
-
AutoModelForSeq2SeqLM.from_pretrained('ai4bharat/indictrans2-en-indic-dist-200M', trust_remote_code=True, cache_dir='/app/models/indictrans2'); \
|
| 51 |
-
print('✓ IndicTrans2
|
| 52 |
except Exception as e: \
|
| 53 |
-
print(f'⚠️ IndicTrans2 cache failed (non-blocking): {e}')"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
# ── Layer 6: Copy application code LAST ──────────────────────
|
| 56 |
# Code changes don't invalidate the expensive model cache layers.
|
|
|
|
| 40 |
CrossEncoder('cross-encoder/ettin-reranker-68m-v1', max_length=512); \
|
| 41 |
print('✓ Ettin Reranker cached')"
|
| 42 |
|
| 43 |
+
# ── Layer 5a: Pre-cache IndicTrans2 English→Indic model ──────
|
| 44 |
+
# This is the English→Indic direction model (Sprint 18).
|
| 45 |
+
# Required for local translation of English responses back to user's language.
|
| 46 |
RUN python -c "\
|
| 47 |
try: \
|
| 48 |
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM; \
|
| 49 |
+
AutoTokenizer.from_pretrained('ai4bharat/indictrans2-en-indic-dist-200M', trust_remote_code=True, cache_dir='/app/models/indictrans2/en_to_indic'); \
|
| 50 |
+
AutoModelForSeq2SeqLM.from_pretrained('ai4bharat/indictrans2-en-indic-dist-200M', trust_remote_code=True, cache_dir='/app/models/indictrans2/en_to_indic'); \
|
| 51 |
+
print('✓ IndicTrans2 English→Indic model cached'); \
|
| 52 |
except Exception as e: \
|
| 53 |
+
print(f'⚠️ IndicTrans2 en-indic cache failed (non-blocking): {e}')"
|
| 54 |
+
|
| 55 |
+
# ── Layer 5b: Pre-cache IndicTrans2 Indic→English model ──────
|
| 56 |
+
# This is the REVERSE direction model (Sprint 18).
|
| 57 |
+
# Required for local translation of Hindi/Tamil queries INTO English
|
| 58 |
+
# before semantic search against the English vector corpus.
|
| 59 |
+
RUN python -c "\
|
| 60 |
+
try: \
|
| 61 |
+
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM; \
|
| 62 |
+
AutoTokenizer.from_pretrained('ai4bharat/indictrans2-indic-en-dist-200M', trust_remote_code=True, cache_dir='/app/models/indictrans2/indic_to_en'); \
|
| 63 |
+
AutoModelForSeq2SeqLM.from_pretrained('ai4bharat/indictrans2-indic-en-dist-200M', trust_remote_code=True, cache_dir='/app/models/indictrans2/indic_to_en'); \
|
| 64 |
+
print('✓ IndicTrans2 Indic→English model cached'); \
|
| 65 |
+
except Exception as e: \
|
| 66 |
+
print(f'⚠️ IndicTrans2 indic-en cache failed (non-blocking): {e}')"
|
| 67 |
|
| 68 |
# ── Layer 6: Copy application code LAST ──────────────────────
|
| 69 |
# Code changes don't invalidate the expensive model cache layers.
|
api.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
import hashlib
|
| 3 |
import re
|
| 4 |
from datetime import datetime
|
|
@@ -15,6 +16,7 @@ from slowapi.util import get_remote_address
|
|
| 15 |
from slowapi.errors import RateLimitExceeded
|
| 16 |
from bhashini import translate_text, LANGUAGE_CODES
|
| 17 |
from eligibility.engine import check_eligibility
|
|
|
|
| 18 |
from whatsapp.webhook import router as whatsapp_router
|
| 19 |
from config import settings
|
| 20 |
|
|
@@ -45,6 +47,10 @@ app.add_middleware(
|
|
| 45 |
expose_headers=["X-Sources", "X-Translation-Provider"],
|
| 46 |
)
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
# --- MODELS ---
|
| 49 |
class SearchQuery(BaseModel):
|
| 50 |
question: str = Field(..., min_length=3, max_length=500)
|
|
@@ -77,11 +83,17 @@ class IngestRequest(BaseModel):
|
|
| 77 |
doc_type: Optional[str] = "scheme"
|
| 78 |
|
| 79 |
class EligibilityRequest(BaseModel):
|
| 80 |
-
annual_income: float = Field(
|
| 81 |
-
age: int = Field(
|
| 82 |
is_farmer: bool = False
|
| 83 |
state: str = ""
|
| 84 |
caste_category: str = "General"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
# --- INITIALIZE AI (LAZY LOADED) ---
|
| 87 |
embedding_model = None
|
|
@@ -172,6 +184,22 @@ async def health_check():
|
|
| 172 |
"checks": checks
|
| 173 |
}
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
@app.post("/api/eligibility/check")
|
| 176 |
async def check_scheme_eligibility(request: EligibilityRequest):
|
| 177 |
profile = request.model_dump()
|
|
@@ -231,7 +259,40 @@ async def rag_query(request: Request, query: SearchQuery):
|
|
| 231 |
english_query = await translate_text(query.question, user_language, 'english')
|
| 232 |
print(f"Translated query: '{query.question}' → '{english_query}'")
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
# --- STEP 2: EMBED THE ENGLISH QUERY ---
|
|
|
|
| 235 |
model = get_embedding_model()
|
| 236 |
# Nomic requires search_query prefix for citizens' questions
|
| 237 |
query_numbers = model.encode(f"search_query: {english_query}", normalize_embeddings=True).tolist()
|
|
|
|
| 1 |
import os
|
| 2 |
+
import gc
|
| 3 |
import hashlib
|
| 4 |
import re
|
| 5 |
from datetime import datetime
|
|
|
|
| 16 |
from slowapi.errors import RateLimitExceeded
|
| 17 |
from bhashini import translate_text, LANGUAGE_CODES
|
| 18 |
from eligibility.engine import check_eligibility
|
| 19 |
+
from neuro_symbolic import run_neuro_symbolic_pipeline
|
| 20 |
from whatsapp.webhook import router as whatsapp_router
|
| 21 |
from config import settings
|
| 22 |
|
|
|
|
| 47 |
expose_headers=["X-Sources", "X-Translation-Provider"],
|
| 48 |
)
|
| 49 |
|
| 50 |
+
from middleware.memory_guard import MemoryGuardMiddleware
|
| 51 |
+
app.add_middleware(MemoryGuardMiddleware)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
# --- MODELS ---
|
| 55 |
class SearchQuery(BaseModel):
|
| 56 |
question: str = Field(..., min_length=3, max_length=500)
|
|
|
|
| 83 |
doc_type: Optional[str] = "scheme"
|
| 84 |
|
| 85 |
class EligibilityRequest(BaseModel):
|
| 86 |
+
annual_income: float = Field(default=0, ge=0)
|
| 87 |
+
age: int = Field(default=0, ge=0, le=120)
|
| 88 |
is_farmer: bool = False
|
| 89 |
state: str = ""
|
| 90 |
caste_category: str = "General"
|
| 91 |
+
gender: str = "Unknown"
|
| 92 |
+
is_bpl: bool = False
|
| 93 |
+
land_size_hectares: float = Field(default=0.0, ge=0)
|
| 94 |
+
is_disabled: bool = False
|
| 95 |
+
occupation: str = "Unknown"
|
| 96 |
+
|
| 97 |
|
| 98 |
# --- INITIALIZE AI (LAZY LOADED) ---
|
| 99 |
embedding_model = None
|
|
|
|
| 184 |
"checks": checks
|
| 185 |
}
|
| 186 |
|
| 187 |
+
@app.get("/debug/memory")
|
| 188 |
+
async def debug_memory():
|
| 189 |
+
"""Live memory inspection endpoint (Sprint 18)."""
|
| 190 |
+
from middleware.memory_guard import get_memory_mb
|
| 191 |
+
import bhashini
|
| 192 |
+
|
| 193 |
+
return {
|
| 194 |
+
"rss_mb": round(get_memory_mb(), 1),
|
| 195 |
+
"gc_counts": gc.get_count(),
|
| 196 |
+
"gc_threshold": gc.get_threshold(),
|
| 197 |
+
"models_loaded": list(bhashini._indictrans_models.keys()),
|
| 198 |
+
"embedding_loaded": embedding_model is not None,
|
| 199 |
+
"reranker_loaded": reranker_model is not None,
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
|
| 203 |
@app.post("/api/eligibility/check")
|
| 204 |
async def check_scheme_eligibility(request: EligibilityRequest):
|
| 205 |
profile = request.model_dump()
|
|
|
|
| 259 |
english_query = await translate_text(query.question, user_language, 'english')
|
| 260 |
print(f"Translated query: '{query.question}' → '{english_query}'")
|
| 261 |
|
| 262 |
+
# --- STEP 2.5: NEURO-SYMBOLIC ELIGIBILITY DETECTION (Sprint 19) ---
|
| 263 |
+
# If the query is about eligibility, route through OpenFisca
|
| 264 |
+
# instead of the probabilistic RAG pipeline.
|
| 265 |
+
ns_result = run_neuro_symbolic_pipeline(english_query, groq_client)
|
| 266 |
+
|
| 267 |
+
if ns_result["type"] in ("eligibility_result", "followup_question"):
|
| 268 |
+
ns_response = ns_result["response"]
|
| 269 |
+
|
| 270 |
+
# Translate the response back to user's language if needed
|
| 271 |
+
if user_language != 'english':
|
| 272 |
+
ns_response = await translate_text(ns_response, 'english', user_language)
|
| 273 |
+
|
| 274 |
+
async def ns_stream():
|
| 275 |
+
yield ns_response
|
| 276 |
+
|
| 277 |
+
headers = {
|
| 278 |
+
"X-Accel-Buffering": "no",
|
| 279 |
+
"X-Translation-Provider": "OpenFisca-Deterministic",
|
| 280 |
+
"X-Eligibility-Type": ns_result["type"],
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
if ns_result.get("eligible_schemes"):
|
| 284 |
+
headers["X-Sources"] = "|".join(ns_result["eligible_schemes"])
|
| 285 |
+
|
| 286 |
+
return StreamingResponse(
|
| 287 |
+
ns_stream(),
|
| 288 |
+
media_type="text/plain",
|
| 289 |
+
headers=headers
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
# If intent is "informational", continue to the standard RAG pipeline below
|
| 293 |
+
|
| 294 |
# --- STEP 2: EMBED THE ENGLISH QUERY ---
|
| 295 |
+
|
| 296 |
model = get_embedding_model()
|
| 297 |
# Nomic requires search_query prefix for citizens' questions
|
| 298 |
query_numbers = model.encode(f"search_query: {english_query}", normalize_embeddings=True).tolist()
|
bhashini.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
import httpx
|
| 2 |
import os
|
|
|
|
| 3 |
import torch
|
|
|
|
| 4 |
|
| 5 |
INDICTRANS_LANG_MAP = {
|
| 6 |
"hindi": "hin_Deva",
|
|
@@ -28,91 +30,137 @@ BHASHINI_USER_ID = os.environ.get("BHASHINI_USER_ID")
|
|
| 28 |
BHASHINI_API_KEY = os.environ.get("BHASHINI_API_KEY")
|
| 29 |
BHASHINI_INFERENCE_URL = "https://dhruva-api.bhashini.gov.in/services/inference/pipeline"
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
if _indictrans_tokenizer is not None:
|
| 37 |
-
return True
|
| 38 |
-
try:
|
| 39 |
-
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
| 40 |
-
print("⏳ Loading IndicTrans2 translation model...")
|
| 41 |
-
_indictrans_tokenizer = AutoTokenizer.from_pretrained(
|
| 42 |
-
"ai4bharat/indictrans2-en-indic-dist-200M",
|
| 43 |
-
trust_remote_code=True,
|
| 44 |
-
cache_dir="/app/models/indictrans2"
|
| 45 |
-
)
|
| 46 |
-
_indictrans_model = AutoModelForSeq2SeqLM.from_pretrained(
|
| 47 |
-
"ai4bharat/indictrans2-en-indic-dist-200M",
|
| 48 |
-
trust_remote_code=True,
|
| 49 |
-
cache_dir="/app/models/indictrans2"
|
| 50 |
-
)
|
| 51 |
-
print("✅ IndicTrans2 loaded successfully")
|
| 52 |
-
return True
|
| 53 |
-
except Exception as e:
|
| 54 |
-
print(f"❌ IndicTrans2 load error: {e}")
|
| 55 |
-
return False
|
| 56 |
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
-
def
|
| 59 |
"""
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
"""
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
assert _indictrans_tokenizer is not None
|
| 69 |
-
assert _indictrans_model is not None
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
src_code = INDICTRANS_LANG_MAP.get(src.lower().strip(), "eng_Latn")
|
| 72 |
tgt_code = INDICTRANS_LANG_MAP.get(tgt.lower().strip(), "eng_Latn")
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
| 74 |
try:
|
| 75 |
from IndicTransToolkit.processor import IndicProcessor
|
| 76 |
ip = IndicProcessor(inference=True)
|
| 77 |
-
|
| 78 |
-
# CRITICAL FIX 1:
|
| 79 |
-
#
|
| 80 |
-
# WITHOUT THIS: tokenizer produces gibberish predictions.
|
| 81 |
batch = ip.preprocess_batch([text], src_lang=src_code, tgt_lang=tgt_code)
|
| 82 |
-
|
| 83 |
-
inputs =
|
| 84 |
batch,
|
| 85 |
truncation=True,
|
| 86 |
padding="longest",
|
| 87 |
return_tensors="pt",
|
| 88 |
return_attention_mask=True
|
| 89 |
)
|
| 90 |
-
|
| 91 |
with torch.inference_mode():
|
| 92 |
-
generated_tokens =
|
| 93 |
**inputs,
|
| 94 |
num_beams=4,
|
| 95 |
num_return_sequences=1,
|
| 96 |
max_length=512,
|
| 97 |
early_stopping=True
|
| 98 |
)
|
| 99 |
-
|
| 100 |
-
# CRITICAL FIX 2:
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
with _indictrans_tokenizer.as_target_tokenizer():
|
| 104 |
-
decoded = _indictrans_tokenizer.batch_decode(
|
| 105 |
generated_tokens,
|
| 106 |
skip_special_tokens=True,
|
| 107 |
clean_up_tokenization_spaces=True
|
| 108 |
)
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
except ImportError:
|
| 113 |
# Fallback if IndicTransToolkit not installed
|
| 114 |
print("⚠️ IndicTransToolkit not found, using basic tokenizer")
|
| 115 |
-
inputs =
|
| 116 |
text,
|
| 117 |
src_lang=src_code,
|
| 118 |
return_tensors="pt",
|
|
@@ -121,17 +169,22 @@ def local_indictrans_translate(text: str, src: str, tgt: str) -> str:
|
|
| 121 |
max_length=512
|
| 122 |
)
|
| 123 |
with torch.inference_mode():
|
| 124 |
-
outputs =
|
| 125 |
**inputs,
|
| 126 |
tgt_lang=tgt_code,
|
| 127 |
max_length=512,
|
| 128 |
num_beams=4,
|
| 129 |
early_stopping=True
|
| 130 |
)
|
| 131 |
-
decoded =
|
| 132 |
-
|
| 133 |
-
)
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
async def translate_text(
|
|
|
|
| 1 |
import httpx
|
| 2 |
import os
|
| 3 |
+
import gc
|
| 4 |
import torch
|
| 5 |
+
import threading
|
| 6 |
|
| 7 |
INDICTRANS_LANG_MAP = {
|
| 8 |
"hindi": "hin_Deva",
|
|
|
|
| 30 |
BHASHINI_API_KEY = os.environ.get("BHASHINI_API_KEY")
|
| 31 |
BHASHINI_INFERENCE_URL = "https://dhruva-api.bhashini.gov.in/services/inference/pipeline"
|
| 32 |
|
| 33 |
+
# Thread-safe model registry (prevents race conditions on concurrent requests)
|
| 34 |
+
_model_lock = threading.Lock()
|
| 35 |
|
| 36 |
+
# Model registry: direction → (tokenizer, model)
|
| 37 |
+
_indictrans_models: dict[str, tuple] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
# Model identifiers
|
| 40 |
+
INDIC_TO_EN_MODEL = "ai4bharat/indictrans2-indic-en-dist-200M"
|
| 41 |
+
EN_TO_INDIC_MODEL = "ai4bharat/indictrans2-en-indic-dist-200M"
|
| 42 |
|
| 43 |
+
def _get_model_for_direction(src_code: str, tgt_code: str) -> tuple:
|
| 44 |
"""
|
| 45 |
+
Lazy-load the correct IndicTrans2 model based on translation direction.
|
| 46 |
+
|
| 47 |
+
CRITICAL DESIGN DECISION: We load only ONE model at a time and unload
|
| 48 |
+
the other to stay within the 16GB RAM ceiling. Each model is ~800MB.
|
| 49 |
+
Loading both simultaneously would consume ~1.6GB just for translation,
|
| 50 |
+
on top of Nomic (768-dim, ~550MB) and Ettin reranker (~270MB).
|
| 51 |
+
|
| 52 |
+
Memory budget:
|
| 53 |
+
- Python runtime + FastAPI: ~200MB
|
| 54 |
+
- Nomic embed-text-v1: ~550MB
|
| 55 |
+
- Ettin reranker-68m-v1: ~270MB
|
| 56 |
+
- IndicTrans2 (one direction): ~800MB
|
| 57 |
+
- Supabase client + misc: ~100MB
|
| 58 |
+
- Total: ~1.9GB active, well under 16GB
|
| 59 |
+
|
| 60 |
+
We can afford to keep BOTH loaded (~2.7GB active). If OOM occurs,
|
| 61 |
+
the executor should switch to single-model-at-a-time with gc.collect().
|
| 62 |
"""
|
| 63 |
+
global _indictrans_models
|
| 64 |
+
|
| 65 |
+
# Determine direction
|
| 66 |
+
is_to_english = (tgt_code == "eng_Latn")
|
| 67 |
+
model_name = INDIC_TO_EN_MODEL if is_to_english else EN_TO_INDIC_MODEL
|
| 68 |
+
direction_key = "indic_to_en" if is_to_english else "en_to_indic"
|
| 69 |
+
|
| 70 |
+
with _model_lock:
|
| 71 |
+
if direction_key in _indictrans_models:
|
| 72 |
+
return _indictrans_models[direction_key]
|
| 73 |
+
|
| 74 |
+
try:
|
| 75 |
+
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
|
| 76 |
+
import torch
|
| 77 |
+
|
| 78 |
+
print(f"⏳ Loading IndicTrans2 ({direction_key}): {model_name}...")
|
| 79 |
+
|
| 80 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 81 |
+
model_name,
|
| 82 |
+
trust_remote_code=True,
|
| 83 |
+
cache_dir=f"/app/models/indictrans2/{direction_key}"
|
| 84 |
+
)
|
| 85 |
+
model = AutoModelForSeq2SeqLM.from_pretrained(
|
| 86 |
+
model_name,
|
| 87 |
+
trust_remote_code=True,
|
| 88 |
+
cache_dir=f"/app/models/indictrans2/{direction_key}"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
# Force CPU evaluation mode for minimal memory
|
| 92 |
+
model.eval()
|
| 93 |
+
|
| 94 |
+
_indictrans_models[direction_key] = (tokenizer, model)
|
| 95 |
+
print(f"✅ IndicTrans2 ({direction_key}) loaded successfully")
|
| 96 |
+
return (tokenizer, model)
|
| 97 |
+
|
| 98 |
+
except Exception as e:
|
| 99 |
+
print(f"❌ IndicTrans2 ({direction_key}) load error: {e}")
|
| 100 |
+
raise RuntimeError(f"IndicTrans2 {direction_key} model not available: {e}")
|
| 101 |
|
|
|
|
|
|
|
| 102 |
|
| 103 |
+
def local_indictrans_translate(text: str, src: str, tgt: str) -> str:
|
| 104 |
+
"""
|
| 105 |
+
Bidirectional IndicTrans2 translation with memory cleanup.
|
| 106 |
+
|
| 107 |
+
CRITICAL FIX (Sprint 18): Now supports BOTH directions:
|
| 108 |
+
- Indic → English (using indictrans2-indic-en-dist-200M)
|
| 109 |
+
- English → Indic (using indictrans2-en-indic-dist-200M)
|
| 110 |
+
|
| 111 |
+
Uses IndicProcessor for correct tokenization (Sprint 17 fix preserved).
|
| 112 |
+
"""
|
| 113 |
src_code = INDICTRANS_LANG_MAP.get(src.lower().strip(), "eng_Latn")
|
| 114 |
tgt_code = INDICTRANS_LANG_MAP.get(tgt.lower().strip(), "eng_Latn")
|
| 115 |
+
|
| 116 |
+
# Get the correct model for this direction
|
| 117 |
+
tokenizer, model = _get_model_for_direction(src_code, tgt_code)
|
| 118 |
+
|
| 119 |
try:
|
| 120 |
from IndicTransToolkit.processor import IndicProcessor
|
| 121 |
ip = IndicProcessor(inference=True)
|
| 122 |
+
|
| 123 |
+
# CRITICAL FIX 1: IndicProcessor.preprocess_batch() injects
|
| 124 |
+
# <src_lang> and <tgt_lang> tokens. Without this: gibberish.
|
|
|
|
| 125 |
batch = ip.preprocess_batch([text], src_lang=src_code, tgt_lang=tgt_code)
|
| 126 |
+
|
| 127 |
+
inputs = tokenizer(
|
| 128 |
batch,
|
| 129 |
truncation=True,
|
| 130 |
padding="longest",
|
| 131 |
return_tensors="pt",
|
| 132 |
return_attention_mask=True
|
| 133 |
)
|
| 134 |
+
|
| 135 |
with torch.inference_mode():
|
| 136 |
+
generated_tokens = model.generate(
|
| 137 |
**inputs,
|
| 138 |
num_beams=4,
|
| 139 |
num_return_sequences=1,
|
| 140 |
max_length=512,
|
| 141 |
early_stopping=True
|
| 142 |
)
|
| 143 |
+
|
| 144 |
+
# CRITICAL FIX 2: as_target_tokenizer() prevents vocabulary bleed
|
| 145 |
+
with tokenizer.as_target_tokenizer():
|
| 146 |
+
decoded = tokenizer.batch_decode(
|
|
|
|
|
|
|
| 147 |
generated_tokens,
|
| 148 |
skip_special_tokens=True,
|
| 149 |
clean_up_tokenization_spaces=True
|
| 150 |
)
|
| 151 |
+
|
| 152 |
+
result = ip.postprocess_batch(decoded, lang=tgt_code)[0].strip()
|
| 153 |
+
|
| 154 |
+
# SPRINT 18: Aggressive memory cleanup after every translation
|
| 155 |
+
del inputs, generated_tokens, decoded, batch
|
| 156 |
+
gc.collect()
|
| 157 |
+
|
| 158 |
+
return result
|
| 159 |
+
|
| 160 |
except ImportError:
|
| 161 |
# Fallback if IndicTransToolkit not installed
|
| 162 |
print("⚠️ IndicTransToolkit not found, using basic tokenizer")
|
| 163 |
+
inputs = tokenizer(
|
| 164 |
text,
|
| 165 |
src_lang=src_code,
|
| 166 |
return_tensors="pt",
|
|
|
|
| 169 |
max_length=512
|
| 170 |
)
|
| 171 |
with torch.inference_mode():
|
| 172 |
+
outputs = model.generate(
|
| 173 |
**inputs,
|
| 174 |
tgt_lang=tgt_code,
|
| 175 |
max_length=512,
|
| 176 |
num_beams=4,
|
| 177 |
early_stopping=True
|
| 178 |
)
|
| 179 |
+
decoded = tokenizer.batch_decode(outputs, skip_special_tokens=True)
|
| 180 |
+
|
| 181 |
+
result = decoded[0].strip()
|
| 182 |
+
|
| 183 |
+
# Memory cleanup
|
| 184 |
+
del inputs, outputs, decoded
|
| 185 |
+
gc.collect()
|
| 186 |
+
|
| 187 |
+
return result
|
| 188 |
|
| 189 |
|
| 190 |
async def translate_text(
|
eligibility/__pycache__/__init__.cpython-312.pyc
CHANGED
|
Binary files a/eligibility/__pycache__/__init__.cpython-312.pyc and b/eligibility/__pycache__/__init__.cpython-312.pyc differ
|
|
|
eligibility/__pycache__/engine.cpython-312.pyc
CHANGED
|
Binary files a/eligibility/__pycache__/engine.cpython-312.pyc and b/eligibility/__pycache__/engine.cpython-312.pyc differ
|
|
|
eligibility/__pycache__/entities.cpython-312.pyc
CHANGED
|
Binary files a/eligibility/__pycache__/entities.cpython-312.pyc and b/eligibility/__pycache__/entities.cpython-312.pyc differ
|
|
|
eligibility/__pycache__/variables.cpython-312.pyc
CHANGED
|
Binary files a/eligibility/__pycache__/variables.cpython-312.pyc and b/eligibility/__pycache__/variables.cpython-312.pyc differ
|
|
|
eligibility/engine.py
CHANGED
|
@@ -15,7 +15,13 @@ def build_tax_system():
|
|
| 15 |
TAX_SYSTEM = build_tax_system()
|
| 16 |
|
| 17 |
ELIGIBILITY_VARIABLES = [
|
|
|
|
| 18 |
"eligible_pm_kisan",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"eligible_chiranjeevi",
|
| 20 |
"eligible_palanhar",
|
| 21 |
"eligible_ekal_nari",
|
|
@@ -39,7 +45,12 @@ def check_eligibility(profile: dict) -> dict:
|
|
| 39 |
"age": {"2024": profile.get("age", 0)},
|
| 40 |
"is_farmer": {"2024": profile.get("is_farmer", False)},
|
| 41 |
"state": {"2024": profile.get("state", "Unknown")},
|
| 42 |
-
"caste_category": {"2024": profile.get("caste_category", "General")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
},
|
| 45 |
"households": {"household": {"members": ["citizen"]}}
|
|
|
|
| 15 |
TAX_SYSTEM = build_tax_system()
|
| 16 |
|
| 17 |
ELIGIBILITY_VARIABLES = [
|
| 18 |
+
# National Schemes
|
| 19 |
"eligible_pm_kisan",
|
| 20 |
+
"eligible_pmjay",
|
| 21 |
+
"eligible_pmay_gramin",
|
| 22 |
+
"eligible_ujjwala",
|
| 23 |
+
"eligible_scholarship_sc_st",
|
| 24 |
+
# Rajasthan State Schemes
|
| 25 |
"eligible_chiranjeevi",
|
| 26 |
"eligible_palanhar",
|
| 27 |
"eligible_ekal_nari",
|
|
|
|
| 45 |
"age": {"2024": profile.get("age", 0)},
|
| 46 |
"is_farmer": {"2024": profile.get("is_farmer", False)},
|
| 47 |
"state": {"2024": profile.get("state", "Unknown")},
|
| 48 |
+
"caste_category": {"2024": profile.get("caste_category", "General")},
|
| 49 |
+
"gender": {"2024": profile.get("gender", "Unknown")},
|
| 50 |
+
"is_bpl": {"2024": profile.get("is_bpl", False)},
|
| 51 |
+
"land_size_hectares": {"2024": profile.get("land_size_hectares", 0.0)},
|
| 52 |
+
"is_disabled": {"2024": profile.get("is_disabled", False)},
|
| 53 |
+
"occupation": {"2024": profile.get("occupation", "Unknown")},
|
| 54 |
}
|
| 55 |
},
|
| 56 |
"households": {"household": {"members": ["citizen"]}}
|
eligibility/variables.py
CHANGED
|
@@ -39,6 +39,43 @@ class caste_category(Variable):
|
|
| 39 |
label = "SC/ST/OBC/General"
|
| 40 |
default_value = "General"
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
# Eligibility output variables
|
| 43 |
class eligible_pm_kisan(Variable):
|
| 44 |
value_type = bool
|
|
@@ -138,4 +175,79 @@ class eligible_ayushman_arogya(Variable):
|
|
| 138 |
label = "Mukhyamantree Ayushman Arogya Yojana"
|
| 139 |
def formula(person, period, parameters):
|
| 140 |
# Approximated: Healthcare for residents under income threshold
|
| 141 |
-
return (person("state", period) == "Rajasthan") * (person("annual_income", period) <= 800000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
label = "SC/ST/OBC/General"
|
| 40 |
default_value = "General"
|
| 41 |
|
| 42 |
+
class gender(Variable):
|
| 43 |
+
value_type = str
|
| 44 |
+
entity = Person
|
| 45 |
+
definition_period = ETERNITY
|
| 46 |
+
label = "Gender (Male/Female/Other)"
|
| 47 |
+
default_value = "Unknown"
|
| 48 |
+
|
| 49 |
+
class is_bpl(Variable):
|
| 50 |
+
"""Below Poverty Line status — used by PMJAY and other welfare schemes."""
|
| 51 |
+
value_type = bool
|
| 52 |
+
entity = Person
|
| 53 |
+
definition_period = ETERNITY
|
| 54 |
+
label = "Is the person below poverty line"
|
| 55 |
+
default_value = False
|
| 56 |
+
|
| 57 |
+
class land_size_hectares(Variable):
|
| 58 |
+
"""Agricultural land holding in hectares — used by PM-KISAN."""
|
| 59 |
+
value_type = float
|
| 60 |
+
entity = Person
|
| 61 |
+
definition_period = ETERNITY
|
| 62 |
+
label = "Agricultural land size in hectares"
|
| 63 |
+
default_value = 0.0
|
| 64 |
+
|
| 65 |
+
class is_disabled(Variable):
|
| 66 |
+
value_type = bool
|
| 67 |
+
entity = Person
|
| 68 |
+
definition_period = ETERNITY
|
| 69 |
+
label = "Person with disability status"
|
| 70 |
+
default_value = False
|
| 71 |
+
|
| 72 |
+
class occupation(Variable):
|
| 73 |
+
value_type = str
|
| 74 |
+
entity = Person
|
| 75 |
+
definition_period = ETERNITY
|
| 76 |
+
label = "Primary occupation"
|
| 77 |
+
default_value = "Unknown"
|
| 78 |
+
|
| 79 |
# Eligibility output variables
|
| 80 |
class eligible_pm_kisan(Variable):
|
| 81 |
value_type = bool
|
|
|
|
| 175 |
label = "Mukhyamantree Ayushman Arogya Yojana"
|
| 176 |
def formula(person, period, parameters):
|
| 177 |
# Approximated: Healthcare for residents under income threshold
|
| 178 |
+
return (person("state", period) == "Rajasthan") * (person("annual_income", period) <= 800000)
|
| 179 |
+
|
| 180 |
+
# --- SPRINT 19: NATIONAL SCHEME EXPANSION ---
|
| 181 |
+
|
| 182 |
+
class eligible_pmjay(Variable):
|
| 183 |
+
"""
|
| 184 |
+
Pradhan Mantri Jan Arogya Yojana (Ayushman Bharat)
|
| 185 |
+
National Health Protection Scheme - up to ₹5 lakh/family/year
|
| 186 |
+
|
| 187 |
+
Eligibility (simplified from SECC deprivation criteria):
|
| 188 |
+
- Annual income below ₹5 lakh
|
| 189 |
+
- OR BPL status
|
| 190 |
+
- Available nationwide
|
| 191 |
+
"""
|
| 192 |
+
value_type = bool
|
| 193 |
+
entity = Person
|
| 194 |
+
definition_period = YEAR
|
| 195 |
+
label = "Eligible for Ayushman Bharat PMJAY"
|
| 196 |
+
def formula(person, period, parameters):
|
| 197 |
+
income = person("annual_income", period)
|
| 198 |
+
bpl = person("is_bpl", period)
|
| 199 |
+
return (income <= 500000) + bpl # OR logic via addition + bool conversion
|
| 200 |
+
|
| 201 |
+
class eligible_pmay_gramin(Variable):
|
| 202 |
+
"""
|
| 203 |
+
Pradhan Mantri Awas Yojana - Gramin (Rural Housing)
|
| 204 |
+
|
| 205 |
+
Eligibility:
|
| 206 |
+
- Annual income below ₹3 lakh
|
| 207 |
+
- Must be from rural area (approximated by BPL or low income)
|
| 208 |
+
"""
|
| 209 |
+
value_type = bool
|
| 210 |
+
entity = Person
|
| 211 |
+
definition_period = YEAR
|
| 212 |
+
label = "Eligible for PMAY Gramin (Rural Housing)"
|
| 213 |
+
def formula(person, period, parameters):
|
| 214 |
+
income = person("annual_income", period)
|
| 215 |
+
return income <= 300000
|
| 216 |
+
|
| 217 |
+
class eligible_ujjwala(Variable):
|
| 218 |
+
"""
|
| 219 |
+
Pradhan Mantri Ujjwala Yojana (Free LPG connection)
|
| 220 |
+
|
| 221 |
+
Eligibility:
|
| 222 |
+
- BPL household
|
| 223 |
+
- Adult woman (age >= 18)
|
| 224 |
+
"""
|
| 225 |
+
value_type = bool
|
| 226 |
+
entity = Person
|
| 227 |
+
definition_period = YEAR
|
| 228 |
+
label = "Eligible for Ujjwala Yojana (Free LPG)"
|
| 229 |
+
def formula(person, period, parameters):
|
| 230 |
+
bpl = person("is_bpl", period)
|
| 231 |
+
age_val = person("age", period)
|
| 232 |
+
gender_val = person("gender", period)
|
| 233 |
+
return bpl * (age_val >= 18) * (gender_val == "Female")
|
| 234 |
+
|
| 235 |
+
class eligible_scholarship_sc_st(Variable):
|
| 236 |
+
"""
|
| 237 |
+
Post-Matric Scholarship for SC/ST Students (National)
|
| 238 |
+
|
| 239 |
+
Eligibility:
|
| 240 |
+
- SC or ST category
|
| 241 |
+
- Age 16-35 (post-matric)
|
| 242 |
+
- Annual income below ₹2.5 lakh
|
| 243 |
+
"""
|
| 244 |
+
value_type = bool
|
| 245 |
+
entity = Person
|
| 246 |
+
definition_period = YEAR
|
| 247 |
+
label = "Eligible for SC/ST Post-Matric Scholarship"
|
| 248 |
+
def formula(person, period, parameters):
|
| 249 |
+
caste = person("caste_category", period)
|
| 250 |
+
age_val = person("age", period)
|
| 251 |
+
income = person("annual_income", period)
|
| 252 |
+
is_sc_st = (caste == "SC") + (caste == "ST") # OR via addition
|
| 253 |
+
return is_sc_st * (age_val >= 16) * (age_val <= 35) * (income <= 250000)
|
middleware/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Sprint 18: Middleware package initialization
|
middleware/memory_guard.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GovBridge India — Memory Guard Middleware (Sprint 18)
|
| 3 |
+
|
| 4 |
+
Prevents progressive RAM bloat by:
|
| 5 |
+
1. Running gc.collect() after every request
|
| 6 |
+
2. Logging RSS memory usage for monitoring
|
| 7 |
+
3. Providing a /debug/memory endpoint for live inspection
|
| 8 |
+
|
| 9 |
+
Target: Hugging Face Spaces (CPU Basic: 2 vCPU, 16GB RAM)
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import gc
|
| 13 |
+
import os
|
| 14 |
+
import psutil
|
| 15 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 16 |
+
from starlette.requests import Request
|
| 17 |
+
from starlette.responses import Response, JSONResponse
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_memory_mb() -> float:
|
| 21 |
+
"""Get current process RSS in MB."""
|
| 22 |
+
process = psutil.Process(os.getpid())
|
| 23 |
+
return process.memory_info().rss / (1024 * 1024)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class MemoryGuardMiddleware(BaseHTTPMiddleware):
|
| 27 |
+
"""
|
| 28 |
+
Post-request garbage collection middleware.
|
| 29 |
+
|
| 30 |
+
CRITICAL DESIGN: Python's default GC thresholds (700, 10, 10) are
|
| 31 |
+
tuned for short-lived scripts, not long-running ML servers. PyTorch
|
| 32 |
+
tensors create circular references that the generational GC doesn't
|
| 33 |
+
collect promptly. Forcing gc.collect() after every request ensures
|
| 34 |
+
translation tensors are freed immediately.
|
| 35 |
+
|
| 36 |
+
Performance impact: gc.collect() takes 1-5ms on this workload.
|
| 37 |
+
Negligible vs. the 2-4s translation + inference latency.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
# Threshold in MB. If exceeded, log a warning.
|
| 41 |
+
MEMORY_WARNING_THRESHOLD_MB = 12_000 # 12GB of 16GB
|
| 42 |
+
|
| 43 |
+
async def dispatch(self, request: Request, call_next):
|
| 44 |
+
response = await call_next(request)
|
| 45 |
+
|
| 46 |
+
# Force garbage collection after every request
|
| 47 |
+
collected = gc.collect()
|
| 48 |
+
|
| 49 |
+
# Log memory state for monitoring (only on heavy endpoints)
|
| 50 |
+
if request.url.path in ("/api/rag/query", "/webhook/whatsapp"):
|
| 51 |
+
mem_mb = get_memory_mb()
|
| 52 |
+
if mem_mb > self.MEMORY_WARNING_THRESHOLD_MB:
|
| 53 |
+
print(f"🚨 MEMORY WARNING: {mem_mb:.0f}MB RSS (threshold: {self.MEMORY_WARNING_THRESHOLD_MB}MB)")
|
| 54 |
+
elif collected > 0:
|
| 55 |
+
print(f"🧹 GC freed {collected} objects | RSS: {mem_mb:.0f}MB")
|
| 56 |
+
|
| 57 |
+
return response
|
neuro_symbolic.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GovBridge India — Neuro-Symbolic Bridge (Sprint 19)
|
| 3 |
+
|
| 4 |
+
This module implements the LLM → OpenFisca pipeline:
|
| 5 |
+
1. INTENT DETECTION: Classify if a user query is an eligibility question
|
| 6 |
+
2. PARAMETER EXTRACTION: Use LLM to extract structured demographic params
|
| 7 |
+
3. MISSING DATA DETECTION: Identify what info is still needed
|
| 8 |
+
4. DETERMINISTIC EVALUATION: Send extracted params to OpenFisca
|
| 9 |
+
5. NATURAL LANGUAGE RESPONSE: Convert boolean results back to prose
|
| 10 |
+
|
| 11 |
+
ARCHITECTURAL LAW: The LLM NEVER performs arithmetic or eligibility logic.
|
| 12 |
+
It is STRICTLY confined to:
|
| 13 |
+
- Understanding natural language
|
| 14 |
+
- Extracting structured data
|
| 15 |
+
- Generating conversational prose from deterministic outputs
|
| 16 |
+
|
| 17 |
+
All math is done by OpenFisca. Zero exceptions.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import re
|
| 22 |
+
from typing import Optional
|
| 23 |
+
from groq import Groq
|
| 24 |
+
from eligibility.engine import check_eligibility
|
| 25 |
+
|
| 26 |
+
# ── Intent Classification Prompt ────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
INTENT_CLASSIFIER_PROMPT = """You are a classification engine. Your ONLY job is to determine if the user's question is asking about ELIGIBILITY for a government scheme.
|
| 29 |
+
|
| 30 |
+
Eligibility questions include:
|
| 31 |
+
- "Am I eligible for PM-KISAN?"
|
| 32 |
+
- "Can I get Ayushman Bharat?"
|
| 33 |
+
- "What schemes can I apply for?"
|
| 34 |
+
- "I am a farmer with 2 hectares, what benefits do I get?"
|
| 35 |
+
- "मुझे पीएम किसान मिल सकता है?"
|
| 36 |
+
- "I earn 3 lakhs, am I eligible for housing scheme?"
|
| 37 |
+
|
| 38 |
+
NON-eligibility questions include:
|
| 39 |
+
- "What is PM-KISAN?" (informational, not personal eligibility)
|
| 40 |
+
- "How to apply for Ayushman Bharat?" (procedural)
|
| 41 |
+
- "What documents do I need?" (procedural)
|
| 42 |
+
- "Tell me about farming schemes" (browsing/informational)
|
| 43 |
+
|
| 44 |
+
Respond with EXACTLY one word: "ELIGIBILITY" or "INFORMATIONAL"
|
| 45 |
+
Nothing else. No explanation."""
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# ── Parameter Extraction Prompt ─────────────────────────────────────────────
|
| 49 |
+
|
| 50 |
+
PARAM_EXTRACTION_PROMPT = """You are a strict data extraction engine for Indian government scheme eligibility.
|
| 51 |
+
|
| 52 |
+
From the user's message, extract ONLY the demographic parameters that are EXPLICITLY stated or directly implied. Do NOT guess or infer values that are not mentioned.
|
| 53 |
+
|
| 54 |
+
Return a JSON object with ONLY the fields that were mentioned. Use these exact field names:
|
| 55 |
+
- "annual_income": number (in INR, convert lakhs to absolute: 2.5 lakhs = 250000)
|
| 56 |
+
- "age": integer
|
| 57 |
+
- "is_farmer": boolean
|
| 58 |
+
- "state": string (Indian state name)
|
| 59 |
+
- "caste_category": string (one of: "General", "OBC", "SC", "ST")
|
| 60 |
+
- "gender": string (one of: "Male", "Female", "Other")
|
| 61 |
+
- "is_bpl": boolean (below poverty line)
|
| 62 |
+
- "land_size_hectares": float
|
| 63 |
+
- "is_disabled": boolean
|
| 64 |
+
- "occupation": string
|
| 65 |
+
|
| 66 |
+
CRITICAL RULES:
|
| 67 |
+
1. Only include fields the user actually mentioned.
|
| 68 |
+
2. If user says "I earn 2 lakhs", set "annual_income": 200000.
|
| 69 |
+
3. If user says "I'm a farmer with 3 acres", set "is_farmer": true, "land_size_hectares": 1.21 (1 acre = 0.4047 hectares).
|
| 70 |
+
4. If user says "SC category", set "caste_category": "SC".
|
| 71 |
+
5. Return ONLY valid JSON. No markdown, no explanation.
|
| 72 |
+
6. If NO parameters can be extracted, return: {}
|
| 73 |
+
|
| 74 |
+
User message: {user_message}"""
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ── Missing Data Detection ──────────────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
# Minimum required fields for a meaningful eligibility check
|
| 80 |
+
MINIMUM_REQUIRED_FIELDS = {"annual_income", "age", "state"}
|
| 81 |
+
|
| 82 |
+
# Human-readable prompts for missing fields
|
| 83 |
+
MISSING_FIELD_PROMPTS = {
|
| 84 |
+
"annual_income": "What is your annual household income (in Rupees)?",
|
| 85 |
+
"age": "How old are you?",
|
| 86 |
+
"state": "Which Indian state do you live in?",
|
| 87 |
+
"is_farmer": "Are you a registered farmer?",
|
| 88 |
+
"caste_category": "What is your social category (General/OBC/SC/ST)?",
|
| 89 |
+
"gender": "What is your gender?",
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ── Core Functions ──────────────────────────────────────────────────────────
|
| 94 |
+
|
| 95 |
+
def classify_intent(query: str, groq_client: Groq) -> str:
|
| 96 |
+
"""
|
| 97 |
+
Classify whether a user query is about eligibility or informational.
|
| 98 |
+
Returns "ELIGIBILITY" or "INFORMATIONAL".
|
| 99 |
+
"""
|
| 100 |
+
try:
|
| 101 |
+
response = groq_client.chat.completions.create(
|
| 102 |
+
model="llama-3.3-70b-versatile",
|
| 103 |
+
messages=[
|
| 104 |
+
{"role": "system", "content": INTENT_CLASSIFIER_PROMPT},
|
| 105 |
+
{"role": "user", "content": query}
|
| 106 |
+
],
|
| 107 |
+
temperature=0.0,
|
| 108 |
+
max_tokens=10,
|
| 109 |
+
stream=False
|
| 110 |
+
)
|
| 111 |
+
result = (response.choices[0].message.content or "").strip().upper()
|
| 112 |
+
return "ELIGIBILITY" if "ELIGIBILITY" in result else "INFORMATIONAL"
|
| 113 |
+
except Exception as e:
|
| 114 |
+
print(f"⚠️ Intent classification failed: {e}")
|
| 115 |
+
return "INFORMATIONAL" # Safe fallback: treat as info query
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def extract_parameters(query: str, groq_client: Groq) -> dict:
|
| 119 |
+
"""
|
| 120 |
+
Use LLM to extract structured demographic parameters from natural language.
|
| 121 |
+
Returns a dict of extracted parameters (only fields mentioned by user).
|
| 122 |
+
"""
|
| 123 |
+
try:
|
| 124 |
+
response = groq_client.chat.completions.create(
|
| 125 |
+
model="llama-3.3-70b-versatile",
|
| 126 |
+
messages=[
|
| 127 |
+
{"role": "system", "content": "You are JSON extraction engine. Return ONLY valid JSON."},
|
| 128 |
+
{"role": "user", "content": PARAM_EXTRACTION_PROMPT.format(user_message=query)}
|
| 129 |
+
],
|
| 130 |
+
temperature=0.0,
|
| 131 |
+
max_tokens=256,
|
| 132 |
+
stream=False
|
| 133 |
+
)
|
| 134 |
+
raw = response.choices[0].message.content or "{}"
|
| 135 |
+
|
| 136 |
+
# Strip markdown code fences if present
|
| 137 |
+
raw = re.sub(r'^```(?:json)?\s*', '', raw.strip())
|
| 138 |
+
raw = re.sub(r'\s*```$', '', raw.strip())
|
| 139 |
+
|
| 140 |
+
params = json.loads(raw)
|
| 141 |
+
if not isinstance(params, dict):
|
| 142 |
+
return {}
|
| 143 |
+
return params
|
| 144 |
+
except (json.JSONDecodeError, Exception) as e:
|
| 145 |
+
print(f"⚠️ Parameter extraction failed: {e}")
|
| 146 |
+
return {}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def detect_missing_fields(extracted: dict) -> list[str]:
|
| 150 |
+
"""
|
| 151 |
+
Check if the minimum required fields are present.
|
| 152 |
+
Returns a list of missing field names.
|
| 153 |
+
"""
|
| 154 |
+
missing = []
|
| 155 |
+
for field in MINIMUM_REQUIRED_FIELDS:
|
| 156 |
+
if field not in extracted or extracted[field] in (None, "", 0):
|
| 157 |
+
missing.append(field)
|
| 158 |
+
return missing
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def generate_followup_question(missing_fields: list[str], groq_client: Groq, target_language: str = "english") -> str:
|
| 162 |
+
"""
|
| 163 |
+
Generate a natural language follow-up question asking for missing data.
|
| 164 |
+
This is driven by OpenFisca's requirements, not LLM guessing.
|
| 165 |
+
"""
|
| 166 |
+
questions = [MISSING_FIELD_PROMPTS.get(f, f"Please provide your {f}.") for f in missing_fields[:2]]
|
| 167 |
+
combined = " Also, " .join(questions)
|
| 168 |
+
|
| 169 |
+
prompt = f"""The user wants to check their eligibility for government schemes, but we need more information.
|
| 170 |
+
Ask them the following in a friendly, conversational way: {combined}
|
| 171 |
+
Keep it under 50 words. Be warm and encouraging."""
|
| 172 |
+
|
| 173 |
+
try:
|
| 174 |
+
response = groq_client.chat.completions.create(
|
| 175 |
+
model="llama-3.3-70b-versatile",
|
| 176 |
+
messages=[
|
| 177 |
+
{"role": "system", "content": "You are GovBridge AI, a friendly government scheme assistant."},
|
| 178 |
+
{"role": "user", "content": prompt}
|
| 179 |
+
],
|
| 180 |
+
temperature=0.3,
|
| 181 |
+
max_tokens=100,
|
| 182 |
+
stream=False
|
| 183 |
+
)
|
| 184 |
+
return response.choices[0].message.content or combined
|
| 185 |
+
except Exception:
|
| 186 |
+
return combined
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def format_eligibility_results(results: dict, profile: dict, groq_client: Groq) -> str:
|
| 190 |
+
"""
|
| 191 |
+
Convert OpenFisca's deterministic boolean results into conversational prose.
|
| 192 |
+
|
| 193 |
+
CRITICAL: The LLM does NOT perform any eligibility logic here.
|
| 194 |
+
It ONLY reads the pre-computed True/False values and translates
|
| 195 |
+
them into natural language. The math was done by OpenFisca.
|
| 196 |
+
"""
|
| 197 |
+
eligible = [k.replace("eligible_", "").replace("_", " ").title()
|
| 198 |
+
for k, v in results.items() if v]
|
| 199 |
+
not_eligible = [k.replace("eligible_", "").replace("_", " ").title()
|
| 200 |
+
for k, v in results.items() if not v]
|
| 201 |
+
|
| 202 |
+
prompt = f"""Based on a deterministic eligibility check (NOT your opinion), here are the results:
|
| 203 |
+
|
| 204 |
+
ELIGIBLE schemes (TRUE): {', '.join(eligible) if eligible else 'None'}
|
| 205 |
+
NOT ELIGIBLE schemes (FALSE): {', '.join(not_eligible) if not_eligible else 'None'}
|
| 206 |
+
|
| 207 |
+
User profile: Income ₹{profile.get('annual_income', 'N/A')}, Age {profile.get('age', 'N/A')}, State: {profile.get('state', 'N/A')}
|
| 208 |
+
|
| 209 |
+
Write a clear, friendly summary. List each eligible scheme as a bullet point.
|
| 210 |
+
Mention the key reason for eligibility (income/age/category match).
|
| 211 |
+
Keep under 200 words. Do NOT add any schemes not in the list above.
|
| 212 |
+
Do NOT perform any additional eligibility calculations."""
|
| 213 |
+
|
| 214 |
+
try:
|
| 215 |
+
response = groq_client.chat.completions.create(
|
| 216 |
+
model="llama-3.3-70b-versatile",
|
| 217 |
+
messages=[
|
| 218 |
+
{"role": "system", "content":
|
| 219 |
+
"You are GovBridge AI. You ONLY report results from the eligibility engine. "
|
| 220 |
+
"You NEVER perform your own eligibility calculations. "
|
| 221 |
+
"You NEVER add schemes not listed in the input. "
|
| 222 |
+
"Respond exclusively in English."},
|
| 223 |
+
{"role": "user", "content": prompt}
|
| 224 |
+
],
|
| 225 |
+
temperature=0.1,
|
| 226 |
+
max_tokens=400,
|
| 227 |
+
stream=False
|
| 228 |
+
)
|
| 229 |
+
return response.choices[0].message.content or f"You are eligible for: {', '.join(eligible)}"
|
| 230 |
+
except Exception:
|
| 231 |
+
if eligible:
|
| 232 |
+
return f"Based on your profile, you may be eligible for: {', '.join(eligible)}."
|
| 233 |
+
return "Based on your profile, no matching schemes were found. Try adjusting your criteria."
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def run_neuro_symbolic_pipeline(
|
| 237 |
+
query: str,
|
| 238 |
+
groq_client: Groq,
|
| 239 |
+
existing_profile: Optional[dict] = None
|
| 240 |
+
) -> dict:
|
| 241 |
+
"""
|
| 242 |
+
Main entry point for the Neuro-Symbolic Bridge.
|
| 243 |
+
|
| 244 |
+
Returns a dict with:
|
| 245 |
+
- "type": "eligibility_result" | "followup_question" | "informational"
|
| 246 |
+
- "response": str (the response text)
|
| 247 |
+
- "extracted_params": dict (what we extracted)
|
| 248 |
+
- "eligible_schemes": list[str] (if type is eligibility_result)
|
| 249 |
+
- "full_results": dict (raw OpenFisca output)
|
| 250 |
+
"""
|
| 251 |
+
# Step 1: Classify intent
|
| 252 |
+
intent = classify_intent(query, groq_client)
|
| 253 |
+
|
| 254 |
+
if intent == "INFORMATIONAL":
|
| 255 |
+
return {"type": "informational"}
|
| 256 |
+
|
| 257 |
+
# Step 2: Extract parameters from the query
|
| 258 |
+
extracted = extract_parameters(query, groq_client)
|
| 259 |
+
|
| 260 |
+
# Merge with any existing profile data (from previous turns)
|
| 261 |
+
if existing_profile:
|
| 262 |
+
merged = {**existing_profile, **extracted}
|
| 263 |
+
else:
|
| 264 |
+
merged = extracted
|
| 265 |
+
|
| 266 |
+
# Step 3: Check for missing required fields
|
| 267 |
+
missing = detect_missing_fields(merged)
|
| 268 |
+
|
| 269 |
+
if missing:
|
| 270 |
+
followup = generate_followup_question(missing, groq_client)
|
| 271 |
+
return {
|
| 272 |
+
"type": "followup_question",
|
| 273 |
+
"response": followup,
|
| 274 |
+
"extracted_params": merged,
|
| 275 |
+
"missing_fields": missing,
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
# Step 4: Run OpenFisca deterministic evaluation
|
| 279 |
+
results = check_eligibility(merged)
|
| 280 |
+
|
| 281 |
+
# Step 5: Format results into natural language
|
| 282 |
+
response_text = format_eligibility_results(results, merged, groq_client)
|
| 283 |
+
|
| 284 |
+
eligible_schemes = [k.replace("eligible_", "").replace("_", " ").title()
|
| 285 |
+
for k, v in results.items() if v]
|
| 286 |
+
|
| 287 |
+
return {
|
| 288 |
+
"type": "eligibility_result",
|
| 289 |
+
"response": response_text,
|
| 290 |
+
"extracted_params": merged,
|
| 291 |
+
"eligible_schemes": eligible_schemes,
|
| 292 |
+
"full_results": results,
|
| 293 |
+
}
|
requirements.txt
CHANGED
|
@@ -11,7 +11,7 @@ uvicorn[standard]
|
|
| 11 |
# --- ML / AI (CPU-ONLY WHEELS) ---
|
| 12 |
# CRITICAL: --extra-index-url in Dockerfile forces CPU torch
|
| 13 |
# DO NOT add torch here — it is installed separately in Dockerfile Layer 2
|
| 14 |
-
sentence-transformers
|
| 15 |
einops
|
| 16 |
huggingface-hub
|
| 17 |
|
|
@@ -41,3 +41,10 @@ feedparser
|
|
| 41 |
tenacity
|
| 42 |
pydantic
|
| 43 |
pydantic-settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
# --- ML / AI (CPU-ONLY WHEELS) ---
|
| 12 |
# CRITICAL: --extra-index-url in Dockerfile forces CPU torch
|
| 13 |
# DO NOT add torch here — it is installed separately in Dockerfile Layer 2
|
| 14 |
+
sentence-transformers==2.7.0
|
| 15 |
einops
|
| 16 |
huggingface-hub
|
| 17 |
|
|
|
|
| 41 |
tenacity
|
| 42 |
pydantic
|
| 43 |
pydantic-settings
|
| 44 |
+
|
| 45 |
+
# --- Memory Monitoring (Sprint 18) ---
|
| 46 |
+
psutil
|
| 47 |
+
|
| 48 |
+
# --- Deterministic Rules Engine (Sprint 19) ---
|
| 49 |
+
openfisca-core>=44.0.0
|
| 50 |
+
|
tests/test_core.py
CHANGED
|
@@ -523,3 +523,103 @@ class TestBhashiniLanguageMaps:
|
|
| 523 |
parts = code.split("_")
|
| 524 |
assert len(parts[0]) == 3, f"Script code for {lang} should be 3 chars"
|
| 525 |
assert len(parts[1]) == 4, f"Script name for {lang} should be 4 chars"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
parts = code.split("_")
|
| 524 |
assert len(parts[0]) == 3, f"Script code for {lang} should be 3 chars"
|
| 525 |
assert len(parts[1]) == 4, f"Script name for {lang} should be 4 chars"
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
# ── Sprint 19: OpenFisca Neuro-Symbolic Tests ────────────────────────────
|
| 529 |
+
|
| 530 |
+
class TestOpenFiscaEngine:
|
| 531 |
+
"""Test the deterministic eligibility engine."""
|
| 532 |
+
|
| 533 |
+
def test_pm_kisan_eligible(self):
|
| 534 |
+
"""Farmer with low income should qualify for PM-KISAN."""
|
| 535 |
+
from eligibility.engine import check_eligibility
|
| 536 |
+
profile = {
|
| 537 |
+
"annual_income": 150000,
|
| 538 |
+
"age": 45,
|
| 539 |
+
"is_farmer": True,
|
| 540 |
+
"state": "Rajasthan",
|
| 541 |
+
"caste_category": "General",
|
| 542 |
+
}
|
| 543 |
+
results = check_eligibility(profile)
|
| 544 |
+
assert results["eligible_pm_kisan"] is True
|
| 545 |
+
|
| 546 |
+
def test_pm_kisan_not_eligible_high_income(self):
|
| 547 |
+
"""Non-farmer should NOT qualify for PM-KISAN."""
|
| 548 |
+
from eligibility.engine import check_eligibility
|
| 549 |
+
profile = {
|
| 550 |
+
"annual_income": 500000,
|
| 551 |
+
"age": 30,
|
| 552 |
+
"is_farmer": False,
|
| 553 |
+
"state": "Delhi",
|
| 554 |
+
"caste_category": "General",
|
| 555 |
+
}
|
| 556 |
+
results = check_eligibility(profile)
|
| 557 |
+
assert results["eligible_pm_kisan"] is False
|
| 558 |
+
|
| 559 |
+
def test_pmjay_eligible_low_income(self):
|
| 560 |
+
"""Low income citizen should qualify for PMJAY."""
|
| 561 |
+
from eligibility.engine import check_eligibility
|
| 562 |
+
profile = {
|
| 563 |
+
"annual_income": 300000,
|
| 564 |
+
"age": 35,
|
| 565 |
+
"is_farmer": False,
|
| 566 |
+
"state": "Maharashtra",
|
| 567 |
+
"caste_category": "OBC",
|
| 568 |
+
}
|
| 569 |
+
results = check_eligibility(profile)
|
| 570 |
+
assert results["eligible_pmjay"] is True
|
| 571 |
+
|
| 572 |
+
def test_pmjay_not_eligible_high_income(self):
|
| 573 |
+
"""High income citizen should NOT qualify for PMJAY."""
|
| 574 |
+
from eligibility.engine import check_eligibility
|
| 575 |
+
profile = {
|
| 576 |
+
"annual_income": 800000,
|
| 577 |
+
"age": 35,
|
| 578 |
+
"is_farmer": False,
|
| 579 |
+
"state": "Maharashtra",
|
| 580 |
+
"caste_category": "General",
|
| 581 |
+
}
|
| 582 |
+
results = check_eligibility(profile)
|
| 583 |
+
assert results["eligible_pmjay"] is False
|
| 584 |
+
|
| 585 |
+
def test_all_variables_present(self):
|
| 586 |
+
"""Engine should evaluate all registered variables without crashing."""
|
| 587 |
+
from eligibility.engine import check_eligibility, ELIGIBILITY_VARIABLES
|
| 588 |
+
profile = {
|
| 589 |
+
"annual_income": 200000,
|
| 590 |
+
"age": 30,
|
| 591 |
+
"is_farmer": True,
|
| 592 |
+
"state": "Rajasthan",
|
| 593 |
+
"caste_category": "SC",
|
| 594 |
+
"gender": "Male",
|
| 595 |
+
"is_bpl": True,
|
| 596 |
+
"land_size_hectares": 1.5,
|
| 597 |
+
"is_disabled": False,
|
| 598 |
+
"occupation": "Farmer",
|
| 599 |
+
}
|
| 600 |
+
results = check_eligibility(profile)
|
| 601 |
+
assert len(results) == len(ELIGIBILITY_VARIABLES)
|
| 602 |
+
# All keys should be present
|
| 603 |
+
for var in ELIGIBILITY_VARIABLES:
|
| 604 |
+
assert var in results
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
class TestNeuroSymbolicBridge:
|
| 608 |
+
"""Test the intent classification and parameter extraction."""
|
| 609 |
+
|
| 610 |
+
def test_missing_field_detection(self):
|
| 611 |
+
"""Should detect missing required fields."""
|
| 612 |
+
from neuro_symbolic import detect_missing_fields
|
| 613 |
+
# Empty profile — everything is missing
|
| 614 |
+
missing = detect_missing_fields({})
|
| 615 |
+
assert "annual_income" in missing
|
| 616 |
+
assert "age" in missing
|
| 617 |
+
assert "state" in missing
|
| 618 |
+
|
| 619 |
+
def test_complete_profile_no_missing(self):
|
| 620 |
+
"""Complete profile should have no missing required fields."""
|
| 621 |
+
from neuro_symbolic import detect_missing_fields
|
| 622 |
+
profile = {"annual_income": 200000, "age": 30, "state": "Delhi"}
|
| 623 |
+
missing = detect_missing_fields(profile)
|
| 624 |
+
assert len(missing) == 0
|
| 625 |
+
|