"""Streamlit-интерфейс утилиты Ru2SQL. Архитектурно — клиент REST API на FastAPI. Соответствует разделу 3.5 пояснительной записки: все обращения к модели и базе данных идут через HTTP к ``src.api.main:app``. Запуск двух процессов: uvicorn src.api.main:app --reload # на 127.0.0.1:8000 streamlit run streamlit_app.py # на 127.0.0.1:8501 """ from __future__ import annotations import logging import os import sys import warnings from pathlib import Path # ────────────────────────────────────────────── # Глушим шумные warning'и # ────────────────────────────────────────────── # Streamlit-watcher ходит по всему пакету transformers (image-processors) # и спамит ModuleNotFoundError про torchvision. На работу это не влияет — # Qwen2.5-Coder text-only, torchvision не нужен. warnings.filterwarnings("ignore", message=".*torchvision.*") logging.getLogger("transformers").setLevel(logging.ERROR) logging.getLogger("streamlit.watcher.local_sources_watcher").setLevel(logging.ERROR) import httpx import streamlit as st ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT)) # Бизнес-словарь парсим локально — он не требует обращения к серверу from src.business.vocabulary import BusinessVocabulary API_URL = os.environ.get("RU2SQL_API_URL", "http://127.0.0.1:8000") QUERY_TIMEOUT = 1800.0 # 30 минут — фактически безлимит SHORT_TIMEOUT = 10.0 # для /health, /schema # ────────────────────────────────────────────── # Конфигурация страницы # ────────────────────────────────────────────── st.set_page_config( page_title="Ru2SQL", layout="wide", initial_sidebar_state="expanded", ) # ────────────────────────────────────────────── # CSS — оформление в стиле тёмной темы GitHub # ────────────────────────────────────────────── st.markdown(""" """, unsafe_allow_html=True) # ────────────────────────────────────────────── # Session state # ────────────────────────────────────────────── def _default_vocab_yaml() -> str: example = ROOT / "configs" / "example_vocabulary.yaml" if example.exists(): return example.read_text(encoding="utf-8") return "company: Моя компания\n\nterms: {}\nfilters: {}\nnotes: []\n" def _init_state(): defaults = { "history": [], "api_health": None, # dict | None "api_error": None, # str | None "connection_string": "", "schema_tables": None, # list[TablePayload-like dict] | None "schema_error": None, "vocabulary": None, # BusinessVocabulary | None "vocab_yaml": _default_vocab_yaml(), "db_mode": None, "warmup_done": False, } for k, v in defaults.items(): if k not in st.session_state: st.session_state[k] = v _init_state() # ────────────────────────────────────────────── # Обёртки над API # ────────────────────────────────────────────── def _api_get_health() -> dict | None: """GET /health. None если API недоступен.""" try: r = httpx.get(f"{API_URL}/health", timeout=SHORT_TIMEOUT) r.raise_for_status() return r.json() except Exception as e: st.session_state.api_error = str(e) return None def _api_get_schema(cs: str) -> tuple[list[dict] | None, str | None]: """POST /schema. Возвращает (tables, error).""" try: r = httpx.post( f"{API_URL}/schema", json={"connection_string": cs, "include_samples": True}, timeout=SHORT_TIMEOUT, ) if r.status_code != 200: try: return None, r.json().get("detail", r.text) except Exception: return None, r.text return r.json().get("tables", []), None except Exception as e: return None, str(e) def _api_query(question: str, cs: str, vocab: BusinessVocabulary | None) -> dict: """POST /query — генерация SQL + опциональное исполнение.""" payload = { "question": question, "connection_string": cs, "execute": True, } if vocab is not None and bool(vocab): payload["vocabulary"] = { "company": vocab.company, "terms": vocab.terms, "filters": vocab.filters, "notes": vocab.notes, } r = httpx.post(f"{API_URL}/query", json=payload, timeout=QUERY_TIMEOUT) if r.status_code != 200: try: detail = r.json().get("detail", r.text) except Exception: detail = r.text raise RuntimeError(f"API вернул {r.status_code}: {detail}") return r.json() def _api_warmup() -> tuple[bool, str | None]: """POST /warmup — короткий прогон для прогрева модели на CPU.""" try: r = httpx.post(f"{API_URL}/warmup", timeout=QUERY_TIMEOUT) if r.status_code == 200: return True, None return False, r.text except Exception as e: return False, str(e) def _load_vocab_from_yaml(yaml_text: str) -> BusinessVocabulary: import tempfile tmp = Path(tempfile.mktemp(suffix=".yaml")) tmp.write_text(yaml_text, encoding="utf-8") try: return BusinessVocabulary.from_yaml(tmp) finally: tmp.unlink(missing_ok=True) # ────────────────────────────────────────────── # Диалог редактирования бизнес-словаря # ────────────────────────────────────────────── @st.dialog("Бизнес-словарь", width="large") def vocab_dialog(): st.caption( "Опишите термины и метрики компании в формате YAML. " "Модель будет учитывать их при генерации SQL." ) yaml_text = st.text_area( "YAML-конфигурация", value=st.session_state.vocab_yaml, height=480, label_visibility="collapsed", ) c1, c2 = st.columns(2) with c1: if st.button("Применить", type="primary", width='stretch'): try: st.session_state.vocabulary = _load_vocab_from_yaml(yaml_text) st.session_state.vocab_yaml = yaml_text st.rerun() except Exception as e: st.error(f"Ошибка синтаксиса YAML: {e}") with c2: if st.button("Отмена", width='stretch'): st.rerun() # ────────────────────────────────────────────── # Sidebar # ────────────────────────────────────────────── with st.sidebar: # ── API ── st.markdown('
API
', unsafe_allow_html=True) health = _api_get_health() st.session_state.api_health = health if health is None: st.markdown('API недоступен', unsafe_allow_html=True) st.caption(f"Адрес: {API_URL}") st.caption("Запусти в отдельной консоли: `uvicorn src.api.main:app --reload`") if st.session_state.api_error: st.caption(f"Причина: {st.session_state.api_error[:160]}") else: if health.get("model_loaded"): st.markdown( f'✅ {health.get("base_model", "модель")}', unsafe_allow_html=True, ) else: st.markdown( '⏳ Модель ещё загружается', unsafe_allow_html=True, ) st.caption("Подождите несколько минут — модель ещё инициализируется.") st.markdown('База данных
', unsafe_allow_html=True) modes = ["Демо-база", "Загрузить файл", "Строка подключения"] prev = st.session_state.db_mode db_mode = st.radio( "Источник данных", modes, index=modes.index(prev) if prev in modes else None, label_visibility="collapsed", ) if db_mode != prev: st.session_state.schema_tables = None st.session_state.connection_string = "" st.session_state.db_mode = db_mode cs = "" if db_mode == "Демо-база": st.caption("Встроенная база: интернет-магазин электроники, 120 заказов.") cs = str(ROOT / "data" / "demo" / "sales.sqlite") elif db_mode == "Загрузить файл": uploaded = st.file_uploader( "SQLite-файл базы данных", type=["sqlite", "db"], label_visibility="collapsed", ) if uploaded: import tempfile tmp_db = Path(tempfile.mktemp(suffix=".sqlite")) tmp_db.write_bytes(uploaded.read()) cs = str(tmp_db) else: st.caption("Перетащите .sqlite или .db файл сюда") else: cs = st.text_input( "Строка подключения", placeholder="postgresql://user:pass@host:5432/db", value=st.session_state.connection_string, label_visibility="collapsed", ) st.caption("PostgreSQL · MySQL · SQLite (sqlite:///path)") if cs and st.button("Подключиться", width='stretch', type="primary"): with st.spinner("Чтение схемы…"): tables, err = _api_get_schema(cs) if err: st.error(f"Ошибка подключения: {err}") st.session_state.schema_tables = None else: st.session_state.schema_tables = tables st.session_state.connection_string = cs st.session_state.schema_error = None if not st.session_state.get("warmup_done", False): with st.spinner("Прогрев модели (запускается один раз за сессию)…"): ok, _err = _api_warmup() if ok: st.session_state.warmup_done = True # Автозагрузка словаря для демо-базы if "sales" in cs and st.session_state.vocabulary is None: vp = ROOT / "configs" / "example_vocabulary.yaml" if vp.exists(): try: st.session_state.vocabulary = _load_vocab_from_yaml( vp.read_text(encoding="utf-8") ) except Exception: pass st.success(f"Подключено. Таблиц: {len(tables)}") if st.session_state.schema_tables is not None: n = len(st.session_state.schema_tables) st.markdown( '✅ База данных подключена', unsafe_allow_html=True, ) with st.expander(f"Таблицы ({n})"): for t in st.session_state.schema_tables: st.code(t.get("name", ""), language=None) st.markdown('Бизнес-словарь
', unsafe_allow_html=True) if st.session_state.vocabulary: v = st.session_state.vocabulary label = v.company if v.company else "Загружен" st.markdown(f'✅ {label}', unsafe_allow_html=True) if v.terms: st.caption(f"Терминов: {len(v.terms)}") else: st.caption("Словарь не применён") if st.button("Редактировать словарь", width='stretch'): vocab_dialog() # ────────────────────────────────────────────── # Шапка # ────────────────────────────────────────────── st.markdown("""Ru2SQL — генеративная модель преобразования запросов
к базе данных на русском языке в запросы на языке SQL
Qwen2.5-Coder-3B-Instruct · QLoRA на PAUQ · SQLite / PostgreSQL / MySQL
Примеры запросов
', unsafe_allow_html=True) ex_cols = st.columns(3) examples = [ "Какая выручка за 2026 год?", "Топ-5 клиентов по сумме заказов", "Сколько заказов у каждого менеджера?", ] for i, ex in enumerate(examples): with ex_cols[i]: if st.button(ex, key=f"ex_{i}", width='stretch'): question = ex run_btn = True if run_btn and question.strip(): cs = st.session_state.connection_string vocab = st.session_state.vocabulary with st.spinner("Запрос к API. Это может занять несколько минут"): try: resp = _api_query(question, cs, vocab) except Exception as e: st.error(f"Ошибка: {e}") st.stop() st.markdown("**Сгенерированный SQL**") st.markdown(f'