Spaces:
Sleeping
Sleeping
Configure production environment variables for Vercel deployment
Browse files- Dockerfile +19 -0
- README.md +8 -0
- api.py +189 -73
- requirements.txt +4 -4
- test_ingest.py +30 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Layer 1: Install dependencies (Cached)
|
| 6 |
+
COPY requirements.txt .
|
| 7 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
+
|
| 9 |
+
# Layer 2: Pre-download model (Cached permanently)
|
| 10 |
+
RUN python -c "\
|
| 11 |
+
from sentence_transformers import SentenceTransformer; \
|
| 12 |
+
model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2'); \
|
| 13 |
+
print('✓ Model cached at build time — zero runtime download needed')"
|
| 14 |
+
|
| 15 |
+
# Layer 3: Copy application code (Updates instantly on code changes)
|
| 16 |
+
COPY . .
|
| 17 |
+
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: GovBridge India API
|
| 3 |
+
emoji: 🏛️
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: green
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
---
|
api.py
CHANGED
|
@@ -1,104 +1,220 @@
|
|
| 1 |
import os
|
| 2 |
-
import
|
| 3 |
-
import
|
| 4 |
-
from
|
|
|
|
|
|
|
| 5 |
from fastapi.responses import StreamingResponse
|
| 6 |
-
from pydantic import BaseModel
|
| 7 |
from supabase import create_client, Client
|
| 8 |
from groq import Groq
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
|
|
|
| 11 |
SUPABASE_URL = os.environ.get("SUPABASE_URL")
|
| 12 |
-
SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
|
| 13 |
GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
|
| 14 |
-
|
| 15 |
|
| 16 |
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 17 |
groq_client = Groq(api_key=GROQ_API_KEY)
|
| 18 |
|
| 19 |
app = FastAPI()
|
| 20 |
|
| 21 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
app.add_middleware(
|
| 23 |
CORSMiddleware,
|
| 24 |
allow_origins=["*"],
|
| 25 |
-
allow_credentials=False,
|
| 26 |
allow_methods=["*"],
|
| 27 |
allow_headers=["*"],
|
|
|
|
| 28 |
)
|
| 29 |
|
|
|
|
| 30 |
class SearchQuery(BaseModel):
|
| 31 |
-
question: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
@app.post("/api/rag/query")
|
| 34 |
-
|
|
|
|
| 35 |
print(f"Citizen asked: {query.question}")
|
| 36 |
|
| 37 |
-
# NEW: Hugging Face API Fallback (Zero RAM Usage)
|
| 38 |
-
hf_url = "https://api-inference.huggingface.co/models/sentence-transformers/all-MiniLM-L6-v2"
|
| 39 |
-
headers = {"Authorization": f"Bearer {HF_TOKEN}"}
|
| 40 |
-
|
| 41 |
-
print("Pinging Hugging Face...")
|
| 42 |
try:
|
| 43 |
-
|
| 44 |
-
hf_url,
|
| 45 |
-
headers=headers,
|
| 46 |
-
json={
|
| 47 |
-
"inputs": [query.question],
|
| 48 |
-
"options": {"wait_for_model": True} # Forces HF to wake up automatically
|
| 49 |
-
},
|
| 50 |
-
timeout=120.0 # Gives the model plenty of time to boot up
|
| 51 |
-
)
|
| 52 |
-
hf_data = hf_response.json()
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
yield "The AI Brain is experiencing heavy traffic. Please try asking again in 15 seconds!"
|
| 59 |
-
return StreamingResponse(error_msg(), media_type="text/plain")
|
| 60 |
-
|
| 61 |
-
query_numbers = hf_data[0]
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
)
|
| 99 |
-
for chunk in response:
|
| 100 |
-
delta = chunk.choices[0].delta.content or ""
|
| 101 |
-
if delta:
|
| 102 |
-
yield delta
|
| 103 |
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
import hashlib
|
| 3 |
+
import re
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from fastapi import FastAPI, Request
|
| 7 |
from fastapi.responses import StreamingResponse
|
| 8 |
+
from pydantic import BaseModel, Field, field_validator
|
| 9 |
from supabase import create_client, Client
|
| 10 |
from groq import Groq
|
| 11 |
from fastapi.middleware.cors import CORSMiddleware
|
| 12 |
+
from sentence_transformers import SentenceTransformer
|
| 13 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 14 |
+
from slowapi.util import get_remote_address
|
| 15 |
+
from slowapi.errors import RateLimitExceeded
|
| 16 |
|
| 17 |
+
# --- SECURE KEYS ---
|
| 18 |
SUPABASE_URL = os.environ.get("SUPABASE_URL")
|
| 19 |
+
SUPABASE_KEY = os.environ.get("SUPABASE_KEY")
|
| 20 |
GROQ_API_KEY = os.environ.get("GROQ_API_KEY")
|
| 21 |
+
ADMIN_SECRET = os.environ.get("ADMIN_SECRET", "change-this-in-production")
|
| 22 |
|
| 23 |
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 24 |
groq_client = Groq(api_key=GROQ_API_KEY)
|
| 25 |
|
| 26 |
app = FastAPI()
|
| 27 |
|
| 28 |
+
# --- RATE LIMITER CONFIG ---
|
| 29 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 30 |
+
app.state.limiter = limiter
|
| 31 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 32 |
+
|
| 33 |
+
# --- CORS MIDDLEWARE (Gotcha 2 Fix: expose_headers applied) ---
|
| 34 |
app.add_middleware(
|
| 35 |
CORSMiddleware,
|
| 36 |
allow_origins=["*"],
|
| 37 |
+
allow_credentials=False,
|
| 38 |
allow_methods=["*"],
|
| 39 |
allow_headers=["*"],
|
| 40 |
+
expose_headers=["X-Sources"],
|
| 41 |
)
|
| 42 |
|
| 43 |
+
# --- MODELS ---
|
| 44 |
class SearchQuery(BaseModel):
|
| 45 |
+
question: str = Field(..., min_length=3, max_length=500)
|
| 46 |
+
|
| 47 |
+
@field_validator('question')
|
| 48 |
+
@classmethod
|
| 49 |
+
def clean_input(cls, v):
|
| 50 |
+
v = re.sub(r'<[^>]+>', '', v) # strip HTML tags
|
| 51 |
+
v = re.sub(r'\s+', ' ', v).strip() # normalize whitespace
|
| 52 |
+
if not v:
|
| 53 |
+
raise ValueError('Question is empty after cleaning')
|
| 54 |
+
return v
|
| 55 |
+
|
| 56 |
+
class IngestRequest(BaseModel):
|
| 57 |
+
title: str
|
| 58 |
+
text: str
|
| 59 |
+
ministry: Optional[str] = None
|
| 60 |
+
state: Optional[str] = None
|
| 61 |
+
source_url: Optional[str] = None
|
| 62 |
+
doc_type: Optional[str] = "scheme"
|
| 63 |
+
|
| 64 |
+
# --- INITIALIZE AI ---
|
| 65 |
+
print("Loading heavy PyTorch AI Model...")
|
| 66 |
+
embedding_model = SentenceTransformer('sentence-transformers/all-MiniLM-L6-v2')
|
| 67 |
+
|
| 68 |
+
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
|
| 69 |
+
paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]
|
| 70 |
+
chunks = []
|
| 71 |
+
current = ""
|
| 72 |
+
for para in paragraphs:
|
| 73 |
+
if len(current) + len(para) < chunk_size:
|
| 74 |
+
current += " " + para
|
| 75 |
+
else:
|
| 76 |
+
if current.strip():
|
| 77 |
+
chunks.append(current.strip())
|
| 78 |
+
current = para
|
| 79 |
+
if current.strip():
|
| 80 |
+
chunks.append(current.strip())
|
| 81 |
+
if len(chunks) <= 1:
|
| 82 |
+
return chunks
|
| 83 |
+
overlapped = [chunks[0]]
|
| 84 |
+
for i in range(1, len(chunks)):
|
| 85 |
+
tail = chunks[i-1][-overlap:] if len(chunks[i-1]) > overlap else chunks[i-1]
|
| 86 |
+
overlapped.append(tail + " " + chunks[i])
|
| 87 |
+
return overlapped
|
| 88 |
+
|
| 89 |
+
# --- ENDPOINTS ---
|
| 90 |
+
|
| 91 |
+
@app.get("/health")
|
| 92 |
+
async def health_check():
|
| 93 |
+
checks = {}
|
| 94 |
+
try:
|
| 95 |
+
test_vec = embedding_model.encode("test", normalize_embeddings=True)
|
| 96 |
+
checks["embedding_model"] = {"status": "ok", "dims": len(test_vec)}
|
| 97 |
+
except Exception as e:
|
| 98 |
+
checks["embedding_model"] = {"status": "error", "detail": str(e)}
|
| 99 |
+
try:
|
| 100 |
+
supabase.table("document_chunks").select("id").limit(1).execute()
|
| 101 |
+
checks["supabase"] = {"status": "ok"}
|
| 102 |
+
except Exception as e:
|
| 103 |
+
checks["supabase"] = {"status": "error", "detail": str(e)}
|
| 104 |
+
checks["groq"] = {
|
| 105 |
+
"status": "ok" if os.environ.get("GROQ_API_KEY") else "missing"
|
| 106 |
+
}
|
| 107 |
+
all_ok = all(v["status"] == "ok" for v in checks.values())
|
| 108 |
+
return {
|
| 109 |
+
"status": "healthy" if all_ok else "degraded",
|
| 110 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 111 |
+
"checks": checks
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
@app.post("/api/admin/ingest")
|
| 115 |
+
async def ingest_document(request: IngestRequest, admin_key: str = ""):
|
| 116 |
+
if admin_key != ADMIN_SECRET:
|
| 117 |
+
async def denied():
|
| 118 |
+
yield "Unauthorized"
|
| 119 |
+
return StreamingResponse(denied(), status_code=401, media_type="text/plain")
|
| 120 |
+
|
| 121 |
+
print(f"Ingesting: '{request.title}' ({len(request.text)} chars)")
|
| 122 |
+
doc_hash = hashlib.sha256(request.text.encode()).hexdigest()[:16]
|
| 123 |
+
chunks = chunk_text(request.text)
|
| 124 |
+
print(f"Created {len(chunks)} chunks")
|
| 125 |
+
|
| 126 |
+
embeddings = embedding_model.encode(
|
| 127 |
+
chunks,
|
| 128 |
+
normalize_embeddings=True,
|
| 129 |
+
batch_size=32,
|
| 130 |
+
show_progress_bar=False
|
| 131 |
+
).tolist()
|
| 132 |
+
|
| 133 |
+
rows = []
|
| 134 |
+
for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)):
|
| 135 |
+
rows.append({
|
| 136 |
+
"chunk_index": i,
|
| 137 |
+
"chunk_text": chunk,
|
| 138 |
+
"scheme_title": request.title,
|
| 139 |
+
"ministry": request.ministry,
|
| 140 |
+
"state": request.state,
|
| 141 |
+
"source_url": request.source_url,
|
| 142 |
+
"doc_type": request.doc_type,
|
| 143 |
+
"embedding": embedding,
|
| 144 |
+
"content_hash": doc_hash
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
result = supabase.table("document_chunks").upsert(
|
| 148 |
+
rows,
|
| 149 |
+
on_conflict="content_hash,chunk_index"
|
| 150 |
+
).execute()
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
"status": "success",
|
| 154 |
+
"title": request.title,
|
| 155 |
+
"chunks_created": len(chunks),
|
| 156 |
+
"doc_hash": doc_hash
|
| 157 |
+
}
|
| 158 |
|
| 159 |
@app.post("/api/rag/query")
|
| 160 |
+
@limiter.limit("10/minute")
|
| 161 |
+
async def rag_query(request: Request, query: SearchQuery):
|
| 162 |
print(f"Citizen asked: {query.question}")
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
try:
|
| 165 |
+
query_numbers = embedding_model.encode(query.question, normalize_embeddings=True).tolist()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
+
result = supabase.rpc("match_documents", {
|
| 168 |
+
"query_embedding": query_numbers,
|
| 169 |
+
"match_count": 5
|
| 170 |
+
}).execute()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
|
| 172 |
+
chunks = result.data
|
| 173 |
+
|
| 174 |
+
if not chunks:
|
| 175 |
+
context = "This information is not available in the GovBridge database for this query."
|
| 176 |
+
source_titles = []
|
| 177 |
+
else:
|
| 178 |
+
context = "\n\n---\n\n".join([
|
| 179 |
+
f"[Document: {c.get('scheme_title', 'Unknown')}]\n{c.get('chunk_text', '')}"
|
| 180 |
+
for c in chunks
|
| 181 |
+
])
|
| 182 |
+
source_titles = list(set([
|
| 183 |
+
c.get('scheme_title', 'Unknown')
|
| 184 |
+
for c in chunks
|
| 185 |
+
if c.get('scheme_title')
|
| 186 |
+
]))
|
| 187 |
+
|
| 188 |
+
def stream_answer():
|
| 189 |
+
response = groq_client.chat.completions.create(
|
| 190 |
+
model="llama-3.3-70b-versatile",
|
| 191 |
+
messages=[
|
| 192 |
+
{"role": "system", "content":
|
| 193 |
+
"You are GovBridge AI, a helpful assistant for Indian citizens. "
|
| 194 |
+
"Answer the user's question using ONLY the provided context. "
|
| 195 |
+
"Format benefits as bullet points where applicable."},
|
| 196 |
+
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query.question}"}
|
| 197 |
+
],
|
| 198 |
+
temperature=0.1,
|
| 199 |
+
max_tokens=1024,
|
| 200 |
+
stream=True
|
| 201 |
+
)
|
| 202 |
+
for chunk in response:
|
| 203 |
+
delta = chunk.choices[0].delta.content or ""
|
| 204 |
+
if delta:
|
| 205 |
+
yield delta
|
| 206 |
+
|
| 207 |
+
return StreamingResponse(
|
| 208 |
+
stream_answer(),
|
| 209 |
+
media_type="text/plain",
|
| 210 |
+
headers={
|
| 211 |
+
"X-Accel-Buffering": "no",
|
| 212 |
+
"X-Sources": "|".join(source_titles)
|
| 213 |
+
}
|
| 214 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
+
except Exception as e:
|
| 217 |
+
print(f"System Error: {e}")
|
| 218 |
+
async def crash_msg():
|
| 219 |
+
yield "The AI Brain encountered an internal logic error. Please try again."
|
| 220 |
+
return StreamingResponse(crash_msg(), media_type="text/plain")
|
requirements.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
fastapi
|
| 2 |
-
uvicorn
|
| 3 |
-
|
| 4 |
-
supabase
|
| 5 |
groq
|
| 6 |
-
|
|
|
|
|
|
| 1 |
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
sentence-transformers
|
|
|
|
| 4 |
groq
|
| 5 |
+
supabase
|
| 6 |
+
slowapi
|
test_ingest.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
# Replace this with your actual Hugging Face Space URL
|
| 5 |
+
HF_API_URL = "https://harshrawat18-govbridge-api.hf.space"
|
| 6 |
+
|
| 7 |
+
# Replace this with the ADMIN_SECRET you just created in Hugging Face
|
| 8 |
+
ADMIN_SECRET = "govbridge-1008-1119wfgdfgwfwjhghjwgjhvbwvc"
|
| 9 |
+
|
| 10 |
+
# The real data we are sending to the AI
|
| 11 |
+
payload = {
|
| 12 |
+
"title": "PM-KISAN (Pradhan Mantri Kisan Samman Nidhi)",
|
| 13 |
+
"text": "Under the PM-KISAN scheme, all landholding farmers' families shall be provided the financial benefit of Rs. 6000 per annum per family payable in three equal installments of Rs. 2000 each, every four months. The scheme aims to supplement the financial needs of the farmers in procuring various inputs to ensure proper crop health and appropriate yields, commensurate with the anticipated farm income.",
|
| 14 |
+
"ministry": "Ministry of Agriculture and Farmers Welfare",
|
| 15 |
+
"doc_type": "scheme"
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
print("Sending data across the bridge to Hugging Face...")
|
| 19 |
+
|
| 20 |
+
response = requests.post(
|
| 21 |
+
f"{HF_API_URL}/api/admin/ingest?admin_key={ADMIN_SECRET}",
|
| 22 |
+
json=payload
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
if response.status_code == 200:
|
| 26 |
+
print("✅ SUCCESS!")
|
| 27 |
+
print(json.dumps(response.json(), indent=2))
|
| 28 |
+
else:
|
| 29 |
+
print("❌ FAILED!")
|
| 30 |
+
print(response.text)
|