kzropx commited on
Commit
f6e03f5
·
verified ·
1 Parent(s): 1e5c09c

Switch back to fast sorted-index app (old dataset) + num/aadhar aliases + async pinger

Browse files
Files changed (1) hide show
  1. app.py +114 -138
app.py CHANGED
@@ -1,52 +1,45 @@
1
  """Gradio UI + FastAPI backend for ICMR + HITEK Search API.
2
 
3
  Runs on Hugging Face Spaces (free Gradio tier).
4
- Queries the raw parquet parts of Kzr0xx/Icmr-and-hitek directly via DuckDB
5
- (httpfs range reads). Each data file is scanned in its own thread with a
6
- LIMIT + timeout so responses stay fast (~1-3s) even on 100GB remote data.
7
  """
8
  import asyncio
9
  import json
10
  import os
11
  import threading
12
- import time
13
- from concurrent.futures import ThreadPoolExecutor, TimeoutError as FutTimeout
14
  from typing import Any
15
 
16
  import duckdb
17
  import gradio as gr
 
18
  from fastapi import FastAPI, HTTPException, Query, Response
19
  from pydantic import BaseModel
20
 
21
  # ── Config ──────────────────────────────────────────────────────────────────
22
  BASE = os.path.dirname(os.path.abspath(__file__))
23
- HF_DATA_BASE = os.environ.get(
24
- "ICMR_HF_DATA_BASE",
25
- "https://huggingface.co/datasets/Kzr0xx/Icmr-and-hitek/resolve/main",
26
  ).rstrip("/")
27
- DATA_FILES = [
28
- part.strip()
29
- for part in os.environ.get(
30
- "ICMR_DATA_FILES", "part1.parquet,part2a.parquet,part2b_new.parquet"
31
- ).split(",")
32
- if part.strip()
33
- ]
34
- PARALLELISM = int(os.environ.get("ICMR_PARALLEL", "3"))
35
- THREADS_PER_CONN = int(os.environ.get("ICMR_THREADS_PER_CONN", "1"))
36
  DUPLICATE_CAP = 2
37
- QUERY_TIMEOUT = float(os.environ.get("ICMR_QUERY_TIMEOUT", "25"))
38
 
39
  SEARCH_FIELDS = [
40
  "name", "fathersName", "phoneNumber", "aadharNumber", "otherNumber",
41
  "address", "district", "pincode", "state", "town", "source",
42
  ]
43
  NUMBER_FIELDS = ["phoneNumber", "aadharNumber", "otherNumber"]
44
- # Fields whose exact search we allow on the raw 100GB dataset (filter pushdown
45
- # works well on these; long text fields would be far slower).
46
- EXACT_FIELDS = set(SEARCH_FIELDS)
47
- SLOW_CONTAINS_FIELDS = {"address", "source", "fathersName"}
48
 
49
- DATA_URLS = [f"{HF_DATA_BASE}/{f}" for f in DATA_FILES]
 
 
 
 
 
 
50
 
51
  CREDITS = {
52
  "developer": "@kzr0x",
@@ -54,25 +47,40 @@ CREDITS = {
54
  "channel": "@API_WALLAH",
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
57
  # ── DuckDB Connection Pool ──────────────────────────────────────────────────
58
- _conns: dict[int, dict[str, duckdb.DuckDBPyConnection]] = {}
59
  _conns_lock = threading.Lock()
60
  _thread_local = threading.local()
61
- pool = ThreadPoolExecutor(max_workers=PARALLELISM * len(DATA_FILES) + 2,
62
- thread_name_prefix="duck")
63
 
64
- _query_cache: dict[str, tuple[float, dict]] = {}
65
- _cache_lock = threading.Lock()
66
- CACHE_TTL = 300
67
- CACHE_MAX = 512
68
 
69
 
70
  def _new_conn() -> duckdb.DuckDBPyConnection:
71
  con = duckdb.connect()
 
 
 
72
  con.execute("INSTALL parquet; LOAD parquet;")
73
  con.execute("INSTALL httpfs; LOAD httpfs;")
 
 
 
 
 
74
  con.execute(f"SET threads = {THREADS_PER_CONN}")
75
- con.execute("SET enable_object_cache = true")
76
  return con
77
 
78
 
@@ -80,38 +88,17 @@ def _thread_id() -> int:
80
  tid = getattr(_thread_local, "id", None)
81
  if tid is None:
82
  with _conns_lock:
83
- tid = os.getpid() * 100000 + threading.get_ident() % 100000
84
  _thread_local.id = tid
85
  return tid
86
 
87
 
88
- def _get_conn(url: str) -> duckdb.DuckDBPyConnection:
89
  ident = _thread_id()
90
  with _conns_lock:
91
- per_thread = _conns.setdefault(ident, {})
92
- con = per_thread.get(url)
93
- if con is None:
94
- con = _new_conn()
95
- per_thread[url] = con
96
- return con
97
-
98
-
99
- def _cache_get(key: str):
100
- with _cache_lock:
101
- item = _query_cache.get(key)
102
- if item and time.time() - item[0] < CACHE_TTL:
103
- return item[1]
104
- if item:
105
- _query_cache.pop(key, None)
106
- return None
107
-
108
-
109
- def _cache_put(key: str, val: dict):
110
- with _cache_lock:
111
- if len(_query_cache) >= CACHE_MAX:
112
- oldest = min(_query_cache, key=lambda k: _query_cache[k][0])
113
- _query_cache.pop(oldest, None)
114
- _query_cache[key] = (time.time(), val)
115
 
116
 
117
  # ── Dedup & Connected Records ───────────────────────────────────────────────
@@ -152,67 +139,37 @@ def _cap_duplicates(rows: list[dict]) -> list[dict]:
152
 
153
 
154
  # ── Search Logic ────────────────────────────────────────────────────────────
155
- def _scan_one_file(url: str, sql: str, fetch_limit: int) -> list[tuple]:
156
- """Run sql against a single parquet file with its own connection."""
157
- con = _get_conn(url)
158
- return con.execute(sql).fetchmany(fetch_limit)
159
-
160
-
161
  def _run_field_search(field: str, value: str, mode: str, limit: int) -> dict:
162
- if field not in EXACT_FIELDS:
 
163
  raise ValueError(f"Unknown field: {field}")
164
- if mode == "contains" and field in SLOW_CONTAINS_FIELDS:
165
- return {"field": field, "value": value, "mode": mode,
166
- "count": 0, "results": [],
167
- "note": f"contains search disabled for slow field '{field}'"}
168
-
169
- cache_key = f"{field}|{mode}|{value}|{limit}"
170
- cached = _cache_get(cache_key)
171
- if cached is not None:
172
- return dict(cached, cached=True)
173
-
174
  v = value.replace("'", "''")
175
- fetch_limit = limit * DUPLICATE_CAP + 20
176
 
177
  if mode == "exact":
178
- where = f"{field} = '{v}'"
 
 
 
 
 
 
 
 
 
179
  elif mode == "contains":
 
 
 
180
  v2 = v.replace("%", r"\%").replace("_", r"\_")
181
- where = f"{field} ILIKE '%{v2}%' ESCAPE '\\'"
182
  else:
183
  raise ValueError(f"Unknown mode: {mode}")
184
 
185
- # One query per file, run in parallel, each with an early LIMIT.
186
- # DuckDB prunes row-groups via parquet statistics, so exact matches
187
- # on well-clustered files return in ~1-3s.
188
- futures = []
189
- inner = ThreadPoolExecutor(max_workers=len(DATA_URLS))
190
- try:
191
- for url in DATA_URLS:
192
- sql = (
193
- f"SELECT * FROM read_parquet('{url}') WHERE {where} "
194
- f"LIMIT {fetch_limit}"
195
- )
196
- futures.append(inner.submit(_scan_one_file, url, sql, fetch_limit))
197
- rows: list[tuple] = []
198
- for fut in futures:
199
- try:
200
- rows.extend(fut.result(timeout=QUERY_TIMEOUT))
201
- except FutTimeout:
202
- pass
203
- except Exception:
204
- pass
205
- finally:
206
- inner.shutdown(wait=False, cancel_futures=True)
207
-
208
- cols = ["name", "fathersName", "phoneNumber", "aadharNumber",
209
- "otherNumber", "address", "district", "pincode", "state",
210
- "town", "source"]
211
  results = _cap_duplicates([dict(zip(cols, r)) for r in rows])[:limit]
212
- out = {"field": field, "value": value, "mode": mode,
213
- "count": len(results), "results": results}
214
- _cache_put(cache_key, out)
215
- return out
216
 
217
 
218
  def _unified_search(q: str, limit: int = 10) -> dict:
@@ -221,10 +178,14 @@ def _unified_search(q: str, limit: int = 10) -> dict:
221
 
222
  if is_num:
223
  all_rows = []
224
- searched = ["phoneNumber"]
225
- r = _run_field_search("phoneNumber", q, "exact", limit)
226
- all_rows.extend(r["results"])
227
- if not all_rows:
 
 
 
 
228
  r = _run_field_search("aadharNumber", q, "exact", limit)
229
  all_rows.extend(r["results"])
230
  searched.append("aadharNumber")
@@ -234,11 +195,7 @@ def _unified_search(q: str, limit: int = 10) -> dict:
234
  "count": len(all_rows), "results": all_rows,
235
  }
236
  else:
237
- r = _run_field_search("name", q, "contains", limit)
238
- return {
239
- "query": q, "searched_fields": ["name"],
240
- "count": r["count"], "results": r["results"],
241
- }
242
 
243
 
244
  # ── FastAPI (for API access) ────────────────────────────────────────────────
@@ -252,25 +209,22 @@ class BatchRequest(BaseModel):
252
 
253
  @fastapi_app.get("/")
254
  def root():
255
- return {"app": "ICMR + HITEK Search API", "records": 2_504_793_870,
256
- "data_files": DATA_FILES, "columns": SEARCH_FIELDS,
257
- "credits": CREDITS, "docs": "/docs"}
 
 
 
 
 
 
258
 
259
 
260
  @fastapi_app.get("/health")
261
  def health():
262
- return {"status": "ok", "data_source": HF_DATA_BASE, "data_files": DATA_FILES}
263
-
264
-
265
- # ── Parameter aliases ────────────────────────────────────────────────────────
266
- ALIAS_FIELDS = {
267
- "num": "phoneNumber", "number": "phoneNumber", "mobile": "phoneNumber",
268
- "phone": "phoneNumber", "aadhar": "aadharNumber", "aadhaar": "aadharNumber",
269
- "name": "name", "father": "fathersName", "fathersname": "fathersName",
270
- "other": "otherNumber", "othernumber": "otherNumber", "address": "address",
271
- "district": "district", "pin": "pincode", "pincode": "pincode",
272
- "state": "state", "town": "town", "city": "town", "source": "source",
273
- }
274
 
275
 
276
  @fastapi_app.get("/search")
@@ -327,8 +281,7 @@ async def search_parallel(req: BatchRequest):
327
  loop = asyncio.get_running_loop()
328
  tasks = [
329
  loop.run_in_executor(pool, _run_field_search,
330
- ALIAS_FIELDS.get(str(item.get("field", "phoneNumber")).lower(),
331
- item.get("field", "phoneNumber")),
332
  item.get("value", ""),
333
  item.get("mode", "exact"),
334
  int(item.get("limit", req.limit)))
@@ -341,6 +294,29 @@ async def search_parallel(req: BatchRequest):
341
  media_type="application/json")
342
 
343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  # ── Gradio UI ───────────────────────────────────────────────────────────────
345
  def format_result(row: dict) -> str:
346
  """Format a single result record as readable text."""
@@ -349,6 +325,7 @@ def format_result(row: dict) -> str:
349
  val = row.get(field, "")
350
  if val:
351
  lines.append(f"**{field}:** {val}")
 
352
  cn = row.get("connected_numbers", [])
353
  if cn:
354
  nums = ", ".join(f"{c['field']}={c['value']}" for c in cn)
@@ -372,7 +349,7 @@ def search_ui(query: str, limit: int) -> str:
372
  searched = ", ".join(data.get("searched_fields", []))
373
 
374
  if not results:
375
- return f"🔍 **Query:** `{q}`\n**Searched:** {searched}\n\n❌ **No data found** for this query."
376
 
377
  header = f"🔍 **Query:** `{q}` | **Found:** {count} results | **Searched:** {searched}\n\n---\n\n"
378
  parts = []
@@ -388,6 +365,7 @@ def build_ui():
388
  css="""
389
  .main-title { text-align: center; margin-bottom: 0; }
390
  .subtitle { text-align: center; color: #666; margin-top: 0; }
 
391
  """
392
  ) as demo:
393
  gr.Markdown("# 🔍 ICMR + HITEK Search API", elem_classes="main-title")
@@ -424,16 +402,14 @@ def build_ui():
424
  with gr.Accordion("📡 API Info", open=False):
425
  gr.Markdown(f"""
426
  **Endpoints** (via FastAPI):
 
427
  - `GET /search?num=<number>` — Phone search
428
  - `GET /search?aadhar=<aadhaar>` — Aadhaar search
429
  - `GET /search?mobile=<number>` — Phone search (alias)
430
- - `GET /search?name=<text>&mode=contains` — Name search
431
- - `GET /search?q=<value>&field=<field>&mode=<exact|contains>` — Any field
432
- - `GET /search/parallel` (POST) — Batch queries
433
  - `GET /health` — Health check
434
  - `GET /docs` — Swagger UI
435
 
436
- **Source:** [HF Dataset](https://huggingface.co/datasets/Kzr0xx/Icmr-and-hitek)
437
  **Developer:** @kzr0x | **Team:** TEAM DARK KNIGHT APIs
438
  """)
439
 
 
1
  """Gradio UI + FastAPI backend for ICMR + HITEK Search API.
2
 
3
  Runs on Hugging Face Spaces (free Gradio tier).
4
+ Uses remote HF sorted split indexes — no local database download needed.
 
 
5
  """
6
  import asyncio
7
  import json
8
  import os
9
  import threading
10
+ from concurrent.futures import ThreadPoolExecutor
 
11
  from typing import Any
12
 
13
  import duckdb
14
  import gradio as gr
15
+ import httpx # <-- added for pinger
16
  from fastapi import FastAPI, HTTPException, Query, Response
17
  from pydantic import BaseModel
18
 
19
  # ── Config ──────────────────────────────────────────────────────────────────
20
  BASE = os.path.dirname(os.path.abspath(__file__))
21
+ HF_INDEX_BASE = os.environ.get(
22
+ "ICMR_HF_INDEX_BASE",
23
+ "https://huggingface.co/datasets/Kzr0xx/icrm-hitek-full-db-mixed/resolve/main",
24
  ).rstrip("/")
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
+ IDX_PHONE = "idx_phone"
37
+ IDX_AADHAR = "idx_aadhar"
38
+
39
+ REMOTE_INDEXES = {
40
+ "phone": [f"{HF_INDEX_BASE}/idx_phone.{i}.parquet" for i in range(7)],
41
+ "aadhar": [f"{HF_INDEX_BASE}/idx_aadhar.{i}.parquet" for i in range(7)],
42
+ }
43
 
44
  CREDITS = {
45
  "developer": "@kzr0x",
 
47
  "channel": "@API_WALLAH",
48
  }
49
 
50
+ # ── Parameter aliases ────────────────────────────────────────────────────────
51
+ ALIAS_FIELDS = {
52
+ "num": "phoneNumber", "number": "phoneNumber", "mobile": "phoneNumber",
53
+ "phone": "phoneNumber", "aadhar": "aadharNumber", "aadhaar": "aadharNumber",
54
+ "name": "name", "father": "fathersName", "fathersname": "fathersName",
55
+ "other": "otherNumber", "othernumber": "otherNumber", "address": "address",
56
+ "district": "district", "pin": "pincode", "pincode": "pincode",
57
+ "state": "state", "town": "town", "city": "town", "source": "source",
58
+ }
59
+
60
  # ── DuckDB Connection Pool ──────────────────────────────────────────────────
61
+ _conns: list[duckdb.DuckDBPyConnection] = []
62
  _conns_lock = threading.Lock()
63
  _thread_local = threading.local()
64
+ pool = ThreadPoolExecutor(max_workers=PARALLELISM, thread_name_prefix="duck")
65
+
66
 
67
+ def _idx_ready(kind: str) -> bool:
68
+ return kind in REMOTE_INDEXES
 
 
69
 
70
 
71
  def _new_conn() -> duckdb.DuckDBPyConnection:
72
  con = duckdb.connect()
73
+ # Vercel fix: set home & extension dir to /tmp
74
+ con.execute("SET home_directory='/tmp'")
75
+ con.execute("SET extension_directory='/tmp/duckdb_extensions'")
76
  con.execute("INSTALL parquet; LOAD parquet;")
77
  con.execute("INSTALL httpfs; LOAD httpfs;")
78
+ # Create sorted index views from remote HF parts
79
+ for kind, urls in REMOTE_INDEXES.items():
80
+ view = f"people_{kind}"
81
+ lst = ", ".join(f"'{u}'" for u in urls)
82
+ con.execute(f"CREATE OR REPLACE VIEW {view} AS SELECT * FROM read_parquet([{lst}])")
83
  con.execute(f"SET threads = {THREADS_PER_CONN}")
 
84
  return con
85
 
86
 
 
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 & Connected Records ───────────────────────────────────────────────
 
139
 
140
 
141
  # ── Search Logic ────────────────────────────────────────────────────────────
 
 
 
 
 
 
142
  def _run_field_search(field: str, value: str, mode: str, limit: int) -> dict:
143
+ field = ALIAS_FIELDS.get(field.lower(), field)
144
+ if field not in SEARCH_FIELDS:
145
  raise ValueError(f"Unknown field: {field}")
 
 
 
 
 
 
 
 
 
 
146
  v = value.replace("'", "''")
 
147
 
148
  if mode == "exact":
149
+ if field == "phoneNumber" and _idx_ready("phone"):
150
+ view = "people_phone"
151
+ elif field == "aadharNumber" and _idx_ready("aadhar"):
152
+ view = "people_aadhar"
153
+ elif field == "otherNumber":
154
+ # otherNumber not sorted — skip to avoid slow scan
155
+ return {"field": field, "value": value, "mode": mode, "count": 0, "results": []}
156
+ else:
157
+ return {"field": field, "value": value, "mode": mode, "count": 0, "results": []}
158
+ sql = f"SELECT * FROM {view} WHERE {field} = '{v}' LIMIT {limit * DUPLICATE_CAP + 20}"
159
  elif mode == "contains":
160
+ if field == "name":
161
+ # Name search not available in remote-only mode
162
+ return {"field": field, "value": value, "mode": mode, "count": 0, "results": []}
163
  v2 = v.replace("%", r"\%").replace("_", r"\_")
164
+ sql = f"SELECT * FROM people_phone WHERE {field} ILIKE '%{v2}%' ESCAPE '\\' LIMIT {limit * DUPLICATE_CAP + 20}"
165
  else:
166
  raise ValueError(f"Unknown mode: {mode}")
167
 
168
+ con = _get_conn()
169
+ rows = con.execute(sql).fetchall()
170
+ cols = [d[0] for d in con.description]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  results = _cap_duplicates([dict(zip(cols, r)) for r in rows])[:limit]
172
+ return {"field": field, "value": value, "mode": mode, "count": len(results), "results": results}
 
 
 
173
 
174
 
175
  def _unified_search(q: str, limit: int = 10) -> dict:
 
178
 
179
  if is_num:
180
  all_rows = []
181
+ searched = []
182
+ # Phone index first (fast)
183
+ if _idx_ready("phone"):
184
+ r = _run_field_search("phoneNumber", q, "exact", limit)
185
+ all_rows.extend(r["results"])
186
+ searched.append("phoneNumber")
187
+ # Aadhar index second
188
+ if not all_rows and _idx_ready("aadhar"):
189
  r = _run_field_search("aadharNumber", q, "exact", limit)
190
  all_rows.extend(r["results"])
191
  searched.append("aadharNumber")
 
195
  "count": len(all_rows), "results": all_rows,
196
  }
197
  else:
198
+ return {"query": q, "searched_fields": [], "count": 0, "results": []}
 
 
 
 
199
 
200
 
201
  # ── FastAPI (for API access) ────────────────────────────────────────────────
 
209
 
210
  @fastapi_app.get("/")
211
  def root():
212
+ return {
213
+ "app": "ICMR + HITEK Search API",
214
+ "records": 2_504_793_870,
215
+ "indexes": {"phone": _idx_ready("phone"), "aadhar": _idx_ready("aadhar")},
216
+ "index_source": INDEX_SOURCE,
217
+ "columns": SEARCH_FIELDS,
218
+ "credits": CREDITS,
219
+ "docs": "/docs",
220
+ }
221
 
222
 
223
  @fastapi_app.get("/health")
224
  def health():
225
+ return {"status": "ok", "raw_database_required": False,
226
+ "indexes": {"phone": _idx_ready("phone"), "aadhar": _idx_ready("aadhar")},
227
+ "index_source": INDEX_SOURCE}
 
 
 
 
 
 
 
 
 
228
 
229
 
230
  @fastapi_app.get("/search")
 
281
  loop = asyncio.get_running_loop()
282
  tasks = [
283
  loop.run_in_executor(pool, _run_field_search,
284
+ item.get("field", "phoneNumber"),
 
285
  item.get("value", ""),
286
  item.get("mode", "exact"),
287
  int(item.get("limit", req.limit)))
 
294
  media_type="application/json")
295
 
296
 
297
+ # ── Pinger (keeps app alive) ──────────────────────────────────────────────
298
+ async def pinger():
299
+ """Ping the /health endpoint every 2 minutes to prevent idle shutdown."""
300
+ port = os.getenv("PORT", "7860") # default Gradio port; change if needed
301
+ url = f"http://localhost:{port}/health"
302
+ async with httpx.AsyncClient(timeout=10) as client:
303
+ while True:
304
+ await asyncio.sleep(120) # 2 minutes
305
+ try:
306
+ resp = await client.get(url)
307
+ if resp.status_code == 200:
308
+ print(f"[Pinger] OK at {asyncio.get_event_loop().time()}")
309
+ else:
310
+ print(f"[Pinger] Unexpected status: {resp.status_code}")
311
+ except Exception as e:
312
+ print(f"[Pinger] Error: {e}")
313
+
314
+
315
+ @fastapi_app.on_event("startup")
316
+ async def startup_event():
317
+ asyncio.create_task(pinger())
318
+
319
+
320
  # ── Gradio UI ───────────────────────────────────────────────────────────────
321
  def format_result(row: dict) -> str:
322
  """Format a single result record as readable text."""
 
325
  val = row.get(field, "")
326
  if val:
327
  lines.append(f"**{field}:** {val}")
328
+ # Connected numbers
329
  cn = row.get("connected_numbers", [])
330
  if cn:
331
  nums = ", ".join(f"{c['field']}={c['value']}" for c in cn)
 
349
  searched = ", ".join(data.get("searched_fields", []))
350
 
351
  if not results:
352
+ return f"🔍 **Query:** `{q}`\n**Searched:** {searched}\n\n❌ **No data found** for this number."
353
 
354
  header = f"🔍 **Query:** `{q}` | **Found:** {count} results | **Searched:** {searched}\n\n---\n\n"
355
  parts = []
 
365
  css="""
366
  .main-title { text-align: center; margin-bottom: 0; }
367
  .subtitle { text-align: center; color: #666; margin-top: 0; }
368
+ .footer { text-align: center; color: #888; margin-top: 20px; }
369
  """
370
  ) as demo:
371
  gr.Markdown("# 🔍 ICMR + HITEK Search API", elem_classes="main-title")
 
402
  with gr.Accordion("📡 API Info", open=False):
403
  gr.Markdown(f"""
404
  **Endpoints** (via FastAPI):
405
+ - `GET /search?q=<number>` — Phone/Aadhaar search
406
  - `GET /search?num=<number>` — Phone search
407
  - `GET /search?aadhar=<aadhaar>` — Aadhaar search
408
  - `GET /search?mobile=<number>` — Phone search (alias)
 
 
 
409
  - `GET /health` — Health check
410
  - `GET /docs` — Swagger UI
411
 
412
+ **Source:** [HF Dataset](https://huggingface.co/datasets/Kzr0xx/icrm-hitek-full-db-mixed)
413
  **Developer:** @kzr0x | **Team:** TEAM DARK KNIGHT APIs
414
  """)
415