"""Second Degree -- Gradio MVP: a single conversation with Mort the permit clerk. Win by warming the clerk up (charm or a convincing story) until he opens up about the Telecaster he wants and points you to Sal at the lumber mill. Trust is hidden; a reaction emoji after each line is the only explicit feedback. Run: uv run python app.py UI-only (no model): SECOND_DEGREE_FAKE=1 uv run python app.py """ from __future__ import annotations import os from pathlib import Path import gradio as gr import yaml from characters.schema import Character from engine.conversation import respond from engine.speech import MAX_RECORDING_SECONDS, SpeechTranscriptionError, transcribe_audio from engine.state import LevelState, STATUS_WON, STATUS_LOST ROOT = Path(__file__).resolve().parent REACTION_EMOJI = {"happy": "😊", "neutral": "😐", "sad": "😞"} # Must match the engine's recording guard (engine/speech.py) so the UI countdown # never auto-stops a recording the engine would then reject as too long. RECORDING_LIMIT_SECONDS = MAX_RECORDING_SECONDS SPEECH_CSS = """ .speech-status { font-size: 13px; color: #2b211b; } .speech-status-row { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 6px; } .speech-time { color: #7a1f1f; font-variant-numeric: tabular-nums; } .speech-meter { height: 8px; overflow: hidden; border: 1px solid #6b5b48; background: #d7d0c2; } .speech-meter-fill { height: 100%; background: #d73124; transition: width 180ms linear; } #voice-input { min-height: 64px; } """ def mood_md(reaction: str) -> str: emoji = REACTION_EMOJI.get(reaction, REACTION_EMOJI["neutral"]) return f"
{emoji}
" def load_config() -> dict: with open(ROOT / "config.yaml", "r", encoding="utf-8") as fh: return yaml.safe_load(fh) def resolve_model_path(m: dict) -> str: """Return a local GGUF path, downloading from the Hub if only repo_id is given.""" path = m.get("path") if path: p = (ROOT / path) if not os.path.isabs(path) else Path(path) if p.exists(): return str(p) repo_id = m.get("repo_id") if repo_id: from huggingface_hub import hf_hub_download return hf_hub_download( repo_id=repo_id, filename=m["filename"], local_dir=str(ROOT / "models"), ) raise FileNotFoundError( f"No model found. Set model.path to a local GGUF or model.repo_id+filename. " f"(checked path={path!r})" ) def _on_zerogpu() -> bool: """True on an HF Spaces ZeroGPU runtime (HF sets SPACES_ZERO_GPU), or if forced.""" return os.environ.get("SPACES_ZERO_GPU") not in (None, "", "0") \ or os.environ.get("SECOND_DEGREE_ZEROGPU") == "1" def build_runtime(cfg: dict, open_up_threshold: int | None = None): """Pick a runtime: fake (UI testing), ZeroGPU (HF Space), or local llama.cpp. Returns (runtime, is_fake). """ if os.environ.get("SECOND_DEGREE_FAKE") == "1": from engine.fake_runtime import FakeRuntime return FakeRuntime(open_up_threshold=open_up_threshold or CHARACTER.trust_threshold), True m = cfg["model"] model_path = resolve_model_path(m) # hf_hub_download here runs on CPU at startup if _on_zerogpu(): from engine.runtime import ZeroGpuLlamaRuntime runtime = ZeroGpuLlamaRuntime( model_path=model_path, n_ctx=m.get("n_ctx", 4096), temperature=m.get("temperature", 0.85), max_tokens=m.get("max_tokens", 220), flash_attn=m.get("flash_attn", True), ) return runtime, False from engine.runtime import LlamaRuntime runtime = LlamaRuntime( model_path=model_path, n_ctx=m.get("n_ctx", 4096), n_gpu_layers=m.get("n_gpu_layers", 0), temperature=m.get("temperature", 0.85), max_tokens=m.get("max_tokens", 220), ) return runtime, False # --- session-shared objects (loaded once) ---------------------------------- CFG = load_config() CHARACTER = Character.from_yaml(ROOT / CFG["game"]["default_character"]) INCOMING_LEAD = CFG["game"].get("incoming_lead") def build_system_prompt( character: Character, incoming_lead: str | None = None, reveal_secret: bool = True ) -> str: return character.render_system_prompt( incoming_lead=incoming_lead, reveal_secret=reveal_secret ) # Two static prompts: the locked one withholds the secret/referral entirely, so the # model can't leak them before trust is earned; the unlocked one carries them so the # model can finally open up. We pick per turn based on the current trust level. SYSTEM_PROMPT_LOCKED = build_system_prompt( CHARACTER, incoming_lead=INCOMING_LEAD, reveal_secret=False ) SYSTEM_PROMPT_UNLOCKED = build_system_prompt( CHARACTER, incoming_lead=INCOMING_LEAD, reveal_secret=True ) _RUNTIME = None # lazily loaded so the UI can start before the model loads _IS_FAKE = False # On a ZeroGPU Space, import the runtime module now (at startup) so its module-level # @spaces.GPU function is decorated before ZeroGPU's startup scan runs -- otherwise the # Space dies with "No @spaces.GPU function detected during startup". The import is cheap # (llama_cpp loads lazily inside the GPU call); the model still downloads/loads on the # first turn, so the UI comes up immediately. Skipped off-Space to keep dev dep-free. if _on_zerogpu(): os.environ.setdefault( "SECOND_DEGREE_GPU_DURATION", str(CFG["model"].get("gpu_duration", 120)) ) from engine import runtime as _runtime # noqa: F401 (registers @spaces.GPU at import) def get_runtime(): global _RUNTIME, _IS_FAKE if _RUNTIME is None: _RUNTIME, _IS_FAKE = build_runtime(CFG, CHARACTER.trust_threshold) return _RUNTIME def active_few_shot(reveal_secret: bool) -> list[dict]: # The fake runtime reconstructs trust by scanning assistant turns, so it must # not see the few-shot demos (their trust values would leak in). The real model # needs them to learn the output format -- but the reveal demo is withheld until # the character unlocks, matching the gated system prompt. return [] if _IS_FAKE else CHARACTER.few_shot_for(reveal_secret) def fresh_level() -> LevelState: return LevelState(character_id=CHARACTER.id, trust=CHARACTER.initial_trust) def opening_chat() -> list[dict]: return [{"role": "assistant", "content": CHARACTER.opening_line or "Window 3. State your business."}] def speech_status_md(message: str = "Ready.", remaining: int | None = None) -> str: if remaining is None: percent = 0 time_label = "" else: clamped = max(0, min(RECORDING_LIMIT_SECONDS, int(remaining))) percent = int((clamped / RECORDING_LIMIT_SECONDS) * 100) time_label = f"{clamped}s left" return ( "
" f"
{message}{time_label}
" "
" f"
" "
" "
" ) def start_recording(): return ( gr.update(value=None, recording=True), RECORDING_LIMIT_SECONDS, speech_status_md("Recording...", RECORDING_LIMIT_SECONDS), gr.update(active=True), ) def tick_recording(remaining: int | None): next_remaining = max(0, int(remaining or 0) - 1) if next_remaining <= 0: return ( 0, speech_status_md("Transcribing...", 0), gr.update(active=False), gr.update(recording=False), ) return ( next_remaining, speech_status_md("Recording...", next_remaining), gr.update(active=True), gr.update(), ) def transcribe_recording(audio_path: str | None, current_message: str): current_message = current_message or "" if not audio_path: return ( current_message, speech_status_md("No audio captured. Try again."), gr.update(active=False), 0, ) try: transcript = transcribe_audio(audio_path) except SpeechTranscriptionError as exc: return current_message, speech_status_md(str(exc)), gr.update(active=False), 0 return transcript, speech_status_md("Ready."), gr.update(active=False), 0 def turn_handler(message: str, history: list[dict], level: LevelState): message = (message or "").strip() if not message or level.status != "in_progress": return "", history, level, mood_md(level.last_reaction), _status_banner(level, CHARACTER) runtime = get_runtime() # Gate on trust *entering* this turn: once trust has crossed the threshold, the # next turn unlocks the secret so the clerk can open up. Until then he literally # cannot see (and so cannot leak) the Telecaster or Sal. reveal_secret = level.trust >= CHARACTER.trust_threshold turn = respond( runtime, SYSTEM_PROMPT_UNLOCKED if reveal_secret else SYSTEM_PROMPT_LOCKED, active_few_shot(reveal_secret), level, message, threshold=CHARACTER.trust_threshold, ) history = history + [ {"role": "user", "content": message}, {"role": "assistant", "content": turn.say}, ] return "", history, level, mood_md(turn.reaction), _status_banner(level, CHARACTER) def _status_banner(level: LevelState, character: Character = CHARACTER) -> str: if level.status == STATUS_WON: owned = character.owned_item.strip() desire = character.secret_desire.strip() referral = character.referral.strip() return ( "### 🤝 They'll make a deal!\n" f"**They'll trade you:** {owned}\n\n" f"**In exchange for:** {desire}\n\n" f"**Where to find it:** {referral}\n\n" "*Level 2 coming soon...*" ) if level.status == STATUS_LOST: return "### 🚪 \"Get out of my office.\"\nMort has shut you out. Hit **Start over** to try again." return "" def restart(): return opening_chat(), fresh_level(), mood_md("neutral"), "", speech_status_md(), 0, gr.update(active=False), gr.update(value=None, recording=False) def build_ui() -> gr.Blocks: scene_path = ROOT / CHARACTER.scene_image scene = str(scene_path) if scene_path.exists() else None with gr.Blocks(title="Second Degree") as demo: gr.Markdown("# Second Degree\n*Get the pothole on your street filled. Start by sweet-talking the clerk.*") level_state = gr.State(fresh_level()) with gr.Row(): with gr.Column(scale=1): gr.Image(value=scene, label=CHARACTER.name, show_label=True, interactive=False, height=360) mood = gr.Markdown(mood_md("neutral")) with gr.Column(scale=2): chatbot = gr.Chatbot(value=opening_chat(), height=420, label=CHARACTER.name) banner = gr.Markdown("") with gr.Row(): msg = gr.Textbox(placeholder="What do you say to Mort?", scale=4, show_label=False) record_btn = gr.Button("Mic", variant="secondary", scale=1) send = gr.Button("Say it", variant="primary", scale=1) with gr.Row(): voice = gr.Audio( sources=["microphone"], type="filepath", format="wav", label="Voice input", show_label=False, scale=2, min_width=180, recording=False, buttons=[], elem_id="voice-input", ) with gr.Column(scale=3): speech_status = gr.Markdown(speech_status_md()) recording_remaining = gr.State(0) recording_timer = gr.Timer(1, active=False) restart_btn = gr.Button("Start over", variant="secondary") for trigger in (msg.submit, send.click): trigger(turn_handler, [msg, chatbot, level_state], [msg, chatbot, level_state, mood, banner]) record_btn.click( start_recording, None, [voice, recording_remaining, speech_status, recording_timer], show_progress="hidden", ) recording_timer.tick( tick_recording, recording_remaining, [recording_remaining, speech_status, recording_timer, voice], show_progress="hidden", ) voice.stop_recording( transcribe_recording, [voice, msg], [msg, speech_status, recording_timer, recording_remaining], show_progress="minimal", ) restart_btn.click( restart, None, [chatbot, level_state, mood, banner, speech_status, recording_remaining, recording_timer, voice], ) return demo if __name__ == "__main__": build_ui().launch(css=SPEECH_CSS)