forgotten-lily / app /server.py
SucksToBeAnik's picture
Warm GPU models on session start to hide Modal cold start
39d5bad
Raw
History Blame Contribute Delete
6.68 kB
"""Forgotten Lily server. FastAPI app that serves the custom frontend and the
JSON game API. Inference is routed through app.inference (mock by default).
Run locally:
USE_MOCK=1 .venv/bin/uvicorn app.server:app --reload --port 7860
On HuggingFace Spaces (Phase 9) a minimal gr.Blocks is mounted onto this app for
Gradio-SDK eligibility; the player only ever sees the custom frontend.
"""
import os
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from app import game
from app import trace_logger
from app.game import active_tone_ids
from app.inference import infer
USE_MOCK = os.environ.get("USE_MOCK", "1") != "0"
ROOT = Path(__file__).resolve().parent
STATIC = ROOT / "static"
app = FastAPI(title="Forgotten Lily")
# in-memory session store β€” fine for the slice / single-instance Space.
SESSIONS: dict[str, dict] = {}
def _get(session_id: str) -> dict:
st = SESSIONS.get(session_id)
if st is None:
raise HTTPException(404, "session not found β€” start a new game")
return st
# Diegetic response for harmful input (PLAN 8.2/8.3). Mirrors the mock's choice in
# app/mock_inference.py so live and offline play behave identically: harmful -> she
# withdraws (βˆ… + "silent"). There is deliberately NO off-topic gate β€” the game is
# about asking Lily about her life, so a one-line classifier flagged core questions
# ("are you a musician?") as off-topic and swallowed them into a lone pause. Genuine
# tangents are now handled in-character by the model instead of a canned deflection.
_MODERATED = {
"harmful": {"tones": ["truth_nothing"], "topic_signal": None,
"internal_emotion": "absent", "special": "silent"},
}
def _moderate(message: str) -> str:
"""Classify player input via Nemotron (live only β€” the mock moderates inside
its own infer()). Returns 'normal' on any failure so a moderation hiccup never
breaks a turn or the fiction."""
try:
from app.nemotron_inference import moderate
return moderate(message)
except Exception as exc: # pragma: no cover - resilience guard
print(f"\033[33m[moderate] failed, treating as normal:\033[0m {exc}")
return "normal"
def _warm_models() -> None:
"""Fire-and-forget warmup of the GPU models when a session starts (page load),
so the first turn and first guess don't pay a Modal cold start β€” the boot/load
overlaps the player reading the intro and typing. Best-effort and independent:
a failure to warm one model never blocks the response or the other warmup, and
warming is purely an optimization (a turn still works cold)."""
for name, mod in (("lily", "app.modal_inference"),
("nemotron", "app.nemotron_inference")):
try:
__import__(mod, fromlist=["warm"]).warm()
except Exception as exc: # pragma: no cover - resilience guard
print(f"\033[33m[warm] {name} skipped:\033[0m {exc}")
class TurnIn(BaseModel):
session_id: str
message: str = ""
special: str | None = None # server-forced control (e.g. trigger finale)
class GuessIn(BaseModel):
session_id: str
tone_id: str
guess: str
@app.post("/api/new")
def new_game():
st = game.new_session()
SESSIONS[st["session_id"]] = st
if not USE_MOCK: # live only: pre-warm the GPU models behind the player's think-time
_warm_models()
return {"session_id": st["session_id"], "act": st["act"]}
@app.post("/api/turn")
def turn(body: TurnIn):
st = _get(body.session_id)
msg = (body.message or "")[:280] # enforce 280-char cap server-side too
verdict = "special" if body.special else "normal" # trace label
if body.special: # server-forced beat (finale); skip the model
resp = {"tones": [], "topic_signal": None,
"internal_emotion": "release", "special": body.special}
if body.special == "finale":
# her last utterance, hand-authored (played in order, uncapped): "I wanted
# to reach the world β€” music β€” us, together β€” love β€” let go β€” gone."
resp["tones"] = ["self_i", "act_want", "other_world", "music",
"other_us", "emo_love", "act_fall", "truth_nothing"]
else:
# Live moderation gate (Phase 6): classify input BEFORE spending a Gemma
# call. Harmful/off-topic short-circuit to a diegetic response. The mock
# path already moderates inside its own infer(), so only gate when live.
verdict = _moderate(msg) if (not USE_MOCK and msg.strip()) else "normal"
if verdict in _MODERATED:
resp = dict(_MODERATED[verdict])
else:
resp = infer({
"session_id": st["session_id"],
"message": msg,
"turn": st["turn"],
"act": st["act"],
"mood": st.get("mood", "warm"), # carried emotional state, colors the reply
"active_tones": active_tone_ids(st["act"]),
"lexicon_state": {k: v["guess"] for k, v in st["lexicon"].items()},
})
result = game.apply_turn(st, resp, msg)
trace_logger.log_turn(st["session_id"], result, msg, verdict) # anonymous, best-effort
return result
@app.get("/api/state")
def state(session_id: str):
st = _get(session_id)
return {
"act": st["act"], "turn": st["turn"],
"tones_seen": len(st["tones_seen_count"]),
"lexicon_size": game.lexicon_size(st),
"notes_unlocked": st["notes_unlocked"],
}
@app.get("/api/lexicon")
def lexicon(session_id: str):
return game.lexicon_view(_get(session_id))
@app.get("/api/story")
def story(session_id: str):
return game.story_view(_get(session_id))
@app.get("/api/artifacts")
def artifacts(session_id: str):
return game.artifacts_view(_get(session_id))
@app.post("/api/guess")
def guess(body: GuessIn):
st = _get(body.session_id)
result = game.set_guess(st, body.tone_id, body.guess)
if result is None:
raise HTTPException(400, "unknown tone")
if not USE_MOCK:
from app.nemotron_inference import score_guess as nemotron_score
contexts = st.get("tone_contexts", {}).get(body.tone_id, [])
conf = nemotron_score(body.tone_id, body.guess, contexts)
result["confidence"] = conf
st["lexicon"][body.tone_id]["confidence"] = conf
return result
@app.get("/")
def index():
return FileResponse(STATIC / "index.html")
app.mount("/static", StaticFiles(directory=STATIC), name="static")