asvp / app.py
kontextox's picture
Update app.py
1d54212 verified
#!/usr/bin/env python3
"""
Реєстр виконавчих проваджень ASVP
Пошук боржників за ПІБ · Регістр №28 Мін'юсту України
Architecture (v8 — zero Gradio UI, MutationObserver-based state machine):
• Search input is a raw <input> — zero Gradio Textbox in visible UI.
• Hidden gr.Textbox + gr.Button are off-screen data bridges only.
• JS bridges: custom input → hidden Gradio textbox → hidden button click.
• Two-stage startup: HF download → local disk → DuckDB in-memory.
• LRU search cache — repeat queries return instantly.
• Status polling via gr.Timer (Gradio-native), NO custom fetch/API calls.
• Auto-retry from URL: MutationObserver on status bar → re-triggers search.
• Multi-field search: custom HTML pill toggles (CSV bridge via hidden Textbox).
• Match indicators: result cards highlight which fields matched.
• State machine v9: MutationObserver + Gradio class-change detection.
NO polling. Zero setInterval. Event-driven only.
"""
import gradio as gr
import duckdb
import glob
import html as html_mod
import os
import threading
import time
from collections import OrderedDict
from huggingface_hub import snapshot_download
# ═══════════════════════════ CONFIG ═══════════════════════════
DATASET_REPO = "kontextox/asvp-debtors-dataset"
LOCAL_CACHE = "/data/asvp-dataset"
TITLE = "Реєстр боржників — ASVP"
MIN_QUERY_LEN = 3
MAX_RESULTS = 100
DB_THREADS = 4
CACHE_MAX = 500
WANTED_COLS = [
"DEBTOR_NAME", "DEBTOR_BIRTHDATE", "DEBTOR_CODE",
"CREDITOR_NAME", "CREDITOR_CODE", "VP_ORDERNUM", "VP_BEGINDATE",
"VP_STATE", "ORG_NAME", "DVS_CODE",
"PHONE_NUM", "EMAIL_ADDR", "BANK_ACCOUNT",
]
SEARCHABLE_FIELDS = OrderedDict([
("DEBTOR_NAME", "ПІБ боржника"),
("CREDITOR_NAME", "ПІБ стягувача"),
("VP_ORDERNUM", "Номер провадження"),
("DEBTOR_CODE", "РНОКПП боржника"),
("CREDITOR_CODE", "РНОКПП стягувача"),
("DEBTOR_BIRTHDATE","Дата народження"),
("VP_BEGINDATE", "Дата відкриття"),
("VP_STATE", "Стан провадження"),
("ORG_NAME", "Орган виконавця"),
("DVS_CODE", "Код органу ДВС"),
("PHONE_NUM", "Телефон"),
("EMAIL_ADDR", "Email"),
("BANK_ACCOUNT", "Банківський рахунок"),
])
DEFAULT_FIELDS = ["DEBTOR_NAME", "CREDITOR_NAME"]
HAS_TIMER = hasattr(gr, "Timer")
# ═══════════════════════════ DATABASE ═══════════════════════════
_con: duckdb.DuckDBPyConnection | None = None
_lock = threading.Lock()
_ready = False
_error_msg: str | None = None
_status: str = "init"
_record_count: int = 0
# ═══════════════════════════ SEARCH CACHE ═══════════════════════════
_cache: OrderedDict[str, str] = OrderedDict()
_cache_lock = threading.Lock()
def _cache_get(key: str) -> str | None:
with _cache_lock:
if key in _cache:
_cache.move_to_end(key)
return _cache[key]
return None
def _cache_set(key: str, value: str) -> None:
with _cache_lock:
_cache[key] = value
_cache.move_to_end(key)
while len(_cache) > CACHE_MAX:
_cache.popitem(last=False)
# ═══════════════════════════ DB INIT ═══════════════════════════
def _ensure_local_files() -> bool:
global _status
existing = glob.glob(os.path.join(LOCAL_CACHE, "data", "*.parquet"))
if existing:
print(f"[DB] Using {len(existing)} cached parquet files")
return True
_status = "downloading"
print(f"[DB] Downloading dataset to {LOCAL_CACHE} ...")
try:
snapshot_download(
repo_id=DATASET_REPO,
repo_type="dataset",
local_dir=LOCAL_CACHE,
max_workers=1,
)
n = len(glob.glob(os.path.join(LOCAL_CACHE, "data", "*.parquet")))
print(f"[DB] Downloaded {n} parquet files")
return True
except Exception as exc:
print(f"[DB] Download failed: {exc}")
return False
def _load_into_duckdb() -> None:
global _con, _ready, _status, _error_msg, _record_count
parquet_dir = os.path.join(LOCAL_CACHE, "data", "*.parquet")
if not glob.glob(parquet_dir):
_error_msg = f"Parquet files not found at {parquet_dir}"
_status = "error"
return
_status = "loading"
cols = ", ".join(WANTED_COLS)
try:
con = duckdb.connect(":memory:")
con.execute(f"PRAGMA threads={DB_THREADS}")
con.execute(
f"CREATE TABLE debtors AS SELECT {cols} FROM '{parquet_dir}'"
)
_record_count = con.execute("SELECT COUNT(*) FROM debtors").fetchone()[0]
_con = con
_ready = True
_status = "ready"
print(f"[DB] Loaded {_record_count:,} records")
except Exception as exc:
_error_msg = str(exc)
_status = "error"
print(f"[DB] DuckDB load failed: {exc}")
def _init_db() -> None:
global _status, _error_msg
if not _ensure_local_files():
_error_msg = "Не вдалося завантажити датасет з HuggingFace."
_status = "error"
return
_load_into_duckdb()
threading.Thread(target=_init_db, daemon=True, name="db-loader").start()
# ═══════════════════════════ HELPERS ═══════════════════════════
def _esc(val) -> str:
if val is None:
return "—"
s = str(val)
if s.strip() in ("", "nan", "None", "NaN", "NaT"):
return "—"
return html_mod.escape(s)
def _plural_uk(n: int) -> str:
m10, m100 = n % 10, n % 100
if m10 == 1 and m100 != 11:
return "результат"
if m10 in (2, 3, 4) and m100 not in (12, 13, 14):
return "результати"
return "результатів"
def _msg(cls: str, icon: str, title: str, body: str = "") -> str:
body_html = f"<p class='msg-body'>{body}</p>" if body else ""
return (
f"<div class='msg {cls}'>"
f"<div class='msg-icon'>{icon}</div>"
f"<div class='msg-title'>{title}</div>"
f"{body_html}</div>"
)
def _field(label: str, val, match: bool = False) -> str:
cls = "fld fld-match" if match else "fld"
return (
f"<div class='{cls}'><div class='fld-label'>{label}</div>"
f"<div class='fld-value'>{_esc(val)}</div></div>"
)
def _classify_state(raw) -> tuple[str, str]:
if raw is None or str(raw).strip() in ("", "nan", "None"):
return "", ""
vp = str(raw).strip().lower()
if "закін" in vp or "заверш" in vp:
return "state-closed", "Завершено"
if "припин" in vp:
return "state-suspended", "Припинено"
return "state-open", "Відкрито"
def _find_match_fields(row, query: str, fields: list[str]) -> set[str]:
q_low = query.lower().strip()
matched = set()
for col in fields:
val = row.get(col)
if val is None:
continue
s = str(val).lower()
if q_low in s:
matched.add(col)
return matched
# ═══════════════════════════ FIELD PILLS ═══════════════════════════
def _render_field_pills(selected: list[str] | None = None) -> str:
sel = set(selected or list(DEFAULT_FIELDS))
pills = []
for key, label in SEARCHABLE_FIELDS.items():
active = " active" if key in sel else ""
pills.append(
f'<span class="fpill{active}" data-field="{key}">'
f'{html_mod.escape(label)}</span>'
)
return '<div class="fpill-wrap">' + "".join(pills) + "</div>"
# ═══════════════════════════ RESULTS RENDERING ═══════════════════════════
def _render_results(
df, query: str, fields: list[str] | None = None, from_cache: bool = False
) -> str:
n = len(df)
search_fields = fields or list(DEFAULT_FIELDS)
cache_tag = "<span class='cache-tag'>з кешу</span>" if from_cache else ""
field_tags = " ".join(
f"<span class='field-tag'>{SEARCHABLE_FIELDS.get(f, f)}</span>"
for f in search_fields
)
h = (
f"<div class='results-header'>"
f"<div class='results-header-left'>"
f"<span class='results-count'>{n} {_plural_uk(n)}</span>"
f"<span class='results-query'>для «{_esc(query)}»</span>"
f"</div>"
f"<div class='results-header-right'>"
f"{field_tags}"
f"{cache_tag}</div>"
f"</div>"
)
for idx, r in df.iterrows():
match_cols = _find_match_fields(r, query, search_fields)
state_cls, state_lbl = _classify_state(r.get("VP_STATE"))
state_badge = ""
if state_cls and _esc(r.get("VP_STATE")) != "—":
state_badge = f"<span class='badge {state_cls}'>{state_lbl}</span>"
match_badges = ""
if len(search_fields) > 1 and match_cols:
tags = "".join(
f"<span class='match-tag'>{SEARCHABLE_FIELDS.get(c, c)}</span>"
for c in search_fields
if c in match_cols
)
match_badges = f"<div class='card-matches'>{tags}</div>"
h += (
f"<div class='card' style='animation-delay:{idx * 25}ms'>"
f"<div class='card-top'>"
f"<div class='card-name'>{_esc(r.get('DEBTOR_NAME'))}</div>"
f"<div class='card-badges'>{state_badge}</div>"
f"</div>"
f"{match_badges}"
f"<div class='card-body'>"
f"{_field('Дата народження', r.get('DEBTOR_BIRTHDATE'), 'DEBTOR_BIRTHDATE' in match_cols)}"
f"{_field('РНОКПП боржника', r.get('DEBTOR_CODE'), 'DEBTOR_CODE' in match_cols)}"
f"{_field('ПІБ стягувача', r.get('CREDITOR_NAME'), 'CREDITOR_NAME' in match_cols)}"
f"{_field('РНОКПП стягувача', r.get('CREDITOR_CODE'), 'CREDITOR_CODE' in match_cols)}"
f"{_field('Номер провадження', r.get('VP_ORDERNUM'), 'VP_ORDERNUM' in match_cols)}"
f"{_field('Дата відкриття', r.get('VP_BEGINDATE'), 'VP_BEGINDATE' in match_cols)}"
f"{_field('Стан', r.get('VP_STATE'), 'VP_STATE' in match_cols)}"
f"{_field('Орган виконавця', r.get('ORG_NAME'), 'ORG_NAME' in match_cols)}"
f"{_field('Код органу ДВС', r.get('DVS_CODE'), 'DVS_CODE' in match_cols)}"
f"{_field('Телефон', r.get('PHONE_NUM'), 'PHONE_NUM' in match_cols)}"
f"{_field('Email', r.get('EMAIL_ADDR'), 'EMAIL_ADDR' in match_cols)}"
f"{_field('Рахунок', r.get('BANK_ACCOUNT'), 'BANK_ACCOUNT' in match_cols)}"
f"</div></div>"
)
return h
def _welcome_html() -> str:
return _msg(
"hint", "⌕", "Введіть запит для пошуку",
"Оберіть поля для пошуку нижче. За замовчуванням — "
"ПІБ боржника. База налічує 13 колонок.",
)
def _waiting_html(q: str) -> str:
return (
"<div class='search-loader'>"
"<div class='loader-spinner'></div>"
"<div class='loader-label'>Очікування бази даних</div>"
f"<div class='loader-query-box'>«{_esc(q)}»</div>"
"<div class='loader-sub'>Пошук розпочнеться автоматично</div>"
"</div>"
)
# ═══════════════════════════ SEARCH ═══════════════════════════
def _validate_fields(fields) -> list[str]:
if not fields:
return list(DEFAULT_FIELDS)
valid = [f for f in fields if f in SEARCHABLE_FIELDS]
return valid if valid else list(DEFAULT_FIELDS)
def _parse_fields_csv(fields: str | None) -> list[str] | None:
"""Parse a CSV string of field names into a list."""
if not fields:
return None
return [f.strip() for f in fields.split(",") if f.strip()] if fields else None
def search(query: str, fields: list | None = None) -> str:
if not _ready:
if _status == "downloading":
return _msg("loading", "⏳", "Завантаження датасету",
"Це відбувається лише при першому запуску.")
if _status == "loading":
return _msg("loading", "⏳", "Завантаження в пам'ять")
if _error_msg:
return _msg("error", "✕", "Помилка", _esc(_error_msg))
return _msg("loading", "⏳", "Ініціалізація")
q = (query or "").strip()
if len(q) < MIN_QUERY_LEN:
return _msg("hint", "ℹ", "Почніть пошук",
f"Введіть щонайменше {MIN_QUERY_LEN} символи.")
valid_fields = _validate_fields(fields)
cache_key = q.lower() + "|" + ",".join(sorted(valid_fields))
cached = _cache_get(cache_key)
if cached is not None:
return cached
try:
with _lock:
cols = ", ".join(WANTED_COLS)
conditions = " OR ".join(
f"{col} ILIKE ?" for col in valid_fields
)
params = [f"%{q}%" for _ in valid_fields] + [MAX_RESULTS]
df = _con.execute(
f"SELECT {cols} FROM debtors WHERE {conditions} LIMIT ?",
params,
).df()
except Exception as exc:
return _msg("error", "✕", "Помилка пошуку", _esc(str(exc)))
if df.empty:
field_info = ", ".join(
SEARCHABLE_FIELDS.get(f, f) for f in valid_fields
)
html = _msg(
"empty", "🔍", "Нічого не знайдено",
f"За запитом «{_esc(q)}» у полях {field_info} результатів не знайдено.",
)
_cache_set(cache_key, html)
return html
html = _render_results(df, q, valid_fields)
_cache_set(cache_key, html)
return html
# ═══════════════════════════ STATUS ═══════════════════════════
def get_status() -> str:
"""Return current status HTML."""
if _ready:
return (
f'<div class="status status--ready">'
f'<span class="status-pulse"></span> '
f'{_record_count:,} записів</div>'
)
if _status == "error":
return (
f'<div class="status status--err">'
f'<span class="status-pulse"></span> Помилка завантаження</div>'
)
return (
'<div class="status status--wait">'
'<span class="status-pulse"></span> Завантаження бази даних …</div>'
)
def _timer_tick():
"""Called by gr.Timer every 3s. Stops timer when DB is ready."""
status_html = get_status()
if _ready:
return status_html, gr.update(active=False)
return status_html, gr.update()
def search_and_status(query: str, fields: str | None = None) -> tuple[str, str]:
"""Search entry-point. Accepts fields as CSV string. Returns (results_html, status_html)."""
parsed_fields = _parse_fields_csv(fields)
results = search(query, parsed_fields)
status = get_status()
_ts = f'<!-- t:{time.time_ns()} -->'
return f'{_ts}{results}', status
# ═══════════════════════════ PAGE LOAD ═══════════════════════════
def _on_load(q: str, fields: str | None = None):
"""Page-load handler: return pills + status + optional search results. Accepts fields as CSV string."""
q_stripped = (q or "").strip()
parsed_fields = _parse_fields_csv(fields)
valid_fields = _validate_fields(parsed_fields)
pills_html = _render_field_pills(valid_fields)
status_html = get_status()
_ts = f'<!-- t:{time.time_ns()} -->'
if q_stripped and len(q_stripped) >= MIN_QUERY_LEN:
if _ready:
return pills_html, f'{_ts}{search(q_stripped, valid_fields)}', status_html
return pills_html, f'{_ts}{_waiting_html(q_stripped)}', status_html
return pills_html, f'{_ts}{_welcome_html()}', status_html
# ═══════════════════════════ CSS ═══════════════════════════
CSS = """
/* ══════════════ BASE RESET ══════════════ */
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
html, body {
margin: 0; padding: 0;
background: #050505;
color: #e4e4e7;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
}
/* ══════════════ NUKE GRADIO — kill everything ══════════════ */
.gradio-container {
background: transparent !important;
max-width: 100% !important;
padding: 0 !important;
overflow-x: hidden;
}
.gradio-container,
.gradio-container * {
pointer-events: none !important;
}
.gradio-container > footer,
footer,
.gradio-container .gr-footer { display: none !important; }
.gradio-container .contain,
.gradio-container .main,
.gradio-container .gr-block,
.gradio-container .gr-box,
.gradio-container .gr-form,
.gradio-container .gr-row,
.gradio-container .gr-column,
.gradio-container .gr-panel,
.gradio-container .gr-gap,
.gradio-container > .gr-block-container,
.gradio-container > .gr-block-container > .main {
border: none !important;
background: transparent !important;
box-shadow: none !important;
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
gap: 0 !important;
outline: none !important;
}
.gradio-container .wrap { gap: 0 !important; }
.gradio-container .gr-padding { padding: 0 !important; }
.gradio-container .label-wrap,
.gradio-container label { display: none !important; }
.gradio-container .toast-wrap,
.gradio-container .toast,
.gradio-container .toast-body,
.gradio-container .gr-progress,
.gradio-container .eta-bar,
.gradio-container .progress-text,
.gradio-container .progress-bar,
.gradio-container .generating,
.gradio-container .gr-loading,
.gradio-container .loading-wrap,
.gradio-container .lds-ellipsis,
.gradio-container .gr-button-loading,
.gradio-container .gr-pending,
.gradio-container [data-testid="pending"],
.gradio-container [data-testid="status-tracker"],
.gradio-container .gr-loading-container,
.gradio-container .gr-loading-wrapper,
.gradio-container .gr-spinner,
.gradio-container .indicator { display: none !important; }
/* Kill Gradio 6 semi-transparent loading overlays on output components */
.gradio-container .gradio-html::after,
.gradio-container .gradio-html .prose::after,
.gradio-container [data-testid="html"]::after,
.gradio-container .gr-component::after,
.gradio-container .component-wrapper::after {
display: none !important;
content: none !important;
background: none !important;
opacity: 0 !important;
pointer-events: none !important;
}
.gradio-container .gradio-html.is-generating,
.gradio-container .gradio-html.pending,
.gradio-container .gr-component.is-generating,
.gradio-container .component-wrapper.is-generating {
opacity: 1 !important;
}
.gradio-container .gr-block::before,
.gradio-container .gr-block::after,
.gradio-container .gr-box::before,
.gradio-container .gr-box::after,
.gradio-container .gr-panel::before,
.gradio-container .gr-panel::after {
display: none !important;
content: none !important;
}
.gradio-container .hide {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
position: static !important;
}
/* ══════════════ HIDDEN BRIDGE COMPONENTS ══════════════ */
#q-hidden,
#fields-hidden,
#search-trigger {
position: fixed !important;
left: -99999px !important;
top: -99999px !important;
width: 1px !important;
height: 1px !important;
opacity: 0 !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
border: none !important;
padding: 0 !important;
margin: 0 !important;
pointer-events: none !important;
}
/* ══════════════ TIMER COMPONENT ══════════════ */
.gradio-container .gr-timer,
[id$="status-timer"] {
display: none !important;
position: fixed !important;
left: -99999px !important;
top: -99999px !important;
width: 0 !important;
height: 0 !important;
overflow: hidden !important;
pointer-events: none !important;
}
/* ══════════════ RE-ENABLE pointer-events on our custom HTML ══════════════ */
#search-bar,
#search-bar *,
#search-input,
#search-btn,
#fields,
#fields *,
#res,
#res *,
#top-loader,
.fpill,
.card,
.retry-btn,
a {
pointer-events: auto !important;
}
/* ══════════════ STATUS INDICATOR ══════════════ */
#status-bar { pointer-events: none !important; }
.status {
display: flex; align-items: center; justify-content: center;
gap: 8px; padding: 16px 24px;
font-size: 12px; color: #52525b; font-weight: 400;
letter-spacing: 0.02em; user-select: none;
}
.status--ready { color: #3f3f46; }
.status--wait { color: #52525b; }
.status--err { color: #7f1d1d; }
.status-pulse {
display: inline-block; width: 6px; height: 6px;
border-radius: 50%; background: #52525b;
animation: statusPulse 2s ease-in-out infinite;
}
.status--ready .status-pulse { background: #22c55e; animation: none; opacity: .6; }
.status--err .status-pulse { background: #ef4444; animation: none; }
@keyframes statusPulse { 0%,100%{opacity:.3} 50%{opacity:1} }
/* ══════════════ HEADER ══════════════ */
.hdr { text-align: center; padding: 48px 24px 0; pointer-events: none !important; }
.hdr h1 {
font-size: 40px; font-weight: 700; color: #fafafa;
margin: 0 0 12px; letter-spacing: -0.04em; line-height: 1.1;
}
.hdr p { font-size: 14px; color: #3f3f46; margin: 0; line-height: 1.5; }
/* ══════════════ SEARCH BAR — pure custom HTML, zero Gradio ══════════════ */
.srow {
max-width: 580px; width: 100%;
margin: 24px auto 0;
padding: 0 24px;
display: flex;
align-items: stretch;
gap: 0;
position: relative;
z-index: 99999;
}
.srow-input-wrap {
flex: 1; min-width: 0;
position: relative;
}
.srow-icon {
position: absolute;
left: 16px; top: 50%; transform: translateY(-50%);
font-size: 18px; color: #3f3f46;
pointer-events: none; z-index: 2;
line-height: 1;
}
#search-input {
display: block;
width: 100%; font-size: 15px;
height: 50px; line-height: 50px;
border-radius: 12px 0 0 12px;
border: 1px solid #1c1c1e;
border-right: none;
background: #0f0f11; color: #fafafa;
padding: 0 0 0 44px; margin: 0;
outline: none; box-shadow: none;
transition: border-color .2s, background .2s, box-shadow .2s;
font-family: inherit;
-webkit-font-smoothing: inherit;
caret-color: #e4e4e7;
}
#search-input:focus {
border-color: #2a2a2e; background: #111114;
}
#search-input::placeholder { color: #333338; }
#search-input.readonly {
cursor: wait;
opacity: .7;
}
#search-btn {
height: 50px; width: 90px; min-width: 90px; max-width: 90px;
border-radius: 0 12px 12px 0;
font-size: 14px; font-weight: 500;
padding: 0; margin: 0;
border: 1px solid #1c1c1e; border-left: none;
background: #16161a; color: #a1a1aa;
cursor: pointer; transition: all .15s;
letter-spacing: .01em; white-space: nowrap;
font-family: inherit;
-webkit-font-smoothing: inherit;
}
#search-btn:hover { background: #1e1e24; color: #e4e4e7; }
#search-btn:active { background: #0f0f11; }
/* ── Loading state: PROMINENT visual feedback ── */
#search-input.loading {
border-color: rgba(99,102,241,.45) !important;
box-shadow: 0 0 0 2px rgba(99,102,241,.15), 0 0 30px -4px rgba(99,102,241,.2) !important;
background: rgba(99,102,241,.03) !important;
}
#search-btn.loading {
color: #6366f1 !important;
background: rgba(99,102,241,.08) !important;
box-shadow: 0 0 20px -2px rgba(99,102,241,.15) !important;
border-color: rgba(99,102,241,.2) !important;
pointer-events: none !important;
cursor: wait !important;
}
.srow-progress {
display: none;
height: 3px;
background: #0a0a0d;
border-radius: 0 0 0 12px;
overflow: hidden;
margin-top: -1px;
}
.srow.loading .srow-progress {
display: block;
}
.srow-progress-bar {
height: 100%;
width: 40%;
background: linear-gradient(90deg, transparent, #6366f1, #818cf8, transparent);
animation: progressSlide 1s ease-in-out infinite;
border-radius: 2px;
}
@keyframes progressSlide {
0% { transform: translateX(-150%); }
100% { transform: translateX(350%); }
}
/* ══════════════ FIELD PILLS ══════════════ */
#fields .html-wrap { display: block !important; }
.fpill-wrap {
max-width: 700px; width: 100%;
margin: 12px auto 0;
padding: 0 24px;
display: flex; flex-wrap: wrap; gap: 0;
line-height: 1;
}
.fpill {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
margin: 0 4px 6px 0;
border-radius: 100px;
border: 1px solid #16161a;
background: #0a0a0d;
color: #3f3f46;
font-size: 12px;
font-weight: 400;
cursor: pointer;
transition: all .15s;
user-select: none;
line-height: 1;
white-space: nowrap;
}
.fpill:hover { border-color: #222226; color: #52525b; }
.fpill.active { background: #141418; color: #a1a1aa; border-color: #27272a; }
/* ══════════════ TOP LOADING BAR ══════════════ */
#top-loader {
position: fixed; top: 0; left: 0; width: 100%; height: 3px;
background: linear-gradient(90deg, transparent 0%, #6366f1 20%, #818cf8 50%, #6366f1 80%, transparent 100%);
background-size: 200% 100%;
z-index: 999999; pointer-events: none;
opacity: 0; transition: opacity .15s;
}
#top-loader.active {
opacity: 1;
animation: topLoaderSlide 1.2s ease-in-out infinite;
}
@keyframes topLoaderSlide {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ══════════════ SEARCH LOADER ══════════════ */
.search-loader {
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 80px 20px 72px; gap: 16px;
min-height: 320px;
animation: loaderFadeIn .3s ease;
}
@keyframes loaderFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.loader-spinner {
width: 42px; height: 42px;
border: 3px solid #1a1a22; border-top-color: #6366f1;
border-right-color: #4f46e5;
border-radius: 50%; animation: spin .7s linear infinite;
box-shadow: 0 0 20px -2px rgba(99,102,241,.35);
}
.loader-label {
font-size: 16px; font-weight: 600; color: #a5b4fc;
text-align: center;
letter-spacing: .02em;
}
.loader-query-box {
display: inline-block;
margin-top: 4px;
padding: 8px 20px;
border-radius: 10px;
border: 1px solid rgba(99,102,241,.2);
background: rgba(99,102,241,.06);
font-size: 15px;
color: #e0e7ff;
letter-spacing: .01em;
text-align: center;
}
.loader-sub {
font-size: 13px; color: #71717a; max-width: 320px;
text-align: center; line-height: 1.5;
}
.loader-elapsed {
font-size: 13px; color: #818cf8; margin-top: 2px;
font-variant-numeric: tabular-nums; letter-spacing: .04em;
font-weight: 500;
}
.retry-btn {
margin-top: 10px; padding: 6px 16px;
border-radius: 8px; border: 1px solid #1c1c1e;
background: #0f0f11; color: #71717a;
font-size: 13px; cursor: pointer; transition: all .15s;
}
.retry-btn:hover { background: #16161a; color: #a1a1aa; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: .5; } to { opacity: 1; } }
@keyframes msgSlide { from { opacity:.4; transform:translateY(4px); } to { opacity:1; transform:translateY(0); } }
/* ══════════════ PROMINENT SEARCH LOADER ══════════════ */
.loader-ring-wrap {
position: relative;
width: 88px; height: 88px;
display: flex; align-items: center; justify-content: center;
}
.loader-ring {
position: absolute;
width: 88px; height: 88px;
border: 3px solid rgba(99,102,241,.15);
border-top-color: #818cf8;
border-right-color: #6366f1;
border-radius: 50%;
animation: spin .8s linear infinite;
box-shadow: 0 0 40px -2px rgba(99,102,241,.5), inset 0 0 24px -2px rgba(99,102,241,.2);
}
.loader-ring-inner {
position: absolute;
width: 60px; height: 60px;
border: 2px solid rgba(99,102,241,.1);
border-bottom-color: #a5b4fc;
border-left-color: #818cf8;
border-radius: 50%;
animation: spin 1.2s linear infinite reverse;
}
.loader-hint {
font-size: 13px; color: #6366f1;
letter-spacing: .04em;
text-align: center;
}
/* ══════════════ RESULTS ══════════════ */
#res { max-width: 800px; margin: 0 auto; padding: 0 24px 40px; min-height: 100px; }
.results-header {
display: flex; align-items: center; justify-content: space-between;
flex-wrap: wrap; gap: 8px;
padding: 32px 0 20px; border-bottom: 1px solid #111115; margin-bottom: 12px;
}
.results-header-left { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.results-header-right { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.results-count { font-size: 13px; font-weight: 500; color: #71717a; }
.results-query { font-size: 13px; color: #3f3f46; }
.cache-tag {
font-size: 11px; font-weight: 500; color: #3f3f46;
background: #111115; padding: 2px 8px;
border-radius: 100px; letter-spacing: .03em;
}
.field-tag {
font-size: 10px; font-weight: 500; color: #3f3f46;
background: #0c0c0f; padding: 2px 7px;
border-radius: 100px; letter-spacing: .02em;
border: 1px solid #131316;
}
/* ══════════════ CARDS ══════════════ */
.card {
background: #0a0a0d; border: 1px solid #131316;
border-radius: 14px; padding: 22px 26px; margin-bottom: 8px;
transition: border-color .15s, transform .15s;
animation: cardIn .25s ease both;
}
.card:hover { border-color: #1e1e24; transform: translateY(-1px); }
@keyframes cardIn { from { opacity:.5; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
.card-top {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; margin-bottom: 16px; padding-bottom: 14px;
border-bottom: 1px solid #0f0f12;
}
.card-name { font-size: 17px; font-weight: 600; color: #fafafa; letter-spacing: -.01em; line-height: 1.4; }
.card-badges { display: flex; align-items: center; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.badge {
font-size: 11px; font-weight: 500; padding: 3px 10px;
border-radius: 100px; white-space: nowrap; flex-shrink: 0; letter-spacing: .02em;
}
.state-open { background: rgba(34,197,94,.06); color: #22c55e; border: 1px solid rgba(34,197,94,.12); }
.state-suspended { background: rgba(234,179,8,.06); color: #eab308; border: 1px solid rgba(234,179,8,.12); }
.state-closed { background: rgba(82,82,91,.06); color: #52525b; border: 1px solid rgba(82,82,91,.12); }
.card-matches {
display: flex; flex-wrap: wrap; gap: 4px;
margin-bottom: 14px; padding-bottom: 12px;
border-bottom: 1px solid #0f0f12;
}
.match-tag {
font-size: 10px; font-weight: 500;
padding: 2px 8px; border-radius: 100px;
background: rgba(99,102,241,.06); color: #818cf8;
border: 1px solid rgba(99,102,241,.12); letter-spacing: .02em;
}
.card-body { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 32px; }
.fld-label { font-size: 10px; font-weight: 500; color: #333338; text-transform: uppercase; letter-spacing: .06em; margin-bottom: 2px; }
.fld-value { font-size: 13px; color: #a1a1aa; word-break: break-word; line-height: 1.5; }
.fld-match { background: rgba(99,102,241,.03); border-radius: 6px; padding: 4px 8px; margin: -4px -8px; }
.fld-match .fld-label { color: #6366f1; }
.fld-match .fld-value { color: #c7d2fe; }
/* ══════════════ MESSAGES ══════════════ */
.msg {
text-align: center; padding: 40px 20px;
display: flex; flex-direction: column; align-items: center;
gap: 8px; animation: msgSlide .25s ease both;
}
.msg-icon { font-size: 32px; color: #1a1a1e; line-height: 1; margin-bottom: 4px; }
.msg-title { font-size: 16px; font-weight: 500; color: #52525b; }
.msg-body { font-size: 14px; color: #333338; line-height: 1.6; max-width: 400px; margin: 0; }
.msg.empty .msg-icon { color: #1a1a1e; }
.msg.error .msg-icon { color: #7f1d1d; }
.msg.error .msg-title { color: #ef4444; }
.msg.hint .msg-icon { color: #1a1a1e; }
.msg.loading .msg-icon { color: #27272a; animation: spin 1.5s linear infinite; display: inline-block; }
::selection { background: rgba(99,102,241,.2); color: #fafafa; }
/* ══════════════ RESPONSIVE ══════════════ */
@media (max-width: 640px) {
.hdr { padding: 32px 16px 0; }
.hdr h1 { font-size: 28px; }
.srow { padding: 0 16px; flex-direction: column; }
#search-input {
border-radius: 12px 12px 0 0;
border-right: 1px solid #1c1c1e;
border-bottom: none;
}
#search-btn {
border-radius: 0 0 12px 12px;
border-left: 1px solid #1c1c1e;
max-width: 100%; width: 100%; height: 44px;
}
.fpill-wrap { padding: 0 16px; }
.card { padding: 18px 20px; border-radius: 12px; }
.card-body { grid-template-columns: 1fr; gap: 10px 0; }
.card-top { flex-direction: column; align-items: flex-start; gap: 8px; }
.results-header { padding-top: 24px; }
.results-header-left, .results-header-right { width: 100%; }
#res { padding: 0 16px 80px; }
.fld-match { padding: 4px 6px; margin: -4px -6px; }
}
"""
# ═══════════════════════════ CUSTOM SEARCH BAR HTML ═══════════════════════════
_SEARCH_BAR_HTML = (
'<div class="srow" id="search-bar">'
'<div class="srow-input-wrap">'
'<span class="srow-icon">\u2315</span>'
'<input type="text" id="search-input" placeholder="\u041f\u0406\u0411, \u043d\u043e\u043c\u0435\u0440 \u043f\u0440\u043e\u0432\u0430\u0434\u0436\u0435\u043d\u043d\u044f, \u043a\u043e\u0434 \u2026" autocomplete="off" autofocus />'
'</div>'
'<button type="button" id="search-btn">\u0417\u043d\u0430\u0439\u0442\u0438</button>'
'<div class="srow-progress"><div class="srow-progress-bar"></div></div>'
'</div>'
'<div id="top-loader"></div>'
)
# ═══════════════════════════ PER-EVENT JS ═══════════════════════════
# JS runs BEFORE Python on hidden button click.
# MINIMAL: only reads pills + query from DOM and returns them.
# ALL loading UI is managed EXCLUSIVELY by _HEAD_JS state machine.
_SKELETON_JS = """(q, _fieldsCSV) => {
var fields = [];
document.querySelectorAll('.fpill.active').forEach(function(p) {
fields.push(p.dataset.field);
});
if (!fields.length) fields = ['DEBTOR_NAME', 'CREDITOR_NAME'];
/* Always read from visible custom input — never trust the Gradio param */
var ci = document.getElementById('search-input');
q = ci ? ci.value.trim() : (q || '').trim();
return [q || '', fields.join(',')];
}"""
# JS runs on page load — reads ?q= / #q= AND ?f= / #f=
_JS_READ_URL = """() => {
var params = new URLSearchParams(location.search);
var q = params.get('q');
var f = params.get('f');
if (!q || !f) {
var h = location.hash;
var qm = h.match(/[?&]q=([^&]+)/);
var fm = h.match(/[?&]f=([^&]+)/);
if (qm) q = qm[1];
if (fm) f = fm[1];
}
var decoded = q ? decodeURIComponent(q) : '';
var fieldsCSV = f ? f : 'DEBTOR_NAME,CREDITOR_NAME';
return [decoded, fieldsCSV];
}"""
# ═══════════════════════════ HEAD JS — CLIENT STATE MACHINE v9 ═══════════════════════════
_HEAD_JS = """
<script>
(function () {
/*
* ══════════════════════════════════════════════════════════════════
* STATE MACHINE v9 — Event-driven, zero-poll design
* ══════════════════════════════════════════════════════════════════
*
* States:
* IDLE — normal state, input enabled
* SEARCHING — search in progress, input locked, animation shown
*
* Transition triggers:
* IDLE → SEARCHING: user clicks search or auto-retry fires
* SEARCHING → IDLE: Svelte updates #res (MutationObserver detects)
* OR Gradio removes .is-generating/.pending
* OR safety timeout (30s)
*
* Detection strategy (DUAL, no polling):
* 1. PRIMARY: MutationObserver on content wrapper watches for
* our unique marker element being removed.
* This fires when Svelte replaces innerHTML with results.
* 2. SECONDARY: MutationObserver on #res watches for Gradio's
* .is-generating/.pending class being removed.
* This fires when Gradio finishes server round-trip.
* 3. FALLBACK: Safety timeout (30s) as last resort.
*
* Edge cases handled:
* - 2nd+ search: content wrapper found robustly, old results replaced
* - Checkbox toggle: no search triggered, pills update instantly
* - URL params on load: cosmetic skeleton shown, no enterLoading
* - DB not ready: waiting message shown, auto-retry on DB ready
* - Cached results: fast response, animation shown briefly then removed
* - No results: "nothing found" message, animation removed correctly
* - Error: error message, animation removed correctly
* - Empty/short query: blocked client-side, no loading state entered
*/
var _autoTriggered = false;
window._searchActive = false;
window._searchStartTime = 0;
var _searchId = 0;
/* Observer references for cleanup */
var _contentObserver = null;
var _classObserver = null;
var _safetyTimer = null;
var _elapsedTimer = null;
var _elapsedStart = 0;
/* ── Fix Gradio postMessage cross-origin error ── */
try {
var _origPM = window.postMessage;
window.postMessage = function(msg, targetOrigin, transfer) {
try { return _origPM.call(this, msg, targetOrigin, transfer); }
catch(e) {}
};
} catch(e) {}
/* ── helpers ────────────────────────────── */
function _readUrlQuery() {
var params = new URLSearchParams(location.search);
var q = params.get('q');
if (q) return decodeURIComponent(q);
var h = location.hash;
var m = h.match(/[?&]q=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
function _readUrlFields() {
var params = new URLSearchParams(location.search);
var f = params.get('f');
if (f) return f.split(',').map(function(s) { return decodeURIComponent(s.trim()); });
var h = location.hash;
var m = h.match(/[?&]f=([^&]+)/);
return m ? m[1].split(',').map(function(s) { return decodeURIComponent(s.trim()); })
: ['DEBTOR_NAME', 'CREDITOR_NAME'];
}
function _escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
/* ── Field labels for dynamic page title ── */
var _FIELD_LABELS = {
'DEBTOR_NAME': '\\u041f\\u0406\\u0411 \\u0431\\u043e\\u0440\\u0436\\u043d\\u0438\\u043a\\u0430',
'CREDITOR_NAME': '\\u041f\\u0406\\u0411 \\u0441\\u0442\\u044f\\u0433\\u0443\\u0432\\u0430\\u0447\\u0430',
'VP_ORDERNUM': '\\u041d\\u043e\\u043c\\u0435\\u0440 \\u043f\\u0440\\u043e\\u0432\\u0430\\u0434\\u0436\\u0435\\u043d\\u043d\\u044f',
'DEBTOR_CODE': '\\u0420\\u041d\\u041e\\u041a\\u041f\\u041f \\u0431\\u043e\\u0440\\u0436\\u043d\\u0438\\u043a\\u0430',
'CREDITOR_CODE': '\\u0420\\u041d\\u041e\\u041a\\u041f\\u041f \\u0441\\u0442\\u044f\\u0433\\u0443\\u0432\\u0430\\u0447\\u0430',
'DEBTOR_BIRTHDATE': '\\u0414\\u0430\\u0442\\u0430 \\u043d\\u0430\\u0440\\u043e\\u0434\\u0436\\u0435\\u043d\\u043d\\u044f',
'VP_BEGINDATE': '\\u0414\\u0430\\u0442\\u0430 \\u0432\\u0456\\u0434\\u043a\\u0440\\u0438\\u0442\\u0442\\u044f',
'VP_STATE': '\\u0421\\u0442\\u0430\\u043d \\u043f\\u0440\\u043e\\u0432\\u0430\\u0434\\u0436\\u0435\\u043d\\u043d\\u044f',
'ORG_NAME': '\\u041e\\u0440\\u0433\\u0430\\u043d \\u0432\\u0438\\u043a\\u043e\\u043d\\u0430\\u0432\\u0446\\u044f',
'DVS_CODE': '\\u041a\\u043e\\u0434 \\u0414\\u0412\\u0421',
'PHONE_NUM': '\\u0422\\u0435\\u043b\\u0435\\u0444\\u043e\\u043d',
'EMAIL_ADDR': 'Email',
'BANK_ACCOUNT': '\\u0420\\u0430\\u0445\\u0443\\u043d\\u043e\\u043a'
};
function _updateTitle(q, fields) {
if (!q) { document.title = '\\u0420\\u0435\\u0454\\u0441\\u0442\\u0440 \\u0431\\u043e\\u0440\\u0436\\u043d\\u0438\\u043a\\u0456\\u0432 \\u2014 ASVP'; return; }
var labels = (fields || []).map(function(f) { return _FIELD_LABELS[f] || f; });
var fieldStr = labels.join('/') || '\\u041f\\u043e\\u0448\\u0443\\u043a';
document.title = fieldStr + ': ' + q + ' \\u2014 \\u0420\\u0435\\u0454\\u0441\\u0442\\u0440 \\u0431\\u043e\\u0440\\u0436\\u043d\\u0438\\u043a\\u0456\\u0432 \\u2014 ASVP';
}
window._updateTitle = _updateTitle;
/* ── GRADIO BRIDGE FUNCTIONS ── */
/* Set native value on a Gradio hidden textarea/input, triggering reactivity */
function _setGradioValue(elemId, value) {
var gradioTA = document.querySelector('#' + elemId + ' textarea, #' + elemId + ' input');
if (!gradioTA) return;
try {
var proto = gradioTA.tagName === 'TEXTAREA'
? window.HTMLTextAreaElement.prototype
: window.HTMLInputElement.prototype;
var desc = Object.getOwnPropertyDescriptor(proto, 'value');
if (desc && desc.set) {
desc.set.call(gradioTA, value);
} else {
gradioTA.value = value;
}
gradioTA.dispatchEvent(new Event('input', { bubbles: true }));
gradioTA.dispatchEvent(new Event('change', { bubbles: true }));
} catch(e) {
gradioTA.value = value;
}
}
function _syncFieldsToGradio(fieldsCSV) {
_setGradioValue('fields-hidden', fieldsCSV);
}
function _triggerGradioSearch() {
_setGradioValue('q-hidden', document.getElementById('search-input').value || '');
setTimeout(function() {
var trigger = document.querySelector('#search-trigger button, #search-trigger');
if (trigger) trigger.click();
}, 30);
}
/* ── ROBUST CONTENT WRAPPER DETECTION ── */
/*
* Gradio 6's HTML component wraps content in .html-wrap or .prose.
* We must find the correct element to inject skeleton into.
* Fallback strategy tries multiple selectors and heuristics.
*/
function _getContentWrapper() {
var resEl = document.getElementById('res');
if (!resEl) return null;
/* Strategy 1: Known Gradio inner wrappers */
var w = resEl.querySelector('.html-wrap');
if (w) return w;
w = resEl.querySelector('.prose');
if (w) return w;
/* Strategy 2: First direct child that is a visible div (not label/wrapper chrome) */
for (var c = resEl.firstElementChild; c; c = c.nextElementSibling) {
if (c.tagName === 'LABEL') continue;
var cls = (c.className || '');
if (cls.indexOf('label-wrap') >= 0) continue;
/* Accept any div that has actual content (children or text) */
if (c.tagName === 'DIV' && (c.children.length > 0 || c.textContent.length > 10)) {
return c;
}
}
/* Strategy 3: Last resort — the #res element itself */
return resEl;
}
/* ── URL + TITLE UPDATE ── */
/*
* Notify parent/top frames of URL change.
* HuggingFace Spaces embeds in an iframe at huggingface.co.
* We try multiple message formats since HF's listener API is undocumented.
*/
function _notifyParent(url) {
var fullUrl = location.origin + url;
var msgs = [
{type: 'url_change', url: url},
{type: 'url_change', url: fullUrl},
{type: 'setUrl', url: fullUrl},
];
var targets = [];
if (window.parent && window.parent !== window) targets.push(window.parent);
if (window.top && window.top !== window && window.top !== window.parent) targets.push(window.top);
for (var i = 0; i < msgs.length; i++) {
for (var j = 0; j < targets.length; j++) {
try { targets[j].postMessage(msgs[i], '*'); } catch(e) {}
}
}
}
function _updateUrl(q, fields) {
if (!q) return;
var enc = encodeURIComponent(q);
var fStr = (fields || ['DEBTOR_NAME', 'CREDITOR_NAME']).join(',');
var u = new URL(location);
u.searchParams.set('q', enc);
u.searchParams.set('f', fStr);
history.replaceState({}, '', u.toString());
_updateTitle(q, fields);
_notifyParent(u.pathname + u.search);
}
/* ══════════════════════════════════════════════════════════
* STATE MACHINE — enterLoading / exitLoading
* ══════════════════════════════════════════════════════════ */
function _stopAllTimers() {
if (_contentObserver) { _contentObserver.disconnect(); _contentObserver = null; }
if (_classObserver) { _classObserver.disconnect(); _classObserver = null; }
if (_safetyTimer) { clearTimeout(_safetyTimer); _safetyTimer = null; }
if (_elapsedTimer) { clearInterval(_elapsedTimer); _elapsedTimer = null; }
}
/*
* enterLoading(q): IDLE → SEARCHING
*
* 1. Increment search ID for stale-detection
* 2. Lock input, show visual loading indicators
* 3. Find content wrapper and inject skeleton with unique marker
* 4. Start elapsed timer
* 5. Start MutationObserver (PRIMARY): watch for marker removal
* 6. Start MutationObserver (SECONDARY): watch for Gradio class removal
* 7. Start safety timeout (30s)
*/
function enterLoading(q) {
_stopAllTimers();
var myId = ++_searchId;
window._searchActive = true;
window._searchStartTime = _elapsedStart = Date.now();
/* Lock input, show loading UI */
var ci = document.getElementById('search-input');
var btn = document.getElementById('search-btn');
var srow = ci ? ci.closest('.srow') : null;
var topLoader = document.getElementById('top-loader');
if (ci) { ci.readOnly = true; ci.classList.add('loading', 'readonly'); }
if (btn) { btn.classList.add('loading'); btn.textContent = '\\u041f\\u043e\\u0448\\u0443\\u043a ...'; }
if (srow) srow.classList.add('loading');
if (topLoader) topLoader.classList.add('active');
/* Inject skeleton into content wrapper */
var wrapper = _getContentWrapper();
var markerId = '_ld-' + myId;
if (wrapper) {
wrapper.innerHTML =
'<div class="search-loader">' +
'<div id="' + markerId + '">' +
'<div class="loader-ring-wrap">' +
'<div class="loader-ring"></div>' +
'<div class="loader-ring-inner"></div>' +
'</div>' +
'<div class="loader-label">\\u041f\\u043e\\u0448\\u0443\\u043a . . .</div>' +
'<div class="loader-query-box">\\u00ab' + _escHtml(q) + '\\u00bb</div>' +
'<div class="loader-elapsed">0.0\\u0441</div>' +
'<div class="loader-hint">\\u0411\\u0443\\u0434\\u044c \\u043b\\u0430\\u0441\\u043a\\u0430, \\u0437\\u0430\\u0447\\u0435\\u043a\\u0430\\u0439\\u0442\\u0435</div>' +
'</div></div>';
}
/* Elapsed timer — updates the counter in the skeleton */
_elapsedTimer = setInterval(function() {
var el = document.querySelector('.loader-elapsed');
if (el && _elapsedStart) {
el.textContent = ((Date.now() - _elapsedStart) / 1000).toFixed(1) + '\\u0441';
}
}, 100);
/*
* PRIMARY DETECTION: MutationObserver on content wrapper.
* Watches for our unique marker being removed from the DOM.
* This happens when Svelte sets innerHTML on the wrapper with
* server response (results, error, or "nothing found" message).
*
* Started in next tick (setTimeout 5ms) to skip our own
* innerHTML injection which would immediately trigger it.
*/
setTimeout(function() {
if (!window._searchActive || myId !== _searchId) return;
if (!wrapper) return;
/* Guard: only fire once per search */
var _exited = false;
function _tryExit() {
if (_exited || !window._searchActive || myId !== _searchId) return;
_exited = true;
if (_contentObserver) { _contentObserver.disconnect(); _contentObserver = null; }
if (_classObserver) { _classObserver.disconnect(); _classObserver = null; }
requestAnimationFrame(function() {
if (myId === _searchId && window._searchActive) exitLoading();
});
}
/* Observer 1: marker removal in content wrapper */
_contentObserver = new MutationObserver(function() {
/* Check if our unique marker still exists anywhere in the wrapper */
if (!wrapper.querySelector('#' + markerId)) {
_tryExit();
}
});
_contentObserver.observe(wrapper, { childList: true, subtree: true });
/* Observer 2: Gradio .is-generating/.pending class removal on #res */
var resEl = document.getElementById('res');
if (resEl) {
var _pendingWasAdded = false;
_classObserver = new MutationObserver(function() {
var cls = resEl.className || '';
var hasPending = cls.indexOf('is-generating') >= 0 || cls.indexOf('pending') >= 0;
if (hasPending) {
_pendingWasAdded = true;
} else if (_pendingWasAdded) {
/* .is-generating was added then removed — server round-trip complete */
_tryExit();
}
});
_classObserver.observe(resEl, { attributes: true, attributeFilter: ['class'] });
}
}, 5);
/* Safety timeout: unblock after 30s no matter what */
_safetyTimer = setTimeout(function() {
if (myId === _searchId && window._searchActive) exitLoading();
}, 30000);
}
/*
* exitLoading(): SEARCHING → IDLE
* • Unlocks input, removes all loading visual indicators
* • Disconnects all observers, clears all timers
*/
function exitLoading() {
window._searchActive = false;
_stopAllTimers();
var ci = document.getElementById('search-input');
var btn = document.getElementById('search-btn');
var srow = ci ? ci.closest('.srow') : null;
var topLoader = document.getElementById('top-loader');
if (ci) { ci.readOnly = false; ci.classList.remove('loading', 'readonly'); }
if (btn) { btn.classList.remove('loading'); btn.textContent = '\\u0417\\u043d\\u0430\\u0439\\u0442\\u0438'; }
if (srow) srow.classList.remove('loading');
if (topLoader) topLoader.classList.remove('active');
}
/* Expose for auto-retry */
window._enterLoading = enterLoading;
window._exitLoading = exitLoading;
/* ── Search event handler ── */
function _initSearchHandlers() {
var customInput = document.getElementById('search-input');
var customBtn = document.getElementById('search-btn');
if (!customInput || !customBtn) return;
customBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
/* Guard: don't start a new search while one is in progress */
if (window._searchActive) return;
var q = customInput.value.trim();
/* Client-side validation: block short/empty queries */
if (!q || q.length < 3) return;
/* Read currently active pills */
var fields = [];
document.querySelectorAll('.fpill.active').forEach(function(p) {
fields.push(p.dataset.field);
});
if (!fields.length) fields = ['DEBTOR_NAME', 'CREDITOR_NAME'];
/* Sync state: pills → hidden Gradio textbox, URL, title */
_syncFieldsToGradio(fields.join(','));
_updateUrl(q, fields);
/* Transition: IDLE → SEARCHING */
enterLoading(q);
/* Trigger Gradio search (hidden bridge) */
_triggerGradioSearch();
});
customInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
customBtn.click();
}
});
}
/* ── pill click handler (event delegation) ── */
document.addEventListener('click', function(e) {
if (e.target.classList && e.target.classList.contains('fpill')) {
e.preventDefault();
e.stopPropagation();
e.target.classList.toggle('active');
/* No search triggered — user toggles pills, then clicks search */
}
});
/* ── sync pills from URL ?f= param on load ── */
function _syncPillsFromUrl() {
var fields = _readUrlFields();
if (!fields || (fields.length === 2 && fields[0] === 'DEBTOR_NAME' && fields[1] === 'CREDITOR_NAME')) return;
document.querySelectorAll('.fpill').forEach(function(pill) {
if (fields.indexOf(pill.dataset.field) >= 0) {
pill.classList.add('active');
} else {
pill.classList.remove('active');
}
});
}
/* ── sync custom input from URL ?q= param on load ── */
function _syncInputFromUrl() {
var q = _readUrlQuery();
if (!q) return;
var customInput = document.getElementById('search-input');
if (customInput) customInput.value = q;
}
/* ── auto-retry: watch status bar for "ready" then re-trigger search ── */
function _tryAutoSearch() {
if (_autoTriggered) return;
var customInput = document.getElementById('search-input');
var q = customInput ? (customInput.value || '').trim() : null;
if (!q || q.length < 3) return;
/* Don't re-trigger if results are already shown */
var res = document.getElementById('res');
if (res && res.querySelector('.card')) return;
if (res && res.textContent.indexOf('\\u041d\\u0456\\u0447\\u043e\\u0433\\u043e') >= 0) return;
_autoTriggered = true;
var observer = document.getElementById('status-bar');
if (observer && observer._retryObserver) { observer._retryObserver.disconnect(); }
var fields = [];
document.querySelectorAll('.fpill.active').forEach(function(p) {
fields.push(p.dataset.field);
});
if (!fields.length) fields = ['DEBTOR_NAME', 'CREDITOR_NAME'];
_syncFieldsToGradio(fields.join(','));
_updateUrl(q, fields);
enterLoading(q);
_triggerGradioSearch();
}
function _isStatusReady() {
var statusBar = document.getElementById('status-bar');
if (!statusBar) return false;
var text = statusBar.textContent || '';
return text.indexOf('\\u0437\\u0430\\u043f\\u0438\\u0441\\u0456\\u0432') >= 0;
}
function _setupAutoRetry() {
var statusBar = document.getElementById('status-bar');
if (!statusBar) return;
/*
* CRITICAL: check current status IMMEDIATELY.
* If DB is already ready when we set up the observer (e.g. server was
* warm or demo.load already returned "ready"), no future mutation will
* occur and the observer would never fire. By checking now we catch
* the "already ready" case.
*/
if (_isStatusReady()) {
setTimeout(_tryAutoSearch, 100);
}
var observer = new MutationObserver(function() {
if (_autoTriggered) { observer.disconnect(); return; }
if (!_isStatusReady()) return;
/* Small delay to let Gradio finish rendering the status update */
setTimeout(_tryAutoSearch, 200);
});
statusBar._retryObserver = observer;
observer.observe(statusBar, { childList: true, subtree: true, characterData: true });
setTimeout(function() { observer.disconnect(); }, 300000);
}
/* ── init ───────────────────────────────── */
function init() {
_syncPillsFromUrl();
_syncInputFromUrl();
_initSearchHandlers();
_setupAutoRetry();
/* Sync initial pill state to hidden Gradio textbox */
var fields = [];
document.querySelectorAll('.fpill.active').forEach(function(p) {
fields.push(p.dataset.field);
});
if (!fields.length) fields = ['DEBTOR_NAME', 'CREDITOR_NAME'];
_syncFieldsToGradio(fields.join(','));
/*
* If page loaded with ?q= params, show a cosmetic skeleton.
* This is purely visual — NO enterLoading() is called, so no
* observers or timers are started. The demo.load response will
* replace this skeleton naturally when it arrives.
*
* State remains IDLE throughout.
*/
var q = _readUrlQuery();
if (q && q.length >= 3) {
var urlFields = _readUrlFields();
_updateTitle(q, urlFields);
/* Also notify parent of URL on initial load */
_notifyParent(location.pathname + location.search);
var wrapper = _getContentWrapper();
if (wrapper) {
wrapper.innerHTML =
'<div class="search-loader">' +
'<div class="loader-ring-wrap">' +
'<div class="loader-ring"></div>' +
'<div class="loader-ring-inner"></div>' +
'</div>' +
'<div class="loader-label">\\u041f\\u043e\\u0448\\u0443\\u043a . . .</div>' +
'<div class="loader-query-box">\\u00ab' + _escHtml(q) + '\\u00bb</div>' +
'<div class="loader-sub">\\u0417\\u0430\\u0432\\u0430\\u043d\\u0442\\u0430\\u0436\\u0435\\u043d\\u043d\\u044f \\u0434\\u0430\\u043d\\u0438\\u0445</div>' +
'</div>';
}
}
}
/*
* Startup: wait for Gradio to render our custom HTML elements
* before running init(). On first private-tab load, Gradio JS bundles
* are not cached, so hydration can take several seconds.
* A fixed 300ms timeout is too fragile — we poll every 100ms for up
* to 15s until #search-input appears in the DOM.
*/
function _tryInit(attempts) {
if (typeof attempts === 'undefined') attempts = 150;
var el = document.querySelector('#search-input');
if (el) {
init();
return;
}
if (attempts > 0) {
setTimeout(function() { _tryInit(attempts - 1); }, 100);
} else {
/* Last resort: run init anyway, some features may be degraded */
init();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() { setTimeout(function() { _tryInit(); }, 50); });
} else {
_tryInit();
}
})();
</script>
"""
# ═══════════════════════════ GRADIO APP ═══════════════════════════
with gr.Blocks(title=TITLE) as demo:
# ── Status indicator ──
status_bar = gr.HTML(value=get_status(), elem_id="status-bar")
# ── Header ──
gr.HTML(
'<div class="hdr">'
'<h1>Реєстр боржників</h1>'
'<p>Пошук у Реєстрі виконавчих проваджень ASVP · '
"Регістр №28 Мін\u2019юсту України</p></div>"
)
# ── Custom search bar (pure HTML — no Gradio Textbox) ──
gr.HTML(value=_SEARCH_BAR_HTML)
# ── HIDDEN BRIDGE: Gradio Textbox + Button (off-screen, data only) ──
inp = gr.Textbox(elem_id="q-hidden", show_label=False, container=False)
fields_hidden = gr.Textbox(elem_id="fields-hidden", visible=False, value="DEBTOR_NAME,CREDITOR_NAME")
btn = gr.Button("search", elem_id="search-trigger")
# ── Field selector (pure custom HTML pills) ──
pills_out = gr.HTML(value=_render_field_pills(), elem_id="fields")
# ── Results ──
out = gr.HTML(value=_welcome_html(), elem_id="res")
# ── Status Timer (Gradio-native polling) ──
if HAS_TIMER:
status_timer = gr.Timer(value=3.0, active=True)
status_timer.tick(
fn=_timer_tick,
outputs=[status_bar, status_timer],
)
# ── Search event: hidden button triggers Python search ──
btn.click(
fn=search_and_status,
inputs=[inp, fields_hidden],
outputs=[out, status_bar],
js=_SKELETON_JS,
)
# ── Page load: read URL → return pills + results + status ──
demo.load(
fn=_on_load,
inputs=[inp, fields_hidden],
outputs=[pills_out, out, status_bar],
js=_JS_READ_URL,
)
# ═══════════════════════════ LAUNCH ═══════════════════════════
demo.launch(
css=CSS,
head=_HEAD_JS,
theme=gr.themes.Default(),
ssr_mode=False,
)