Tyycha commited on
Commit
5caa9cd
·
1 Parent(s): 84cbb7f

redesign: professional UI — clean layout, no decorative emojis, updated title

Browse files
Files changed (1) hide show
  1. streamlit_app.py +289 -144
streamlit_app.py CHANGED
@@ -1,14 +1,4 @@
1
- """Streamlit-интерфейс утилиты Ru2SQL.
2
-
3
- Запуск:
4
- streamlit run streamlit_app.py
5
-
6
- Что умеет:
7
- - Подключиться к любой SQLite/PostgreSQL/MySQL базе данных
8
- - Загрузить бизнес-словарь компании из YAML-файла или редактировать прямо в браузере
9
- - Принять вопрос на русском → сгенерировать SQL → выполнить → показать результат
10
- - Хранить историю запросов в текущей сессии
11
- """
12
 
13
  from __future__ import annotations
14
 
@@ -18,7 +8,6 @@ from pathlib import Path
18
 
19
  import streamlit as st
20
 
21
- # Путь к src/
22
  ROOT = Path(__file__).resolve().parent
23
  sys.path.insert(0, str(ROOT))
24
 
@@ -26,8 +15,8 @@ sys.path.insert(0, str(ROOT))
26
  # Конфигурация страницы
27
  # ──────────────────────────────────────────────
28
  st.set_page_config(
29
- page_title="Ru2SQL — Natural Language → SQL",
30
- page_icon="🗄️",
31
  layout="wide",
32
  initial_sidebar_state="expanded",
33
  )
@@ -37,36 +26,164 @@ st.set_page_config(
37
  # ──────────────────────────────────────────────
38
  st.markdown("""
39
  <style>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  .sql-box {
41
- background: #1e1e2e;
42
- color: #cdd6f4;
43
- font-family: 'Courier New', monospace;
44
- font-size: 14px;
45
- padding: 16px;
46
- border-radius: 8px;
47
- border-left: 4px solid #89b4fa;
 
 
48
  white-space: pre-wrap;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  margin: 8px 0;
 
50
  }
51
- .metric-card {
52
- background: #313244;
53
- padding: 12px 16px;
54
- border-radius: 8px;
55
- text-align: center;
56
  }
57
- .status-ok { color: #a6e3a1; font-weight: bold; }
58
- .status-err { color: #f38ba8; font-weight: bold; }
59
- .history-item {
60
- border-left: 3px solid #89b4fa;
61
- padding: 8px 12px;
62
- margin: 6px 0;
63
- background: #1e1e2e;
64
- border-radius: 0 6px 6px 0;
65
  }
66
- /* Скрыть кнопку Stop */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  button[kind="stop"] {
68
  display: none !important;
69
  }
 
 
 
 
 
 
 
 
 
 
 
 
70
  </style>
71
  """, unsafe_allow_html=True)
72
 
@@ -103,13 +220,14 @@ def _init_state():
103
  if k not in st.session_state:
104
  st.session_state[k] = v
105
 
 
106
  _init_state()
107
 
108
 
109
  # ──────────────────────────────────────────────
110
  # Вспомогательные функции
111
  # ──────────────────────────────────────────────
112
- @st.cache_resource(show_spinner="Загружаю модель (~30 с на первый раз)")
113
  def _load_engine():
114
  from src.models.inference import InferenceEngine
115
  engine = InferenceEngine()
@@ -139,12 +257,10 @@ def _load_vocab_from_yaml(yaml_text: str):
139
  # Боковая панель
140
  # ──────────────────────────────────────────────
141
  with st.sidebar:
142
- st.title("⚙️ Настройки")
143
 
144
- # ── Модель — загружается автоматически при старте ──
145
- st.subheader("🤖 Модель")
146
  if not st.session_state.model_loaded:
147
- with st.spinner("Загружаю модель…"):
148
  try:
149
  st.session_state.engine = _load_engine()
150
  st.session_state.model_loaded = True
@@ -152,47 +268,63 @@ with st.sidebar:
152
  st.error(f"Ошибка загрузки модели: {e}")
153
 
154
  if st.session_state.model_loaded:
155
- st.markdown('<span class="status-ok">✅ Модель готова</span>', unsafe_allow_html=True)
 
 
 
156
  else:
157
- st.markdown('<span class="status-err">⚠️ Модель не загружена</span>', unsafe_allow_html=True)
 
 
 
158
 
159
- st.divider()
160
 
161
  # ── База данных ──
162
- st.subheader("🗄️ База данных")
163
-
164
- db_type = st.radio("Тип подключения", ["SQLite файл", "Строка подключения"],
165
- horizontal=True)
166
 
167
- if db_type == "SQLite файл":
168
- uploaded = st.file_uploader("Загрузить .sqlite файл", type=["sqlite", "db"])
169
- use_demo = st.checkbox("Использовать демо-базу", value=True)
 
 
 
170
 
 
 
171
  if use_demo:
172
  demo_path = ROOT / "data" / "demo" / "sales.sqlite"
173
  cs = str(demo_path)
174
- elif uploaded:
175
- import tempfile
176
- tmp_db = Path(tempfile.mktemp(suffix=".sqlite"))
177
- tmp_db.write_bytes(uploaded.read())
178
- cs = str(tmp_db)
179
  else:
180
- cs = ""
 
 
 
 
 
 
 
 
 
 
 
 
181
  else:
182
  cs = st.text_input(
183
  "Строка подключения",
184
- placeholder="postgresql://user:pass@localhost/mydb",
185
  value=st.session_state.db_connection_string,
 
186
  )
 
187
 
188
- if cs and st.button("Подключиться к БД", use_container_width=True):
189
  try:
190
  connector, executor = _connect_db(cs)
191
  tables = connector.list_tables()
192
  st.session_state.db_connector = connector
193
  st.session_state.db_executor = executor
194
  st.session_state.db_connection_string = cs
195
- # Автоматически применяем словарь для демо-базы
196
  if "sales" in cs and st.session_state.vocabulary is None:
197
  try:
198
  demo_vocab_path = ROOT / "configs" / "example_vocabulary.yaml"
@@ -202,91 +334,104 @@ with st.sidebar:
202
  )
203
  except Exception:
204
  pass
205
- st.success(f"Подключено! Таблиц: {len(tables)}")
206
  except Exception as e:
207
  st.error(f"Ошибка подключения: {e}")
208
 
209
  if st.session_state.db_connector:
210
  tables = st.session_state.db_connector.list_tables()
211
- st.markdown('<span class="status-ok">✅ БД подключена</span>', unsafe_allow_html=True)
212
- with st.expander("Таблицы"):
 
 
 
213
  for t in tables:
214
- st.code(t)
215
 
216
- st.divider()
217
 
218
  # ── Бизнес-словарь ──
219
- st.subheader("📖 Бизнес-словарь")
220
 
221
  vocab_yaml = st.text_area(
222
- "YAML-конфигурация",
223
  value=st.session_state.vocab_yaml,
224
- height=260,
225
- help="Определите термины вашей компании — модель будет их учитывать при генерации SQL",
 
 
 
 
226
  )
227
  st.session_state.vocab_yaml = vocab_yaml
228
 
229
  if st.button("Применить словарь", use_container_width=True):
230
  try:
231
  st.session_state.vocabulary = _load_vocab_from_yaml(vocab_yaml)
232
- st.success("Словарь применён!")
233
  except Exception as e:
234
- st.error(f"Ошибка в YAML: {e}")
235
 
236
  if st.session_state.vocabulary:
237
  v = st.session_state.vocabulary
238
- st.markdown(f'<span class="status-ok">✅ Словарь: {v.company or "загружен"}</span>',
239
- unsafe_allow_html=True)
240
- terms_count = len(v.terms)
241
- if terms_count:
242
- st.caption(f"{terms_count} терминов определено")
 
 
243
 
244
 
245
  # ──────────────────────────────────────────────
246
- # Основная область
247
  # ──────────────────────────────────────────────
248
- st.title("🗄️ Ru2SQL — Бизнес-аналитика на русском языке")
249
- st.caption("Задайте вопрос на русс��ом → получите SQL и данные из вашей базы")
 
 
 
 
250
 
251
- tab_query, tab_schema, tab_history = st.tabs(["💬 Запрос", "📐 Схема БД", "🕓 История"])
252
 
253
  # ──────────── Вкладка: Запрос ────────────
254
  with tab_query:
255
  ready = st.session_state.model_loaded and st.session_state.db_connector is not None
256
 
257
  if not ready:
258
- cols = st.columns(2)
259
- with cols[0]:
260
- if not st.session_state.model_loaded:
261
- st.warning("⚠️ Загрузите модель в левой панели")
262
- with cols[1]:
263
- if st.session_state.db_connector is None:
264
- st.warning("⚠️ Подключитесь к базе данных в левой панели")
265
 
266
  question = st.text_area(
267
- аш вопрос",
268
  placeholder="Например: Какая выручка за январь этого года?",
269
- height=100,
270
  disabled=not ready,
 
271
  )
272
 
273
- col_btn, col_hint = st.columns([1, 4])
274
  with col_btn:
275
- run_btn = st.button("▶ Выполнить", type="primary",
276
- disabled=not ready or not question.strip(),
277
- use_container_width=True)
278
- with col_hint:
279
- if ready:
280
- st.caption("Модель сгенерирует SQL и выполнит его на вашей БД")
281
-
282
- # Быстрые примеры
283
  if st.session_state.db_connection_string and "sales" in st.session_state.db_connection_string:
284
- st.caption("💡 Попробуйте:")
285
  example_cols = st.columns(3)
286
  examples = [
287
  "Какая выручка за 2026 год?",
288
  "Топ-5 клиентов по сумме заказов",
289
- "Сколько заказов по каждому менеджеру?",
290
  ]
291
  for i, ex in enumerate(examples):
292
  with example_cols[i]:
@@ -295,73 +440,68 @@ with tab_query:
295
  run_btn = True
296
 
297
  if run_btn and question.strip():
298
- engine = st.session_state.engine
299
  connector = st.session_state.db_connector
300
- executor = st.session_state.db_executor
301
- vocab = st.session_state.vocabulary
302
 
303
- # Обогащаем вопрос бизнес-словарём
304
  enriched_question = vocab.enrich_prompt(question) if vocab else question
305
-
306
- # Получаем схему
307
  schema = connector.render_schema(include_samples=True)
308
 
309
- with st.spinner("Генерирую SQL…"):
310
  t0 = time.time()
311
  result = engine.generate(schema, enriched_question)
312
  gen_time = time.time() - t0
313
 
314
- st.subheader("Сгенерированный SQL")
315
  st.markdown(f'<div class="sql-box">{result.sql}</div>', unsafe_allow_html=True)
316
 
317
- col1, col2 = st.columns(2)
318
- col1.metric("Время генерации", f"{gen_time:.1f} с")
319
-
320
- # Выполняем SQL
321
  if result.sql.strip():
322
- with st.spinner("Выполняю запрос…"):
323
  qr = executor.run(result.sql)
324
 
325
- if qr.success:
326
- col2.metric("Строк в результате", qr.row_count)
327
- st.subheader("Результат")
328
- if qr.rows:
329
- import pandas as pd
330
- df = pd.DataFrame(qr.rows, columns=qr.columns)
331
- st.dataframe(df, use_container_width=True)
332
- else:
333
- st.info("Запрос выполнен успешно, результат пустой")
 
 
 
 
334
  else:
335
- col2.error("Ошибка выполнения")
336
- st.error(f"SQL ошибка: {qr.error}")
 
337
 
338
- # Добавляем в историю
339
  st.session_state.history.append({
340
  "question": question,
341
  "sql": result.sql,
342
- "success": qr.success if result.sql.strip() else False,
343
- "rows": qr.row_count if result.sql.strip() and qr.success else 0,
344
  "time": gen_time,
345
  })
346
 
347
  # ──────────── Вкладка: Схема БД ────────────
348
  with tab_schema:
349
  if st.session_state.db_connector is None:
350
- st.info("Подключитесь к базе данных в левой панели")
351
  else:
352
  connector = st.session_state.db_connector
353
- st.subheader("Структура базы данных")
354
-
355
  show_samples = st.toggle("Показывать примеры строк", value=True)
356
- schema_text = connector.render_schema(include_samples=show_samples)
357
 
358
  for table in connector.get_schema(include_samples=show_samples):
359
- with st.expander(f"📋 {table.name} ({len(table.columns)} колонок)"):
360
  st.code(table.to_ddl(), language="sql")
361
  if show_samples and table.sample_rows:
362
  import pandas as pd
363
  cols = [c.name for c in table.columns]
364
- st.caption("Примеры строк:")
365
  st.dataframe(
366
  pd.DataFrame(table.sample_rows, columns=cols),
367
  use_container_width=True,
@@ -371,19 +511,24 @@ with tab_schema:
371
  with tab_history:
372
  history = st.session_state.history
373
  if not history:
374
- st.info("История запросов пуста. Задайте первый вопрос на вкладке «Запрос».")
375
  else:
376
- st.subheader(f"История запросов ({len(history)})")
377
-
378
- if st.button("Очистить историю"):
379
- st.session_state.history = []
380
- st.rerun()
 
 
381
 
382
  for i, item in enumerate(reversed(history)):
383
- status = "✅" if item["success"] else "❌"
384
- with st.expander(f"{status} {item['question']}", expanded=(i == 0)):
 
 
 
385
  st.markdown(f'<div class="sql-box">{item["sql"]}</div>', unsafe_allow_html=True)
386
- cols = st.columns(3)
387
- cols[0].metric("Время генерации", f"{item['time']:.1f} с")
388
- cols[1].metric("Строк", item["rows"])
389
- cols[2].metric("Статус", "OK" if item["success"] else "Оши��ка")
 
1
+ """Streamlit-интерфейс утилиты Ru2SQL."""
 
 
 
 
 
 
 
 
 
 
2
 
3
  from __future__ import annotations
4
 
 
8
 
9
  import streamlit as st
10
 
 
11
  ROOT = Path(__file__).resolve().parent
12
  sys.path.insert(0, str(ROOT))
13
 
 
15
  # Конфигурация страницы
16
  # ──────────────────────────────────────────────
17
  st.set_page_config(
18
+ page_title="Ru2SQL",
19
+ page_icon="assets/favicon.png" if (ROOT / "assets" / "favicon.png").exists() else None,
20
  layout="wide",
21
  initial_sidebar_state="expanded",
22
  )
 
26
  # ──────────────────────────────────────────────
27
  st.markdown("""
28
  <style>
29
+ /* ── Общий фон и типографика ── */
30
+ [data-testid="stAppViewContainer"] {
31
+ background-color: #0f1117;
32
+ }
33
+ [data-testid="stSidebar"] {
34
+ background-color: #161b22;
35
+ border-right: 1px solid #21262d;
36
+ }
37
+
38
+ /* ── Шапка приложения ── */
39
+ .app-header {
40
+ padding: 28px 0 20px 0;
41
+ border-bottom: 1px solid #21262d;
42
+ margin-bottom: 28px;
43
+ }
44
+ .app-title {
45
+ font-size: 22px;
46
+ font-weight: 700;
47
+ color: #e6edf3;
48
+ letter-spacing: -0.3px;
49
+ margin: 0 0 4px 0;
50
+ line-height: 1.3;
51
+ }
52
+ .app-subtitle {
53
+ font-size: 13px;
54
+ color: #7d8590;
55
+ margin: 0;
56
+ font-weight: 400;
57
+ }
58
+
59
+ /* ── Боковая панель ── */
60
+ .sidebar-section-label {
61
+ font-size: 11px;
62
+ font-weight: 600;
63
+ letter-spacing: 0.8px;
64
+ text-transform: uppercase;
65
+ color: #7d8590;
66
+ padding: 16px 0 8px 0;
67
+ margin: 0;
68
+ }
69
+ .sidebar-divider {
70
+ border: none;
71
+ border-top: 1px solid #21262d;
72
+ margin: 12px 0;
73
+ }
74
+
75
+ /* ── Статусы ── */
76
+ .status-ok {
77
+ display: inline-flex;
78
+ align-items: center;
79
+ gap: 6px;
80
+ color: #3fb950;
81
+ font-size: 13px;
82
+ font-weight: 500;
83
+ }
84
+ .status-err {
85
+ display: inline-flex;
86
+ align-items: center;
87
+ gap: 6px;
88
+ color: #f85149;
89
+ font-size: 13px;
90
+ font-weight: 500;
91
+ }
92
+ .status-warn {
93
+ display: inline-flex;
94
+ align-items: center;
95
+ gap: 6px;
96
+ color: #d29922;
97
+ font-size: 13px;
98
+ font-weight: 500;
99
+ }
100
+
101
+ /* ── SQL-блок ── */
102
  .sql-box {
103
+ background: #161b22;
104
+ color: #e6edf3;
105
+ font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
106
+ font-size: 13px;
107
+ line-height: 1.6;
108
+ padding: 20px 24px;
109
+ border-radius: 6px;
110
+ border: 1px solid #21262d;
111
+ border-left: 3px solid #388bfd;
112
  white-space: pre-wrap;
113
+ margin: 12px 0;
114
+ }
115
+
116
+ /* ── Результирующая панель метрик ── */
117
+ .result-meta {
118
+ display: flex;
119
+ gap: 24px;
120
+ align-items: center;
121
+ padding: 12px 0;
122
+ border-top: 1px solid #21262d;
123
+ border-bottom: 1px solid #21262d;
124
+ margin: 16px 0;
125
+ }
126
+ .result-meta-item {
127
+ font-size: 12px;
128
+ color: #7d8590;
129
+ }
130
+ .result-meta-value {
131
+ font-size: 14px;
132
+ font-weight: 600;
133
+ color: #e6edf3;
134
+ }
135
+
136
+ /* ── История ── */
137
+ .history-entry {
138
+ border: 1px solid #21262d;
139
+ border-radius: 6px;
140
+ padding: 14px 16px;
141
  margin: 8px 0;
142
+ background: #161b22;
143
  }
144
+ .history-question {
145
+ font-size: 14px;
146
+ color: #e6edf3;
147
+ font-weight: 500;
148
+ margin-bottom: 6px;
149
  }
150
+ .history-meta {
151
+ font-size: 12px;
152
+ color: #7d8590;
 
 
 
 
 
153
  }
154
+
155
+ /* ── Примеры запросов ── */
156
+ .examples-label {
157
+ font-size: 12px;
158
+ color: #7d8590;
159
+ margin: 16px 0 8px 0;
160
+ font-weight: 500;
161
+ text-transform: uppercase;
162
+ letter-spacing: 0.5px;
163
+ }
164
+
165
+ /* ── Вкладки ── */
166
+ [data-testid="stTabs"] button {
167
+ font-size: 13px;
168
+ font-weight: 500;
169
+ }
170
+
171
+ /* ── Кнопка Stop ── */
172
  button[kind="stop"] {
173
  display: none !important;
174
  }
175
+
176
+ /* ── Убрать лишние отступы в header ── */
177
+ [data-testid="stHeader"] {
178
+ background: transparent;
179
+ }
180
+
181
+ /* ── Таблица данных ── */
182
+ [data-testid="stDataFrame"] {
183
+ border: 1px solid #21262d;
184
+ border-radius: 6px;
185
+ overflow: hidden;
186
+ }
187
  </style>
188
  """, unsafe_allow_html=True)
189
 
 
220
  if k not in st.session_state:
221
  st.session_state[k] = v
222
 
223
+
224
  _init_state()
225
 
226
 
227
  # ──────────────────────────────────────────────
228
  # Вспомогательные функции
229
  # ──────────────────────────────────────────────
230
+ @st.cache_resource(show_spinner="Инициализация модели…")
231
  def _load_engine():
232
  from src.models.inference import InferenceEngine
233
  engine = InferenceEngine()
 
257
  # Боковая панель
258
  # ──────────────────────────────────────────────
259
  with st.sidebar:
260
+ st.markdown('<p class="sidebar-section-label">Модель</p>', unsafe_allow_html=True)
261
 
 
 
262
  if not st.session_state.model_loaded:
263
+ with st.spinner("Инициализация…"):
264
  try:
265
  st.session_state.engine = _load_engine()
266
  st.session_state.model_loaded = True
 
268
  st.error(f"Ошибка загрузки модели: {e}")
269
 
270
  if st.session_state.model_loaded:
271
+ st.markdown(
272
+ '<span class="status-ok">✅ Qwen2.5-Coder-3B + QLoRA</span>',
273
+ unsafe_allow_html=True,
274
+ )
275
  else:
276
+ st.markdown(
277
+ '<span class="status-err">Модель не загружена</span>',
278
+ unsafe_allow_html=True,
279
+ )
280
 
281
+ st.markdown('<hr class="sidebar-divider">', unsafe_allow_html=True)
282
 
283
  # ── База данных ──
284
+ st.markdown('<p class="sidebar-section-label">База данных</p>', unsafe_allow_html=True)
 
 
 
285
 
286
+ db_type = st.radio(
287
+ "Тип подключения",
288
+ ["SQLite", "PostgreSQL / MySQL"],
289
+ horizontal=True,
290
+ label_visibility="collapsed",
291
+ )
292
 
293
+ if db_type == "SQLite":
294
+ use_demo = st.checkbox("Демо-база (sales.sqlite)", value=True)
295
  if use_demo:
296
  demo_path = ROOT / "data" / "demo" / "sales.sqlite"
297
  cs = str(demo_path)
 
 
 
 
 
298
  else:
299
+ uploaded = st.file_uploader(
300
+ "Загрузить файл базы данных",
301
+ type=["sqlite", "db"],
302
+ label_visibility="collapsed",
303
+ )
304
+ if uploaded:
305
+ import tempfile
306
+ tmp_db = Path(tempfile.mktemp(suffix=".sqlite"))
307
+ tmp_db.write_bytes(uploaded.read())
308
+ cs = str(tmp_db)
309
+ else:
310
+ cs = ""
311
+ st.caption("Выберите .sqlite или .db файл")
312
  else:
313
  cs = st.text_input(
314
  "Строка подключения",
315
+ placeholder="postgresql://user:pass@host:5432/dbname",
316
  value=st.session_state.db_connection_string,
317
+ label_visibility="collapsed",
318
  )
319
+ st.caption("PostgreSQL: postgresql:// | MySQL: mysql+pymysql://")
320
 
321
+ if cs and st.button("Подключиться", use_container_width=True, type="primary"):
322
  try:
323
  connector, executor = _connect_db(cs)
324
  tables = connector.list_tables()
325
  st.session_state.db_connector = connector
326
  st.session_state.db_executor = executor
327
  st.session_state.db_connection_string = cs
 
328
  if "sales" in cs and st.session_state.vocabulary is None:
329
  try:
330
  demo_vocab_path = ROOT / "configs" / "example_vocabulary.yaml"
 
334
  )
335
  except Exception:
336
  pass
337
+ st.success(f"Подключено. Таблиц: {len(tables)}")
338
  except Exception as e:
339
  st.error(f"Ошибка подключения: {e}")
340
 
341
  if st.session_state.db_connector:
342
  tables = st.session_state.db_connector.list_tables()
343
+ st.markdown(
344
+ '<span class="status-ok">✅ База данных подключена</span>',
345
+ unsafe_allow_html=True,
346
+ )
347
+ with st.expander(f"Таблицы ({len(tables)})"):
348
  for t in tables:
349
+ st.code(t, language=None)
350
 
351
+ st.markdown('<hr class="sidebar-divider">', unsafe_allow_html=True)
352
 
353
  # ── Бизнес-словарь ──
354
+ st.markdown('<p class="sidebar-section-label">Бизнес-словарь</p>', unsafe_allow_html=True)
355
 
356
  vocab_yaml = st.text_area(
357
+ "YAML-конфигурация терминов",
358
  value=st.session_state.vocab_yaml,
359
+ height=240,
360
+ help=(
361
+ "Опишите термины и правила вашей компании в формате YAML. "
362
+ "Модель будет учитывать их при генерации SQL-запросов."
363
+ ),
364
+ label_visibility="collapsed",
365
  )
366
  st.session_state.vocab_yaml = vocab_yaml
367
 
368
  if st.button("Применить словарь", use_container_width=True):
369
  try:
370
  st.session_state.vocabulary = _load_vocab_from_yaml(vocab_yaml)
371
+ st.success("Словарь применён.")
372
  except Exception as e:
373
+ st.error(f"Ошибка синтаксиса YAML: {e}")
374
 
375
  if st.session_state.vocabulary:
376
  v = st.session_state.vocabulary
377
+ label = v.company if v.company else "Загружен"
378
+ st.markdown(
379
+ f'<span class="status-ok">✅ {label}</span>',
380
+ unsafe_allow_html=True,
381
+ )
382
+ if v.terms:
383
+ st.caption(f"Терминов: {len(v.terms)}")
384
 
385
 
386
  # ──────────────────────────────────────────────
387
+ # Основная область — заголовок
388
  # ──────────────────────────────────────────────
389
+ st.markdown("""
390
+ <div class="app-header">
391
+ <p class="app-title">Ru2SQL — генеративная модель преобразования запросов<br>к базе данных на русском языке в запросы на языке SQL</p>
392
+ <p class="app-subtitle">Qwen2.5-Coder-3B-Instruct &nbsp;·&nbsp; QLoRA fine-tuning на PAUQ &nbsp;·&nbsp; SQLite / PostgreSQL / MySQL</p>
393
+ </div>
394
+ """, unsafe_allow_html=True)
395
 
396
+ tab_query, tab_schema, tab_history = st.tabs(["Запрос", "Схема базы данных", "История"])
397
 
398
  # ──────────── Вкладка: Запрос ────────────
399
  with tab_query:
400
  ready = st.session_state.model_loaded and st.session_state.db_connector is not None
401
 
402
  if not ready:
403
+ missing = []
404
+ if not st.session_state.model_loaded:
405
+ missing.append("модель инициализируется")
406
+ if st.session_state.db_connector is None:
407
+ missing.append("база данных не подключена")
408
+ st.warning("Система не готова: " + ", ".join(missing) + ". Используйте панель слева.")
 
409
 
410
  question = st.text_area(
411
+ "Вопрос на естественном языке",
412
  placeholder="Например: Какая выручка за январь этого года?",
413
+ height=90,
414
  disabled=not ready,
415
+ label_visibility="visible",
416
  )
417
 
418
+ col_btn, col_spacer = st.columns([1, 5])
419
  with col_btn:
420
+ run_btn = st.button(
421
+ "Выполнить",
422
+ type="primary",
423
+ disabled=not ready or not question.strip(),
424
+ use_container_width=True,
425
+ )
426
+
427
+ # Примеры для демо-базы
428
  if st.session_state.db_connection_string and "sales" in st.session_state.db_connection_string:
429
+ st.markdown('<p class="examples-label">Примеры запросов</p>', unsafe_allow_html=True)
430
  example_cols = st.columns(3)
431
  examples = [
432
  "Какая выручка за 2026 год?",
433
  "Топ-5 клиентов по сумме заказов",
434
+ "Сколько заказов у каждого менеджера?",
435
  ]
436
  for i, ex in enumerate(examples):
437
  with example_cols[i]:
 
440
  run_btn = True
441
 
442
  if run_btn and question.strip():
443
+ engine = st.session_state.engine
444
  connector = st.session_state.db_connector
445
+ executor = st.session_state.db_executor
446
+ vocab = st.session_state.vocabulary
447
 
 
448
  enriched_question = vocab.enrich_prompt(question) if vocab else question
 
 
449
  schema = connector.render_schema(include_samples=True)
450
 
451
+ with st.spinner("Генерация SQL-запроса…"):
452
  t0 = time.time()
453
  result = engine.generate(schema, enriched_question)
454
  gen_time = time.time() - t0
455
 
456
+ st.markdown("**Сгенерированный SQL**")
457
  st.markdown(f'<div class="sql-box">{result.sql}</div>', unsafe_allow_html=True)
458
 
459
+ qr = None
 
 
 
460
  if result.sql.strip():
461
+ with st.spinner("Выполнение запроса…"):
462
  qr = executor.run(result.sql)
463
 
464
+ # Метрики
465
+ c1, c2, c3 = st.columns(3)
466
+ c1.metric("Время генерации", f"{gen_time:.1f} с")
467
+ if qr:
468
+ c2.metric("Строк получено", qr.row_count if qr.success else "—")
469
+ c3.metric("Статус", "Успешно" if qr.success else "Ошибка")
470
+
471
+ if qr and qr.success:
472
+ if qr.rows:
473
+ import pandas as pd
474
+ st.markdown("**Результат**")
475
+ df = pd.DataFrame(qr.rows, columns=qr.columns)
476
+ st.dataframe(df, use_container_width=True)
477
  else:
478
+ st.info("Запрос выполнен. Результат пустой.")
479
+ elif qr and not qr.success:
480
+ st.error(f"Ошибка выполнения SQL: {qr.error}")
481
 
 
482
  st.session_state.history.append({
483
  "question": question,
484
  "sql": result.sql,
485
+ "success": qr.success if qr else False,
486
+ "rows": qr.row_count if qr and qr.success else 0,
487
  "time": gen_time,
488
  })
489
 
490
  # ──────────── Вкладка: Схема БД ────────────
491
  with tab_schema:
492
  if st.session_state.db_connector is None:
493
+ st.info("Подключитесь к базе данных через панель слева.")
494
  else:
495
  connector = st.session_state.db_connector
 
 
496
  show_samples = st.toggle("Показывать примеры строк", value=True)
 
497
 
498
  for table in connector.get_schema(include_samples=show_samples):
499
+ with st.expander(f"{table.name} {len(table.columns)} колонок"):
500
  st.code(table.to_ddl(), language="sql")
501
  if show_samples and table.sample_rows:
502
  import pandas as pd
503
  cols = [c.name for c in table.columns]
504
+ st.caption("Примеры данных:")
505
  st.dataframe(
506
  pd.DataFrame(table.sample_rows, columns=cols),
507
  use_container_width=True,
 
511
  with tab_history:
512
  history = st.session_state.history
513
  if not history:
514
+ st.info("История пуста. Выполните первый запрос на вкладке «Запрос».")
515
  else:
516
+ col_h, col_btn_h = st.columns([5, 1])
517
+ with col_h:
518
+ st.markdown(f"**Запросов в сессии: {len(history)}**")
519
+ with col_btn_h:
520
+ if st.button("Очистить", use_container_width=True):
521
+ st.session_state.history = []
522
+ st.rerun()
523
 
524
  for i, item in enumerate(reversed(history)):
525
+ status_icon = "✅" if item["success"] else "❌"
526
+ with st.expander(
527
+ f"{status_icon} {item['question']}",
528
+ expanded=(i == 0),
529
+ ):
530
  st.markdown(f'<div class="sql-box">{item["sql"]}</div>', unsafe_allow_html=True)
531
+ c1, c2, c3 = st.columns(3)
532
+ c1.metric("Время генерации", f"{item['time']:.1f} с")
533
+ c2.metric("Строк", item["rows"])
534
+ c3.metric("Статус", "Успешно" if item["success"] else "Ошибка")