| """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") |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| _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: |
| 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: |
| print(f"\033[33m[warm] {name} skipped:\033[0m {exc}") |
|
|
|
|
| class TurnIn(BaseModel): |
| session_id: str |
| message: str = "" |
| special: str | None = None |
|
|
|
|
| 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: |
| _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] |
| verdict = "special" if body.special else "normal" |
| if body.special: |
| resp = {"tones": [], "topic_signal": None, |
| "internal_emotion": "release", "special": body.special} |
| if body.special == "finale": |
| |
| |
| resp["tones"] = ["self_i", "act_want", "other_world", "music", |
| "other_us", "emo_love", "act_fall", "truth_nothing"] |
| else: |
| |
| |
| |
| 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"), |
| "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) |
| 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") |
|
|