from __future__ import annotations import base64 import concurrent.futures import html import os import random import tempfile import gradio as gr import requests from tribunal_shared import ( CHARACTER_REGISTRY as OPPONENTS, GENERIC_MODAL_ERROR, INVALID_ARGUMENT_MESSAGE, MODAL_ENDPOINTS, clamp_argument, looks_like_bad_transcript, looks_like_prompt_injection, ) JUDGE_URL = MODAL_ENDPOINTS["judge"] CHARACTER_URL = MODAL_ENDPOINTS["character"] STT_URL = MODAL_ENDPOINTS["stt"] TTS_URL = MODAL_ENDPOINTS["tts"] REQUEST_TIMEOUT = 240 MODAL_PROXY_AUTH_TOKEN_ID_ENV = "MODAL_PROXY_AUTH_TOKEN_ID" MODAL_PROXY_AUTH_TOKEN_SECRET_ENV = "MODAL_PROXY_AUTH_TOKEN_SECRET" def get_modal_proxy_auth_headers(): token_id = os.environ.get(MODAL_PROXY_AUTH_TOKEN_ID_ENV, "").strip() token_secret = os.environ.get(MODAL_PROXY_AUTH_TOKEN_SECRET_ENV, "").strip() if not token_id or not token_secret: return {} return {"Modal-Key": token_id, "Modal-Secret": token_secret} def load_asset_data_uri(filename, mime_type): asset_path = os.path.join(os.path.dirname(__file__), filename) with open(asset_path, "rb") as asset_file: encoded = base64.b64encode(asset_file.read()).decode("ascii") return f"data:{mime_type};base64,{encoded}" LANDING_LOGO_DATA_URI = load_asset_data_uri("logo.jpg", "image/jpeg") CSS = """ @import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@500;700;900&family=Inter:wght@400;500;700;800&display=swap'); :root { --ink: #111318; --paper: #f7efe0; --gold: #c99a3e; --jade: #2f8c68; } html, body { margin: 0 !important; padding: 0 !important; width: 100%; overflow-x: hidden; background: #111111 !important; } .gradio-container, .gradio-container > .main { max-width: none !important; min-height: auto !important; height: auto !important; padding: 0 !important; margin: 0 !important; gap: 0 !important; background: #111111 !important; color: var(--paper); font-family: Inter, system-ui, sans-serif; } #tribunal-app { max-width: none !important; margin: 0 !important; padding: 0 !important; min-height: auto !important; height: auto !important; } #tribunal-app .block, #tribunal-app .form, #tribunal-app .column, #tribunal-app .wrap, #tribunal-app .gap { padding: 0 !important; margin: 0 !important; gap: 0 !important; border: none !important; box-shadow: none !important; background: transparent !important; } #tribunal-app textarea, #tribunal-app input, #tribunal-app select { background: #111111 !important; color: #ddd6ca !important; border: 1px solid rgba(255, 255, 255, 0.16) !important; border-radius: 8px !important; } #landing-view { display: flex !important; flex-direction: column !important; min-height: auto !important; height: auto !important; overflow: visible !important; padding: 0 !important; gap: 0 !important; border: 0 !important; background: linear-gradient(180deg, rgba(18, 18, 18, 0.98), rgba(15, 15, 15, 0.98)), #111111 !important; } #landing-view.hide, #landing-view.hidden, #landing-view[hidden] { display: none !important; } #landing-header { height: 90px; flex: 0 0 auto; display: flex; align-items: center; padding: 0 clamp(28px, 5vw, 92px); border-bottom: 1px solid rgba(255, 255, 255, 0.12); background: #121212; } #landing-brand { display: flex; align-items: center; gap: 18px; min-width: 0; } #landing-brand img { width: 42px; height: 42px; object-fit: contain; } #landing-brand-title { color: #d6d0c6; font-family: Cinzel, serif; font-size: clamp(1.9rem, 3vw, 3.1rem); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; } #landing-stage { flex: 0 0 auto !important; display: grid; place-items: start center; padding: 24px 16px 32px !important; } #docket-card { width: min(92vw, 860px); border: 1px solid rgba(255, 255, 255, 0.16); border-radius: 10px; background: linear-gradient(180deg, rgba(28, 28, 28, 0.99), rgba(22, 22, 22, 0.99)), #171717; box-shadow: 0 22px 60px rgba(0, 0, 0, 0.45); padding: 32px 0 28px; } #docket-inner { width: min(100%, 760px); margin: 0 auto; padding: 0 20px; } .field-label { margin: 0 0 10px; color: rgba(255, 255, 255, 0.5); font-family: "Space Mono", monospace; font-size: 0.8rem; font-weight: 700; letter-spacing: 0.34em; text-transform: uppercase; } #topic-input { margin: 0 0 16px !important; } #stance-input { margin: 0 0 16px !important; } #opponent-input { margin: 0 0 20px !important; } #topic-input textarea { min-height: 120px !important; padding: 16px !important; font-size: 1rem !important; line-height: 1.4 !important; } #stance-input input, #stance-input button, #stance-input [role="combobox"] { min-height: 64px !important; padding: 0 16px !important; font-family: Cinzel, serif !important; font-size: 1.35rem !important; text-transform: uppercase !important; } #opponent-input input, #opponent-input button, #opponent-input [role="combobox"] { min-height: 72px !important; padding: 0 16px !important; font-family: Cinzel, serif !important; font-size: 1.1rem !important; } #start-btn button { width: 100% !important; min-height: 88px !important; border: 2px solid rgba(201, 154, 62, 0.36) !important; border-radius: 0 !important; background: transparent !important; color: #d7ac43 !important; font-family: Cinzel, serif !important; font-size: 1.2rem !important; font-weight: 700 !important; letter-spacing: 0.22em !important; text-transform: uppercase !important; } ul[role="listbox"] { background: #151515 !important; border: 1px solid rgba(255, 255, 255, 0.16) !important; border-radius: 8px !important; } li[data-testid="dropdown-option"] { color: #e2dbcf !important; font-family: Cinzel, serif !important; padding: 14px 18px !important; } .error-text { color: #ffd08a; font-weight: 800; } .debate-shell { margin: 0 !important; padding: 0 !important; border: 1px solid rgba(245, 197, 79, 0.42); background: #06070a; overflow: hidden; } #court-scene { position: relative; min-height: min(720px, 85vh); overflow: hidden; border-radius: 6px; background: linear-gradient(180deg, rgba(255,255,255,0.06), transparent 26%), var(--court-bg) center / cover no-repeat, #b18115; } .court-hud { position: absolute; top: 16px; left: 16px; right: 16px; z-index: 4; display: grid; grid-template-columns: minmax(180px, 0.7fr) minmax(220px, 1fr) minmax(180px, 0.7fr); gap: 14px; } .court-docket, .court-fighter { padding: 10px 12px; border: 2px solid rgba(255, 232, 142, 0.72); background: rgba(8, 11, 17, 0.78); color: #fff7de; } .court-docket { text-align: center; } .court-fighter.right { text-align: right; } .court-fighter-label { display: flex; justify-content: space-between; gap: 10px; font-weight: 900; font-size: 0.9rem; text-transform: uppercase; } .court-hp-track { height: 18px; margin-top: 7px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.36); background: rgba(0, 0, 0, 0.62); } .court-hp-fill { height: 100%; background: linear-gradient(90deg, #18b974, #f2dc63); } .court-hp-fill.warning { background: linear-gradient(90deg, #d98520, #f6d46a); } .court-hp-fill.danger { background: linear-gradient(90deg, #b51f32, #ff785e); } .court-character { position: absolute; z-index: 2; left: clamp(34px, 8vw, 150px); bottom: 188px; width: min(50vw, 620px); max-height: calc(100% - 285px); object-fit: contain; object-position: bottom left; } .court-character.player { left: clamp(60px, 11vw, 190px); width: min(32vw, 380px); image-rendering: pixelated; } .court-verdict-strip { position: absolute; z-index: 3; right: 24px; top: 150px; max-width: min(420px, 42vw); padding: 10px 14px; background: rgba(11, 14, 22, 0.72); color: #e0d0b0; font-family: monospace; font-size: 0.8rem; } .dialogue-box { position: absolute; left: 0; right: 0; bottom: 0; z-index: 5; min-height: 170px; padding: 34px clamp(24px, 8vw, 150px) 24px; border-top: 3px solid rgba(255, 255, 255, 0.9); background: linear-gradient(90deg, rgba(3, 12, 26, 0.94), rgba(10, 17, 30, 0.82)); color: white; } .dialogue-name { position: absolute; top: -28px; left: clamp(24px, 8vw, 150px); min-width: 210px; padding: 8px 28px 9px; font: 700 1.55rem/1 Cinzel, serif; background: linear-gradient(180deg, #3c9bc3, #257fa7); border: 2px solid white; text-align: center; } .dialogue-line { margin: 0; max-width: 1120px; font: 500 clamp(1.4rem, 3vw, 2.4rem)/1.2 Georgia, serif; } .argument-dock { padding: 10px 12px 12px; background: #080a0f; border-top: 1px solid rgba(255, 232, 142, 0.28); } .mic-panel { display: flex; align-items: center; gap: 8px; height: 52px; } .mic-panel button { height: 36px; min-height: 36px !important; padding: 0 16px; border-radius: 6px; background: linear-gradient(180deg, #d4a34b, #a7612f); color: #151312; font-weight: 900; cursor: pointer; } #tribunal-app button.primary { background: linear-gradient(180deg, #d4a34b, #a7612f) !important; color: #151312 !important; font-weight: 900 !important; min-height: 52px !important; } .hidden-runtime, #mic-status, .mic-playback { display: none !important; } #hidden-audio { position: absolute !important; opacity: 0 !important; pointer-events: none !important; width: 1px !important; height: 1px !important; overflow: hidden !important; } .voice-hint { padding: 8px 0; color: rgba(255, 255, 255, 0.38) !important; font-size: 0.75rem !important; text-align: center; text-transform: uppercase; } @media (max-width: 900px) { #landing-header { height: 80px; padding: 0 18px; } #landing-stage { padding: 16px 12px 24px !important; } #docket-card { padding: 24px 0 20px; } #docket-inner { padding: 0 16px; } .court-hud { grid-template-columns: 1fr; position: relative; inset: auto; padding: 12px; } #court-scene { min-height: 720px; } .court-character { left: 50%; transform: translateX(-50%); bottom: 215px; width: min(72vw, 310px); } .court-verdict-strip { display: none; } } """ CUSTOM_JS = """ (() => { if (window.__tribunalRecorderInstalled) return; window.__tribunalRecorderInstalled = true; let recorder = null; let stream = null; let chunks = []; const setStatus = (message, recording = false) => { const status = document.getElementById("mic-status"); if (!status) return; status.textContent = message; status.classList.toggle("recording", recording); }; const setPayload = (value) => { const root = document.getElementById("voice-payload"); const input = root && (root.querySelector("textarea") || root.querySelector("input")); if (!input) { setStatus("Recorder storage is not ready yet."); return; } const descriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value"); if (descriptor && descriptor.set) { descriptor.set.call(input, value); } else { input.value = value; } input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); }; const setButtons = (isRecording) => { const start = document.getElementById("mic-start"); const stop = document.getElementById("mic-stop"); if (start) start.disabled = isRecording; if (stop) stop.disabled = !isRecording; }; document.addEventListener("click", async (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; if (target.id === "mic-start") { event.preventDefault(); try { chunks = []; setPayload(""); stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const options = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? { mimeType: "audio/webm;codecs=opus" } : {}; recorder = new MediaRecorder(stream, options); recorder.ondataavailable = (dataEvent) => { if (dataEvent.data && dataEvent.data.size) chunks.push(dataEvent.data); }; recorder.onstop = () => { const blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" }); const playback = document.getElementById("mic-playback"); if (playback) playback.src = URL.createObjectURL(blob); const reader = new FileReader(); reader.onloadend = () => { setPayload(String(reader.result || "")); setStatus("Recorded. Submit when ready."); }; reader.readAsDataURL(blob); if (stream) stream.getTracks().forEach((track) => track.stop()); setButtons(false); }; recorder.start(); setStatus("Recording...", true); setButtons(true); } catch (error) { setStatus("Microphone unavailable: " + error.message); setButtons(false); } } if (target.id === "mic-stop") { event.preventDefault(); if (recorder && recorder.state === "recording") recorder.stop(); } }); })(); """ def opponent_name(opponent): return OPPONENTS.get(opponent, {}).get("name", "Opponent") def file_url(path): return f"/gradio_api/file={html.escape(path)}" def get_health_bar_html(hp, name, is_user=True): status = "danger" if hp <= 20 else "warning" if hp <= 50 else "" side = "left" if is_user else "right" safe_name = html.escape(name) safe_hp = max(0, min(100, int(hp))) return f"""
{safe_name} {safe_hp}/100 HP
""" def get_sprite_path(opponent, pose="talking"): data = OPPONENTS.get(opponent) or OPPONENTS["oscar_wilde"] safe_pose = pose if pose in {"talking", "thinking", "damage", "victory"} else "talking" return f"sprites/{data['sprite']}_{safe_pose}.png" def get_player_sprite_path(pose="thinking"): safe_pose = pose if pose in {"talking", "thinking", "damage", "victory"} else "thinking" return f"sprites/player_{safe_pose}.png" def get_hp_class(hp): if hp <= 20: return "danger" if hp <= 50: return "warning" return "" def get_arena_html( user_hp=100, opp_hp=100, opponent="oscar_wilde", topic="", stance="For", speaker="Advocate", dialogue="State your case. The tribunal is listening.", active_actor="player", opponent_pose="talking", player_pose="thinking", verdict="", ): data = OPPONENTS.get(opponent) or OPPONENTS["oscar_wilde"] topic_text = topic.strip() if topic else "Awaiting a motion before the court" stance_text = stance or "For" user_hp_safe = max(0, min(100, int(user_hp))) opp_hp_safe = max(0, min(100, int(opp_hp))) if active_actor == "opponent": active_sprite_path = get_sprite_path(opponent, opponent_pose) active_alt = data["name"] sprite_size_class = data.get("sprite_scale", "tall") else: active_sprite_path = get_player_sprite_path(player_pose) active_alt = "Advocate" sprite_size_class = "player" bg_path = "sprites/opp_background.jpg" verdict_html = "" if verdict: escaped_verdict = html.escape(verdict).replace("\n", "
") verdict_html = f'
{escaped_verdict}
' return f"""
Advocate {user_hp_safe}/100
{html.escape(stance_text)} the motion {html.escape(topic_text)}
{html.escape(data["name"])} {opp_hp_safe}/100
{html.escape(active_alt)} {verdict_html}
{html.escape(speaker)}

{html.escape(dialogue)}

""" def calculate_damage(score): if score >= 5: return (score - 4) * 3 return 0 def calculate_fatigue(score): if score < 5: return random.randint(1, 5) return 0 def _coerce_judge_int(value, fallback, minimum, maximum): try: coerced = int(round(float(value))) except (TypeError, ValueError): coerced = fallback return max(minimum, min(maximum, coerced)) def summarize_judge_result(j_res): if not isinstance(j_res, dict) or "error" in j_res: return { "score": 5, "raw_score": 5, "logic": 5, "relevance": 5, "creativity": 3, "reasoning": "Tribunal failed to evaluate the argument.", } raw_score = _coerce_judge_int(j_res.get("score", 5), 5, 1, 10) logic = _coerce_judge_int(j_res.get("logic", raw_score), raw_score, 1, 10) relevance = _coerce_judge_int(j_res.get("relevance", raw_score), raw_score, 1, 10) creativity = _coerce_judge_int(j_res.get("creativity", 3), 3, 1, 5) creativity_scaled = creativity * 2 composite = round((raw_score + logic + relevance + creativity_scaled) / 4) return { "score": max(1, min(10, composite)), "raw_score": raw_score, "logic": logic, "relevance": relevance, "creativity": creativity, "reasoning": str(j_res.get("reasoning", "No reasoning provided.")), } def post_modal_json(url, payload): try: response = requests.post( url, json=payload, headers=get_modal_proxy_auth_headers(), timeout=REQUEST_TIMEOUT, ) response.raise_for_status() return response.json() except Exception: return {"error": GENERIC_MODAL_ERROR} def build_scene_update( user_audio, user_text, chat_history, user_hp, opp_hp, opponent, topic, stance, speaker, dialogue, active_actor, opponent_pose, player_pose, verdict=None, opp_audio=None, ): display_name = opponent_name(opponent) return ( user_audio, user_text, chat_history, user_hp, opp_hp, get_health_bar_html(user_hp, "You", True), get_health_bar_html(opp_hp, display_name, False), get_arena_html( user_hp=user_hp, opp_hp=opp_hp, opponent=opponent, topic=topic, stance=stance, speaker=speaker, dialogue=dialogue, active_actor=active_actor, opponent_pose=opponent_pose, player_pose=player_pose, verdict=verdict, ), opp_audio, ) def build_scene_error(user_audio, user_text, chat_history, user_hp, opp_hp, opponent, topic, stance, dialogue): return build_scene_update( user_audio, user_text, chat_history, user_hp, opp_hp, opponent, topic, stance, speaker="Advocate", dialogue=dialogue, active_actor="player", opponent_pose="thinking", player_pose="thinking", ) def transcribe_argument(audio_payload): if not audio_payload: return "", GENERIC_MODAL_ERROR temp_path = None if isinstance(audio_payload, str) and audio_payload.startswith("data:audio"): header, encoded = audio_payload.split(",", 1) mime = header.split(";", 1)[0].replace("data:", "") suffix = ".webm" if "wav" in mime: suffix = ".wav" elif "mp4" in mime or "m4a" in mime: suffix = ".m4a" elif "ogg" in mime: suffix = ".ogg" audio_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) audio_file.write(base64.b64decode(encoded)) audio_file.close() audio_path = temp_path = audio_file.name else: audio_path = audio_payload try: with open(audio_path, "rb") as audio_file: response = requests.post( STT_URL, files={"file": (os.path.basename(audio_path), audio_file, "audio/webm")}, headers=get_modal_proxy_auth_headers(), timeout=REQUEST_TIMEOUT, ) response.raise_for_status() data = response.json() text = data.get("text", "").strip() except Exception: return "", GENERIC_MODAL_ERROR finally: if temp_path: try: os.remove(temp_path) except OSError: pass return text, "" def synthesize_rebuttal_audio(text): if not text: return None try: response = requests.post( TTS_URL, json={"text": text}, headers=get_modal_proxy_auth_headers(), timeout=REQUEST_TIMEOUT, ) response.raise_for_status() except Exception: return {"error": GENERIC_MODAL_ERROR} audio_file = tempfile.NamedTemporaryFile(delete=False, suffix=".wav") audio_file.write(response.content) audio_file.close() return audio_file.name def preview_player_argument(user_arg, topic, stance, opponent, user_hp, opp_hp): if not user_arg or not user_arg.strip(): return gr.update() prompt = user_arg.strip() if len(prompt) > 140: prompt = prompt[:137].rstrip() + "..." return get_arena_html( user_hp=user_hp, opp_hp=opp_hp, opponent=opponent or "oscar_wilde", topic=topic, stance=stance, speaker="Advocate", dialogue=prompt, active_actor="player", player_pose="talking", opponent_pose="thinking", ) def start_debate(topic, stance, opponent): if not topic or not opponent: return ( gr.update(visible=True), gr.update(visible=False), gr.update(value='
Please enter a topic and select an opponent.
'), [], 100, 100, get_health_bar_html(100, "You", True), get_health_bar_html(100, opponent_name(opponent), False), "", None, ) display_name = opponent_name(opponent) initial_chat = [ { "role": "assistant", "content": ( "**The Grand Tribunal begins.**\n\n" f"**Topic:** {topic}\n" f"**Your Stance:** {stance}\n" f"**Opponent:** {display_name}\n\n" "You have the floor. Present your opening argument." ), } ] return ( gr.update(visible=False), gr.update(visible=True), gr.update(value=""), initial_chat, 100, 100, get_health_bar_html(100, "You", True), get_health_bar_html(100, display_name, False), get_arena_html( user_hp=100, opp_hp=100, opponent=opponent, topic=topic, stance=stance, speaker="Advocate", dialogue="The Grand Tribunal begins. Make your opening argument.", active_actor="player", player_pose="talking", opponent_pose="thinking", verdict=f"{display_name} awaits your opening argument.", ), None, ) def handle_turn(user_audio, user_text, topic, stance, opponent, chat_history, user_hp, opp_hp): display_name = opponent_name(opponent) typed_arg = clamp_argument(user_text) if not user_audio and not typed_arg: return build_scene_error( None, "", chat_history, user_hp, opp_hp, opponent, topic, stance, "State your argument when ready.", ) if typed_arg: user_arg = typed_arg else: user_arg, transcription_error = transcribe_argument(user_audio) if transcription_error: return build_scene_error( None, "", chat_history, user_hp, opp_hp, opponent, topic, stance, "I could not transcribe that recording. Try again with a clearer argument.", ) if looks_like_bad_transcript(user_arg): return build_scene_error( None, "", chat_history, user_hp, opp_hp, opponent, topic, stance, "That recording sounded like noise or a filler phrase. Please try again with a clearer argument.", ) if looks_like_prompt_injection(user_arg): return build_scene_error( None, "", chat_history, user_hp, opp_hp, opponent, topic, stance, INVALID_ARGUMENT_MESSAGE, ) if not user_arg: return build_scene_error( None, "", chat_history, user_hp, opp_hp, opponent, topic, stance, "I could not transcribe that recording. Try again with a clearer argument.", ) chat_history.append({"role": "user", "content": user_arg}) situation_prompt = ( f"(Debate Topic: {topic}. The user is arguing {stance} this topic.)\n" f"User argues: {user_arg}\n" "Directly contradict and fiercely attack the core premise of the user's argument. " "Do not agree with them under any circumstances." ) def run_judge_user(): return post_modal_json(JUDGE_URL, {"topic": topic, "argument": user_arg}) def run_character(): return post_modal_json(CHARACTER_URL, {"character": opponent, "situation": situation_prompt}) with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: future_judge = executor.submit(run_judge_user) future_char = executor.submit(run_character) j_res = future_judge.result() c_res = future_char.result() user_judge = summarize_judge_result(j_res) score = user_judge["score"] reasoning = user_judge["reasoning"] if "error" in c_res: opp_response = GENERIC_MODAL_ERROR else: opp_response = c_res.get("response", GENERIC_MODAL_ERROR) damage = calculate_damage(score) fatigue = calculate_fatigue(score) turn_msg = f"### Tribunal Verdict on Your Argument\n**Score: {score}/10** - *{reasoning}*\n\n" if damage > 0: opp_hp = max(0, opp_hp - damage) turn_msg += f"**Strike landed.** Dealt {damage} damage to {display_name}.\n\n" else: user_hp = max(0, user_hp - fatigue) turn_msg += f"**Mental fatigue.** You suffered {fatigue} self-inflicted damage.\n\n" if opp_hp == 0: turn_msg += f"**Victory.** {display_name} has been defeated." chat_history.append({"role": "assistant", "content": turn_msg}) return build_scene_update( None, "", chat_history, user_hp, opp_hp, opponent, topic, stance, speaker="Advocate", dialogue=f"{display_name} has been defeated. The motion stands with you.", active_actor="player", opponent_pose="damage", player_pose="victory", verdict=f"Your score: {score}/10. Damage dealt: {damage}.", ) if user_hp == 0: turn_msg += "**Defeat.** Your logic has crumbled." chat_history.append({"role": "assistant", "content": turn_msg}) return build_scene_update( None, "", chat_history, user_hp, opp_hp, opponent, topic, stance, speaker="Advocate", dialogue="Your argument collapses under scrutiny.", active_actor="player", opponent_pose="victory", player_pose="damage", verdict=f"Your score: {score}/10. Fatigue suffered: {fatigue}.", ) turn_msg += "---\n\n" def run_tts(): return synthesize_rebuttal_audio(opp_response) def run_judge_opponent(): return post_modal_json(JUDGE_URL, {"topic": topic, "argument": opp_response}) with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: future_tts = executor.submit(run_tts) future_judge2 = executor.submit(run_judge_opponent) tts_res = future_tts.result() j_res2 = future_judge2.result() if isinstance(tts_res, dict) and "error" in tts_res: opp_audio = None opp_response_with_err = f"{opp_response}\n\n({GENERIC_MODAL_ERROR})" else: opp_audio = tts_res opp_response_with_err = opp_response opp_judge = summarize_judge_result(j_res2) opp_score = opp_judge["score"] opp_reasoning = opp_judge["reasoning"] turn_msg += f"### {display_name}'s Rebuttal\n\"{opp_response_with_err}\"\n\n" turn_msg += f"### Tribunal Verdict on Opponent\n**Score: {opp_score}/10** - *{opp_reasoning}*\n\n" opp_damage = calculate_damage(opp_score) opp_fatigue = calculate_fatigue(opp_score) if opp_damage > 0: user_hp = max(0, user_hp - opp_damage) turn_msg += f"**Counterstrike.** You took {opp_damage} damage from the retort." else: opp_hp = max(0, opp_hp - opp_fatigue) turn_msg += f"**Faulty premise.** {display_name} suffers {opp_fatigue} mental fatigue." if user_hp == 0: turn_msg += "\n\n**Defeat.** Your logic has crumbled." scene_speaker = display_name scene_dialogue = "A neat collapse. Even pessimism feels too generous." active_actor = "opponent" opponent_pose = "victory" player_pose = "damage" elif opp_hp == 0: turn_msg += f"\n\n**Victory.** {display_name} has been undone by the exchange." scene_speaker = "Advocate" scene_dialogue = f"{display_name} has been undone by the exchange." active_actor = "player" opponent_pose = "damage" player_pose = "victory" else: scene_speaker = display_name scene_dialogue = opp_response_with_err if opp_audio is None else opp_response active_actor = "opponent" opponent_pose = "talking" if opp_damage > 0 else "damage" player_pose = "damage" if opp_damage > 0 else "thinking" chat_history.append({"role": "assistant", "content": turn_msg}) verdict = ( f"You: {score}/10 | {display_name}: {opp_score}/10\n" f"Damage taken: {opp_damage if opp_damage > 0 else 0}\n" f"Damage dealt: {damage if damage > 0 else 0}" ) return build_scene_update( None, "", chat_history, user_hp, opp_hp, opponent, topic, stance, speaker=scene_speaker, dialogue=scene_dialogue, active_actor=active_actor, opponent_pose=opponent_pose, player_pose=player_pose, verdict=verdict, opp_audio=opp_audio, ) # No fill_height — that was causing nested containers to grow and infinite scroll. with gr.Blocks(elem_id="tribunal-app", css=CSS, theme=gr.themes.Soft()) as demo: topic_state = gr.State("") stance_state = gr.State("") opponent_state = gr.State("") user_hp_state = gr.State(100) opp_hp_state = gr.State(100) with gr.Column(visible=True, elem_id="landing-view") as setup_area: gr.HTML( f"""
The Grand Tribunal
THE GRAND TRIBUNAL
""" ) with gr.Column(elem_id="landing-stage"): with gr.Column(elem_id="docket-card"): with gr.Column(elem_id="docket-inner"): gr.HTML('
DEBATE TOPIC
') topic_input = gr.Textbox( label="Debate Topic", placeholder="Enter the subject of your philosophical contention...", lines=4, show_label=False, container=False, elem_id="topic-input", ) gr.HTML('
YOUR STANCE
') stance_input = gr.Dropdown( choices=[("FOR", "For"), ("AGAINST", "Against")], label="Your Stance", value="For", show_label=False, container=False, elem_id="stance-input", ) gr.HTML('
SELECT OPPONENT
') opponent_input = gr.Dropdown( choices=[ ("Oscar Wilde", "oscar_wilde"), ("Nietzsche", "friedrich_nietzsche"), ("Plato", "plato"), ("Schopenhauer", "schopenhauer"), ], label="Select Opponent", value="friedrich_nietzsche", show_label=False, container=False, elem_id="opponent-input", ) start_btn = gr.Button("ENTER THE TRIBUNAL ->", variant="secondary", elem_id="start-btn") error_box = gr.HTML("", visible=False, elem_id="error-box") with gr.Column(visible=False, elem_classes="debate-shell") as debate_area: # Start empty — don't load 760px court scene on landing page. arena_html = gr.HTML("") with gr.Row(elem_classes=["hidden-runtime"]): user_health_html = gr.HTML(get_health_bar_html(100, "You", True)) opp_health_html = gr.HTML(get_health_bar_html(100, "Opponent", False)) chatbot = gr.Chatbot(label="Tribunal Transcript", height=500, elem_id="tribunal-chat", visible=False) opponent_voice = gr.Audio(label="Opponent Voice", autoplay=True, visible=True, elem_id="hidden-audio") user_audio = gr.Textbox(value="", elem_classes=["hidden-runtime"], elem_id="voice-payload") with gr.Row(elem_classes=["submit-row", "argument-dock"]): gr.HTML( """
Ready
""", elem_classes=["mic-container-block"], scale=1, min_width=170, ) user_text = gr.Textbox( show_label=False, placeholder="Optional typed fallback while testing...", lines=1, max_lines=1, scale=7, ) submit_btn = gr.Button("SUBMIT ARGUMENT", variant="primary", scale=2) gr.HTML( '
USE THE RECORDER, STOP WHEN FINISHED, THEN SUBMIT YOUR ARGUMENT.
' ) start_btn.click( fn=start_debate, inputs=[topic_input, stance_input, opponent_input], outputs=[ setup_area, debate_area, error_box, chatbot, user_hp_state, opp_hp_state, user_health_html, opp_health_html, arena_html, opponent_voice, ], ).then( fn=lambda t, s, o: (t, s, o), inputs=[topic_input, stance_input, opponent_input], outputs=[topic_state, stance_state, opponent_state], ) submit_btn.click( fn=handle_turn, inputs=[ user_audio, user_text, topic_state, stance_state, opponent_state, chatbot, user_hp_state, opp_hp_state, ], outputs=[ user_audio, user_text, chatbot, user_hp_state, opp_hp_state, user_health_html, opp_health_html, arena_html, opponent_voice, ], ) user_text.submit( fn=handle_turn, inputs=[ user_audio, user_text, topic_state, stance_state, opponent_state, chatbot, user_hp_state, opp_hp_state, ], outputs=[ user_audio, user_text, chatbot, user_hp_state, opp_hp_state, user_health_html, opp_health_html, arena_html, opponent_voice, ], ) user_text.change( fn=preview_player_argument, inputs=[ user_text, topic_state, stance_state, opponent_state, user_hp_state, opp_hp_state, ], outputs=[arena_html], show_progress="hidden", ) if __name__ == "__main__": demo.launch( server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")), allowed_paths=["."], css=CSS, theme=gr.themes.Soft(), js=CUSTOM_JS, )