parthislive commited on
Commit
2a55e8a
Β·
verified Β·
1 Parent(s): 94a386d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +75 -137
app.py CHANGED
@@ -1,6 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
2
  import json
3
- import os
4
  import threading
5
  from concurrent.futures import ThreadPoolExecutor
6
  from typing import Any
@@ -16,21 +31,19 @@ HF_INDEX_BASE = os.environ.get(
16
  "PK_HF_INDEX_BASE",
17
  "https://huggingface.co/datasets/parthislive/pk-db/resolve/main/parts",
18
  ).rstrip("/")
19
- INDEX_SOURCE = os.environ.get("PK_INDEX_SOURCE", "remote").lower()
20
- PARALLELISM = int(os.environ.get("PK_PARALLEL", "2"))
21
  THREADS_PER_CONN = int(os.environ.get("PK_THREADS_PER_CONN", "2"))
22
  DUPLICATE_CAP = 2
23
 
24
  SEARCH_FIELDS = ["NUMBER", "CNIC", "NAME", "ADDRESS"]
25
  NUMBER_FIELDS = ["NUMBER"]
26
 
27
- # Remote parquet index files (17 parts)
28
  REMOTE_INDEXES = {
29
  "main": [f"{HF_INDEX_BASE}/data_{i}.parquet" for i in range(17)],
30
  }
31
 
32
  # ── DuckDB Connection Pool ──────────────────────────────────────────────────
33
- _conns: list[duckdb.DuckDBPyConnection] = []
34
  _conns_lock = threading.Lock()
35
  _thread_local = threading.local()
36
  pool = ThreadPoolExecutor(max_workers=PARALLELISM, thread_name_prefix="duck")
@@ -40,20 +53,26 @@ def _idx_ready(kind: str) -> bool:
40
  return kind in REMOTE_INDEXES
41
 
42
 
43
- def _new_conn() -> duckdb.DuckDBPyConnection:
44
  con = duckdb.connect()
45
  con.execute("SET home_directory='/tmp'")
46
  con.execute("SET extension_directory='/tmp/duckdb_extensions'")
47
  con.execute("INSTALL parquet; LOAD parquet;")
48
  con.execute("INSTALL httpfs; LOAD httpfs;")
49
-
50
- # Create view from remote HF parts
 
 
 
 
 
51
  for kind, urls in REMOTE_INDEXES.items():
52
  view = f"people_{kind}"
53
  lst = ", ".join(f"'{u}'" for u in urls)
54
- con.execute(f"CREATE OR REPLACE VIEW {view} AS SELECT * FROM read_parquet([{lst}])")
55
-
56
- con.execute(f"SET threads = {THREADS_PER_CONN}")
 
57
  return con
58
 
59
 
@@ -66,7 +85,7 @@ def _thread_id() -> int:
66
  return tid
67
 
68
 
69
- def _get_conn() -> duckdb.DuckDBPyConnection:
70
  ident = _thread_id()
71
  with _conns_lock:
72
  while len(_conns) <= ident:
@@ -74,16 +93,14 @@ def _get_conn() -> duckdb.DuckDBPyConnection:
74
  return _conns[ident]
75
 
76
 
77
- # ── Dedup & Connected Records ───────────────────────────────────────────────
78
  def _person_key(row: dict) -> tuple:
79
  cnic = (row.get("CNIC") or "").strip()
80
- num = (row.get("NUMBER") or "").strip()
81
- if cnic or num:
82
- return (str(cnic), str(num))
83
- return (row.get("NAME") or "").strip(), ""
84
 
85
 
86
- def _connected_numbers(row: dict) -> list[dict]:
87
  connected, seen = [], set()
88
  for field in NUMBER_FIELDS:
89
  raw = row.get(field)
@@ -97,9 +114,8 @@ def _connected_numbers(row: dict) -> list[dict]:
97
  return connected
98
 
99
 
100
- def _cap_duplicates(rows: list[dict]) -> list[dict]:
101
- seen: dict[tuple, int] = {}
102
- out = []
103
  for r in rows:
104
  k = _person_key(r)
105
  n = seen.get(k, 0)
@@ -111,33 +127,40 @@ def _cap_duplicates(rows: list[dict]) -> list[dict]:
111
  return out
112
 
113
 
 
 
 
 
 
 
 
 
114
  # ── Search Logic ────────────────────────────────────────────────────────────
115
  def _run_field_search(field: str, value: str, mode: str, limit: int) -> dict:
116
  if field not in SEARCH_FIELDS:
117
  raise ValueError(f"Unknown field: {field}")
118
-
119
  v = str(value).replace("'", "''")
120
  view = "people_main"
 
121
 
122
  if mode == "exact":
123
  if field == "NUMBER":
124
  try:
125
- v = int(value)
126
- sql = f"SELECT * FROM {view} WHERE NUMBER = {v} LIMIT {limit * DUPLICATE_CAP + 20}"
127
  except:
128
  return {"field": field, "value": value, "mode": mode, "count": 0, "results": []}
129
  else:
130
- sql = f"SELECT * FROM {view} WHERE {field} = '{v}' LIMIT {limit * DUPLICATE_CAP + 20}"
131
  elif mode == "contains":
132
  v2 = v.replace("%", r"\%").replace("_", r"\_")
133
- sql = f"SELECT * FROM {view} WHERE {field} ILIKE '%{v2}%' ESCAPE '\\' LIMIT {limit * DUPLICATE_CAP + 20}"
134
  else:
135
  raise ValueError(f"Unknown mode: {mode}")
136
 
137
  con = _get_conn()
138
- rows = con.execute(sql).fetchall()
139
- cols = [d[0] for d in con.description]
140
- results = _cap_duplicates([dict(zip(cols, r)) for r in rows])[:limit]
141
  return {"field": field, "value": value, "mode": mode, "count": len(results), "results": results}
142
 
143
 
@@ -145,36 +168,27 @@ def _unified_search(q: str, limit: int = 10) -> dict:
145
  q = q.strip()
146
  if not q:
147
  return {"query": q, "searched_fields": [], "count": 0, "results": []}
148
-
149
  is_num = q.isdigit() and len(q) >= 8
150
- all_rows = []
151
- searched = []
152
-
153
  if is_num:
154
- # NUMBER search
155
  r = _run_field_search("NUMBER", q, "exact", limit)
156
  all_rows.extend(r["results"])
157
  searched.append("NUMBER")
158
-
159
- # CNIC search (always try)
160
  if not all_rows:
161
  r = _run_field_search("CNIC", q, "exact", limit)
162
  all_rows.extend(r["results"])
163
  searched.append("CNIC")
164
-
165
- # NAME search (if not a number)
166
  if not all_rows and not is_num:
167
  r = _run_field_search("NAME", q, "contains", limit)
168
  all_rows.extend(r["results"])
169
  searched.append("NAME")
170
-
171
  all_rows = _cap_duplicates(all_rows)[:limit]
172
- return {
173
- "query": q,
174
- "searched_fields": searched,
175
- "count": len(all_rows),
176
- "results": all_rows,
177
- }
178
 
179
 
180
  # ── FastAPI ─────────────────────────────────────────────────────────────────
@@ -182,30 +196,18 @@ fastapi_app = FastAPI(title="PK DB Search API")
182
 
183
 
184
  class BatchRequest(BaseModel):
185
- queries: list[dict[str, Any]]
186
  limit: int = 10
187
 
188
 
189
  @fastapi_app.get("/")
190
  def root():
191
- return {
192
- "app": "PK DB Search API",
193
- "records": 218_697_206,
194
- "indexes": {"main": _idx_ready("main")},
195
- "index_source": INDEX_SOURCE,
196
- "columns": SEARCH_FIELDS,
197
- "docs": "/docs",
198
- "developer": "@UnknownGuy9876 | channel @SGCodexs",
199
- }
200
 
201
 
202
  @fastapi_app.get("/health")
203
  def health():
204
- return {
205
- "status": "ok",
206
- "indexes": {"main": _idx_ready("main")},
207
- "index_source": INDEX_SOURCE
208
- }
209
 
210
 
211
  @fastapi_app.get("/search")
@@ -220,13 +222,11 @@ async def search(
220
  q_val = (q or number or "").strip()
221
  if not q_val:
222
  raise HTTPException(422, "Provide q or number")
223
-
224
  loop = asyncio.get_running_loop()
225
  if field:
226
  data = await loop.run_in_executor(pool, _run_field_search, field, q_val, mode, limit)
227
  else:
228
  data = await loop.run_in_executor(pool, _unified_search, q_val, limit)
229
-
230
  result = {"success": bool(data["count"]), **data, "number": q_val, "total": data["count"]}
231
  content = json.dumps(result, indent=2 if pretty else None, ensure_ascii=False)
232
  return Response(content=content, media_type="application/json")
@@ -260,31 +260,6 @@ async def api_address(address: str = Query(...)):
260
  return {"status": "success" if data["count"] else "not_found", **data}
261
 
262
 
263
- @fastapi_app.post("/search/parallel")
264
- async def search_parallel(req: BatchRequest):
265
- if not req.queries:
266
- raise HTTPException(400, "queries must not be empty")
267
- if len(req.queries) > 50:
268
- raise HTTPException(400, "max 50 queries per batch")
269
-
270
- loop = asyncio.get_running_loop()
271
- tasks = [
272
- loop.run_in_executor(
273
- pool, _run_field_search,
274
- item.get("field", "NUMBER"),
275
- item.get("value", ""),
276
- item.get("mode", "exact"),
277
- int(item.get("limit", req.limit))
278
- )
279
- for item in req.queries
280
- ]
281
- results = await asyncio.gather(*tasks)
282
- return Response(
283
- content=json.dumps({"searches": len(req.queries), "results": list(results)}, indent=2, ensure_ascii=False),
284
- media_type="application/json"
285
- )
286
-
287
-
288
  # ── Pinger ──────────────────────────────────────────────────────────────────
289
  async def pinger():
290
  port = os.getenv("PORT", "7860")
@@ -293,10 +268,9 @@ async def pinger():
293
  while True:
294
  await asyncio.sleep(120)
295
  try:
296
- resp = await client.get(url)
297
- print(f"[Pinger] {resp.status_code}")
298
- except Exception as e:
299
- print(f"[Pinger] Error: {e}")
300
 
301
 
302
  @fastapi_app.on_event("startup")
@@ -311,30 +285,22 @@ def format_result(row: dict) -> str:
311
  val = row.get(field, "")
312
  if val:
313
  lines.append(f"**{field}:** {val}")
314
- cn = row.get("connected_numbers", [])
315
- if cn:
316
- nums = ", ".join(f"{c['field']}={c['value']}" for c in cn)
317
- lines.append(f"**connected:** {nums}")
318
  return "\n\n".join(lines)
319
 
320
 
321
  def search_ui(query: str, limit: int) -> str:
322
  if not query or not query.strip():
323
  return "⚠️ Kuch toh search karo β€” NUMBER, CNIC, NAME ya ADDRESS daalo."
324
-
325
  q = query.strip()
326
  try:
327
  data = _unified_search(q, int(limit))
328
  except Exception as e:
329
  return f"❌ Error: {str(e)}"
330
-
331
  count = data["count"]
332
  results = data["results"]
333
  searched = ", ".join(data.get("searched_fields", []))
334
-
335
  if not results:
336
  return f"πŸ” **Query:** `{q}`\n**Searched:** {searched}\n\n❌ **No data found**."
337
-
338
  header = f"πŸ” **Query:** `{q}` | **Found:** {count} results | **Searched:** {searched}\n\n---\n\n"
339
  parts = []
340
  for i, row in enumerate(results, 1):
@@ -345,52 +311,24 @@ def search_ui(query: str, limit: int) -> str:
345
  def build_ui():
346
  with gr.Blocks(title="PK DB Search") as demo:
347
  gr.Markdown("# πŸ” PK DB Search")
348
- gr.Markdown("Search **218M Pakistan records** β€” NUMBER, CNIC, NAME, ADDRESS")
349
-
350
  with gr.Row():
351
  with gr.Column(scale=3):
352
- query_input = gr.Textbox(
353
- label="Search Query",
354
- placeholder="NUMBER, CNIC, NAME ya ADDRESS daalo...",
355
- lines=1,
356
- )
357
  with gr.Column(scale=1):
358
- limit_slider = gr.Slider(
359
- minimum=1, maximum=50, value=10, step=1,
360
- label="Max Results",
361
- )
362
-
363
  search_btn = gr.Button("πŸ” Search", variant="primary", size="lg")
364
  output = gr.Markdown(label="Results")
365
-
366
  search_btn.click(fn=search_ui, inputs=[query_input, limit_slider], outputs=output)
367
  query_input.submit(fn=search_ui, inputs=[query_input, limit_slider], outputs=output)
368
-
369
- gr.Markdown("---")
370
- with gr.Accordion("πŸ“‘ API Info", open=False):
371
- gr.Markdown("""
372
- **Endpoints:**
373
- - `GET /api/number?number=<num>` β€” Search by NUMBER
374
- - `GET /api/cnic?cnic=<cnic>` β€” Search by CNIC
375
- - `GET /api/name?name=<name>` β€” Search by NAME
376
- - `GET /api/address?address=<addr>` β€” Search by ADDRESS
377
- - `GET /search?q=<value>` β€” Unified search
378
- - `GET /health` β€” Health check
379
- - `GET /docs` β€” Swagger UI
380
-
381
- **Source:** [HF Dataset](https://huggingface.co/datasets/parthislive/pk-db)
382
- """)
383
-
384
- gr.Markdown(
385
- "---\n"
386
- "<div style='text-align:center;color:#888;'>"
387
- "πŸ‘¨β€πŸ’» **Developer:** @UnknownGuy9876 | πŸ“’ **Channel:** @SGCodexs"
388
- "</div>"
389
- )
390
-
391
  return demo
392
 
393
 
394
- # ── Mount Gradio on FastAPI ─────────────────────────────────────────────────
395
  demo = build_ui()
396
- app = gr.mount_gradio_app(fastapi_app, demo, path="/ui")
 
 
 
 
 
 
 
1
+ # ============================================================
2
+ # HfFolder monkey-patch (MUST be before gradio import)
3
+ # ============================================================
4
+ import os
5
+ try:
6
+ import huggingface_hub
7
+ if not hasattr(huggingface_hub, "HfFolder"):
8
+ class _HfFolder:
9
+ @staticmethod
10
+ def get_token():
11
+ return os.getenv("HF_TOKEN", "")
12
+ huggingface_hub.HfFolder = _HfFolder
13
+ except ImportError:
14
+ pass
15
+ # ============================================================
16
+
17
  import asyncio
18
  import json
 
19
  import threading
20
  from concurrent.futures import ThreadPoolExecutor
21
  from typing import Any
 
31
  "PK_HF_INDEX_BASE",
32
  "https://huggingface.co/datasets/parthislive/pk-db/resolve/main/parts",
33
  ).rstrip("/")
34
+ PARALLELISM = int(os.environ.get("PK_PARALLEL", "1"))
 
35
  THREADS_PER_CONN = int(os.environ.get("PK_THREADS_PER_CONN", "2"))
36
  DUPLICATE_CAP = 2
37
 
38
  SEARCH_FIELDS = ["NUMBER", "CNIC", "NAME", "ADDRESS"]
39
  NUMBER_FIELDS = ["NUMBER"]
40
 
 
41
  REMOTE_INDEXES = {
42
  "main": [f"{HF_INDEX_BASE}/data_{i}.parquet" for i in range(17)],
43
  }
44
 
45
  # ── DuckDB Connection Pool ──────────────────────────────────────────────────
46
+ _conns: list = []
47
  _conns_lock = threading.Lock()
48
  _thread_local = threading.local()
49
  pool = ThreadPoolExecutor(max_workers=PARALLELISM, thread_name_prefix="duck")
 
53
  return kind in REMOTE_INDEXES
54
 
55
 
56
+ def _new_conn():
57
  con = duckdb.connect()
58
  con.execute("SET home_directory='/tmp'")
59
  con.execute("SET extension_directory='/tmp/duckdb_extensions'")
60
  con.execute("INSTALL parquet; LOAD parquet;")
61
  con.execute("INSTALL httpfs; LOAD httpfs;")
62
+
63
+ con.execute("SET memory_limit='400MB'")
64
+ con.execute("SET enable_object_cache=true")
65
+ con.execute("SET http_keep_alive=true")
66
+ con.execute("SET http_timeout=60000")
67
+ con.execute("SET threads=2")
68
+
69
  for kind, urls in REMOTE_INDEXES.items():
70
  view = f"people_{kind}"
71
  lst = ", ".join(f"'{u}'" for u in urls)
72
+ con.execute(
73
+ f"CREATE OR REPLACE VIEW {view} AS "
74
+ f"SELECT NUMBER, CNIC, NAME, ADDRESS FROM read_parquet([{lst}])"
75
+ )
76
  return con
77
 
78
 
 
85
  return tid
86
 
87
 
88
+ def _get_conn():
89
  ident = _thread_id()
90
  with _conns_lock:
91
  while len(_conns) <= ident:
 
93
  return _conns[ident]
94
 
95
 
96
+ # ── Dedup ───────────────────────────────────────────────────────────────────
97
  def _person_key(row: dict) -> tuple:
98
  cnic = (row.get("CNIC") or "").strip()
99
+ num = str(row.get("NUMBER") or "").strip()
100
+ return (cnic, num)
 
 
101
 
102
 
103
+ def _connected_numbers(row: dict) -> list:
104
  connected, seen = [], set()
105
  for field in NUMBER_FIELDS:
106
  raw = row.get(field)
 
114
  return connected
115
 
116
 
117
+ def _cap_duplicates(rows: list) -> list:
118
+ seen, out = {}, []
 
119
  for r in rows:
120
  k = _person_key(r)
121
  n = seen.get(k, 0)
 
127
  return out
128
 
129
 
130
+ # ── Fetch Without Pandas ────────────────────────────────────────────────────
131
+ def _fetch_rows(con, sql):
132
+ cur = con.execute(sql)
133
+ cols = [d[0] for d in cur.description]
134
+ rows = cur.fetchall()
135
+ return [dict(zip(cols, r)) for r in rows]
136
+
137
+
138
  # ── Search Logic ────────────────────────────────────────────────────────────
139
  def _run_field_search(field: str, value: str, mode: str, limit: int) -> dict:
140
  if field not in SEARCH_FIELDS:
141
  raise ValueError(f"Unknown field: {field}")
142
+
143
  v = str(value).replace("'", "''")
144
  view = "people_main"
145
+ cols = "NUMBER, CNIC, NAME, ADDRESS"
146
 
147
  if mode == "exact":
148
  if field == "NUMBER":
149
  try:
150
+ sql = f"SELECT {cols} FROM {view} WHERE NUMBER = {int(value)} LIMIT {limit * DUPLICATE_CAP + 20}"
 
151
  except:
152
  return {"field": field, "value": value, "mode": mode, "count": 0, "results": []}
153
  else:
154
+ sql = f"SELECT {cols} FROM {view} WHERE {field} = '{v}' LIMIT {limit * DUPLICATE_CAP + 20}"
155
  elif mode == "contains":
156
  v2 = v.replace("%", r"\%").replace("_", r"\_")
157
+ sql = f"SELECT {cols} FROM {view} WHERE {field} ILIKE '%{v2}%' ESCAPE '\\' LIMIT {limit * DUPLICATE_CAP + 20}"
158
  else:
159
  raise ValueError(f"Unknown mode: {mode}")
160
 
161
  con = _get_conn()
162
+ raw_rows = _fetch_rows(con, sql)
163
+ results = _cap_duplicates(raw_rows)[:limit]
 
164
  return {"field": field, "value": value, "mode": mode, "count": len(results), "results": results}
165
 
166
 
 
168
  q = q.strip()
169
  if not q:
170
  return {"query": q, "searched_fields": [], "count": 0, "results": []}
171
+
172
  is_num = q.isdigit() and len(q) >= 8
173
+ all_rows, searched = [], []
174
+
 
175
  if is_num:
 
176
  r = _run_field_search("NUMBER", q, "exact", limit)
177
  all_rows.extend(r["results"])
178
  searched.append("NUMBER")
179
+
 
180
  if not all_rows:
181
  r = _run_field_search("CNIC", q, "exact", limit)
182
  all_rows.extend(r["results"])
183
  searched.append("CNIC")
184
+
 
185
  if not all_rows and not is_num:
186
  r = _run_field_search("NAME", q, "contains", limit)
187
  all_rows.extend(r["results"])
188
  searched.append("NAME")
189
+
190
  all_rows = _cap_duplicates(all_rows)[:limit]
191
+ return {"query": q, "searched_fields": searched, "count": len(all_rows), "results": all_rows}
 
 
 
 
 
192
 
193
 
194
  # ── FastAPI ─────────────────────────────────────────────────────────────────
 
196
 
197
 
198
  class BatchRequest(BaseModel):
199
+ queries: list
200
  limit: int = 10
201
 
202
 
203
  @fastapi_app.get("/")
204
  def root():
205
+ return {"app": "PK DB Search API", "records": 218_697_206, "columns": SEARCH_FIELDS}
 
 
 
 
 
 
 
 
206
 
207
 
208
  @fastapi_app.get("/health")
209
  def health():
210
+ return {"status": "ok"}
 
 
 
 
211
 
212
 
213
  @fastapi_app.get("/search")
 
222
  q_val = (q or number or "").strip()
223
  if not q_val:
224
  raise HTTPException(422, "Provide q or number")
 
225
  loop = asyncio.get_running_loop()
226
  if field:
227
  data = await loop.run_in_executor(pool, _run_field_search, field, q_val, mode, limit)
228
  else:
229
  data = await loop.run_in_executor(pool, _unified_search, q_val, limit)
 
230
  result = {"success": bool(data["count"]), **data, "number": q_val, "total": data["count"]}
231
  content = json.dumps(result, indent=2 if pretty else None, ensure_ascii=False)
232
  return Response(content=content, media_type="application/json")
 
260
  return {"status": "success" if data["count"] else "not_found", **data}
261
 
262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  # ── Pinger ──────────────────────────────────────────────────────────────────
264
  async def pinger():
265
  port = os.getenv("PORT", "7860")
 
268
  while True:
269
  await asyncio.sleep(120)
270
  try:
271
+ await client.get(url)
272
+ except Exception:
273
+ pass
 
274
 
275
 
276
  @fastapi_app.on_event("startup")
 
285
  val = row.get(field, "")
286
  if val:
287
  lines.append(f"**{field}:** {val}")
 
 
 
 
288
  return "\n\n".join(lines)
289
 
290
 
291
  def search_ui(query: str, limit: int) -> str:
292
  if not query or not query.strip():
293
  return "⚠️ Kuch toh search karo β€” NUMBER, CNIC, NAME ya ADDRESS daalo."
 
294
  q = query.strip()
295
  try:
296
  data = _unified_search(q, int(limit))
297
  except Exception as e:
298
  return f"❌ Error: {str(e)}"
 
299
  count = data["count"]
300
  results = data["results"]
301
  searched = ", ".join(data.get("searched_fields", []))
 
302
  if not results:
303
  return f"πŸ” **Query:** `{q}`\n**Searched:** {searched}\n\n❌ **No data found**."
 
304
  header = f"πŸ” **Query:** `{q}` | **Found:** {count} results | **Searched:** {searched}\n\n---\n\n"
305
  parts = []
306
  for i, row in enumerate(results, 1):
 
311
  def build_ui():
312
  with gr.Blocks(title="PK DB Search") as demo:
313
  gr.Markdown("# πŸ” PK DB Search")
314
+ gr.Markdown("Search **218M Pakistan records**")
 
315
  with gr.Row():
316
  with gr.Column(scale=3):
317
+ query_input = gr.Textbox(label="Search Query", placeholder="NUMBER, CNIC, NAME ya ADDRESS", lines=1)
 
 
 
 
318
  with gr.Column(scale=1):
319
+ limit_slider = gr.Slider(minimum=1, maximum=50, value=10, step=1, label="Max Results")
 
 
 
 
320
  search_btn = gr.Button("πŸ” Search", variant="primary", size="lg")
321
  output = gr.Markdown(label="Results")
 
322
  search_btn.click(fn=search_ui, inputs=[query_input, limit_slider], outputs=output)
323
  query_input.submit(fn=search_ui, inputs=[query_input, limit_slider], outputs=output)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
324
  return demo
325
 
326
 
 
327
  demo = build_ui()
328
+ app = gr.mount_gradio_app(fastapi_app, demo, path="/ui")
329
+
330
+
331
+ # ============================================================
332
+ # HF Space Gradio SDK Launch (module level, NOT in __main__)
333
+ # ============================================================
334
+ demo.launch(server_name="0.0.0.0", server_port=7860)