#!/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""
)
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""
)
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 = (
''
'
'
'\u2315 '
' '
'
'
'
\u0417\u043d\u0430\u0439\u0442\u0438 '
'
'
'
'
'
'
)
# ═══════════════════════════ 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,
)