"""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}
"
"
"
"
"
)
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)