styhero commited on
Commit
ab0b655
Β·
verified Β·
1 Parent(s): bf99c4a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +128 -146
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 _query_one(index_path, col, value):
53
- con = new_con()
54
- t0 = time.perf_counter()
55
- df = con.execute(f"""
56
- SELECT mobile1, mobile2, row_group_id, row_start, row_end, filename
57
- FROM read_parquet('{index_path}')
58
- WHERE {col} = '{value}'
59
- ORDER BY mobile2
60
- """).fetchdf()
 
 
 
 
 
61
  return df, (time.perf_counter() - t0) * 1000
62
 
63
- def query_both_parallel(target):
64
- results = {}
65
- with ThreadPoolExecutor(max_workers=2) as ex:
66
- futures = {
67
- ex.submit(_query_one, MOBILE1_IDX, "mobile1", target): "mobile1",
68
- ex.submit(_query_one, MOBILE2_IDX, "mobile2", target): "mobile2",
69
- }
70
- for f in as_completed(futures):
71
- df, ms = f.result()
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
- t0 = time.perf_counter()
79
- df = con.execute(f"""
 
 
 
 
 
 
 
80
  SELECT mobile1, mobile2, email, id, name, fname, address, circle
81
- FROM read_parquet('{hf_path}', file_row_number=true)
82
- WHERE file_row_number BETWEEN {row_start} AND {row_end}
83
- AND {search_col} = '{target}'
84
- """).fetchdf()
 
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("", placeholder="Enter mobile number β€” e.g. 919876543210",
420
- label_visibility="collapsed")
 
 
 
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
- # ── Search with progress ────────────────────────────────────────────────────────
 
 
 
429
 
430
- all_dfs, errors = [], []
431
- t0 = time.perf_counter()
432
 
433
  with st.status("Searching...", expanded=False) as status:
 
434
 
435
- idx_results = query_both_parallel(target)
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)