Blind-Detective / app.py
dbadeev's picture
Update app.py
841ea0f verified
Raw
History Blame Contribute Delete
30.9 kB
"""
Gradio Space: «Слепой Детектив Легковесных Моделей»
Поток игры:
1. Выбор произведения → bootstrap (3 вопроса × 3 ответа)
2. Пользователь задаёт вопрос → judge-фильтр → answer (3 ответа)
3. Пользователь размечает цвета → расчёт очков → Продолжить/Закончить
4. Шаги 2-3 повторяются ROUND_LIMIT раз
5. Финальный отчёт с пометкой верных цветов
"""
from pathlib import Path
from dotenv import load_dotenv
# load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".env")
load_dotenv()
import json
import os
import random
from typing import Dict, List
import gradio as gr
import httpx
import pandas as pd
# ── Конфигурация ──────────────────────────────────────────────
# ROOT = Path(__file__).resolve().parent.parent
ROOT = Path(__file__).resolve().parent
CORPUS_PATH = Path(os.getenv("CORPUS_PATH", str(ROOT / "data" / "corpus.json")))
ROUND_LIMIT = int(os.getenv("ROUND_LIMIT", "2"))
BOOTSTRAP_URL = os.getenv("BOOTSTRAP_URL", "")
ANSWER_URL = os.getenv("ANSWER_URL", "")
JUDGE_URL = os.getenv("JUDGE_URL", "")
HEALTH_URL = os.getenv("HEALTH_URL", "")
print(f"CORPUS_PATH = {CORPUS_PATH}")
print(f"LLAMA_ROLE = {os.getenv('LLAMA_ROLE', 'reader')}")
print(f"QWEN_ROLE = {os.getenv('QWEN_ROLE', 'philosopher')}")
print(f"NEMO_ROLE = {os.getenv('NEMO_ROLE', 'scholar')}")
if not all([BOOTSTRAP_URL, ANSWER_URL, JUDGE_URL]):
raise RuntimeError(
"Не заданы Modal URL! Установи BOOTSTRAP_URL, ANSWER_URL, JUDGE_URL "
"через переменные окружения или .env"
)
COLORS = ["red", "yellow", "green"]
MODEL_KEYS = ["llama", "qwen", "nemo"]
MODEL_LABELS = {"llama": "Saiga / Llama", "qwen": "Qwen", "nemo": "Vikhr / Nemo"}
COLOR_RU = {"red": "🔴 Красная", "yellow": "🟡 Жёлтая", "green": "🟢 Зелёная"}
COLOR_RU_SHORT = {"red": "Красный", "yellow": "Жёлтый", "green": "Зелёный"}
# ── CSS ───────────────────────────────────────────────────────
CSS = """
:root {
--bg: #101217;
--card: #171b22;
--text: #f0f4ff;
--muted: #c8d0e0;
--red: #ff6b6b;
--yellow: #fde047;
--green: #4ade80;
--accent: #60a5fa;
}
body, .gradio-container {
background: var(--bg) !important;
color: var(--text) !important;
font-family: ui-sans-serif, system-ui, sans-serif;
}
/* Тёмный текст для элементов на белом фоне */
.gradio-container label { color: #333 !important; }
.gradio-container h1 { color: #f0f4ff !important; }
.gradio-container h2 { color: #f0f4ff !important; }
.gradio-container h3 { color: #333 !important; }
.gradio-container p { color: #333 !important; }
.gradio-container .markdown h1 { color: #f0f4ff !important; }
.gradio-container .markdown h2 { color: #f0f4ff !important; }
.gradio-container .markdown h3 { color: #333 !important; }
.gradio-container .markdown p { color: #333 !important; }
.gradio-container .markdown strong { color: #111 !important; }
.gradio-container .radio label { color: #333 !important; }
.gradio-container .radio-group label { color: #333 !important; }
.gradio-container .markdown > p:first-of-type { color: #f0f4ff !important; }
/* Светлый текст только внутри тёмных панелей */
.panel p, .panel span, .panel div,
.bs-question p, .bs-question span,
.answer-red p, .answer-red span,
.answer-yellow p, .answer-yellow span,
.answer-green p, .answer-green span,
.neutral-answer p, .neutral-answer span,
.score-box, .final-report { color: #f0f4ff !important; }
.panel {
border-radius: 14px;
border: 1px solid #2d3442;
background: var(--card);
padding: 14px 18px;
margin-bottom: 10px;
line-height: 1.65;
color: var(--text) !important;
}
.bs-question {
background: #1c2130;
border-left: 4px solid #3d4a6b;
border-radius: 10px;
padding: 10px 16px;
margin-bottom: 6px;
font-weight: 600;
color: var(--text) !important;
}
.answer-red { border-left: 6px solid var(--red); background: #1e1316; }
.answer-yellow { border-left: 6px solid var(--yellow); background: #1c1b10; }
.answer-green { border-left: 6px solid var(--green); background: #101c14; }
.answer-red p, .answer-red span, .answer-red div,
.answer-yellow p, .answer-yellow span, .answer-yellow div,
.answer-green p, .answer-green span, .answer-green div {
color: var(--text) !important;
}
.answer-label-red { color: var(--red); font-weight: 700; font-size: 13px; }
.answer-label-yellow { color: var(--yellow); font-weight: 700; font-size: 13px; }
.answer-label-green { color: var(--green); font-weight: 700; font-size: 13px; }
.score-box {
font-size: 20px; font-weight: 700;
color: var(--text) !important;
padding: 12px 20px;
background: var(--card);
border-radius: 12px;
border: 1px solid #2d3442;
}
.final-report {
font-size: 22px; font-weight: 700;
color: var(--accent) !important;
padding: 16px; border-radius: 14px;
border: 1px solid var(--accent);
background: #0d1b2a;
}
.neutral-answer {
border-left: 4px solid #555;
background: #1a1d25;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 8px;
color: var(--text) !important;
}
.color-radio label { font-size: 15px !important; color: #333 !important; }
.color-radio input[type="radio"] { accent-color: #60a5fa; }
.reveal-correct { color: #16a34a !important; font-weight: 700; }
.reveal-wrong { color: #dc2626 !important; }
.loading-idle { color: #888 !important; font-size: 14px; }
.loading-active { color: #2563eb !important; font-size: 16px; font-weight: 600; animation: pulse 1.5s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
"""
# ── Корпус ────────────────────────────────────────────────────
def load_corpus() -> List[Dict]:
if CORPUS_PATH.exists():
with open(CORPUS_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
if data:
return data
return [{
"work_id": "demo-1",
"title": "Я вас любил",
"author": "А.С. Пушкин",
"genre": "poem",
"difficulty": "easy",
"text": (
"Я вас любил: любовь ещё, быть может,\n"
"В душе моей угасла не совсем;\n"
"Но пусть она вас больше не тревожит;\n"
"Я не хочу печалить вас ничем.\n\n"
"Я вас любил безмолвно, безнадежно,\n"
"То робостью, то ревностью томим;\n"
"Я вас любил так искренно, так нежно,\n"
"Как дай вам Бог любимой быть другим."
),
}]
# CORPUS = load_corpus()
_DIFFICULTY_ORDER = {"easy": 0, "medium": 1, "hard": 2}
CORPUS = sorted(
load_corpus(),
key=lambda x: _DIFFICULTY_ORDER.get(x.get("difficulty", "easy"), 1)
)
WORK_INDEX = {
f"{x.get('author','?')}{x.get('title','?')} [{x.get('genre','?')}, {x.get('difficulty','easy')}]"
: x for x in CORPUS
}
# ── HTTP ──────────────────────────────────────────────────────
async def post_json(url: str, payload: dict) -> dict:
async with httpx.AsyncClient(timeout=300.0) as client:
resp = await client.post(url, json=payload)
resp.raise_for_status()
return resp.json()
# ── Состояние игры ────────────────────────────────────────────
def fresh_state() -> dict:
return {
"round": 0,
"score": 0,
"history": [],
"color_map": {},
"current_work": None,
"bootstrap_answers": [],
"last_answers": [],
"last_question": None,
"game_over": False,
}
def _is_bootstrap_duplicate(question: str, state: dict) -> bool:
"""True если вопрос совпадает (с точностью до регистра/пробелов) с одним из bootstrap-вопросов."""
q_norm = question.strip().lower()
for item in state.get("bootstrap_answers", []):
bs_q = item.get("question", "").strip().lower()
if q_norm == bs_q:
return True
return False
def reshuffle_colors(state: dict) -> dict:
keys = MODEL_KEYS[:]
random.shuffle(keys)
state["color_map"] = dict(zip(COLORS, keys))
return state
# ── Рендер ────────────────────────────────────────────────────
def render_score(state: dict) -> str:
r, s = state["round"], state["score"]
max_possible = r * 3
return f'<div class="score-box">Раунд {r}/{ROUND_LIMIT} &nbsp;|&nbsp; Очки: {s}/{max_possible}</div>'
def render_bootstrap(questions_data: list, color_map: dict) -> str:
model_to_color = {v: k for k, v in color_map.items()}
html = '<div style="margin-bottom:16px;">'
html += '<h3 style="color:#e0e8ff !important;margin-bottom:12px;">📖 Ознакомительные вопросы</h3>'
for i, item in enumerate(questions_data, 1):
q = item.get("question", "—")
html += (
f'<div class="bs-question">'
f'<span style="color:#8898b0;font-size:12px;">Вопрос {i}</span><br>'
f'<span style="color:#f0f4ff;">{q}</span>'
f'</div>'
)
answers = item.get("answers", [])
for ans in answers:
text = ans.get("answer", "—")
model_key = ans.get("model_key", "")
color = model_to_color.get(model_key, "red")
color_label = COLOR_RU.get(color, color)
html += (
f'<div class="panel answer-{color}">'
f'<span class="answer-label-{color}">{color_label}</span>'
f'<p style="margin-top:6px;color:#f0f4ff;">{text}</p>'
f'</div>'
)
html += '<hr style="border-color:#2d3442;margin:12px 0;">'
html += '</div>'
return html
def render_answers_neutral(last_answers: list) -> str:
html = ""
for i, ans in enumerate(last_answers, 1):
text = ans.get("answer", "—")
html += (
f'<div class="neutral-answer">'
f'<b style="color:#a0b0d0;">Ответ {i}</b><br>'
f'<span style="color:#f0f4ff;">{text}</span>'
f'</div>'
)
return html
def build_final_report(state: dict) -> str:
history = state["history"]
if not history:
return '<div class="final-report">Нет данных.</div>'
total_rounds = len(history)
total_score = state["score"]
max_possible = total_rounds * 3
pct = int(total_score / max_possible * 100) if max_possible else 0
html = f'<div class="final-report">🏁 Итого: {total_score} очков из {max_possible} ({pct}%)</div>\n'
html += '<div style="margin-top:16px;">'
html += '<h3 style="color:#c8d0e0;">Подробная статистика по раундам</h3>'
for h in history:
rnd = h["round"]
question = h["question"]
color_map = h["color_map"]
user_guess = h["user_guess"]
answers = h["answers"]
round_correct = h.get("round_correct", 0)
color = "#4ade80" if round_correct == 3 else "#fde047" if round_correct >= 1 else "#ff6b6b"
html += (
f'<div class="panel" style="margin-bottom:12px;">'
f'<b style="color:{color};">Раунд {rnd}{round_correct}/3 очков</b><br>'
f'<span style="color:#8898b0;">Вопрос:</span> '
f'<span style="color:#f0f4ff;">{question}</span>'
)
for i, ans in enumerate(answers, 1):
model_key = ans["model_key"]
correct_color = "?"
for c, mk in color_map.items():
if mk == model_key:
correct_color = c
break
user_chosen_color = user_guess.get(f"answer_{i}", "?")
is_this_one_correct = (user_chosen_color == correct_color)
color_label = COLOR_RU.get(correct_color, correct_color)
model_label = MODEL_LABELS.get(model_key, model_key)
chosen_color_label = COLOR_RU.get(user_chosen_color, user_chosen_color)
check = "✅" if is_this_one_correct else "❌"
html += (
f'<br><span style="color:#8898b0;">Ответ {i}:</span> '
f'{color_label} — <b style="color:#f0f4ff;">{model_label}</b>. '
f'<span style="color:#c8d0e0;">Вы выбрали: {chosen_color_label}</span> {check}'
)
html += '</div>'
html += '</div>'
return html
# ── Игровые функции ───────────────────────────────────────────
async def select_work(work_label: str, state: dict):
if not work_label:
return state, "", "", "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
work = WORK_INDEX[work_label]
state = fresh_state()
state["current_work"] = work
state = reshuffle_colors(state)
payload = {
"work_id": work["work_id"],
"title": work["title"],
"author": work["author"],
"genre": work.get("genre", "poem"),
"text": work["text"],
"seed": random.randint(0, 9999),
}
try:
data = await post_json(BOOTSTRAP_URL, payload)
except Exception as e:
return state, f"...Ошибка bootstrap...", "", "", \
gr.update(visible=False), gr.update(visible=False), \
gr.update(visible=False), gr.update(visible=False), \
gr.update(visible=False), \
gr.update(interactive=True) # ← start_btn остаётся доступной
questions_data = data.get("questions", [])
state["bootstrap_answers"] = questions_data
bootstrap_html = render_bootstrap(questions_data, state["color_map"])
work_html = (
f'<div class="panel">'
f'<b style="font-size:16px;">{work["title"]}</b> — {work["author"]}<br>'
f'<pre style="white-space:pre-wrap;font-family:inherit;color:#c8d0e0;'
f'margin-top:8px;">{work["text"]}</pre>'
f'</div>'
)
score_html = render_score(state)
return (
state, work_html, bootstrap_html, score_html,
gr.update(visible=True), # question_section
gr.update(visible=False), # answers_section
gr.update(visible=False), # guess_section
gr.update(visible=False), # round_result_section
gr.update(visible=True), # loading_idle ← показать подсказку
gr.update(interactive=False) # ← start_btn
)
async def submit_question(question: str, state: dict):
if not question.strip():
return state, "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
work = state["current_work"]
payload = {
"work_id": work["work_id"],
"title": work["title"],
"author": work["author"],
"genre": work.get("genre", "poem"),
"text": work["text"],
"question": question.strip(),
}
try:
judge_data = await post_json(JUDGE_URL, payload)
except Exception as e:
return (
state,
f'<p style="color:#ff6b6b">Ошибка judge: {e}</p>',
"",
gr.update(visible=False),
gr.update(visible=False),
)
if judge_data.get("decision") == "block":
return (
state,
'<p style="color:#facc15;font-size:14px">⚠️ Вопрос не связан с произведением '
'или выглядит как спам. Попробуй переформулировать.</p>',
"", # answers_html — пустая строка
gr.update(visible=False), # answers_section
gr.update(visible=False), # guess_section
)
# Проверка на дубль bootstrap-вопроса
if _is_bootstrap_duplicate(question, state):
return (
state,
'<p style="color:#facc15;font-size:14px">⚠️ Этот вопрос уже был в ознакомительном блоке — '
'модели ответят так же, и разметка очевидна. '
'Придумай свой вопрос, чтобы игра была честной!</p>',
"", # answers_html — пустая строка, не gr.update
gr.update(visible=False), # answers_section
gr.update(visible=False), # guess_section
)
try:
answer_data = await post_json(ANSWER_URL, payload)
except Exception as e:
return (
state,
f'<p style="color:#ff6b6b">Ошибка answer: {e}</p>',
"",
gr.update(visible=False),
gr.update(visible=False),
)
items = answer_data.get("items", [])
ans_by_model = {
a["model_key"]: a["answer"]
for a in items if isinstance(a, dict) and "model_key" in a
}
answers_ordered = []
for model_key in MODEL_KEYS:
answers_ordered.append({
"answer": ans_by_model.get(model_key, "Нет ответа."),
"model_key": model_key,
})
random.shuffle(answers_ordered)
state["last_answers"] = answers_ordered
state["last_question"] = question.strip()
answers_html = render_answers_neutral(answers_ordered)
return (
state,
'<p style="color:#16a34a;font-size:14px;">✅ Вопрос принят. Выбери цвета для ответов.</p>',
answers_html,
gr.update(visible=True),
gr.update(visible=True),
)
def submit_guess(
guess_1: str, guess_2: str, guess_3: str, state: dict
):
if state["game_over"]:
return state, "", gr.update(visible=False), gr.update(visible=False), gr.update(visible=False), gr.update(visible=False)
color_map = state["color_map"]
last_answers = state["last_answers"]
user_guess_colors = [guess_1, guess_2, guess_3]
correct_count = 0
total_answers = len(last_answers)
for i, ans in enumerate(last_answers):
model_key = ans["model_key"]
correct_color = None
for c, mk in color_map.items():
if mk == model_key:
correct_color = c
break
if i < len(user_guess_colors) and user_guess_colors[i] == correct_color:
correct_count += 1
is_correct = (correct_count == total_answers)
state["score"] += correct_count
state["history"].append({
"round": state["round"] + 1,
"question": state["last_question"],
"answers": list(state["last_answers"]),
"color_map": dict(color_map),
"user_guess": {
f"answer_{i+1}": user_guess_colors[i]
for i in range(len(user_guess_colors))
},
"correct": is_correct,
"round_correct": correct_count,
})
state["round"] += 1
score_html = render_score(state)
game_over = state["round"] >= ROUND_LIMIT
state["game_over"] = game_over
return (
state,
score_html,
gr.update(visible=True),
gr.update(visible=not game_over),
gr.update(visible=not game_over),
gr.update(visible=game_over),
)
def reset_for_next_round(state: dict):
return (
state, # state
"", # question_input
"", # judge_msg ← добавить в outputs и очищать
"", # answers_html
"", # round_result_html
gr.update(visible=True), # question_section
gr.update(visible=False), # answers_section
gr.update(visible=False), # guess_section
gr.update(visible=False), # round_result_section
gr.update(visible=False), # loading_active
gr.update(visible=True), # loading_idle ← показать подсказку
gr.update(value=None), # guess_1 (сбрасываем выбор)
gr.update(value=None), # guess_2 (сбрасываем выбор)
gr.update(value=None), # guess_3 (сбрасываем выбор)
)
# ── Gradio UI ─────────────────────────────────────────────────
# with gr.Blocks(css=CSS, title="Слепой Детектив") as demo:
with gr.Blocks(title="Слепой Детектив") as demo:
state = gr.State(fresh_state())
gr.Markdown(
"# 🔍 Слепой Детектив Легковесных Моделей\n"
"<span style='color:#c8d0e0 !important;font-size:18px;'>"
"Три модели отвечают на вопросы о литературном тексте. "
"Угадай, кто есть кто, по стилю рассуждений.</span>"
)
with gr.Row():
work_dropdown = gr.Dropdown(
choices=list(WORK_INDEX.keys()),
label="Выберите произведение",
scale=4,
)
start_btn = gr.Button("▶ Начать", variant="primary", scale=1)
work_display = gr.HTML()
bootstrap_html = gr.HTML()
score_html = gr.HTML()
# ── Вопрос пользователя ───────────────────────────────────
with gr.Group(visible=False) as question_section:
gr.Markdown("### ✏️ Задай свой вопрос по произведению")
question_input = gr.Textbox(
placeholder="Например: О чём говорит последняя строфа?",
label="Вопрос",
lines=2,
)
ask_btn = gr.Button("Задать вопрос", variant="primary")
judge_msg = gr.HTML()
loading_idle = gr.HTML(
value='<div class="loading-idle">💡 Задай вопрос и нажми «Задать вопрос»</div>',
visible=False,
)
loading_active = gr.HTML(
value='<div class="loading-active">⏳ Модели обрабатывают вопрос...</div>',
visible=False,
)
# ── Ответы моделей (нейтральные) ──────────────────────────
with gr.Group(visible=False) as answers_section:
gr.Markdown("### 💬 Ответы моделей")
answers_html = gr.HTML()
# ── Угадывание ────────────────────────────────────────────
with gr.Group(visible=False) as guess_section:
gr.Markdown("### 🎯 Кто есть кто? Разметь цвета")
gr.Markdown("Для каждого ответа выбери цвет модели:")
color_choices = [
("🔴 Красный", "red"),
("🟡 Жёлтый", "yellow"),
("🟢 Зелёный", "green"),
]
with gr.Row():
guess_1 = gr.Radio(
choices=color_choices,
label="Ответ 1 — цвет модели:",
elem_classes=["color-radio"],
)
guess_2 = gr.Radio(
choices=color_choices,
label="Ответ 2 — цвет модели:",
elem_classes=["color-radio"],
)
guess_3 = gr.Radio(
choices=color_choices,
label="Ответ 3 — цвет модели:",
elem_classes=["color-radio"],
)
guess_btn = gr.Button("Проверить", variant="primary")
# ── Результат раунда ──────────────────────────────────────
with gr.Group(visible=False) as round_result_section:
gr.Markdown("### 📊 Результат раунда")
round_result_html = gr.HTML()
# Кнопки ВЫНЕСЕНЫ из группы — управляются независимо
with gr.Row(visible=False) as action_row:
next_btn = gr.Button("➡ Продолжить", variant="primary")
finish_btn = gr.Button("🏁 Закончить", variant="secondary")
# ── Финал ─────────────────────────────────────────────────
with gr.Group(visible=False) as final_section:
gr.Markdown("## 🏁 Игра окончена!")
final_html = gr.HTML()
restart_btn = gr.Button("🔄 Начать заново", variant="secondary")
# ── Привязки событий ──────────────────────────────────────
start_btn.click(
fn=select_work,
inputs=[work_dropdown, state],
outputs=[
state, work_display, bootstrap_html, score_html,
question_section, answers_section, guess_section, round_result_section,
loading_idle, start_btn,
],
)
ask_btn.click(
fn=lambda: (
gr.update(visible=False), # loading_idle скрыть
gr.update(visible=True), # loading_active показать
gr.update(visible=False), # answers_section скрыть
gr.update(visible=False), # guess_section скрыть
),
outputs=[loading_idle, loading_active, answers_section, guess_section],
).then(
fn=submit_question,
inputs=[question_input, state],
outputs=[state, judge_msg, answers_html, answers_section, guess_section],
).then(
fn=lambda: gr.update(visible=False),
outputs=[loading_active],
)
def _handle_guess_result(g1, g2, g3, s):
state_out, score_out, _rr_vis, _next_vis, _finish_vis, _final_vis = submit_guess(g1, g2, g3, s)
history = state_out["history"]
if history:
h = history[-1]
rnd = h["round"]
correct_count = h["round_correct"]
color = "4ade80" if correct_count == 3 else "fde047" if correct_count >= 1 else "ff6b6b"
rr_html = f'<p style="color:#{color};font-weight:700;font-size:18px">Раунд {rnd}{correct_count}/3 очков</p>'
else:
rr_html = ""
game_over = state_out["game_over"]
final_html_content = build_final_report(state_out) if game_over else ""
return (
state_out,
score_out,
rr_html,
gr.update(visible=not game_over), # guess_section ← скрыть после конца игры
gr.update(visible=True), # round_result_section
gr.update(visible=not game_over), # action_row
gr.update(visible=game_over), # final_section
final_html_content, # final_html
gr.update(interactive=not game_over), # ask_btn
)
guess_btn.click(
fn=_handle_guess_result,
inputs=[guess_1, guess_2, guess_3, state],
outputs=[
state, score_html, round_result_html,
guess_section, # ← добавить
round_result_section,
action_row,
final_section,
final_html,
ask_btn,
],
)
next_btn.click(
fn=reset_for_next_round,
inputs=[state],
outputs=[
state, question_input, judge_msg, answers_html, round_result_html,
question_section, answers_section, guess_section, round_result_section,
loading_active, loading_idle,
guess_1, guess_2, guess_3
],
)
finish_btn.click(
fn=lambda s: (
s,
build_final_report(s),
gr.update(visible=False), # round_result_section
gr.update(visible=False), # action_row
gr.update(visible=False), # guess_section
gr.update(visible=True), # final_section
),
inputs=[state],
outputs=[state, final_html, round_result_section, action_row, guess_section, final_section],
)
restart_btn.click(
fn=lambda: (
fresh_state(),
"", # work_display
"", # bootstrap_html
"", # score_html
"", # question_input
gr.update(visible=False), # question_section
gr.update(visible=False), # answers_section
gr.update(visible=False), # guess_section
gr.update(visible=False), # action_row
gr.update(visible=False), # round_result_section ← добавить
gr.update(visible=False), # final_section
gr.update(visible=False), # loading_active ← добавить
gr.update(visible=False), # loading_idle
gr.update(interactive=True), # start_btn
gr.update(interactive=True), # ask_btn
),
outputs=[
state, work_display, bootstrap_html, score_html, question_input,
question_section, answers_section, guess_section, action_row,
round_result_section, # ← добавить
final_section,
loading_active, # ← добавить
loading_idle,
start_btn, ask_btn,
],
)
if __name__ == "__main__":
# demo.launch()
demo.launch(css=CSS)