f-id / app.py
marcodsn's picture
replaced MiniCPM5-1B with MiniCPM4.1-8B
a354cf0
Raw
History Blame Contribute Delete
15.7 kB
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 import local as local_llm # noqa: E402 (registers @spaces.GPU)
from id.llm.client import LLMClient, LLMError # noqa: E402
from id.llm.prompts import PromptRegistry # noqa: E402
# In local (no-API) mode, fetch the GGUF on CPU at startup so the first turn
# does not spend its GPU budget downloading ~2.5 GB.
if os.getenv("F_ID_PROVIDER", "local") == "local":
try:
local_llm.prefetch()
except Exception: # never block app startup on a prefetch hiccup
pass
def build_config() -> Config:
cfg = load_config(ROOT / "config.toml")
# Default: fully self-contained, runs a small model in-process (no API).
provider = os.getenv("F_ID_PROVIDER", "local")
model = os.getenv("F_ID_MODEL", "openbmb/MiniCPM4.1-8B")
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)
# Paper palette. We override Gradio's design tokens for BOTH the default and
# `.dark` scopes so the light aesthetic renders identically no matter what the
# visitor's browser/system theme is (the `?__theme=light` redirect is not
# reliable inside the HF Spaces iframe).
_TOKENS = """
--body-background-fill: #f4f2ee;
--background-fill-primary: #fdfcfb;
--background-fill-secondary: #efece7;
--block-background-fill: #efece7;
--block-label-background-fill: #efece7;
--block-title-background-fill: transparent;
--panel-background-fill: #fdfcfb;
--stat-background-fill: #efece7;
--input-background-fill: #ffffff;
--input-background-fill-focus: #ffffff;
--input-background-fill-hover: #ffffff;
--code-background-fill: #efece7;
--table-even-background-fill: #fdfcfb;
--table-odd-background-fill: #f4f2ee;
--body-text-color: #1a1714;
--body-text-color-subdued: #5f5a53;
--block-label-text-color: #5f5a53;
--block-title-text-color: #1a1714;
--block-info-text-color: #5f5a53;
--input-placeholder-color: #9a958d;
--link-text-color: #2f2b27;
--link-text-color-hover: #000000;
--border-color-primary: #d8d5cf;
--border-color-accent: #d8d5cf;
--border-color-accent-subdued: #e6e3de;
--block-border-color: #d8d5cf;
--input-border-color: #d8d5cf;
--input-border-color-focus: #8c887f;
--panel-border-color: #d8d5cf;
--table-border-color: #d8d5cf;
--color-accent: #2f2b27;
--color-accent-soft: #efece7;
--button-primary-background-fill: #2f2b27;
--button-primary-background-fill-hover: #000000;
--button-primary-text-color: #ffffff;
--button-primary-border-color: #2f2b27;
--button-secondary-background-fill: #efece7;
--button-secondary-background-fill-hover: #e2dfd9;
--button-secondary-text-color: #1a1714;
--button-secondary-border-color: #d8d5cf;
"""
CSS = (
":root, html, body, body.dark, .dark, gradio-app, "
".gradio-container, .gradio-container.dark {"
+ _TOKENS
+ "}\n"
+ """
:root {
--font-heading: "EB Garamond", Georgia, serif;
}
/* Force the outermost surfaces light too (vars cascade down, not up). */
html, body, body.dark, gradio-app, .gradio-container, .gradio-container.dark {
background: var(--body-background-fill) !important;
color: var(--body-text-color) !important;
}
.gradio-container { max-width: 1120px !important; margin: 0 auto !important; }
h1, h2, h3, h4 { font-family: var(--font-heading) !important; font-weight: 600 !important; }
/* Header */
.app-title { padding: 20px 4px 12px; border-bottom: 1px solid var(--border-color-primary); margin-bottom: 4px; }
.app-title h1 { font-size: 34px; line-height: 1.1; margin: 0; letter-spacing: 0.5px; color: var(--body-text-color); }
.app-title p { color: var(--body-text-color-subdued); margin-top: 6px; font-size: 15px; }
/* Side panels (status + notes) */
.side-panel { background: var(--block-background-fill); border: 1px solid var(--border-color-primary); border-radius: 10px; padding: 14px 16px; }
.side-panel code { background: var(--background-fill-secondary); color: var(--color-accent); padding: 1px 5px; border-radius: 4px; }
"""
)
THEME = gr.themes.Base(
primary_hue="neutral",
secondary_hue="stone",
neutral_hue="stone",
font=[gr.themes.GoogleFont("IBM Plex Sans"), "system-ui", "sans-serif"],
font_mono=[gr.themes.GoogleFont("IBM Plex Mono"), "monospace"],
).set(
body_background_fill="#f4f2ee",
body_text_color="#1a1714",
block_background_fill="#fdfcfb",
block_label_text_color="#5f5a53",
border_color_primary="#d8d5cf",
input_background_fill="#ffffff",
button_primary_background_fill="#2f2b27",
button_primary_background_fill_hover="#000000",
button_primary_text_color="#ffffff",
)
with gr.Blocks(title="ID") as demo:
gr.HTML(
"""
<div class="app-title">
<h1>ID</h1>
<p>An LLM-driven investigation game built as a small Gradio Space.</p>
</div>
"""
)
session_state = gr.State("")
with gr.Row():
with gr.Column(scale=2):
world = gr.Dropdown(
choices=list(WORLD_LABELS),
value=DEFAULT_WORLD,
label="Case",
interactive=True,
)
with gr.Column(scale=0, min_width=140):
start = gr.Button("Start", variant="primary")
with gr.Row(equal_height=False):
with gr.Column(scale=3):
chat = gr.Chatbot(label="Transcript", height=560, resizable=True)
with gr.Tab("Talk"):
character = gr.Dropdown(label="Character", choices=[])
message = gr.Textbox(label="Question", lines=3)
talk_btn = gr.Button("Ask", variant="primary")
with gr.Tab("Look"):
location = gr.Dropdown(label="Location", choices=[])
query = gr.Textbox(label="Inspect", lines=2)
look_btn = gr.Button("Look", variant="primary")
with gr.Tab("Confront"):
confront_character = gr.Dropdown(label="Character", choices=[])
with gr.Row():
claim_a = gr.Textbox(label="Statement A")
claim_b = gr.Textbox(label="Statement B")
confront_btn = gr.Button("Confront", variant="primary")
with gr.Tab("Accuse"):
accuse_character = gr.Dropdown(label="Culprit", choices=[])
means = gr.Textbox(label="Means")
motive = gr.Textbox(label="Motive")
opportunity = gr.Textbox(label="Opportunity")
accuse_btn = gr.Button("Accuse", variant="primary")
with gr.Column(scale=2):
status = gr.Markdown(label="Status", elem_classes="side-panel")
notes = gr.Markdown(label="Notes", elem_classes="side-panel")
start.click(
start_case,
inputs=[world],
outputs=[
session_state,
chat,
notes,
status,
character,
confront_character,
location,
accuse_character,
],
)
talk_btn.click(
talk,
inputs=[session_state, character, message, chat],
outputs=[chat, message, notes, status],
)
look_btn.click(
look,
inputs=[session_state, location, query, chat],
outputs=[chat, query, notes, status],
)
confront_btn.click(
confront,
inputs=[session_state, confront_character, claim_a, claim_b, chat],
outputs=[chat, notes, status],
)
accuse_btn.click(
accuse,
inputs=[session_state, accuse_character, means, motive, opportunity, chat],
outputs=[chat, notes, status],
)
demo.load(
start_case,
inputs=[world],
outputs=[
session_state,
chat,
notes,
status,
character,
confront_character,
location,
accuse_character,
],
)
if __name__ == "__main__":
demo.launch(theme=THEME, css=CSS)