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

redesign: segmented DB switcher, vocab modal dialog, larger typography

Browse files
Files changed (1) hide show
  1. streamlit_app.py +274 -207
streamlit_app.py CHANGED
@@ -16,7 +16,6 @@ sys.path.insert(0, str(ROOT))
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,163 +25,173 @@ st.set_page_config(
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)
@@ -207,14 +216,15 @@ def _default_vocab_yaml() -> str:
207
 
208
  def _init_state():
209
  defaults = {
210
- "history": [],
211
- "model_loaded": False,
212
- "engine": None,
213
- "db_connector": None,
214
- "db_executor": None,
215
- "vocabulary": None,
216
  "db_connection_string": "",
217
- "vocab_yaml": _default_vocab_yaml(),
 
218
  }
219
  for k, v in defaults.items():
220
  if k not in st.session_state:
@@ -238,9 +248,7 @@ def _load_engine():
238
  def _connect_db(cs: str):
239
  from src.db.connector import DbConnector
240
  from src.db.executor import SqlExecutor
241
- connector = DbConnector(cs)
242
- executor = SqlExecutor(cs)
243
- return connector, executor
244
 
245
 
246
  def _load_vocab_from_yaml(yaml_text: str):
@@ -253,19 +261,69 @@ def _load_vocab_from_yaml(yaml_text: str):
253
  return vocab
254
 
255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  # ──────────────────────────────────────────────
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
267
  except Exception as e:
268
- st.error(f"Ошибка загрузки модели: {e}")
269
 
270
  if st.session_state.model_loaded:
271
  st.markdown(
@@ -278,62 +336,64 @@ with st.sidebar:
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"
331
- if demo_vocab_path.exists():
332
- st.session_state.vocabulary = _load_vocab_from_yaml(
333
- demo_vocab_path.read_text(encoding="utf-8")
334
- )
335
- except Exception:
336
- pass
337
  st.success(f"Подключено. Таблиц: {len(tables)}")
338
  except Exception as e:
339
  st.error(f"Ошибка подключения: {e}")
@@ -348,29 +408,10 @@ with st.sidebar:
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
@@ -380,16 +421,41 @@ with st.sidebar:
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
 
@@ -410,9 +476,8 @@ with tab_query:
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])
@@ -425,19 +490,22 @@ with tab_query:
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]:
438
  if st.button(ex, key=f"ex_{i}", use_container_width=True):
439
  question = ex
440
- run_btn = True
441
 
442
  if run_btn and question.strip():
443
  engine = st.session_state.engine
@@ -445,12 +513,12 @@ with tab_query:
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**")
@@ -461,7 +529,6 @@ with tab_query:
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:
@@ -475,16 +542,16 @@ with tab_query:
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
  # ──────────── Вкладка: Схема БД ────────────
@@ -492,8 +559,8 @@ 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)} колонок"):
@@ -513,22 +580,22 @@ with tab_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 "Ошибка")
 
16
  # ──────────────────────────────────────────────
17
  st.set_page_config(
18
  page_title="Ru2SQL",
 
19
  layout="wide",
20
  initial_sidebar_state="expanded",
21
  )
 
25
  # ──────────────────────────────────────────────
26
  st.markdown("""
27
  <style>
28
+ /* ── Глобальный шрифт и фон ── */
29
+ html, body, [data-testid="stAppViewContainer"] {
30
+ background-color: #0d1117;
31
+ font-size: 16px;
32
  }
33
  [data-testid="stSidebar"] {
34
  background-color: #161b22;
35
+ border-right: 1px solid #30363d;
36
  }
37
+ [data-testid="stHeader"] { background: transparent; }
38
 
39
+ /* ── Шапка ── */
40
  .app-header {
41
+ padding: 32px 0 24px 0;
42
+ border-bottom: 1px solid #30363d;
43
+ margin-bottom: 32px;
44
  }
45
  .app-title {
46
+ font-size: 26px;
47
  font-weight: 700;
48
  color: #e6edf3;
49
+ letter-spacing: -0.4px;
50
+ line-height: 1.35;
51
+ margin: 0 0 8px 0;
52
  }
53
  .app-subtitle {
54
+ font-size: 14px;
55
  color: #7d8590;
56
  margin: 0;
57
  font-weight: 400;
58
+ letter-spacing: 0.1px;
59
  }
60
 
61
+ /* ── Сайдбар: секции ── */
62
+ .sb-label {
63
+ font-size: 10px;
64
+ font-weight: 700;
65
+ letter-spacing: 1px;
66
  text-transform: uppercase;
67
  color: #7d8590;
68
+ padding: 20px 0 8px 0;
69
  margin: 0;
70
  }
71
+ .sb-divider {
72
  border: none;
73
+ border-top: 1px solid #30363d;
74
+ margin: 4px 0 0 0;
75
  }
76
 
77
  /* ── Статусы ── */
78
+ .status-ok { color: #3fb950; font-size: 13px; font-weight: 600; }
79
+ .status-err { color: #f85149; font-size: 13px; font-weight: 600; }
80
+
81
+ /* ── DB-переключатель: стилизуем radio как сегментированные кнопки ── */
82
+ div[data-testid="stRadio"] > label { display: none; }
83
+ div[data-testid="stRadio"] > div {
84
+ display: flex;
85
+ gap: 0;
86
+ border: 1px solid #30363d;
87
+ border-radius: 8px;
88
+ overflow: hidden;
89
+ background: #0d1117;
90
  }
91
+ div[data-testid="stRadio"] > div > label {
92
+ flex: 1;
93
+ display: flex !important;
94
  align-items: center;
95
+ justify-content: center;
96
+ padding: 8px 4px;
97
+ font-size: 12px;
98
  font-weight: 500;
99
+ color: #7d8590;
100
+ cursor: pointer;
101
+ border-right: 1px solid #30363d;
102
+ transition: background 0.15s, color 0.15s;
103
+ text-align: center;
104
  }
105
+ div[data-testid="stRadio"] > div > label:last-child { border-right: none; }
106
+ div[data-testid="stRadio"] > div > label:has(input:checked) {
107
+ background: #21262d;
108
+ color: #e6edf3;
109
+ }
110
+ div[data-testid="stRadio"] > div > label:hover:not(:has(input:checked)) {
111
+ background: #161b22;
112
+ color: #c9d1d9;
113
+ }
114
+ div[data-testid="stRadio"] > div > label > div:first-child { display: none; }
115
+
116
+ /* ── Кнопка словаря ── */
117
+ .vocab-status {
118
+ font-size: 12px;
119
+ color: #7d8590;
120
+ margin-top: 6px;
121
  }
122
 
123
  /* ── SQL-блок ── */
124
  .sql-box {
125
  background: #161b22;
126
  color: #e6edf3;
127
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
128
+ font-size: 14px;
129
+ line-height: 1.7;
130
  padding: 20px 24px;
131
+ border-radius: 8px;
132
+ border: 1px solid #30363d;
133
  border-left: 3px solid #388bfd;
134
  white-space: pre-wrap;
135
+ margin: 14px 0;
136
  }
137
 
138
+ /* ── Вкладки ── */
139
+ [data-testid="stTabs"] button { font-size: 15px; font-weight: 500; }
140
+
141
+ /* ── Примеры запросов ── */
142
+ .examples-label {
143
+ font-size: 11px;
144
+ font-weight: 700;
145
+ letter-spacing: 0.8px;
146
+ text-transform: uppercase;
 
 
 
147
  color: #7d8590;
148
+ margin: 24px 0 10px 0;
149
  }
150
+
151
+ /* ── Поле ввода вопроса ── */
152
+ [data-testid="stTextArea"] textarea {
153
+ font-size: 16px !important;
154
+ line-height: 1.6 !important;
155
  }
156
 
157
+ /* ── Кнопка «Выполнить» ── */
158
+ [data-testid="stButton"] > button[kind="primary"] {
159
+ font-size: 15px;
160
+ padding: 10px 28px;
161
+ border-radius: 8px;
162
+ font-weight: 600;
 
163
  }
164
+
165
+ /* ── Метрики ── */
166
+ [data-testid="stMetric"] label {
167
+ font-size: 12px !important;
168
+ color: #7d8590 !important;
169
  }
170
+ [data-testid="stMetricValue"] {
171
+ font-size: 22px !important;
172
+ color: #e6edf3 !important;
173
  }
174
 
175
+ /* ── Предупреждение о неготовности ── */
176
+ [data-testid="stAlertContainer"] {
177
+ border-radius: 8px;
178
+ font-size: 14px;
 
 
 
 
179
  }
180
 
181
+ /* ── Expander схемы ── */
182
+ [data-testid="stExpander"] summary {
183
+ font-size: 15px;
184
  font-weight: 500;
185
  }
186
 
187
+ /* ── Скрыть кнопку Stop ── */
188
+ button[kind="stop"] { display: none !important; }
 
 
189
 
190
+ /* ── Модальный диалог словаря ── */
191
+ [data-testid="stDialog"] textarea {
192
+ font-family: 'JetBrains Mono', 'Fira Code', monospace !important;
193
+ font-size: 13px !important;
194
+ line-height: 1.6 !important;
 
 
 
 
 
195
  }
196
  </style>
197
  """, unsafe_allow_html=True)
 
216
 
217
  def _init_state():
218
  defaults = {
219
+ "history": [],
220
+ "model_loaded": False,
221
+ "engine": None,
222
+ "db_connector": None,
223
+ "db_executor": None,
224
+ "vocabulary": None,
225
  "db_connection_string": "",
226
+ "vocab_yaml": _default_vocab_yaml(),
227
+ "db_mode": "Демо-база",
228
  }
229
  for k, v in defaults.items():
230
  if k not in st.session_state:
 
248
  def _connect_db(cs: str):
249
  from src.db.connector import DbConnector
250
  from src.db.executor import SqlExecutor
251
+ return DbConnector(cs), SqlExecutor(cs)
 
 
252
 
253
 
254
  def _load_vocab_from_yaml(yaml_text: str):
 
261
  return vocab
262
 
263
 
264
+ def _auto_connect_demo():
265
+ """Подключить демо-ба��у и словарь к ней."""
266
+ demo_path = ROOT / "data" / "demo" / "sales.sqlite"
267
+ cs = str(demo_path)
268
+ try:
269
+ connector, executor = _connect_db(cs)
270
+ st.session_state.db_connector = connector
271
+ st.session_state.db_executor = executor
272
+ st.session_state.db_connection_string = cs
273
+ if st.session_state.vocabulary is None:
274
+ vocab_path = ROOT / "configs" / "example_vocabulary.yaml"
275
+ if vocab_path.exists():
276
+ st.session_state.vocabulary = _load_vocab_from_yaml(
277
+ vocab_path.read_text(encoding="utf-8")
278
+ )
279
+ except Exception:
280
+ pass
281
+
282
+
283
+ # ──────────────────────────────────────────────
284
+ # Модальный диалог бизнес-словаря
285
+ # ──────────────────────────────────────────────
286
+ @st.dialog("Бизнес-словарь", width="large")
287
+ def vocab_dialog():
288
+ st.caption(
289
+ "Опишите термины, метрики и правила вашей компании в формате YAML. "
290
+ "Модель будет учитывать их при генерации SQL."
291
+ )
292
+ yaml_text = st.text_area(
293
+ "YAML-конфигурация",
294
+ value=st.session_state.vocab_yaml,
295
+ height=480,
296
+ label_visibility="collapsed",
297
+ )
298
+ col1, col2 = st.columns([1, 1])
299
+ with col1:
300
+ if st.button("Применить", type="primary", use_container_width=True):
301
+ try:
302
+ vocab = _load_vocab_from_yaml(yaml_text)
303
+ st.session_state.vocabulary = vocab
304
+ st.session_state.vocab_yaml = yaml_text
305
+ st.rerun()
306
+ except Exception as e:
307
+ st.error(f"Ошибка синтаксиса YAML: {e}")
308
+ with col2:
309
+ if st.button("Отмена", use_container_width=True):
310
+ st.rerun()
311
+
312
+
313
  # ──────────────────────────────────────────────
314
  # Боковая панель
315
  # ──────────────────────────────────────────────
316
  with st.sidebar:
 
317
 
318
+ # ── Модель ──
319
+ st.markdown('<p class="sb-label">Модель</p>', unsafe_allow_html=True)
320
  if not st.session_state.model_loaded:
321
  with st.spinner("Инициализация…"):
322
  try:
323
+ st.session_state.engine = _load_engine()
324
  st.session_state.model_loaded = True
325
  except Exception as e:
326
+ st.error(f"Ошибка: {e}")
327
 
328
  if st.session_state.model_loaded:
329
  st.markdown(
 
336
  unsafe_allow_html=True,
337
  )
338
 
339
+ st.markdown('<hr class="sb-divider">', unsafe_allow_html=True)
340
 
341
  # ── База данных ──
342
+ st.markdown('<p class="sb-label">База данных</p>', unsafe_allow_html=True)
343
 
344
+ db_mode = st.radio(
345
+ "db_mode",
346
+ ["Демо-база", "Загрузить файл", "Строка подключения"],
347
  horizontal=True,
348
+ index=["Демо-база", "Загрузить файл", "Строка подключения"].index(
349
+ st.session_state.db_mode
350
+ ),
351
  )
352
+ st.session_state.db_mode = db_mode
353
 
354
+ cs = ""
355
+
356
+ if db_mode == "Демо-база":
357
+ st.caption("Встроенная база: интернет-магазин электроники, 120 заказов.")
358
+ demo_path = ROOT / "data" / "demo" / "sales.sqlite"
359
+ cs = str(demo_path)
360
+
361
+ elif db_mode == "Загрузить файл":
362
+ uploaded = st.file_uploader(
363
+ "SQLite-файл базы данных",
364
+ type=["sqlite", "db"],
365
+ label_visibility="collapsed",
366
+ )
367
+ if uploaded:
368
+ import tempfile
369
+ tmp_db = Path(tempfile.mktemp(suffix=".sqlite"))
370
+ tmp_db.write_bytes(uploaded.read())
371
+ cs = str(tmp_db)
372
  else:
373
+ st.caption("Перетащите .sqlite или .db файл сюда")
374
+
375
+ else: # Строка подключения
 
 
 
 
 
 
 
 
 
 
 
376
  cs = st.text_input(
377
  "Строка подключения",
378
+ placeholder="postgresql://user:pass@host:5432/db",
379
  value=st.session_state.db_connection_string,
380
  label_visibility="collapsed",
381
  )
382
+ st.caption("PostgreSQL · MySQL (mysql+pymysql://) · SQLite (sqlite:///path)")
383
 
384
  if cs and st.button("Подключиться", use_container_width=True, type="primary"):
385
  try:
386
  connector, executor = _connect_db(cs)
387
  tables = connector.list_tables()
388
+ st.session_state.db_connector = connector
389
+ st.session_state.db_executor = executor
390
  st.session_state.db_connection_string = cs
391
  if "sales" in cs and st.session_state.vocabulary is None:
392
+ vocab_path = ROOT / "configs" / "example_vocabulary.yaml"
393
+ if vocab_path.exists():
394
+ st.session_state.vocabulary = _load_vocab_from_yaml(
395
+ vocab_path.read_text(encoding="utf-8")
396
+ )
 
 
 
397
  st.success(f"Подключено. Таблиц: {len(tables)}")
398
  except Exception as e:
399
  st.error(f"Ошибка подключения: {e}")
 
408
  for t in tables:
409
  st.code(t, language=None)
410
 
411
+ st.markdown('<hr class="sb-divider">', unsafe_allow_html=True)
412
 
413
  # ── Бизнес-словарь ──
414
+ st.markdown('<p class="sb-label">Бизнес-словарь</p>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
415
 
416
  if st.session_state.vocabulary:
417
  v = st.session_state.vocabulary
 
421
  unsafe_allow_html=True,
422
  )
423
  if v.terms:
424
+ st.markdown(
425
+ f'<span class="vocab-status">Терминов: {len(v.terms)}</span>',
426
+ unsafe_allow_html=True,
427
+ )
428
+ else:
429
+ st.markdown(
430
+ '<span class="vocab-status">Словарь не применён</span>',
431
+ unsafe_allow_html=True,
432
+ )
433
+
434
+ if st.button("Редактировать словарь", use_container_width=True):
435
+ vocab_dialog()
436
+
437
+
438
+ # ──────────────────────────────────────────────
439
+ # Автоподключение демо-базы при первом запуске
440
+ # ──────────────────────────────────────────────
441
+ if (
442
+ st.session_state.db_mode == "Демо-база"
443
+ and st.session_state.db_connector is None
444
+ ):
445
+ _auto_connect_demo()
446
 
447
 
448
  # ──────────────────────────────────────────────
449
+ # Основная область — шапка
450
  # ──────────────────────────────────────────────
451
  st.markdown("""
452
  <div class="app-header">
453
+ <p class="app-title">Ru2SQL — генеративная модель преобразования запросов<br>
454
+ к базе данных на русском языке в запросы на языке SQL</p>
455
+ <p class="app-subtitle">
456
+ Qwen2.5-Coder-3B-Instruct &nbsp;·&nbsp; QLoRA fine-tuning на датасете PAUQ
457
+ &nbsp;·&nbsp; SQLite / PostgreSQL / MySQL
458
+ </p>
459
  </div>
460
  """, unsafe_allow_html=True)
461
 
 
476
  question = st.text_area(
477
  "Вопрос на естественном языке",
478
  placeholder="Например: Какая выручка за январь этого года?",
479
+ height=100,
480
  disabled=not ready,
 
481
  )
482
 
483
  col_btn, col_spacer = st.columns([1, 5])
 
490
  )
491
 
492
  # Примеры для демо-базы
493
+ if (
494
+ st.session_state.db_connection_string
495
+ and "sales" in st.session_state.db_connection_string
496
+ ):
497
  st.markdown('<p class="examples-label">Примеры запросов</p>', unsafe_allow_html=True)
498
+ ex_cols = st.columns(3)
499
  examples = [
500
  "Какая выручка за 2026 год?",
501
  "Топ-5 клиентов по сумме заказов",
502
  "Сколько заказов у каждого менеджера?",
503
  ]
504
  for i, ex in enumerate(examples):
505
+ with ex_cols[i]:
506
  if st.button(ex, key=f"ex_{i}", use_container_width=True):
507
  question = ex
508
+ run_btn = True
509
 
510
  if run_btn and question.strip():
511
  engine = st.session_state.engine
 
513
  executor = st.session_state.db_executor
514
  vocab = st.session_state.vocabulary
515
 
516
+ enriched = vocab.enrich_prompt(question) if vocab else question
517
+ schema = connector.render_schema(include_samples=True)
518
 
519
  with st.spinner("Генерация SQL-запроса…"):
520
+ t0 = time.time()
521
+ result = engine.generate(schema, enriched)
522
  gen_time = time.time() - t0
523
 
524
  st.markdown("**Сгенерированный SQL**")
 
529
  with st.spinner("Выполнение запроса…"):
530
  qr = executor.run(result.sql)
531
 
 
532
  c1, c2, c3 = st.columns(3)
533
  c1.metric("Время генерации", f"{gen_time:.1f} с")
534
  if qr:
 
542
  df = pd.DataFrame(qr.rows, columns=qr.columns)
543
  st.dataframe(df, use_container_width=True)
544
  else:
545
+ st.info("Запрос выполнен успешно. Результат пустой.")
546
  elif qr and not qr.success:
547
  st.error(f"Ошибка выполнения SQL: {qr.error}")
548
 
549
  st.session_state.history.append({
550
  "question": question,
551
+ "sql": result.sql,
552
+ "success": qr.success if qr else False,
553
+ "rows": qr.row_count if qr and qr.success else 0,
554
+ "time": gen_time,
555
  })
556
 
557
  # ──────────── Вкладка: Схема БД ────────────
 
559
  if st.session_state.db_connector is None:
560
  st.info("Подключитесь к базе данных через панель слева.")
561
  else:
562
+ connector = st.session_state.db_connector
563
+ show_samples = st.toggle("Показывать примеры данных", value=True)
564
 
565
  for table in connector.get_schema(include_samples=show_samples):
566
  with st.expander(f"{table.name} — {len(table.columns)} колонок"):
 
580
  if not history:
581
  st.info("История пуста. Выполните первый запрос на вкладке «Запрос».")
582
  else:
583
+ col_h, col_clr = st.columns([5, 1])
584
  with col_h:
585
  st.markdown(f"**Запросов в сессии: {len(history)}**")
586
+ with col_clr:
587
  if st.button("Очистить", use_container_width=True):
588
  st.session_state.history = []
589
  st.rerun()
590
 
591
  for i, item in enumerate(reversed(history)):
592
+ icon = "✅" if item["success"] else "❌"
593
+ with st.expander(f"{icon} {item['question']}", expanded=(i == 0)):
594
+ st.markdown(
595
+ f'<div class="sql-box">{item["sql"]}</div>',
596
+ unsafe_allow_html=True,
597
+ )
598
  c1, c2, c3 = st.columns(3)
599
  c1.metric("Время генерации", f"{item['time']:.1f} с")
600
+ c2.metric("Строк", item["rows"])
601
+ c3.metric("Статус", "Успешно" if item["success"] else "Ошибка")