#!/usr/bin/env python3 """Gradio web UI for Turnabout — Ace Attorney-style agent evaluation environment.""" from __future__ import annotations import json import glob import os import re as _re from pathlib import Path import gradio as gr from turnabout.envs.text_env import TextCourtEnv from turnabout.i18n import t CASES_DIR = Path(__file__).parent / "turnabout" / "cases" def list_cases() -> list[str]: return sorted(glob.glob(str(CASES_DIR / "*.json"))) def load_case_titles() -> dict[str, str]: titles = {} for path in list_cases(): try: with open(path) as f: data = json.load(f) titles[data.get("title", Path(path).stem)] = path except Exception: titles[Path(path).stem] = path return titles class GameSession: def __init__(self): self.env: TextCourtEnv | None = None self.done = False self.lang = "en" def start(self, case_path: str, difficulty: str, lang: str) -> str: self.lang = lang self.env = TextCourtEnv(case_path=case_path, difficulty=difficulty, max_steps=200, lang=lang) obs = self.env.reset() self.done = False return obs def step(self, action: str) -> tuple[str, float, bool]: if self.env is None or self.done: return t("ui_no_game", self.lang), 0.0, True obs, reward, done, info = self.env.step(action) self.done = done return obs, reward, done def get_valid_action_choices(self) -> list[tuple[str, str]]: if self.env is None or self.done: return [] valid = self.env.engine.get_valid_actions() return [(a.display(self.lang, self.env.case), str(a)) for a in valid] def get_evidence_list(self) -> str: if self.env is None: return t("ui_no_game", self.lang) state = self.env.engine.state if not state or not state.inventory: return t("ui_no_evidence", self.lang) lines = [] for i, eid in enumerate(sorted(state.inventory), 1): ev = self.env.case.get_evidence(eid) if ev: desc = ev.detail if ev.detail else ev.description lines.append(f"**{i}. {ev.name}**\n {desc}") return "\n\n".join(lines) def get_status(self) -> str: if self.env is None: return t("ui_no_game", self.lang) state = self.env.engine.state if not state: return t("ui_no_game", self.lang) phase_key = f"phase_{state.phase.value}" phase = t(phase_key, self.lang) penalties = f"{state.penalties}/{self.env.case.court.penalty_limit}" found = len(state.contradictions_found) required = len(self.env.case.court.win_condition.required_contradiction_ids) return ( f"**{t('ui_phase', self.lang)}:** {phase}\n\n" f"**{t('ui_penalties', self.lang)}:** {penalties}\n\n" f"**{t('ui_contradictions', self.lang)}:** {found}/{required}\n\n" f"**{t('ui_steps', self.lang)}:** {state.step_count}" ) def get_metrics_text(self) -> str: if self.env is None: return "" metrics = self.env.get_metrics() return ( f"**{t('ui_result', self.lang)}:** {t('ui_won', self.lang) if metrics.won else t('ui_lost', self.lang)}\n\n" f"**{t('ui_contradiction_accuracy', self.lang)}:** {metrics.contradiction_accuracy:.1%}\n\n" f"**{t('ui_evidence_coverage', self.lang)}:** {metrics.evidence_coverage:.1%}\n\n" f"**{t('ui_steps', self.lang)}:** {metrics.total_steps}\n\n" f"**{t('ui_composite_score', self.lang)}:** {metrics.composite_score:.3f}" ) session = GameSession() def _msg(role: str, content: str) -> dict: return {"role": role, "content": content} def _lang_code(lang_label: str) -> str: return "zh" if lang_label == "中文" else "en" def on_new_game(case_title: str, difficulty: str, lang_label: str) -> tuple: lang = _lang_code(lang_label) titles = load_case_titles() case_path = titles.get(case_title) if not case_path: cases = list_cases() if not cases: return [_msg("assistant", t("ui_no_cases", lang))], "", "", gr.update(choices=[], value=None), gr.update(interactive=True) case_path = cases[0] obs = session.start(case_path, difficulty, lang) history = [_msg("assistant", obs)] choices = session.get_valid_action_choices() return ( history, session.get_evidence_list(), session.get_status(), gr.update(choices=choices, value=choices[0][1] if choices else None), gr.update(interactive=True), ) def on_execute(action_str: str, chat_history: list, lang_label: str) -> tuple: lang = _lang_code(lang_label) if not action_str: choices = session.get_valid_action_choices() return chat_history, session.get_evidence_list(), session.get_status(), gr.update(choices=choices, value=choices[0][1] if choices else None), gr.update(visible=False), "" display_name = action_str choices = session.get_valid_action_choices() for label, val in choices: if val == action_str: display_name = label break obs, reward, done = session.step(action_str) reward_text = f"\n\n`[{t('ui_reward', lang)}: {reward:+.2f}]`" if reward != 0 else "" response = obs + reward_text chat_history = chat_history + [ _msg("user", display_name), _msg("assistant", response), ] if done: metrics_text = session.get_metrics_text() chat_history = chat_history + [_msg("assistant", f"---\n**{t('ui_game_over', lang)}**\n\n{metrics_text}")] return chat_history, session.get_evidence_list(), session.get_status(), gr.update(choices=[], value=None), gr.update(visible=True), metrics_text new_choices = session.get_valid_action_choices() return ( chat_history, session.get_evidence_list(), session.get_status(), gr.update(choices=new_choices, value=new_choices[0][1] if new_choices else None), gr.update(visible=False), "", ) def on_generate_case(theme: str, gen_difficulty: str, progress=gr.Progress()): yield "Initializing case generator..." try: from dotenv import load_dotenv load_dotenv() except ImportError: pass if not (os.environ.get("GATEWAY_API_KEY") or os.environ.get("OPENAI_API_KEY")): yield ( "Generation is not configured.\n\n" "Set `GATEWAY_API_KEY` for a gateway, or `OPENAI_API_KEY` for " "OpenAI. If using a custom gateway, also set `GATEWAY_URL` and " "`GATEWAY_MODELS`." ) return try: from turnabout.generation.generator import CaseGenerator except ImportError as e: yield f"Import error: {e}" return progress(0.1, desc="Initializing generator...") try: generator = CaseGenerator(backend="openai") except Exception as e: yield f"Failed to create generator: {e}" return try: case = None for value, desc, maybe_case in generator.generate_events( theme=theme or None, difficulty=gen_difficulty, ): progress(value, desc=desc) if maybe_case is None: yield ( "**Generating case...**\n\n" f"{desc}\n\n" "LLM case generation can take a few minutes." ) else: case = maybe_case except Exception as e: yield f"Generation failed: {e}" return if case is None: yield "Generation failed: no case was produced." return progress(0.9, desc="Saving case...") slug = _re.sub(r"[^a-z0-9]+", "_", case.title.lower()).strip("_") out_path = CASES_DIR / f"{slug}.json" with open(out_path, "w") as f: json.dump(case.model_dump(), f, indent=2, ensure_ascii=False) yield f"Case generated successfully!\n\n**{case.title}**\n{case.description}\n\nSaved to: `{out_path.name}`\n\nRefresh the page to see the new case in the Play section." COMMANDS_HELP_EN = """ ### Investigation Commands - `move ` — Travel to a location - `examine ` — Examine an object - `talk ` — Talk to a character - `present to ` — Show evidence to someone - `go to court` — Proceed to court ### Court Commands - `press` — Press the witness on current statement - `present ` — Present evidence against current statement - `next` / `prev` — Navigate between statements """ COMMANDS_HELP_ZH = """ ### 调查阶段命令 - `move <地点>` — 前往某地点 - `examine <物品>` — 调查物品 - `talk <人物>` — 与人物对话 - `present <证据> to <人物>` — 向人物出示证据 - `go to court` — 前往法庭 ### 法庭命令 - `press` — 追问当前证言 - `present <证据>` — 出示证据反驳当前证言 - `next` / `prev` — 切换证言 """ def build_app(): case_titles = load_case_titles() case_choices = list(case_titles.keys()) if case_titles else ["No cases found"] with gr.Blocks(title="Turnabout Bench") as app: gr.Markdown(t("ui_title", "en"), elem_id="app_title") lang_radio = gr.Radio( choices=["English", "中文"], value="English", label="Language / 语言", interactive=True, ) gr.Markdown("## Play") with gr.Row(): with gr.Column(scale=3): chatbot = gr.Chatbot( label=t("ui_game_label", "en"), height=480, ) action_radio = gr.Radio( choices=[], label=t("ui_select_action", "en"), interactive=True, ) execute_btn = gr.Button(t("ui_execute", "en"), variant="primary") with gr.Column(scale=1): with gr.Group(): case_dropdown = gr.Dropdown( choices=case_choices, value=case_choices[0] if case_choices else None, label=t("ui_case", "en"), ) difficulty_radio = gr.Radio( choices=["easy", "hard"], value="easy", label=t("ui_difficulty", "en"), ) new_game_btn = gr.Button(t("ui_new_game", "en"), variant="primary") status_md = gr.Markdown( t("ui_click_new_game", "en"), label=t("ui_status", "en"), ) evidence_md = gr.Markdown( "", label=t("ui_evidence", "en"), ) metrics_box = gr.Markdown("", visible=False, label="Final Metrics") with gr.Accordion(t("ui_commands_ref", "en"), open=False): gr.Markdown(COMMANDS_HELP_EN) new_game_btn.click( fn=on_new_game, inputs=[case_dropdown, difficulty_radio, lang_radio], outputs=[chatbot, evidence_md, status_md, action_radio, execute_btn], ) execute_btn.click( fn=on_execute, inputs=[action_radio, chatbot, lang_radio], outputs=[chatbot, evidence_md, status_md, action_radio, metrics_box, metrics_box], ) gr.Markdown( "---\n" "## Generate Case\n" "Use the configured LLM gateway to generate new cases automatically." ) with gr.Row(): with gr.Column(): theme_input = gr.Textbox( label=t("ui_theme", "en"), placeholder="e.g. 'art heist', 'corporate espionage', 'poisoned cake'", ) gen_difficulty = gr.Radio( choices=["easy", "hard"], value="easy", label=t("ui_difficulty", "en"), ) generate_btn = gr.Button(t("ui_generate_btn", "en"), variant="primary") with gr.Column(): gen_output = gr.Markdown(label=t("ui_result", "en")) generate_btn.click( fn=on_generate_case, inputs=[theme_input, gen_difficulty], outputs=[gen_output], ) gr.Markdown(""" --- ## About Turnabout Bench is an interactive evaluation environment for testing agent long-horizon reasoning, inspired by the **Ace Attorney** game series. **Key Features:** - **Investigation Phase** (hard mode): Explore locations, collect evidence, talk to witnesses - **Court Phase**: Cross-examine witnesses, find contradictions in testimony - **Dual Interface**: Text-based for LLM agents, Gymnasium-compatible for RL agents - **Evaluation Metrics**: Contradiction accuracy, evidence coverage, step efficiency - **LLM Case Generation**: Automatically generate new cases via API **Usage:** ```python # LLM Agent from turnabout.envs.text_env import TextCourtEnv env = TextCourtEnv(case_path="...", difficulty="easy") obs = env.reset() obs, reward, done, info = env.step("present evidence_name") # RL Agent (Gymnasium) import gymnasium as gym import turnabout.envs env = gym.make("Turnabout-Discrete-v0", case_path="...") ``` """) return app.queue() if __name__ == "__main__": app = build_app() app.launch(share=False)