HiTeckGroup commited on
Commit
8ebdf88
·
verified ·
1 Parent(s): c5ec80d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +299 -0
app.py CHANGED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI backend over DuckDB - ICMR + HITEK combined dataset."""
2
+ import asyncio
3
+ import json
4
+ import os
5
+ import threading
6
+ from concurrent.futures import ThreadPoolExecutor
7
+ from typing import Any
8
+ from datetime import datetime
9
+
10
+ import duckdb
11
+ from fastapi import FastAPI, HTTPException, Query, Response
12
+ from pydantic import BaseModel
13
+
14
+ BASE = os.path.dirname(os.path.abspath(__file__))
15
+
16
+ # ── Environment Variables (Secrets से आएँगी) ──────────
17
+ HF_TOKEN = os.environ.get("HF_TOKEN", "")
18
+ API_KEY = os.environ.get("API_KEY", "") # 🔑 Secret से
19
+
20
+ HF_INDEX_BASE = os.environ.get(
21
+ "ICMR_HF_INDEX_BASE",
22
+ "https://huggingface.co/datasets/HiTeckGroup/HiTeckgroop/resolve/main",
23
+ ).rstrip("/")
24
+
25
+ INDEX_SOURCE = os.environ.get("ICMR_INDEX_SOURCE", "remote").lower()
26
+ PARALLELISM = int(os.environ.get("ICMR_PARALLEL", "2"))
27
+ THREADS_PER_CONN = int(os.environ.get("ICMR_THREADS_PER_CONN", "2"))
28
+ DUPLICATE_CAP = 2
29
+
30
+ SEARCH_FIELDS = [
31
+ "name", "fathersName", "phoneNumber", "aadharNumber", "otherNumber",
32
+ "address", "district", "pincode", "state", "town", "source",
33
+ ]
34
+ NUMBER_FIELDS = ["phoneNumber", "aadharNumber", "otherNumber"]
35
+
36
+ REMOTE_INDEXES = {
37
+ "phone": [f"{HF_INDEX_BASE}/idx_phone.{i}.parquet" for i in range(7)],
38
+ "aadhar": [f"{HF_INDEX_BASE}/idx_aadhar.{i}.parquet" for i in range(7)],
39
+ }
40
+
41
+ # 🔑 Valid Keys (secret से load)
42
+ VALID_KEYS = {}
43
+ if API_KEY:
44
+ VALID_KEYS[API_KEY] = {
45
+ "expires": 1830902400000, # 6 November 2026
46
+ }
47
+
48
+ app = FastAPI(title="ICMR + HITEK Search API",
49
+ description="DuckDB-backed search over 2.5B records")
50
+
51
+ _conns: list[duckdb.DuckDBPyConnection] = []
52
+ _conns_lock = threading.Lock()
53
+ _thread_local = threading.local()
54
+ pool = ThreadPoolExecutor(max_workers=PARALLELISM, thread_name_prefix="duck")
55
+
56
+
57
+ def _idx_ready(kind: str) -> bool:
58
+ return kind in REMOTE_INDEXES
59
+
60
+
61
+ def _new_conn() -> duckdb.DuckDBPyConnection:
62
+ con = duckdb.connect()
63
+ con.execute("INSTALL parquet; LOAD parquet;")
64
+ con.execute("INSTALL httpfs; LOAD httpfs;")
65
+
66
+ # 🔐 HF_TOKEN से dataset access (agar private hai)
67
+ if HF_TOKEN:
68
+ con.execute(f"SET http_headers = {{'Authorization': 'Bearer {HF_TOKEN}'}};")
69
+
70
+ for kind, urls in REMOTE_INDEXES.items():
71
+ view = f"people_{kind}"
72
+ lst = ", ".join(f"'{u}'" for u in urls)
73
+ con.execute(f"CREATE OR REPLACE VIEW {view} AS SELECT * FROM read_parquet([{lst}])")
74
+
75
+ con.execute(f"SET threads = {THREADS_PER_CONN}")
76
+ return con
77
+
78
+
79
+ def _thread_id() -> int:
80
+ tid = getattr(_thread_local, "id", None)
81
+ if tid is None:
82
+ with _conns_lock:
83
+ tid = len(_conns)
84
+ _thread_local.id = tid
85
+ return tid
86
+
87
+
88
+ def _get_conn() -> duckdb.DuckDBPyConnection:
89
+ ident = _thread_id()
90
+ with _conns_lock:
91
+ while len(_conns) <= ident:
92
+ _conns.append(_new_conn())
93
+ return _conns[ident]
94
+
95
+
96
+ def _person_key(row: dict[str, Any]) -> tuple[str, ...]:
97
+ ph = (row.get("phoneNumber") or "").strip()
98
+ ad = (row.get("aadharNumber") or "").strip()
99
+ if ph or ad:
100
+ return (ph, ad)
101
+ return (row.get("name") or "").strip(), (row.get("fathersName") or "").strip()
102
+
103
+
104
+ def _cap_duplicates(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
105
+ seen: dict[tuple[str, ...], int] = {}
106
+ out: list[dict[str, Any]] = []
107
+ for r in rows:
108
+ k = _person_key(r)
109
+ n = seen.get(k, 0)
110
+ if n < DUPLICATE_CAP:
111
+ seen[k] = n + 1
112
+ out.append(r)
113
+ return out
114
+
115
+
116
+ def _source_clause(source: str | None) -> str:
117
+ if source and source in ("icmr", "hitek", "inddata"):
118
+ src = "hitek" if source == "hitek" else source
119
+ return f" AND source = '{src}'"
120
+ return ""
121
+
122
+
123
+ def _run_field_search(field: str, value: str, mode: str, limit: int,
124
+ source: str | None = None) -> dict[str, Any]:
125
+ if field not in SEARCH_FIELDS:
126
+ raise ValueError(f"Unknown field: {field}")
127
+ v = value.replace("'", "''")
128
+
129
+ if mode == "exact":
130
+ if field == "phoneNumber" and _idx_ready("phone"):
131
+ view = "people_phone"
132
+ elif field == "aadharNumber" and _idx_ready("aadhar"):
133
+ view = "people_aadhar"
134
+ else:
135
+ return {"field": field, "value": value, "mode": mode, "count": 0, "results": []}
136
+
137
+ sql = (f"SELECT * FROM {view} WHERE {field} = '{v}'"
138
+ f"{_source_clause(source)} LIMIT {limit * DUPLICATE_CAP + 20}")
139
+ elif mode == "contains":
140
+ v2 = v.replace("%", r"\%").replace("_", r"\_")
141
+ sql = (f"SELECT * FROM people_phone WHERE {field} ILIKE '%{v2}%' ESCAPE '\\'"
142
+ f"{_source_clause(source)} LIMIT {limit * DUPLICATE_CAP + 20}")
143
+ else:
144
+ raise ValueError(f"Unknown mode: {mode}")
145
+
146
+ con = _get_conn()
147
+ rows = con.execute(sql).fetchall()
148
+ cols = [d[0] for d in con.description]
149
+ results = _cap_duplicates([dict(zip(cols, r)) for r in rows])[:limit]
150
+ return {"field": field, "value": value, "mode": mode, "count": len(results),
151
+ "results": results}
152
+
153
+
154
+ # 🔑 KEY CHECK FUNCTION
155
+ def _check_key(key: str | None) -> tuple[bool, dict | None]:
156
+ if not key:
157
+ return False, {"message": "Provide correct key", "contact": "@dmforbans"}
158
+ if not VALID_KEYS:
159
+ return False, {"message": "Provide correct key", "contact": "@dmforbans"}
160
+
161
+ key_info = VALID_KEYS.get(key)
162
+ if not key_info:
163
+ return False, {"message": "Provide correct key", "contact": "@dmforbans"}
164
+
165
+ now_ms = int(datetime.now().timestamp() * 1000)
166
+ if now_ms > key_info["expires"]:
167
+ expiry_date = datetime.fromtimestamp(key_info["expires"] / 1000).strftime("%Y-%m-%d")
168
+ return False, {
169
+ "message": "Your API key expired",
170
+ "expired_on": expiry_date,
171
+ "contact": "@dmforbans",
172
+ }
173
+
174
+ return True, key_info
175
+
176
+
177
+ def _pretty(data: dict[str, Any], pretty: bool) -> Response:
178
+ if pretty:
179
+ return Response(content=json.dumps(data, indent=2, ensure_ascii=False),
180
+ media_type="application/json")
181
+ return Response(content=json.dumps(data, ensure_ascii=False),
182
+ media_type="application/json")
183
+
184
+
185
+ async def _parallel(queries: list[tuple[str, str, str, int]]) -> list[dict[str, Any]]:
186
+ loop = asyncio.get_running_loop()
187
+
188
+ async def one(t: tuple[str, str, str, int]) -> dict[str, Any]:
189
+ field, value, mode, limit = t
190
+ return await loop.run_in_executor(pool, _run_field_search, field, value, mode, limit)
191
+
192
+ return await asyncio.gather(*[one(t) for t in queries])
193
+
194
+
195
+ async def _unified_search(q: str, limit: int, source: str | None = None) -> dict[str, Any]:
196
+ q = q.strip()
197
+ is_num = q.isdigit() and len(q) >= 8
198
+ loop = asyncio.get_running_loop()
199
+
200
+ if is_num:
201
+ all_rows: list[dict[str, Any]] = []
202
+ searched: list[str] = []
203
+
204
+ if _idx_ready("phone"):
205
+ r = await loop.run_in_executor(pool, _run_field_search, "phoneNumber", q,
206
+ "exact", limit, source)
207
+ all_rows.extend(r["results"])
208
+ searched.append("phoneNumber")
209
+
210
+ if not all_rows and _idx_ready("aadhar"):
211
+ r = await loop.run_in_executor(pool, _run_field_search, "aadharNumber", q,
212
+ "exact", limit, source)
213
+ all_rows.extend(r["results"])
214
+ searched.append("aadharNumber")
215
+
216
+ all_rows = _cap_duplicates(all_rows)[:limit]
217
+ return {"query": q, "searched_fields": searched or list(NUMBER_FIELDS),
218
+ "dedup": DUPLICATE_CAP, "count": len(all_rows), "results": all_rows}
219
+ else:
220
+ return {"query": q, "searched_fields": [], "dedup": DUPLICATE_CAP,
221
+ "count": 0, "results": []}
222
+
223
+
224
+ class BatchRequest(BaseModel):
225
+ queries: list[dict[str, Any]]
226
+ limit: int = 10
227
+ key: str | None = None
228
+
229
+
230
+ # ---- ROUTES ----
231
+ @app.get("/")
232
+ def root():
233
+ return {
234
+ "app": "ICMR + HITEK Search API",
235
+ "status": "protected",
236
+ "contact": "@dmforbans",
237
+ }
238
+
239
+
240
+ @app.get("/health")
241
+ def health():
242
+ return {"status": "ok", "protected": True}
243
+
244
+
245
+ @app.get("/search")
246
+ async def search(
247
+ q: str = Query(..., description="Search term - phone number ya identification number"),
248
+ key: str = Query(..., description="API Key"),
249
+ field: str | None = Query(None, description=f"Restrict to one field"),
250
+ mode: str = Query("exact", pattern="^(exact|contains)$"),
251
+ limit: int = Query(10, ge=1, le=1000),
252
+ source: str | None = Query(None, pattern="^(icmr|hitek|inddata)$"),
253
+ pretty: bool = Query(True),
254
+ ):
255
+ # 🔑 KEY CHECK
256
+ valid, err = _check_key(key)
257
+ if not valid:
258
+ return Response(
259
+ content=json.dumps(err, ensure_ascii=False),
260
+ status_code=403,
261
+ media_type="application/json"
262
+ )
263
+
264
+ if field:
265
+ try:
266
+ data = _run_field_search(field, q, mode, limit, source)
267
+ except ValueError as e:
268
+ raise HTTPException(400, str(e))
269
+ data = {"query": q, "field": field, "mode": mode, "dedup": DUPLICATE_CAP,
270
+ "count": data["count"], "results": data["results"]}
271
+ else:
272
+ data = await _unified_search(q, limit, source)
273
+
274
+ # ✅ Developer हमारा, credit हटाया
275
+ return _pretty({**data, "developer": "@dmforbans"}, pretty)
276
+
277
+
278
+ @app.post("/search/parallel")
279
+ async def search_parallel(req: BatchRequest):
280
+ # 🔑 KEY CHECK
281
+ valid, err = _check_key(req.key)
282
+ if not valid:
283
+ return Response(
284
+ content=json.dumps(err, ensure_ascii=False),
285
+ status_code=403,
286
+ media_type="application/json"
287
+ )
288
+
289
+ if not req.queries:
290
+ raise HTTPException(400, "queries must not be empty")
291
+ if len(req.queries) > 50:
292
+ raise HTTPException(400, "max 50 queries per batch")
293
+
294
+ results = await _parallel([(item.get("field", "phoneNumber"), item.get("value", ""),
295
+ item.get("mode", "exact"), int(item.get("limit", req.limit)))
296
+ for item in req.queries])
297
+ out = [{"error": str(r)} if isinstance(r, Exception) else r for r in results]
298
+ return _pretty({"searches": len(req.queries), "parallelism": PARALLELISM,
299
+ "developer": "@dmforbans", "results": out}, True)