#!/usr/bin/env python3 """ Реєстр виконавчих проваджень ASVP Пошук боржників за ПІБ · Регістр №28 Мін'юсту України Architecture (v8 — zero Gradio UI, MutationObserver-based state machine): • Search input is a raw — 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"

{body}

" if body else "" return ( f"
" f"
{icon}
" f"
{title}
" f"{body_html}
" ) def _field(label: str, val, match: bool = False) -> str: cls = "fld fld-match" if match else "fld" return ( f"
{label}
" f"
{_esc(val)}
" ) 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'' f'{html_mod.escape(label)}' ) return '
' + "".join(pills) + "
" # ═══════════════════════════ 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 = "з кешу" if from_cache else "" field_tags = " ".join( f"{SEARCHABLE_FIELDS.get(f, f)}" for f in search_fields ) h = ( f"
" f"
" f"{n} {_plural_uk(n)}" f"для «{_esc(query)}»" f"
" f"
" f"{field_tags}" f"{cache_tag}
" f"
" ) 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"{state_lbl}" match_badges = "" if len(search_fields) > 1 and match_cols: tags = "".join( f"{SEARCHABLE_FIELDS.get(c, c)}" for c in search_fields if c in match_cols ) match_badges = f"
{tags}
" h += ( f"
" f"
" f"
{_esc(r.get('DEBTOR_NAME'))}
" f"
{state_badge}
" f"
" f"{match_badges}" f"
" 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"
" ) return h def _welcome_html() -> str: return _msg( "hint", "⌕", "Введіть запит для пошуку", "Оберіть поля для пошуку нижче. За замовчуванням — " "ПІБ боржника. База налічує 13 колонок.", ) def _waiting_html(q: str) -> str: return ( "
" "
" "
Очікування бази даних
" f"
«{_esc(q)}»
" "
Пошук розпочнеться автоматично
" "
" ) # ═══════════════════════════ 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'
' f' ' f'{_record_count:,} записів
' ) if _status == "error": return ( f'
' f' Помилка завантаження
' ) return ( '
' ' Завантаження бази даних …
' ) 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'' 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'' 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 = ( '' '
' ) # ═══════════════════════════ 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 = """ """ # ═══════════════════════════ 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( '
' '

Реєстр боржників

' '

Пошук у Реєстрі виконавчих проваджень ASVP · ' "Регістр №28 Мін\u2019юсту України

" ) # ── 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, )