Acytel commited on
Commit
a40c8e4
·
1 Parent(s): ecd9c42

Configure production environment variables for Vercel deployment

Browse files
Files changed (5) hide show
  1. Dockerfile +19 -0
  2. README.md +8 -0
  3. api.py +189 -73
  4. requirements.txt +4 -4
  5. 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 requests
3
- import httpx
4
- from fastapi import FastAPI
 
 
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
- HF_TOKEN = os.environ.get("HF_TOKEN") # New Free API Key
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
- # Allow React to talk to this API securely
 
 
 
 
 
22
  app.add_middleware(
23
  CORSMiddleware,
24
  allow_origins=["*"],
25
- allow_credentials=False, # <-- This is the magic CORS fix!
26
  allow_methods=["*"],
27
  allow_headers=["*"],
 
28
  )
29
 
 
30
  class SearchQuery(BaseModel):
31
- question: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  @app.post("/api/rag/query")
34
- async def rag_query(query: SearchQuery):
 
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
- hf_response = requests.post(
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
- # Safety check if HF is completely overloaded
55
- if isinstance(hf_data, dict) and "error" in hf_data:
56
- print(f"HuggingFace Error: {hf_data}")
57
- async def error_msg():
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
- except Exception as e:
64
- print(f"Critical Network Error: {e}")
65
- async def crash_msg():
66
- yield "The AI Brain is temporarily disconnected from the network. Please try again."
67
- return StreamingResponse(crash_msg(), media_type="text/plain")
68
-
69
- result = supabase.rpc("match_documents", {
70
- "query_embedding": query_numbers,
71
- "match_count": 5
72
- }).execute()
73
-
74
- chunks = result.data
75
-
76
- if not chunks:
77
- context = "No relevant schemes found in the database."
78
- else:
79
- context = "\n\n---\n\n".join([
80
- f"[Document: {c.get('scheme_title', 'Unknown')}]\n{c.get('chunk_text', '')}"
81
- for c in chunks
82
- ])
83
-
84
- def stream_answer():
85
- response = groq_client.chat.completions.create(
86
- model="llama-3.3-70b-versatile",
87
- messages=[
88
- {"role": "system", "content":
89
- "You are GovBridge AI, a helpful assistant for Indian citizens. "
90
- "Answer the user's question using ONLY the provided context. "
91
- "Always cite the official scheme names. If the context does not "
92
- "contain the answer, clearly say 'This information is not available in our database.'"},
93
- {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query.question}"}
94
- ],
95
- temperature=0.1,
96
- max_tokens=1024,
97
- stream=True
 
 
 
 
 
 
 
98
  )
99
- for chunk in response:
100
- delta = chunk.choices[0].delta.content or ""
101
- if delta:
102
- yield delta
103
 
104
- return StreamingResponse(stream_answer(), media_type="text/plain")
 
 
 
 
 
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
- pydantic
4
- supabase
5
  groq
6
- httpx
 
 
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)