neilA / app.py
TriggerFish212's picture
Track taught concepts for lineage and reapplied
3081a6a
Raw
History Blame Contribute Delete
30.6 kB
"""First Contact — Gradio Space entrypoint (SPEC.md §0, §9).
Teach an alien that knows *words* but has never lived a human life. The model is
a stateless function; the alien's growing understanding lives in a plain-Python
concept ledger (game/) injected into the prompt each turn.
ZeroGPU contract (SPEC §0):
* Gradio SDK, model ≤32B.
* The model is loaded onto 'cuda' at MODULE level (here, at import time) — NOT
lazily inside the GPU function.
* Only inference runs inside @spaces.GPU; all state mutation / win-checking /
learning happens outside it.
* All per-user state lives in gr.State — never module globals.
"""
from __future__ import annotations
import html
import os
# `spaces` must be imported before torch for ZeroGPU's CUDA emulation. Locally
# (no `spaces` package) fall back to a no-op decorator so the app still runs on
# the StubBrain with zero extra dependencies.
try:
import spaces
GPU = spaces.GPU
except Exception: # pragma: no cover - exercised only off-Space
def GPU(*args, **kwargs):
if args and callable(args[0]):
return args[0]
def _decorator(fn):
return fn
return _decorator
import gradio as gr
# css/theme live on Blocks() in Gradio <=5 (the pinned Space target) but moved to
# launch() in Gradio 6. Detect so custom styling applies whichever version is
# resolved locally vs. on the Space.
_GR_MAJOR = int(gr.__version__.split(".")[0])
from game.brain import LocalBrain, make_brain
from game.challenges import CHALLENGES
from game.engine import (
advance_challenge,
confirm_candidate,
current_challenge,
is_arc_complete,
new_session,
reject_candidate,
run_turn,
)
from game.models import Concept, GameSession, WorldState
from game.world import check_win
# --------------------------------------------------------------------------- #
# Brain — created ONCE at module level. For BRAIN=local this loads the model to
# 'cuda' here (startup), per SPEC §0. Only the inference call below is wrapped in
# @spaces.GPU; run_turn() does all mutation outside it.
# --------------------------------------------------------------------------- #
try:
_BRAIN = make_brain()
except Exception as exc: # model download/load failed — keep the Space UP on stub
print(
f"[brain] could not build the configured brain: {exc!r}\n"
"[brain] falling back to StubBrain. On the Space, set ZeroGPU hardware "
"and BRAIN=local (HF token in secrets for gated models).",
flush=True,
)
from game.brain import StubBrain
_BRAIN = StubBrain()
_USE_GPU = isinstance(_BRAIN, LocalBrain)
# Loud startup line so the Space logs show which alien is live — a StubBrain here
# means visitors get canned lines (set BRAIN=local), not the real model.
print(
"[brain] active: " + type(_BRAIN).__name__
+ (f" ({_BRAIN.model_id})" if _USE_GPU
else " — zero-GPU stand-in, NOT the real model"),
flush=True,
)
# Sized from the 2026-06 bake-off: Qwen2.5-14B measured ~3.5s median / ~5s p90
# per call, so 20s covers the slow tail ~4x over while keeping ZeroGPU queue
# priority high (shorter declared durations queue better; 120 was the
# pre-measurement guess). Bump this if you switch to a slower model.
@GPU(duration=20)
def _generate_on_gpu(prompt: str) -> str:
return _BRAIN.respond(prompt)
class _TurnBrain:
"""Routes the model call through the @spaces.GPU function when (and only
when) a real GPU brain is loaded. Stub/Modal go straight through.
Infrastructure failures (ZeroGPU queue timeout, quota exhaustion, the
duration cap) are swallowed into an empty reply: empty text fails parsing,
which flows into the §4 retry -> safe-fallback path, so the player sees the
alien 'not understanding' instead of an error toast (SPEC §4: never crash,
never leak)."""
def respond(self, prompt: str) -> str:
try:
return _generate_on_gpu(prompt) if _USE_GPU else _BRAIN.respond(prompt)
except Exception as exc:
print(f"[brain] respond failed: {exc}", flush=True)
return ""
_turn_brain = _TurnBrain()
PLACEHOLDER = "Speak to the alien… (e.g. “hide the blue stone from the other one”)"
# --------------------------------------------------------------------------- #
# Rendering — pure functions over session state -> HTML
# --------------------------------------------------------------------------- #
def _chip(obj, concealed: bool = False) -> str:
color = "blue" if "blue" in obj.id else "red" if "red" in obj.id else "neutral"
if concealed:
return (
f'<span class="chip {color} concealed" title="hidden from the other">'
f'<span class="dot"></span>{html.escape(obj.name)}<span class="lock">▣</span></span>'
)
return f'<span class="chip {color}"><span class="dot"></span>{html.escape(obj.name)}</span>'
def render_world(world: WorldState) -> str:
held = {a_id: list(a.holding) for a_id, a in world.agents.items()}
in_basket = [o for o in world.objects.values() if o.location == "basket"]
on_ground = [o for o in world.objects.values() if o.location == "ground"]
def agent_zone(agent_id: str, glyph: str, label: str) -> str:
chips = "".join(_chip(world.objects[o]) for o in held.get(agent_id, []))
chips = chips or '<span class="empty">empty-handed</span>'
return (
f'<div class="zone agent"><div class="zone-head">{glyph} {label}</div>'
f'<div class="zone-body">{chips}</div></div>'
)
basket_chips = "".join(_chip(o, concealed=o.hidden) for o in in_basket) or (
'<span class="empty">nothing inside</span>'
)
ground_chips = "".join(_chip(o) for o in on_ground) or '<span class="empty">bare</span>'
return (
'<div class="stage">'
+ agent_zone("alien", "◉", "the alien (you teach)")
+ agent_zone("other", "◎", "the other one")
+ f'<div class="zone basket"><div class="zone-head">⬓ the basket</div>'
f'<div class="zone-body">{basket_chips}</div></div>'
+ f'<div class="zone ground"><div class="zone-head">· the ground</div>'
f'<div class="zone-body">{ground_chips}</div></div>'
+ "</div>"
)
def render_convo(history: list[dict]) -> str:
# Each entry is one self-contained block; trim to the recent tail so the
# latest turn (and any win beat) is what's on screen without inner scrolling.
blocks = []
for entry in history[-12:]:
who, text = entry["who"], html.escape(entry.get("text", ""))
if who == "player":
blocks.append(f'<div class="turn player"><span class="tag">you</span>{text}</div>')
elif who == "alien":
gap = (
f'<div class="gap">…did not understand — {html.escape(entry["gap"])}</div>'
if entry.get("gap")
else ""
)
act = (
f'<div class="act">› {html.escape(entry["action"])}</div>'
if entry.get("action")
else ""
)
blocks.append(
f'<div class="turn alien"><div class="voice">{text}</div>{act}{gap}</div>'
)
else: # system beat
kind = entry.get("kind") or "beat"
blocks.append(f'<div class="beat {kind}">{text}</div>')
if not blocks:
blocks.append('<div class="beat hint">The alien waits. Tell it what to do.</div>')
return f'<div class="convo">{"".join(blocks)}</div>'
def _concept_card(c: Concept, highlight: set[str], label_of: dict[str, str]) -> str:
klass = "concept " + ("innate" if c.taught_on_turn == 0 else "learned")
if c.id in highlight:
klass += " glow"
applied = f'<span class="applied">applied ×{c.times_applied}</span>' if c.times_applied else ""
if c.built_from:
names = " + ".join(label_of.get(i, i) for i in c.built_from)
built = f'<span class="built">from {html.escape(names)}</span>'
else:
built = ""
return (
f'<div class="{klass}"><div class="c-label">{html.escape(c.label)}</div>'
f'<div class="c-gloss">{html.escape(c.understanding)}</div>'
f'<div class="c-meta">{built}{applied}</div></div>'
)
def render_ledger(ledger: list[Concept], highlight: set[str] | None = None) -> str:
# Learned concepts first (newest on top — the constellation that just grew),
# innate primitives below a divider. The learned ones are the §9 screenshot.
highlight = highlight or set()
label_of = {c.id: c.label for c in ledger}
learned = sorted(
(c for c in ledger if c.taught_on_turn != 0),
key=lambda c: c.taught_on_turn,
reverse=True,
)
innate = [c for c in ledger if c.taught_on_turn == 0]
cards = [_concept_card(c, highlight, label_of) for c in learned]
if learned:
cards.append('<div class="ledger-div">innate primitives</div>')
cards.extend(_concept_card(c, highlight, label_of) for c in innate)
return f'<div class="ledger">{"".join(cards)}</div>'
def render_header(session: GameSession) -> str:
ch = current_challenge(session)
n, total = session.challenge_index + 1, len(CHALLENGES)
is_gen = ch.teaches is None and bool(ch.relies_on)
kind = "generalize" if is_gen else ("teach" if ch.teaches else "warm-up")
note = (
'<div class="ch-note">↯ No new teaching this round — just ask, and watch '
'whether it understands on its own.</div>' if is_gen else ""
)
return (
f'<div class="ch-head"><div class="ch-meta">CHALLENGE {n}/{total} · '
f'<span class="ch-kind {kind}">{kind}</span></div>'
f'<div class="ch-title">{html.escape(ch.title)}</div>'
f'<div class="ch-blurb">{html.escape(ch.setup_blurb)}</div>{note}</div>'
)
def render_goal(session: GameSession) -> str:
"""A concrete objective + live status from the real win-predicate, so the
player can see what actually counts as winning the stage."""
ch = current_challenge(session)
is_gen = ch.teaches is None and bool(ch.relies_on)
if check_win(session.world, ch):
pill = ('<span class="goal-pill done">✓ it understood!</span>' if is_gen
else '<span class="goal-pill done">✓ done</span>')
else:
pill = ('<span class="goal-pill pending">◌ watching…</span>' if is_gen
else '<span class="goal-pill pending">◌ not yet</span>')
return (
'<div class="goal-strip"><span class="goal-label">goal</span>'
f'<span class="goal-text">{html.escape(ch.goal)}</span>{pill}</div>'
)
def _learn_offer_html(candidate: dict) -> str:
return (
'<div class="offer-inner"><span class="spark">✦</span> The alien thinks it learned '
f'something new: <b>{html.escape(candidate.get("label", "?"))}</b> — '
f'<i>“{html.escape(candidate.get("understanding", ""))}”</i></div>'
)
def _win_banner(ch, reapplied: tuple[str, ...]) -> str:
if ch.teaches is None and ch.relies_on:
used = " + ".join(reapplied) if reapplied else " + ".join(ch.relies_on)
return (
'<div class="banner generalize"><div class="b-big">IT UNDERSTOOD YOU</div>'
f'<div class="b-sub">“{html.escape(ch.title)}” — the alien applied '
f'<b>{html.escape(used)}</b> to a situation you never taught it.</div></div>'
)
return (
'<div class="banner win"><div class="b-big">✦ understood</div>'
f'<div class="b-sub">{html.escape(ch.title)}</div></div>'
)
def render_finale(session: GameSession) -> str:
"""The end-of-arc payoff + shareable artifact: what the alien began with, what
you taught it, and — set apart — what it reached on its own (SPEC §9, §12)."""
learned = [c for c in session.ledger if c.taught_on_turn != 0]
generalized = [c for c in learned if c.via_generalization]
taught = [c for c in learned if not c.via_generalization]
innate = [c for c in session.ledger if c.taught_on_turn == 0]
label_of = {c.id: c.label for c in session.ledger}
nt, ng = len(taught), len(generalized)
if nt and ng:
lead = (f"It began with {len(innate)} bare senses. You taught it {nt}. "
f"From those, it reached {ng} more <b>on its own</b>.")
elif ng:
lead = (f"It began with {len(innate)} bare senses. On its own it reached for "
f"{ng} idea{'s' if ng != 1 else ''} — though you taught it none to "
f"build on.")
elif nt:
lead = (f"It began with {len(innate)} bare senses. You taught it {nt}, "
f"and it carried them into new situations.")
else:
lead = f"It began with {len(innate)} bare senses, and there it remains — for now."
def col(title: str, concepts: list[Concept], self_made: bool = False) -> str:
if not concepts:
return ""
items = ""
for c in concepts:
lineage = ""
if c.built_from:
names = " + ".join(label_of.get(i, i) for i in c.built_from)
lineage = f' <span class="from">(from {html.escape(names)})</span>'
items += (f"<li><b>{html.escape(c.label)}</b> — "
f"{html.escape(c.understanding)}{lineage}</li>")
cls = "finale-col self" if self_made else "finale-col"
return f'<div class="{cls}"><div class="finale-h">{title}</div><ul>{items}</ul></div>'
cols = col("you taught it", taught) + col("it understood alone", generalized, True)
return (
'<div class="banner finale">'
'<div class="b-big">it understands you now</div>'
f'<div class="b-sub">{lead}</div>'
f'<div class="finale-cols">{cols}</div>'
'<div class="finale-foot">First contact complete. Press '
'<b>restart</b> to begin again.</div></div>'
)
# --------------------------------------------------------------------------- #
# State -> outputs bundle
# --------------------------------------------------------------------------- #
def render_all(session: GameSession, highlight: set[str] | None = None) -> dict:
return {
state: session,
header_html: render_header(session),
goal_html: render_goal(session),
world_html: render_world(session.world),
convo_html: render_convo(session.history),
ledger_html: render_ledger(session.ledger, highlight),
}
def _maybe_finale(session: GameSession, out: dict) -> bool:
"""If the arc is complete and no concept is awaiting confirmation, replace the
per-stage banner with the finale and lock the input. Reachable from on_send
(last stage won, nothing to confirm) and on_confirm/on_reject (last concept
just resolved) — the old code only reached it via a continue button that the
last stage never showed."""
if is_arc_complete(session) and not session.pending_candidate:
out[success_banner] = gr.update(visible=True, value=render_finale(session))
out[continue_btn] = gr.update(visible=False)
out[learn_row] = gr.update(visible=False)
out[msg] = gr.update(interactive=False, value="",
placeholder="— first contact complete · press restart —")
return True
return False
# --------------------------------------------------------------------------- #
# Handlers
# --------------------------------------------------------------------------- #
def on_send(session: GameSession, message: str) -> dict:
message = (message or "").strip()
if not message:
return {msg: gr.update()}
res = run_turn(session, message, _turn_brain)
out = render_all(session, highlight=set(res.reapplied))
out[msg] = gr.update(value="")
if res.learn_offer:
out[learn_row] = gr.update(visible=True)
out[learn_label] = _learn_offer_html(res.learn_offer)
else:
out[learn_row] = gr.update(visible=False)
if res.won:
is_last = session.challenge_index >= len(CHALLENGES) - 1
# If the alien proposed a concept, gate "continue" until the player
# resolves it (Yes/No). Otherwise the prominent continue button silently
# drops the concept, and the arc reaches the finale having taught nothing.
out[continue_btn] = gr.update(visible=(not is_last and not res.learn_offer))
out[success_banner] = gr.update(
visible=True, value=_win_banner(current_challenge(session), res.reapplied)
)
else:
out[continue_btn] = gr.update(visible=False)
out[success_banner] = gr.update(visible=False)
_maybe_finale(session, out) # last stage won with nothing to confirm -> finale
return out
def _reveal_continue(session: GameSession, out: dict) -> None:
"""After a pending concept is resolved, show continue if the stage is won
(or the finale if the arc is done)."""
if session.won_current and session.challenge_index < len(CHALLENGES) - 1:
out[continue_btn] = gr.update(visible=True)
_maybe_finale(session, out)
def on_confirm(session: GameSession) -> dict:
cid = (session.pending_candidate or {}).get("id")
confirm_candidate(session)
out = render_all(session, highlight={cid} if cid else set())
out[learn_row] = gr.update(visible=False)
_reveal_continue(session, out) # concept resolved -> continue (or finale)
return out
def on_reject(session: GameSession) -> dict:
reject_candidate(session)
out = render_all(session)
out[learn_row] = gr.update(visible=False)
_reveal_continue(session, out)
return out
def on_continue(session: GameSession) -> dict:
ok = advance_challenge(session)
out = render_all(session)
out[continue_btn] = gr.update(visible=False)
out[learn_row] = gr.update(visible=False)
if ok:
out[success_banner] = gr.update(visible=False)
out[msg] = gr.update(interactive=True, value="", placeholder=PLACEHOLDER)
else: # safety net — the last stage shows no continue button, so this is rare
out[success_banner] = gr.update(visible=True, value=render_finale(session))
out[msg] = gr.update(interactive=False, value="",
placeholder="— first contact complete · press restart —")
return out
def on_restart() -> dict:
session = new_session()
out = render_all(session)
out[continue_btn] = gr.update(visible=False)
out[success_banner] = gr.update(visible=False)
out[learn_row] = gr.update(visible=False)
out[msg] = gr.update(interactive=True, value="", placeholder=PLACEHOLDER)
return out
# --------------------------------------------------------------------------- #
# CSS — committed aesthetic: a xenolinguist's first-contact terminal.
# Deep ink ground, bioluminescent accent for understanding, amber for confusion.
# --------------------------------------------------------------------------- #
CSS = """
@import url('https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,300;0,400;0,500;1,400&family=Space+Mono:wght@400;700&family=Major+Mono+Display&display=swap');
:root {
--ink: #0b0e13; --ink2: #11151d; --panel: #141a24; --edge: #232c3a;
--text: #d8dee9; --muted: #7c889c; --bio: #8be0c8; --bio-dim: #3f6b62;
--amber: #e2b075; --blue: #6fa8dc; --red: #e0736f;
}
.gradio-container { background:
radial-gradient(1200px 600px at 70% -10%, #16202c 0%, var(--ink) 55%) !important;
color: var(--text) !important; font-family: 'Spectral', serif !important; }
#masthead { text-align:center; padding: 10px 0 2px; }
#masthead .title { font-family:'Major Mono Display', monospace; font-size: 1.9rem;
letter-spacing: .12em; color: var(--bio); }
#masthead .sub { color: var(--muted); font-style: italic; font-size: .95rem; }
.fc-panel { background: var(--panel); border: 1px solid var(--edge);
border-radius: 12px; padding: 14px; }
.fc-panel .panel-label { font-family:'Space Mono', monospace; font-size:.72rem;
letter-spacing:.18em; color: var(--muted); text-transform:uppercase; margin-bottom:8px; }
/* header */
.ch-head { margin: 6px 0 2px; }
.ch-meta { font-family:'Space Mono',monospace; font-size:.72rem; letter-spacing:.14em; color:var(--muted); }
.ch-kind { padding:1px 7px; border:1px solid var(--edge); border-radius:999px; }
.ch-kind.teach { color:var(--bio); border-color:var(--bio-dim); }
.ch-kind.generalize { color:var(--amber); border-color:#6b5535; }
.ch-title { font-size:1.5rem; color:var(--text); margin:3px 0; }
.ch-blurb { color:var(--muted); font-size:.98rem; line-height:1.45; }
/* world stage */
.stage { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.zone { background: var(--ink2); border:1px solid var(--edge); border-radius:10px; padding:10px; min-height:64px; }
.zone.ground, .zone.basket { grid-column: span 1; }
.zone-head { font-family:'Space Mono',monospace; font-size:.72rem; color:var(--muted); letter-spacing:.1em; margin-bottom:8px; }
.zone-body { display:flex; flex-wrap:wrap; gap:6px; }
.zone.basket { background: repeating-linear-gradient(45deg,#10141c,#10141c 8px,#121822 8px,#121822 16px); }
.chip { display:inline-flex; align-items:center; gap:6px; padding:4px 10px; border-radius:999px;
background:#1b2330; border:1px solid var(--edge); font-size:.9rem; }
.chip .dot { width:9px; height:9px; border-radius:50%; background:var(--muted); }
.chip.blue .dot { background:var(--blue); box-shadow:0 0 8px var(--blue); }
.chip.red .dot { background:var(--red); box-shadow:0 0 8px var(--red); }
.chip.concealed { border-style:dashed; opacity:.65; color:var(--amber); }
.chip .lock { color:var(--amber); font-size:.8rem; }
.empty { color:#4b5566; font-style:italic; font-size:.85rem; }
/* conversation */
.convo { display:flex; flex-direction:column; gap:10px; padding-right:4px; }
.turn.player { align-self:flex-end; background:#1a2230; border:1px solid var(--edge);
border-radius:12px 12px 2px 12px; padding:7px 12px; max-width:85%; }
.turn.player .tag { font-family:'Space Mono',monospace; font-size:.62rem; color:var(--muted); display:block; letter-spacing:.12em; }
.turn.alien { align-self:flex-start; max-width:90%; }
.voice { font-family:'Space Mono',monospace; font-size:.95rem; line-height:1.5; color:var(--bio);
border-left:2px solid var(--bio-dim); padding:6px 12px; background:#101820; border-radius:2px 10px 10px 2px; }
.gap { color:var(--amber); font-style:italic; font-size:.85rem; padding:2px 12px; opacity:.85; }
.beat { text-align:center; font-family:'Space Mono',monospace; font-size:.74rem; letter-spacing:.14em;
color:var(--muted); text-transform:uppercase; padding:4px 0; }
.beat.win, .beat.learn { color:var(--bio); }
.beat.hint { color:#4b5566; }
/* ledger */
.ledger { display:flex; flex-direction:column; gap:8px; max-height:520px; overflow-y:auto; padding-right:4px; }
.ledger-div { font-family:'Space Mono',monospace; font-size:.64rem; letter-spacing:.16em; text-transform:uppercase;
color:#4b5566; text-align:center; margin:4px 0; border-top:1px dashed var(--edge); padding-top:8px; }
.concept { border:1px solid var(--edge); border-radius:10px; padding:9px 11px; background:var(--ink2); }
.concept.innate { opacity:.7; }
.concept.learned { border-color:var(--bio-dim); background:linear-gradient(180deg,#13201c,#11151d); }
.c-label { font-size:1.05rem; color:var(--text); }
.concept.learned .c-label { color:var(--bio); }
.c-gloss { color:var(--muted); font-size:.88rem; line-height:1.4; }
.c-meta { display:flex; gap:10px; margin-top:5px; font-family:'Space Mono',monospace; font-size:.66rem; letter-spacing:.08em; }
.built { color:var(--amber); }
.applied { color:var(--bio); }
@keyframes glowpulse { 0%{box-shadow:0 0 0 0 rgba(139,224,200,0);} 30%{box-shadow:0 0 22px 4px rgba(139,224,200,.55);} 100%{box-shadow:0 0 0 0 rgba(139,224,200,0);} }
.concept.glow { animation: glowpulse 1.6s ease-out 1; border-color:var(--bio); }
/* learn offer */
#learn_row { background:#15211d; border:1px solid var(--bio-dim); border-radius:12px; padding:6px 12px; }
.offer-inner { color:var(--text); font-size:.95rem; }
.offer-inner .spark { color:var(--bio); }
/* banners */
.banner { text-align:center; border-radius:14px; padding:18px; margin-top:8px; }
.banner.win { background:#13201c; border:1px solid var(--bio-dim); }
.banner.generalize { background:radial-gradient(600px 200px at 50% 0%, #1d2a25, #11151d);
border:1px solid var(--bio); box-shadow:0 0 40px rgba(139,224,200,.18); }
.b-big { font-family:'Major Mono Display',monospace; font-size:1.4rem; color:var(--bio); letter-spacing:.1em; }
.banner.generalize .b-big { animation: glowpulse 2s ease-out 1; }
.b-sub { color:var(--muted); font-style:italic; margin-top:6px; }
/* goal strip + generalization note */
.goal-strip { display:flex; align-items:center; gap:10px; margin:8px 0 2px; font-size:.95rem; color:var(--text); }
.goal-label { font-family:'Space Mono',monospace; font-size:.6rem; letter-spacing:.18em; text-transform:uppercase;
color:var(--muted); border:1px solid var(--edge); border-radius:4px; padding:2px 6px; }
.goal-text { flex:1; }
.goal-pill { font-family:'Space Mono',monospace; font-size:.72rem; letter-spacing:.05em; padding:2px 10px;
border-radius:999px; white-space:nowrap; }
.goal-pill.pending { color:var(--muted); border:1px solid var(--edge); }
.goal-pill.done { color:#06120e; background:var(--bio); font-weight:700; }
.ch-note { color:var(--amber); font-size:.9rem; font-style:italic; margin-top:5px; }
/* mechanical action line under the alien's voice */
.act { font-family:'Space Mono',monospace; font-size:.78rem; color:var(--muted); padding:3px 12px 0; }
/* capability hint */
.cap-hint { color:var(--muted); font-size:.82rem; text-align:center; margin:7px 0 0; opacity:.85; }
.cap-hint b { color:var(--text); font-weight:400; }
/* finale */
.banner.finale { background:radial-gradient(720px 280px at 50% 0%, #1d2a25, #0f1318);
border:1px solid var(--bio); box-shadow:0 0 55px rgba(139,224,200,.22); }
.banner.finale .b-big { animation: glowpulse 2.4s ease-out 1; }
.finale-cols { display:flex; gap:16px; justify-content:center; margin-top:14px; text-align:left; flex-wrap:wrap; }
.finale-col { flex:1; min-width:230px; background:var(--ink2); border:1px solid var(--edge); border-radius:10px; padding:10px 14px; }
.finale-col.self { border-color:var(--bio); background:linear-gradient(180deg,#13201c,#11151d); }
.finale-h { font-family:'Space Mono',monospace; font-size:.62rem; letter-spacing:.16em; text-transform:uppercase;
color:var(--muted); margin-bottom:6px; }
.finale-col.self .finale-h { color:var(--bio); }
.finale-col ul { margin:0; padding-left:16px; }
.finale-col li { font-size:.9rem; color:var(--text); margin:5px 0; line-height:1.4; }
.finale-col .from { color:var(--amber); font-family:'Space Mono',monospace; font-size:.7rem; }
.finale-foot { color:var(--muted); font-style:italic; margin-top:14px; font-size:.9rem; }
/* inputs */
#send_btn { background:var(--bio-dim) !important; color:#06120e !important; border:none !important; }
footer { display:none !important; }
"""
# --------------------------------------------------------------------------- #
# Layout
# --------------------------------------------------------------------------- #
# On Gradio 5 (the Space's pinned sdk_version) css/theme belong on the Blocks
# constructor; on Gradio 6 they belong on launch() (handled in __main__).
_blocks_style = {} if _GR_MAJOR >= 6 else {"css": CSS, "theme": gr.themes.Base()}
with gr.Blocks(title="First Contact", **_blocks_style) as demo:
state = gr.State(new_session())
# Deliver the CSS via an inline <style> tag too. On a Gradio-6 Space css must
# ride on launch(), but Spaces may auto-launch the `demo` object instead of
# running our __main__ — this injection applies the styling regardless of how
# the app is launched (gr.HTML renders raw; there is no sanitizer to strip it).
gr.HTML(f"<style>{CSS}</style>", container=False)
gr.HTML(
'<div id="masthead"><div class="title">first contact</div>'
'<div class="sub">teach an alien that knows words but has never lived a life</div></div>'
)
header_html = gr.HTML()
goal_html = gr.HTML()
with gr.Row(equal_height=False):
with gr.Column(scale=5):
with gr.Group(elem_classes="fc-panel"):
gr.HTML('<div class="panel-label">the world</div>')
world_html = gr.HTML()
with gr.Group(elem_classes="fc-panel"):
gr.HTML('<div class="panel-label">first contact log</div>')
convo_html = gr.HTML()
with gr.Column(scale=4):
with gr.Group(elem_classes="fc-panel"):
gr.HTML('<div class="panel-label">what the alien understands</div>')
ledger_html = gr.HTML()
success_banner = gr.HTML(visible=False)
with gr.Row(visible=False, elem_id="learn_row") as learn_row:
learn_label = gr.HTML()
yes_btn = gr.Button("Yes — it learned that", scale=0, variant="primary")
no_btn = gr.Button("No", scale=0)
with gr.Row():
msg = gr.Textbox(placeholder=PLACEHOLDER, show_label=False, scale=8, autofocus=True)
send_btn = gr.Button("speak", elem_id="send_btn", scale=1)
gr.HTML(
'<div class="cap-hint">the alien can <b>move</b>, <b>pick up</b> a thing, '
'<b>put</b> it in the basket, <b>give</b> it, <b>point</b>, or <b>wait</b> — '
'say it however you like; it will try to understand.</div>'
)
with gr.Row():
continue_btn = gr.Button("continue →", visible=False, variant="primary")
restart_btn = gr.Button("restart", scale=0)
# outputs every handler may touch
OUT = [state, header_html, goal_html, world_html, convo_html, ledger_html,
learn_row, learn_label, continue_btn, success_banner, msg]
send_btn.click(on_send, [state, msg], OUT)
msg.submit(on_send, [state, msg], OUT)
yes_btn.click(on_confirm, [state], OUT)
no_btn.click(on_reject, [state], OUT)
continue_btn.click(on_continue, [state], OUT)
restart_btn.click(on_restart, None, OUT)
demo.load(render_all, [state],
[state, header_html, goal_html, world_html, convo_html, ledger_html])
if __name__ == "__main__":
# Gradio 6 takes css/theme here; Gradio 5 already has them on Blocks above.
_launch_style = {"css": CSS, "theme": gr.themes.Base()} if _GR_MAJOR >= 6 else {}
demo.queue().launch(**_launch_style)