nation_optimizer / server /visualizer_server.py
Kr0issant
(visualizer) implement the whole thing
f2e28ca
Raw
History Blame Contribute Delete
8.69 kB
"""FastAPI app that streams live LLM episodes to the React visualizer.
This is intentionally a *separate* FastAPI app from
:mod:`server.app` (which mounts the OpenEnv environment for RL training and
remote rollout clients). It exposes a small, browser-friendly REST + SSE
surface so the React dashboard at ``visualizer/`` can:
- List the rule-based and LLM-backed inference modes.
- Start a new live run with a chosen seed and policy.
- Stream per-round JSON records over Server-Sent Events while the run is
still in progress.
- Re-fetch the full episode-so-far when the page is refreshed mid-run.
Run it locally with::
uvicorn server.visualizer_server:app --host 0.0.0.0 --port 8001
Or via the convenience wrapper::
python -m scripts.run_visualizer_server
"""
from __future__ import annotations
import json
import logging
import os
import time
from typing import Any, Optional
from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from server.live_runner import (
LiveRun,
LiveRunManager,
build_config_dict,
)
load_dotenv()
LOG = logging.getLogger(__name__)
# ── Mode metadata exposed to the UI ─────────────────────────────────────────
_RULE_BASED_MODES = (
{
"id": "equal_split",
"label": "Equal split",
"description": "Each minister proposes treasury / 6 every round.",
"requires_credentials": False,
},
{
"id": "optimal_zone",
"label": "Optimal zone (1.3Γ— baseline)",
"description": "Targets the profit zone above demand without entering wastage.",
"requires_credentials": False,
},
{
"id": "conservative",
"label": "Conservative (baseline)",
"description": "Each minister proposes its own department baseline.",
"requires_credentials": False,
},
{
"id": "greedy",
"label": "Greedy",
"description": "Greedy proposer baseline; useful for adversarial demos.",
"requires_credentials": False,
},
)
def _llm_mode_entry() -> dict[str, Any]:
return {
"id": "llm",
"label": "LLM (Hugging Face)",
"description": "Drives every minister with the configured HF model.",
"requires_credentials": True,
"default_model_id": os.environ.get("HF_MODEL_ID"),
"credentials_present": bool(os.environ.get("HF_TOKEN"))
and bool(os.environ.get("HF_MODEL_ID")),
}
# ── Request/response models ─────────────────────────────────────────────────
class StartRunBody(BaseModel):
mode: str = Field(
description="One of the ids returned by GET /api/modes."
)
model_id: Optional[str] = Field(default=None, description="HF model id (LLM mode).")
seed: int = Field(default=42, ge=0)
max_rounds: int = Field(default=20, ge=1, le=50)
temperature: float = Field(default=0.2, ge=0.0, le=2.0)
class StartRunResponse(BaseModel):
run_id: str
mode: str
policy: str
seed: int
max_rounds: int
config: dict[str, Any]
# ── App + manager ───────────────────────────────────────────────────────────
app = FastAPI(
title="Nation Optimizer Β· Visualizer",
description="Streams live LLM-driven parliamentary episodes to the React dashboard.",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
manager = LiveRunManager()
# ── Routes ──────────────────────────────────────────────────────────────────
@app.get("/api/health")
def health() -> dict[str, Any]:
return {"ok": True, "ts": time.time()}
@app.get("/api/config")
def config() -> dict[str, Any]:
"""Static game config (sectors, baselines, etc.) for the empty-state UI."""
return build_config_dict()
@app.get("/api/modes")
def modes() -> dict[str, Any]:
return {
"modes": [_llm_mode_entry(), *_RULE_BASED_MODES],
}
@app.get("/api/runs")
def list_runs() -> dict[str, Any]:
return {"runs": manager.list_runs()}
@app.post("/api/runs", response_model=StartRunResponse)
def start_run(body: StartRunBody) -> StartRunResponse:
if body.mode == "llm":
token = os.environ.get("HF_TOKEN")
if not token:
raise HTTPException(
status_code=400,
detail=(
"LLM mode requires HF_TOKEN in the server environment. "
"Either set it in .env or pick a rule-based mode."
),
)
model_id = body.model_id or os.environ.get("HF_MODEL_ID")
if not model_id:
raise HTTPException(
status_code=400,
detail=(
"LLM mode requires model_id (or HF_MODEL_ID env var)."
),
)
else:
token = None
model_id = body.model_id
try:
run = manager.create(
mode=body.mode,
model_id=model_id,
seed=body.seed,
max_rounds=body.max_rounds,
temperature=body.temperature,
token=token,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return StartRunResponse(
run_id=run.run_id,
mode=run.mode,
policy=run.policy,
seed=run.seed,
max_rounds=run.max_rounds,
config=run.config,
)
@app.get("/api/runs/{run_id}/snapshot")
def snapshot(run_id: str) -> dict[str, Any]:
run = manager.get(run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Unknown run_id {run_id!r}.")
return run.snapshot()
@app.get("/api/runs/{run_id}/stream")
async def stream(run_id: str, request: Request) -> StreamingResponse:
"""SSE stream of ``start | round | summary | error | done`` events."""
run = manager.get(run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Unknown run_id {run_id!r}.")
return StreamingResponse(
_sse_iterator(run, request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
# ── SSE plumbing ────────────────────────────────────────────────────────────
async def _sse_iterator(run: LiveRun, request: Request):
"""Yield Server-Sent Events for one client subscriber.
A heartbeat comment (``:keep-alive``) is sent every ~15s so proxies don't
cut the connection during long LLM generations.
"""
import asyncio
queue = run.subscribe()
try:
last_heartbeat = time.time()
while True:
if await request.is_disconnected():
break
event = await asyncio.get_event_loop().run_in_executor(
None, _drain, queue, 1.0
)
if event is not None:
yield _format_sse(event)
if event.get("type") == "done":
break
continue
now = time.time()
if now - last_heartbeat > 15:
yield ": keep-alive\n\n"
last_heartbeat = now
finally:
run.unsubscribe(queue)
def _drain(queue: Any, timeout: float) -> Optional[dict[str, Any]]:
from queue import Empty
try:
return queue.get(timeout=timeout)
except Empty:
return None
def _format_sse(event: dict[str, Any]) -> str:
"""Format a dict as a Server-Sent Event with an explicit ``event:`` name."""
event_type = event.get("type", "message")
payload = event.get("data", {})
body = json.dumps(payload, default=_json_default)
return f"event: {event_type}\ndata: {body}\n\n"
def _json_default(value: Any) -> Any:
if hasattr(value, "to_dict") and callable(value.to_dict):
return value.to_dict()
if hasattr(value, "value"):
return value.value
return str(value)
__all__ = ["app", "manager"]