diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..bf128ccb988a498e5efcd1596012d67c342d083d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +runtime/ +.gradio/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1b76d9bca57566232a14136827f691ae727f5297 --- /dev/null +++ b/README.md @@ -0,0 +1,22 @@ +--- +title: ID +emoji: I +colorFrom: gray +colorTo: stone +sdk: gradio +sdk_version: 5.49.1 +app_file: app.py +pinned: false +--- + +# ID + +A small Gradio build of the LLM-driven investigation game. + +Set `OPENAI_API_KEY` as a Space secret before running. Optional runtime +configuration: + +- `F_ID_PROVIDER`: `openai`, `openrouter`, or `local` +- `F_ID_MODEL`: model name, defaults to `gpt-4o-mini` +- `F_ID_BASE_URL`: OpenAI-compatible API base URL +- `F_ID_API_KEY_ENV`: secret environment variable name diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..6aeff44e55bd43c8ffebc4e0b5cee8f0faddb316 --- /dev/null +++ b/app.py @@ -0,0 +1,379 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from typing import Any + +import gradio as gr + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "src")) + +from id.config import Config, ProviderConfig, load_config # noqa: E402 +from id.engine.loop import Session # noqa: E402 +from id.generator.archive import Archive # noqa: E402 +from id.llm.client import LLMClient, LLMError # noqa: E402 +from id.llm.prompts import PromptRegistry # noqa: E402 + + +def build_config() -> Config: + cfg = load_config(ROOT / "config.toml") + provider = os.getenv("F_ID_PROVIDER", "openai") + model = os.getenv("F_ID_MODEL", "gpt-4o-mini") + base_url = os.getenv("F_ID_BASE_URL") + api_key_env = os.getenv("F_ID_API_KEY_ENV") + + if provider == "openrouter": + base_url = base_url or "https://openrouter.ai/api/v1" + api_key_env = api_key_env or "OPENROUTER_API_KEY" + elif provider == "local": + base_url = base_url or cfg.providers["local"].base_url + api_key_env = api_key_env or "LOCAL_API_KEY" + else: + base_url = base_url or "https://api.openai.com/v1" + api_key_env = api_key_env or "OPENAI_API_KEY" + + cfg.providers[provider] = ProviderConfig( + base_url=base_url, + api_key_env=api_key_env, + default_headers=cfg.providers.get(provider, ProviderConfig( + base_url=base_url, + api_key_env=api_key_env, + )).default_headers, + ) + for tier in cfg.tiers.values(): + tier.provider = provider + tier.model = model + cfg.engine.request_timeout = float(os.getenv("F_ID_REQUEST_TIMEOUT", "180")) + cfg.root = ROOT + cfg.runtime_dir.mkdir(exist_ok=True) + return cfg + + +def make_context() -> tuple[Config, PromptRegistry, LLMClient]: + cfg = build_config() + return cfg, PromptRegistry(cfg.prompts_dir), LLMClient(cfg) + + +def world_entries() -> list[dict[str, Any]]: + cfg, _, _ = make_context() + return Archive(cfg.worlds_dir).entries() + + +def world_choice(entry: dict[str, Any]) -> str: + title = entry.get("title") or entry.get("world_id", "") + one_line = entry.get("one_line", "") + return f"{title} | {one_line}" if one_line else title + + +WORLD_LABELS = {world_choice(entry): entry["world_id"] for entry in world_entries()} +DEFAULT_WORLD = next(iter(WORLD_LABELS), "") + + +def session_from_id(session_id: str) -> Session: + if not session_id: + raise gr.Error("Start a case first.") + cfg, prompts, client = make_context() + return Session.resume(cfg, session_id, prompts, client) + + +def people(session: Session) -> list[str]: + return [c.name for c in session.world.characters.values() if c.role != "victim"] + + +def locations(session: Session) -> list[str]: + return sorted({s.location for s in session.world.timeline.slices}) + + +def intro_markdown(session: Session) -> str: + world = session.world + names = ", ".join(f"{c.name} ({c.role})" for c in world.characters.values()) + body = world.world_md.strip() or world.meta.one_line + return ( + f"### {world.meta.title or world.meta.world_id}\n\n" + f"{body[:1400]}\n\n" + f"**People:** {names}\n\n" + f"**Session:** `{session.state.session_id}`" + ) + + +def notes_markdown(session: Session) -> str: + notes = session.notes() + lines: list[str] = [] + if notes.discovered_clues: + lines.append("### Clues") + lines.extend(f"- `{c['id']}`: {c['reveals']}" for c in notes.discovered_clues) + else: + lines.append("_No clues discovered yet._") + if notes.cracked: + lines.append("\n### Cracked") + lines.append(", ".join(notes.cracked)) + if notes.ledgers: + lines.append("\n### Statements") + for name, claims in notes.ledgers.items(): + lines.append(f"\n**{name}**") + lines.extend(f"- `{c['claim_id']}`: {c['proposition']}" for c in claims) + else: + lines.append("\n_No statements on record yet._") + return "\n".join(lines) + + +def status_markdown(session: Session) -> str: + return ( + f"**Turn:** {session.state.turn} \n" + f"**Status:** {session.state.status.value} \n" + f"**Clues:** {len(session.state.discovered_clues)}" + ) + + +def start_case(label: str) -> tuple[str, list[dict[str, str]], str, str, Any, Any, Any, Any]: + if not label: + raise gr.Error("No archived worlds are available.") + world_id = WORLD_LABELS[label] + cfg, prompts, client = make_context() + session = Session.start(cfg, world_id, prompts, client) + chat = [{"role": "assistant", "content": intro_markdown(session)}] + chars = people(session) + locs = locations(session) + return ( + session.state.session_id, + chat, + notes_markdown(session), + status_markdown(session), + gr.update(choices=chars, value=chars[0] if chars else None), + gr.update(choices=chars, value=chars[0] if chars else None), + gr.update(choices=locs, value=locs[0] if locs else None), + gr.update(choices=chars, value=chars[0] if chars else None), + ) + + +def talk(session_id: str, character: str, message: str, chat: list[dict[str, str]]): + if not message.strip(): + raise gr.Error("Enter a question.") + try: + session = session_from_id(session_id) + outcome = session.talk(character, message.strip()) + except (KeyError, LLMError) as exc: + raise gr.Error(str(exc)) from exc + suffix = "" + if outcome.discovered_clues: + suffix = "\n\n" + "\n".join(f"**Clue discovered:** `{cid}`" for cid in outcome.discovered_clues) + label = f"Talk to {character}" + chat = chat + [ + {"role": "user", "content": f"**{label}:** {message.strip()}"}, + {"role": "assistant", "content": outcome.text + suffix}, + ] + return chat, "", notes_markdown(session), status_markdown(session) + + +def look(session_id: str, location: str | None, query: str, chat: list[dict[str, str]]): + if not query.strip(): + raise gr.Error("Enter something to inspect.") + try: + session = session_from_id(session_id) + answer = session.look(query.strip(), location or None) + except LLMError as exc: + raise gr.Error(str(exc)) from exc + suffix = f"\n\n**Clue discovered:** `{answer.discovered_clue}`" if answer.discovered_clue else "" + where = f"@{location}" if location else "the scene" + chat = chat + [ + {"role": "user", "content": f"**Look {where}:** {query.strip()}"}, + {"role": "assistant", "content": answer.text + suffix}, + ] + return chat, "", notes_markdown(session), status_markdown(session) + + +def confront( + session_id: str, + character: str, + claim_a: str, + claim_b: str, + chat: list[dict[str, str]], +): + if not claim_a.strip() or not claim_b.strip(): + raise gr.Error("Enter two statement ids from Notes.") + try: + session = session_from_id(session_id) + result = session.confront(character, claim_a.strip(), claim_b.strip()) + except KeyError as exc: + raise gr.Error(str(exc)) from exc + label = "Verified contradiction" if result.verified else "No contradiction" + chat = chat + [ + {"role": "user", "content": f"**Confront {character}:** `{claim_a}` vs `{claim_b}`"}, + {"role": "assistant", "content": f"**{label}.**\n\n{result.reason}"}, + ] + return chat, notes_markdown(session), status_markdown(session) + + +def accuse( + session_id: str, + culprit: str, + means: str, + motive: str, + opportunity: str, + chat: list[dict[str, str]], +): + if not culprit: + raise gr.Error("Choose a culprit.") + try: + session = session_from_id(session_id) + result = session.accuse(culprit, means.strip(), motive.strip(), opportunity.strip()) + except KeyError as exc: + raise gr.Error(str(exc)) from exc + chat = chat + [ + {"role": "user", "content": f"**Accuse:** {culprit}"}, + {"role": "assistant", "content": f"**{result.grade}**\n\n{result.debrief}"}, + ] + return chat, notes_markdown(session), status_markdown(session) + + +CSS = """ +:root { + --color-bg: #f9f7f4; + --color-surface: #f1efec; + --color-surface-alt: #e8e6e2; + --color-border: #dcdad6; + --color-text: #1a1a1a; + --color-text-muted: #6b6b6b; + --font-ui: "IBM Plex Sans", system-ui, -apple-system, sans-serif; + --font-heading: "EB Garamond", Georgia, serif; +} +body, .gradio-container { + background: var(--color-bg) !important; + color: var(--color-text) !important; + font-family: var(--font-ui) !important; +} +.gradio-container { max-width: 1120px !important; margin: 0 auto !important; } +h1, h2, h3 { font-family: var(--font-heading) !important; font-weight: 500 !important; } +.app-title { padding: 18px 0 8px; border-bottom: 1px solid var(--color-border); } +.app-title h1 { font-size: 30px; line-height: 1.2; margin: 0; } +.app-title p { color: var(--color-text-muted); margin-top: 6px; } +.gradio-container button.primary { + background: #3d3d3d !important; + border-color: #3d3d3d !important; + color: #fff !important; +} +.gradio-container button { border-radius: 6px !important; } +.gradio-container .block { + border-color: var(--color-border) !important; + border-radius: 8px !important; + background: #fdfcfb !important; +} +textarea, input, select { background: #fdfcfb !important; } +""" + +THEME = gr.themes.Base( + primary_hue="neutral", + secondary_hue="stone", + neutral_hue="stone", + font=["IBM Plex Sans", "system-ui", "sans-serif"], + font_mono=["IBM Plex Mono", "monospace"], +).set( + body_background_fill="#f9f7f4", + block_background_fill="#fdfcfb", + border_color_primary="#dcdad6", + button_primary_background_fill="#3d3d3d", + button_primary_background_fill_hover="#1a1a1a", +) + +with gr.Blocks(theme=THEME, css=CSS, title="ID") as demo: + gr.HTML( + """ +
An LLM-driven investigation game rebuilt as a small Gradio Space.
+