""" 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'
Раунд {r}/{ROUND_LIMIT}  |  Очки: {s}/{max_possible}
' def render_bootstrap(questions_data: list, color_map: dict) -> str: model_to_color = {v: k for k, v in color_map.items()} html = '
' html += '

📖 Ознакомительные вопросы

' for i, item in enumerate(questions_data, 1): q = item.get("question", "—") html += ( f'
' f'Вопрос {i}
' f'{q}' f'
' ) 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'
' f'{color_label}' f'

{text}

' f'
' ) html += '
' html += '
' 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'
' f'Ответ {i}
' f'{text}' f'
' ) return html def build_final_report(state: dict) -> str: history = state["history"] if not history: return '
Нет данных.
' 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'
🏁 Итого: {total_score} очков из {max_possible} ({pct}%)
\n' html += '
' html += '

Подробная статистика по раундам

' 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'
' f'Раунд {rnd} — {round_correct}/3 очков
' f'Вопрос: ' f'{question}' ) 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'
Ответ {i}: ' f'{color_label} — {model_label}. ' f'Вы выбрали: {chosen_color_label} {check}' ) html += '
' html += '
' 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'
' f'{work["title"]} — {work["author"]}
' f'
{work["text"]}
' f'
' ) 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'

Ошибка judge: {e}

', "", gr.update(visible=False), gr.update(visible=False), ) if judge_data.get("decision") == "block": return ( state, '

⚠️ Вопрос не связан с произведением ' 'или выглядит как спам. Попробуй переформулировать.

', "", # answers_html — пустая строка gr.update(visible=False), # answers_section gr.update(visible=False), # guess_section ) # Проверка на дубль bootstrap-вопроса if _is_bootstrap_duplicate(question, state): return ( state, '

⚠️ Этот вопрос уже был в ознакомительном блоке — ' 'модели ответят так же, и разметка очевидна. ' 'Придумай свой вопрос, чтобы игра была честной!

', "", # 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'

Ошибка answer: {e}

', "", 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, '

✅ Вопрос принят. Выбери цвета для ответов.

', 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" "" "Три модели отвечают на вопросы о литературном тексте. " "Угадай, кто есть кто, по стилю рассуждений." ) 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='
💡 Задай вопрос и нажми «Задать вопрос»
', visible=False, ) loading_active = gr.HTML( value='
⏳ Модели обрабатывают вопрос...
', 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'

Раунд {rnd} — {correct_count}/3 очков

' 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)