Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,9 +1,13 @@
|
|
| 1 |
import os
|
|
|
|
| 2 |
import time
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import streamlit as st
|
| 4 |
import duckdb
|
| 5 |
import pandas as pd
|
| 6 |
-
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 7 |
|
| 8 |
# ββ Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 9 |
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
|
@@ -14,6 +18,32 @@ _L2 = os.path.join(BASE_DIR, "mobile2_index.parquet")
|
|
| 14 |
MOBILE1_IDX = _L1 if os.path.exists(_L1) else "hf://datasets/styhero/styhero_dataset/data/mobile1_index.parquet"
|
| 15 |
MOBILE2_IDX = _L2 if os.path.exists(_L2) else "hf://datasets/styhero/styhero_dataset/data/mobile2_index.parquet"
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
|
| 19 |
def fmt_ms(ms):
|
|
@@ -25,7 +55,6 @@ def clean(val):
|
|
| 25 |
s = str(val).strip()
|
| 26 |
if s.lower() in ("nan", "none", ""):
|
| 27 |
return ""
|
| 28 |
-
# Strip trailing .0 from numeric values (DECIMAL stored as float)
|
| 29 |
if s.endswith(".0"):
|
| 30 |
try:
|
| 31 |
s = str(int(float(s)))
|
|
@@ -40,50 +69,96 @@ def sanitize_df(df):
|
|
| 40 |
df[col] = df[col].apply(clean)
|
| 41 |
return df
|
| 42 |
|
| 43 |
-
def new_con():
|
| 44 |
-
con = duckdb.connect()
|
| 45 |
-
con.execute("INSTALL httpfs; LOAD httpfs;")
|
| 46 |
-
con.execute("CREATE SECRET IF NOT EXISTS hf_secret (TYPE HUGGINGFACE, TOKEN ?)", [HF_TOKEN])
|
| 47 |
-
con.execute("SET threads=4; SET enable_progress_bar=false;")
|
| 48 |
-
return con
|
| 49 |
-
|
| 50 |
# ββ Queries ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 51 |
|
| 52 |
-
def
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
return df, (time.perf_counter() - t0) * 1000
|
| 62 |
|
| 63 |
-
def
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
results[futures[f]] = (df, ms)
|
| 73 |
-
return results
|
| 74 |
-
|
| 75 |
-
def _fetch_one(filename, row_start, row_end, search_col, target):
|
| 76 |
-
con = new_con()
|
| 77 |
hf_path = f"{HF_DATASET}/{filename.split('/')[-1]}"
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
SELECT mobile1, mobile2, email, id, name, fname, address, circle
|
| 81 |
-
FROM read_parquet(
|
| 82 |
-
WHERE
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
return sanitize_df(df), (time.perf_counter() - t0) * 1000
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
# ββ SVG icons ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 88 |
|
| 89 |
def ico(path, color="#8b8bff", size=14):
|
|
@@ -96,58 +171,6 @@ ICO_PIN = ico('<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><ci
|
|
| 96 |
ICO_CIRCLE = ico('<circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/>')
|
| 97 |
ICO_PERSON = ico('<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>')
|
| 98 |
|
| 99 |
-
# ββ Skeleton HTML ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 100 |
-
|
| 101 |
-
SKELETON_CSS = """
|
| 102 |
-
<style>
|
| 103 |
-
@keyframes shimmer {
|
| 104 |
-
0% { background-position: -600px 0 }
|
| 105 |
-
100% { background-position: 600px 0 }
|
| 106 |
-
}
|
| 107 |
-
.sk {
|
| 108 |
-
border-radius: 6px;
|
| 109 |
-
background: linear-gradient(90deg,rgba(255,255,255,.05) 25%,rgba(255,255,255,.12) 50%,rgba(255,255,255,.05) 75%);
|
| 110 |
-
background-size: 1200px 100%;
|
| 111 |
-
animation: shimmer 1.5s infinite linear;
|
| 112 |
-
}
|
| 113 |
-
.sk-card {
|
| 114 |
-
background: rgba(255,255,255,.05);
|
| 115 |
-
border: 1px solid rgba(255,255,255,.1);
|
| 116 |
-
border-radius: 16px;
|
| 117 |
-
padding: 20px;
|
| 118 |
-
margin-bottom: 14px;
|
| 119 |
-
}
|
| 120 |
-
.sk-row { display:flex; gap:12px; align-items:center; margin-bottom:10px; }
|
| 121 |
-
.sk-circle { width:36px;height:36px;border-radius:50%; flex-shrink:0; }
|
| 122 |
-
.sk-line { height:14px; }
|
| 123 |
-
.sk-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; }
|
| 124 |
-
.sk-field { display:flex; flex-direction:column; gap:5px; }
|
| 125 |
-
.sk-label { height:9px; width:40%; }
|
| 126 |
-
.sk-value { height:13px; width:75%; }
|
| 127 |
-
</style>
|
| 128 |
-
"""
|
| 129 |
-
|
| 130 |
-
def skeleton_card():
|
| 131 |
-
return """
|
| 132 |
-
<div class="sk-card">
|
| 133 |
-
<div class="sk-row">
|
| 134 |
-
<div class="sk sk-circle"></div>
|
| 135 |
-
<div style="flex:1;display:flex;flex-direction:column;gap:6px">
|
| 136 |
-
<div class="sk sk-line" style="width:55%"></div>
|
| 137 |
-
<div class="sk sk-line" style="width:35%;height:10px"></div>
|
| 138 |
-
</div>
|
| 139 |
-
</div>
|
| 140 |
-
<div class="sk-grid">
|
| 141 |
-
<div class="sk-field"><div class="sk sk-label"></div><div class="sk sk-value"></div></div>
|
| 142 |
-
<div class="sk-field"><div class="sk sk-label"></div><div class="sk sk-value"></div></div>
|
| 143 |
-
<div class="sk-field"><div class="sk sk-label"></div><div class="sk sk-value" style="width:85%"></div></div>
|
| 144 |
-
<div class="sk-field"><div class="sk sk-label"></div><div class="sk sk-value" style="width:60%"></div></div>
|
| 145 |
-
<div class="sk-field" style="grid-column:1/-1"><div class="sk sk-label"></div><div class="sk sk-value" style="width:90%"></div></div>
|
| 146 |
-
</div>
|
| 147 |
-
</div>"""
|
| 148 |
-
|
| 149 |
-
SKELETONS_HTML = SKELETON_CSS + "".join(skeleton_card() for _ in range(3))
|
| 150 |
-
|
| 151 |
# ββ Result card ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 152 |
|
| 153 |
def field_html(ico_svg, label, value, full=False):
|
|
@@ -269,7 +292,6 @@ const resize = () => {{
|
|
| 269 |
if (f.contentWindow === window) f.style.height = (h + 8) + 'px';
|
| 270 |
}});
|
| 271 |
}};
|
| 272 |
-
// ResizeObserver fires on layout shifts, font load, grid reflow
|
| 273 |
if (window.ResizeObserver) {{
|
| 274 |
new ResizeObserver(resize).observe(card);
|
| 275 |
}}
|
|
@@ -288,7 +310,6 @@ st.set_page_config(page_title="Mobile Lookup", layout="centered")
|
|
| 288 |
|
| 289 |
st.markdown("""
|
| 290 |
<style>
|
| 291 |
-
/* ββ Reset & background βββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 292 |
.stApp, .stApp > div {
|
| 293 |
background: linear-gradient(145deg,#060612 0%,#0e0b24 40%,#16103a 100%) !important;
|
| 294 |
background-attachment: fixed !important;
|
|
@@ -300,7 +321,6 @@ st.markdown("""
|
|
| 300 |
max-width: 680px !important;
|
| 301 |
}
|
| 302 |
|
| 303 |
-
/* ββ Remove ALL Streamlit input wrappers ββββββββββββββββββββββββββββββββββ */
|
| 304 |
.stTextInput > div,
|
| 305 |
.stTextInput > div > div {
|
| 306 |
background: transparent !important;
|
|
@@ -309,7 +329,6 @@ st.markdown("""
|
|
| 309 |
padding: 0 !important;
|
| 310 |
}
|
| 311 |
|
| 312 |
-
/* Single clean input */
|
| 313 |
.stTextInput input {
|
| 314 |
background: rgba(255,255,255,.06) !important;
|
| 315 |
border: 1px solid rgba(255,255,255,.12) !important;
|
|
@@ -330,7 +349,6 @@ st.markdown("""
|
|
| 330 |
.stTextInput label { display: none !important; }
|
| 331 |
[data-testid="InputInstructions"] { display: none !important; }
|
| 332 |
|
| 333 |
-
/* ββ Form glass panel βββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 334 |
[data-testid="stForm"] {
|
| 335 |
background: rgba(255,255,255,.04) !important;
|
| 336 |
backdrop-filter: blur(24px) !important;
|
|
@@ -341,7 +359,6 @@ st.markdown("""
|
|
| 341 |
box-shadow: 0 20px 60px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.07) !important;
|
| 342 |
}
|
| 343 |
|
| 344 |
-
/* ββ Submit button ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 345 |
.stFormSubmitButton > button {
|
| 346 |
background: linear-gradient(135deg,#6c5ce7 0%,#a855f7 100%) !important;
|
| 347 |
color: #fff !important;
|
|
@@ -362,13 +379,11 @@ st.markdown("""
|
|
| 362 |
}
|
| 363 |
.stFormSubmitButton > button:active { transform: translateY(0) !important; }
|
| 364 |
|
| 365 |
-
/* ββ Spinner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 366 |
.stSpinner > div {
|
| 367 |
border-color: rgba(255,255,255,.1) !important;
|
| 368 |
border-top-color: #7c6fff !important;
|
| 369 |
}
|
| 370 |
|
| 371 |
-
/* ββ Alerts βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 372 |
[data-testid="stAlert"] {
|
| 373 |
background: rgba(255,255,255,.04) !important;
|
| 374 |
border: 1px solid rgba(255,255,255,.08) !important;
|
|
@@ -377,12 +392,10 @@ st.markdown("""
|
|
| 377 |
color: #d0d0f0 !important;
|
| 378 |
}
|
| 379 |
|
| 380 |
-
/* ββ Scrollbar ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 381 |
::-webkit-scrollbar { width: 6px; }
|
| 382 |
::-webkit-scrollbar-track { background: transparent; }
|
| 383 |
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 3px; }
|
| 384 |
|
| 385 |
-
/* ββ iframe card spacing βββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 386 |
iframe { display: block !important; }
|
| 387 |
[data-testid="stCustomComponentV1"] { margin-bottom: 0 !important; line-height: 0; }
|
| 388 |
</style>
|
|
@@ -416,8 +429,11 @@ st.markdown("""
|
|
| 416 |
# ββ Search form βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 417 |
|
| 418 |
with st.form("search_form"):
|
| 419 |
-
number = st.text_input(
|
| 420 |
-
|
|
|
|
|
|
|
|
|
|
| 421 |
submitted = st.form_submit_button("Search", use_container_width=True, type="primary")
|
| 422 |
|
| 423 |
if not submitted or not number.strip():
|
|
@@ -425,62 +441,28 @@ if not submitted or not number.strip():
|
|
| 425 |
|
| 426 |
target = number.strip()
|
| 427 |
|
| 428 |
-
#
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
-
|
| 431 |
-
t0 = time.perf_counter()
|
| 432 |
|
| 433 |
with st.status("Searching...", expanded=False) as status:
|
|
|
|
| 434 |
|
| 435 |
-
|
| 436 |
-
idx_ms = (time.perf_counter() - t0) * 1000
|
| 437 |
-
m1_df, m1_ms = idx_results["mobile1"]
|
| 438 |
-
m2_df, m2_ms = idx_results["mobile2"]
|
| 439 |
-
|
| 440 |
-
if m1_df.empty and m2_df.empty:
|
| 441 |
status.update(label="No records found", state="error", expanded=False)
|
|
|
|
|
|
|
| 442 |
st.warning(f"No records found for **{target}**")
|
| 443 |
st.stop()
|
| 444 |
|
| 445 |
-
fetch_tasks = []
|
| 446 |
-
for src_col, src_df in [("mobile1", m1_df), ("mobile2", m2_df)]:
|
| 447 |
-
if src_df.empty:
|
| 448 |
-
continue
|
| 449 |
-
for _, rg in src_df[["filename","row_group_id","row_start","row_end"]].drop_duplicates().iterrows():
|
| 450 |
-
fetch_tasks.append({
|
| 451 |
-
"filename": str(rg["filename"]),
|
| 452 |
-
"row_start": int(rg["row_start"]),
|
| 453 |
-
"row_end": int(rg["row_end"]),
|
| 454 |
-
"search_col": src_col,
|
| 455 |
-
})
|
| 456 |
-
|
| 457 |
-
status.update(label="Loading records...", state="running", expanded=False)
|
| 458 |
-
|
| 459 |
-
with ThreadPoolExecutor(max_workers=min(8, len(fetch_tasks))) as ex:
|
| 460 |
-
futures = {
|
| 461 |
-
ex.submit(_fetch_one, t["filename"], t["row_start"], t["row_end"], t["search_col"], target): t
|
| 462 |
-
for t in fetch_tasks
|
| 463 |
-
}
|
| 464 |
-
for f in as_completed(futures):
|
| 465 |
-
try:
|
| 466 |
-
df, _ = f.result()
|
| 467 |
-
if not df.empty:
|
| 468 |
-
all_dfs.append(df)
|
| 469 |
-
except Exception as e:
|
| 470 |
-
errors.append(str(e))
|
| 471 |
-
|
| 472 |
-
fetch_ms = (time.perf_counter() - t0) * 1000
|
| 473 |
status.update(label=f"Done β {fmt_ms(fetch_ms)}", state="complete", expanded=False)
|
| 474 |
|
| 475 |
for err in errors:
|
| 476 |
st.error(err)
|
| 477 |
|
| 478 |
-
if not all_dfs:
|
| 479 |
-
st.warning("No matching rows found in remote parquet.")
|
| 480 |
-
st.stop()
|
| 481 |
-
|
| 482 |
-
combined = pd.concat(all_dfs, ignore_index=True).drop_duplicates()
|
| 483 |
-
|
| 484 |
# ββ Results βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 485 |
|
| 486 |
st.markdown(f"""
|
|
@@ -497,4 +479,4 @@ st.markdown(f"""
|
|
| 497 |
""", unsafe_allow_html=True)
|
| 498 |
|
| 499 |
for i, (_, row) in enumerate(combined.iterrows()):
|
| 500 |
-
show_card(row, i)
|
|
|
|
| 1 |
import os
|
| 2 |
+
import re
|
| 3 |
import time
|
| 4 |
+
import threading
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 7 |
+
|
| 8 |
import streamlit as st
|
| 9 |
import duckdb
|
| 10 |
import pandas as pd
|
|
|
|
| 11 |
|
| 12 |
# ββ Config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 13 |
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
|
|
|
| 18 |
MOBILE1_IDX = _L1 if os.path.exists(_L1) else "hf://datasets/styhero/styhero_dataset/data/mobile1_index.parquet"
|
| 19 |
MOBILE2_IDX = _L2 if os.path.exists(_L2) else "hf://datasets/styhero/styhero_dataset/data/mobile2_index.parquet"
|
| 20 |
|
| 21 |
+
# Allowlist for column names that go into f-strings (can't be SQL params)
|
| 22 |
+
_ALLOWED_COLS = {"mobile1", "mobile2"}
|
| 23 |
+
|
| 24 |
+
# Mobile number validation: digits only, 10β15 chars (covers Indian + intl. format)
|
| 25 |
+
_MOBILE_RE = re.compile(r"^\d{10,15}$")
|
| 26 |
+
|
| 27 |
+
# ββ Connection pool (per-thread reuse) ββββββββββββββββββββββββββββββββββββββββ
|
| 28 |
+
_local = threading.local()
|
| 29 |
+
|
| 30 |
+
def get_con():
|
| 31 |
+
"""Reuse one DuckDB connection per thread. Connection setup costs 50-150ms
|
| 32 |
+
so reusing it across queries in the same request is a big win."""
|
| 33 |
+
con = getattr(_local, "con", None)
|
| 34 |
+
if con is not None:
|
| 35 |
+
return con
|
| 36 |
+
con = duckdb.connect()
|
| 37 |
+
con.execute("INSTALL httpfs; LOAD httpfs;")
|
| 38 |
+
con.execute("CREATE SECRET IF NOT EXISTS hf_secret (TYPE HUGGINGFACE, TOKEN ?)", [HF_TOKEN])
|
| 39 |
+
con.execute("SET threads=4;")
|
| 40 |
+
con.execute("SET enable_progress_bar=false;")
|
| 41 |
+
con.execute("SET enable_object_cache=true;") # cache parquet metadata across queries
|
| 42 |
+
con.execute("SET http_keep_alive=true;")
|
| 43 |
+
con.execute("SET http_retries=3;")
|
| 44 |
+
_local.con = con
|
| 45 |
+
return con
|
| 46 |
+
|
| 47 |
# ββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
|
| 49 |
def fmt_ms(ms):
|
|
|
|
| 55 |
s = str(val).strip()
|
| 56 |
if s.lower() in ("nan", "none", ""):
|
| 57 |
return ""
|
|
|
|
| 58 |
if s.endswith(".0"):
|
| 59 |
try:
|
| 60 |
s = str(int(float(s)))
|
|
|
|
| 69 |
df[col] = df[col].apply(clean)
|
| 70 |
return df
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
# ββ Queries ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 73 |
|
| 74 |
+
def query_both_indexes(target):
|
| 75 |
+
"""Combined UNION ALL query over both index files in a single round trip.
|
| 76 |
+
Parameterized to prevent SQL injection."""
|
| 77 |
+
con = get_con()
|
| 78 |
+
t0 = time.perf_counter()
|
| 79 |
+
df = con.execute("""
|
| 80 |
+
SELECT mobile1, mobile2, row_group_id, row_start, row_end, filename, 'mobile1' AS src
|
| 81 |
+
FROM read_parquet(?)
|
| 82 |
+
WHERE mobile1 = ?
|
| 83 |
+
UNION ALL
|
| 84 |
+
SELECT mobile1, mobile2, row_group_id, row_start, row_end, filename, 'mobile2' AS src
|
| 85 |
+
FROM read_parquet(?)
|
| 86 |
+
WHERE mobile2 = ?
|
| 87 |
+
""", [MOBILE1_IDX, target, MOBILE2_IDX, target]).fetchdf()
|
| 88 |
return df, (time.perf_counter() - t0) * 1000
|
| 89 |
|
| 90 |
+
def _fetch_file_batch(filename, ranges, target):
|
| 91 |
+
"""Fetch all matching row-groups for a single parquet file in one query.
|
| 92 |
+
`ranges` is a list of (row_start, row_end, search_col) tuples."""
|
| 93 |
+
# Validate search columns against allowlist before string interpolation
|
| 94 |
+
for _, _, col in ranges:
|
| 95 |
+
if col not in _ALLOWED_COLS:
|
| 96 |
+
raise ValueError(f"Invalid search column: {col!r}")
|
| 97 |
+
|
| 98 |
+
con = get_con()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
hf_path = f"{HF_DATASET}/{filename.split('/')[-1]}"
|
| 100 |
+
|
| 101 |
+
# Build OR conditions; row_start/row_end are ints (we cast on input), col is allowlisted
|
| 102 |
+
or_parts = []
|
| 103 |
+
params = [hf_path]
|
| 104 |
+
for rs, re_, col in ranges:
|
| 105 |
+
or_parts.append(f"(file_row_number BETWEEN {int(rs)} AND {int(re_)} AND {col} = ?)")
|
| 106 |
+
params.append(target)
|
| 107 |
+
|
| 108 |
+
sql = f"""
|
| 109 |
SELECT mobile1, mobile2, email, id, name, fname, address, circle
|
| 110 |
+
FROM read_parquet(?, file_row_number=true)
|
| 111 |
+
WHERE {' OR '.join(or_parts)}
|
| 112 |
+
"""
|
| 113 |
+
t0 = time.perf_counter()
|
| 114 |
+
df = con.execute(sql, params).fetchdf()
|
| 115 |
return sanitize_df(df), (time.perf_counter() - t0) * 1000
|
| 116 |
|
| 117 |
+
# ββ Cached search βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
+
|
| 119 |
+
@st.cache_data(ttl=300, max_entries=200, show_spinner=False)
|
| 120 |
+
def search(target: str):
|
| 121 |
+
"""Top-level cached search. Returns (combined_df, idx_ms, fetch_ms, errors).
|
| 122 |
+
TTL kept short because results contain PII β don't want them sitting in
|
| 123 |
+
memory indefinitely."""
|
| 124 |
+
errors = []
|
| 125 |
+
t_start = time.perf_counter()
|
| 126 |
+
|
| 127 |
+
idx_df, idx_ms = query_both_indexes(target)
|
| 128 |
+
|
| 129 |
+
if idx_df.empty:
|
| 130 |
+
return pd.DataFrame(), idx_ms, 0.0, errors
|
| 131 |
+
|
| 132 |
+
# Group row ranges by file so we make 1 HTTP query per file, not per row-group
|
| 133 |
+
by_file = defaultdict(list)
|
| 134 |
+
for _, rg in idx_df[["filename", "row_start", "row_end", "src"]].drop_duplicates().iterrows():
|
| 135 |
+
by_file[str(rg["filename"])].append(
|
| 136 |
+
(int(rg["row_start"]), int(rg["row_end"]), str(rg["src"]))
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
all_dfs = []
|
| 140 |
+
if by_file:
|
| 141 |
+
with ThreadPoolExecutor(max_workers=min(8, len(by_file))) as ex:
|
| 142 |
+
futures = {
|
| 143 |
+
ex.submit(_fetch_file_batch, fname, ranges, target): fname
|
| 144 |
+
for fname, ranges in by_file.items()
|
| 145 |
+
}
|
| 146 |
+
for f in as_completed(futures):
|
| 147 |
+
try:
|
| 148 |
+
df, _ = f.result()
|
| 149 |
+
if not df.empty:
|
| 150 |
+
all_dfs.append(df)
|
| 151 |
+
except Exception as e:
|
| 152 |
+
errors.append(str(e))
|
| 153 |
+
|
| 154 |
+
fetch_ms = (time.perf_counter() - t_start) * 1000
|
| 155 |
+
|
| 156 |
+
if not all_dfs:
|
| 157 |
+
return pd.DataFrame(), idx_ms, fetch_ms, errors
|
| 158 |
+
|
| 159 |
+
combined = pd.concat(all_dfs, ignore_index=True).drop_duplicates()
|
| 160 |
+
return combined, idx_ms, fetch_ms, errors
|
| 161 |
+
|
| 162 |
# ββ SVG icons ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 163 |
|
| 164 |
def ico(path, color="#8b8bff", size=14):
|
|
|
|
| 171 |
ICO_CIRCLE = ico('<circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/>')
|
| 172 |
ICO_PERSON = ico('<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>')
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
# ββ Result card ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 175 |
|
| 176 |
def field_html(ico_svg, label, value, full=False):
|
|
|
|
| 292 |
if (f.contentWindow === window) f.style.height = (h + 8) + 'px';
|
| 293 |
}});
|
| 294 |
}};
|
|
|
|
| 295 |
if (window.ResizeObserver) {{
|
| 296 |
new ResizeObserver(resize).observe(card);
|
| 297 |
}}
|
|
|
|
| 310 |
|
| 311 |
st.markdown("""
|
| 312 |
<style>
|
|
|
|
| 313 |
.stApp, .stApp > div {
|
| 314 |
background: linear-gradient(145deg,#060612 0%,#0e0b24 40%,#16103a 100%) !important;
|
| 315 |
background-attachment: fixed !important;
|
|
|
|
| 321 |
max-width: 680px !important;
|
| 322 |
}
|
| 323 |
|
|
|
|
| 324 |
.stTextInput > div,
|
| 325 |
.stTextInput > div > div {
|
| 326 |
background: transparent !important;
|
|
|
|
| 329 |
padding: 0 !important;
|
| 330 |
}
|
| 331 |
|
|
|
|
| 332 |
.stTextInput input {
|
| 333 |
background: rgba(255,255,255,.06) !important;
|
| 334 |
border: 1px solid rgba(255,255,255,.12) !important;
|
|
|
|
| 349 |
.stTextInput label { display: none !important; }
|
| 350 |
[data-testid="InputInstructions"] { display: none !important; }
|
| 351 |
|
|
|
|
| 352 |
[data-testid="stForm"] {
|
| 353 |
background: rgba(255,255,255,.04) !important;
|
| 354 |
backdrop-filter: blur(24px) !important;
|
|
|
|
| 359 |
box-shadow: 0 20px 60px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.07) !important;
|
| 360 |
}
|
| 361 |
|
|
|
|
| 362 |
.stFormSubmitButton > button {
|
| 363 |
background: linear-gradient(135deg,#6c5ce7 0%,#a855f7 100%) !important;
|
| 364 |
color: #fff !important;
|
|
|
|
| 379 |
}
|
| 380 |
.stFormSubmitButton > button:active { transform: translateY(0) !important; }
|
| 381 |
|
|
|
|
| 382 |
.stSpinner > div {
|
| 383 |
border-color: rgba(255,255,255,.1) !important;
|
| 384 |
border-top-color: #7c6fff !important;
|
| 385 |
}
|
| 386 |
|
|
|
|
| 387 |
[data-testid="stAlert"] {
|
| 388 |
background: rgba(255,255,255,.04) !important;
|
| 389 |
border: 1px solid rgba(255,255,255,.08) !important;
|
|
|
|
| 392 |
color: #d0d0f0 !important;
|
| 393 |
}
|
| 394 |
|
|
|
|
| 395 |
::-webkit-scrollbar { width: 6px; }
|
| 396 |
::-webkit-scrollbar-track { background: transparent; }
|
| 397 |
::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 3px; }
|
| 398 |
|
|
|
|
| 399 |
iframe { display: block !important; }
|
| 400 |
[data-testid="stCustomComponentV1"] { margin-bottom: 0 !important; line-height: 0; }
|
| 401 |
</style>
|
|
|
|
| 429 |
# ββ Search form βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 430 |
|
| 431 |
with st.form("search_form"):
|
| 432 |
+
number = st.text_input(
|
| 433 |
+
"Mobile number",
|
| 434 |
+
placeholder="Enter mobile number β e.g. 919876543210",
|
| 435 |
+
label_visibility="collapsed",
|
| 436 |
+
)
|
| 437 |
submitted = st.form_submit_button("Search", use_container_width=True, type="primary")
|
| 438 |
|
| 439 |
if not submitted or not number.strip():
|
|
|
|
| 441 |
|
| 442 |
target = number.strip()
|
| 443 |
|
| 444 |
+
# Input validation β reject anything that isn't 10-15 digits
|
| 445 |
+
if not _MOBILE_RE.match(target):
|
| 446 |
+
st.error("Please enter a valid mobile number (10β15 digits, numbers only).")
|
| 447 |
+
st.stop()
|
| 448 |
|
| 449 |
+
# ββ Search with progress ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 450 |
|
| 451 |
with st.status("Searching...", expanded=False) as status:
|
| 452 |
+
combined, idx_ms, fetch_ms, errors = search(target)
|
| 453 |
|
| 454 |
+
if combined.empty:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 455 |
status.update(label="No records found", state="error", expanded=False)
|
| 456 |
+
for err in errors:
|
| 457 |
+
st.error(err)
|
| 458 |
st.warning(f"No records found for **{target}**")
|
| 459 |
st.stop()
|
| 460 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
status.update(label=f"Done β {fmt_ms(fetch_ms)}", state="complete", expanded=False)
|
| 462 |
|
| 463 |
for err in errors:
|
| 464 |
st.error(err)
|
| 465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
# ββ Results βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 467 |
|
| 468 |
st.markdown(f"""
|
|
|
|
| 479 |
""", unsafe_allow_html=True)
|
| 480 |
|
| 481 |
for i, (_, row) in enumerate(combined.iterrows()):
|
| 482 |
+
show_card(row, i)
|