Download app.py from build-small-hackathon/one-red-paperclip: direct link, hf CLI and curl.
- Browser
- Download file 13.3 kB
-
https://huggingface.co/spaces/build-small-hackathon/one-red-paperclip/resolve/main/app.py
- Command line
-
hf download hf://spaces/build-small-hackathon/one-red-paperclip/app.py
-
curl -L -o app.py https://huggingface.co/spaces/build-small-hackathon/one-red-paperclip/resolve/main/app.py
13.3 kB
| """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"<div style='font-size:64px;text-align:center'>{emoji}</div>" | |
| 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"<span class='speech-time'>{clamped}s left</span>" | |
| return ( | |
| "<div class='speech-status'>" | |
| f"<div class='speech-status-row'><span>{message}</span>{time_label}</div>" | |
| "<div class='speech-meter'>" | |
| f"<div class='speech-meter-fill' style='width: {percent}%'></div>" | |
| "</div>" | |
| "</div>" | |
| ) | |
| 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) | |