HiTeckGroup commited on
Commit
709aa0f
·
verified ·
1 Parent(s): a70e5de

Create app.py

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