Spaces:
Running
Running
| """ | |
| PARRY — compliant Gradio hosting layer (C8). 100% LOCAL inference by design. | |
| Primary posture (per the verified research): `gradio.Server` (Gradio 6.x) on | |
| `sdk: gradio` — a FastAPI subclass running Gradio's engine, with our custom | |
| canvas served at GET / (custom routes take priority over the default UI; this | |
| is the official `ysharma/text-behind-image` pattern, and the custom-UI award | |
| text says "gr.Server is your friend"). | |
| NO external inference calls: the model runs in the visitor's browser via | |
| WebGPU. (Organizer guidance, June 10: external API fallbacks are hard to | |
| review — "the video is the fallback". The earlier Modal relay was removed.) | |
| Defensive: if the pinned Gradio somehow lacks `Server`, we degrade to | |
| FastAPI + gr.mount_gradio_app so the Space still boots while we consult the | |
| hedge ladder (docker swap / gr.Blocks+gr.HTML — see ../space/app_blocks.py). | |
| Endpoints | |
| GET / → static/index.html (the game) | |
| GET /static/* → built Vite bundle (immutable assets) | |
| api "trace_digest" → validates + summarizes an exported BrainTrace (Gradio queue; gradio_client-callable) | |
| api "about" → build/model/grammar info via the queue | |
| POST /funnel → enum-validated JSONL beacons (+ optional CommitScheduler → private HF Dataset) | |
| GET /healthz → {ok, version, grammar_id} | |
| Secrets (Space settings): HF_TOKEN (funnel dataset, optional), | |
| FUNNEL_DATASET (e.g. "user/parry-funnel", optional). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import threading | |
| import time | |
| from pathlib import Path | |
| import gradio as gr | |
| from fastapi import Request | |
| from fastapi.responses import FileResponse, JSONResponse | |
| from fastapi.staticfiles import StaticFiles | |
| HERE = Path(__file__).parent | |
| STATIC = HERE / "static" | |
| DATA = HERE / "data" | |
| DATA.mkdir(exist_ok=True) | |
| FUNNEL_PATH = DATA / "funnel.jsonl" | |
| APP_VERSION = "parry-space-v1" | |
| GRAMMAR_ID = os.environ.get("GRAMMAR_ID", "unset") # injected by CI from grammar/hash | |
| FUNNEL_EVENTS = { | |
| "page_load", | |
| "webgpu_detected", | |
| "webgpu_missing", | |
| "tier_selected", | |
| "model_download_started", | |
| "model_download_complete", | |
| "first_playable", | |
| "match_started", | |
| "match_completed", | |
| "fell_back_to_server", | |
| "bounced_during_download", | |
| "debug_override_used", | |
| "trace_exported", | |
| "boss_reflexes_on", | |
| "pose_mode_on", | |
| } | |
| _funnel_lock = threading.Lock() | |
| # Optional: persist funnel JSONL to a private HF Dataset (ephemeral disk survival). | |
| _scheduler = None | |
| if os.environ.get("HF_TOKEN") and os.environ.get("FUNNEL_DATASET"): | |
| try: | |
| from huggingface_hub import CommitScheduler | |
| _scheduler = CommitScheduler( | |
| repo_id=os.environ["FUNNEL_DATASET"], | |
| repo_type="dataset", | |
| folder_path=str(DATA), | |
| every=5, # minutes | |
| private=True, | |
| ) | |
| except Exception as e: # noqa: BLE001 — funnel persistence is best-effort | |
| print(f"[funnel] CommitScheduler unavailable: {e}") | |
| def _append_funnel(row: dict) -> None: | |
| with _funnel_lock: | |
| with open(FUNNEL_PATH, "a", encoding="utf-8") as f: | |
| f.write(json.dumps(row, separators=(",", ":")) + "\n") | |
| print(f"[funnel] {row.get('event')}") | |
| def _about() -> dict: | |
| return { | |
| "app": "parry", | |
| "version": APP_VERSION, | |
| "grammar_id": GRAMMAR_ID, | |
| "hero_model": "Qwen2.5-1.5B-Instruct (q4f16_1, in-browser WebGPU)", | |
| "fallback": "none — inference is 100% in-browser by design", | |
| "tuned_model": "Jainamshahhh/parry-tactician-1.5b-lora (published fine-tune)", | |
| "thesis": "a sub-100ms reaction loop no network round-trip can serve", | |
| "artifacts": { | |
| "fine_tune_merged": "https://huggingface.co/Jainamshahhh/parry-tactician-1.5b-merged", | |
| "fine_tune_lora": "https://huggingface.co/Jainamshahhh/parry-tactician-1.5b-lora", | |
| "gguf_llama_cpp": "https://huggingface.co/Jainamshahhh/parry-tactician-1.5b-gguf", | |
| "field_notes": "https://huggingface.co/datasets/Jainamshahhh/parry-field-notes", | |
| "agent_traces": "https://huggingface.co/datasets/Jainamshahhh/parry-traces", | |
| "badge_evidence": "see README.md / BADGES.md in this Space repo", | |
| }, | |
| } | |
| def _trace_digest(trace_json: str) -> dict: | |
| """Pure-local BrainTrace summarizer (no model, no external calls): validates | |
| an exported trace from the judge panel and returns its headline stats — | |
| companion utility for the shared-trace (Sharing-is-Caring) flow.""" | |
| try: | |
| rows = json.loads(trace_json) | |
| assert isinstance(rows, list) and rows, "trace must be a non-empty JSON array" | |
| except Exception as e: # noqa: BLE001 | |
| return {"valid": False, "error": str(e)[:200]} | |
| intents: dict[str, int] = {} | |
| plans: list[str] = [] | |
| for r in rows: | |
| if isinstance(r, dict): | |
| i = r.get("intent") | |
| if isinstance(i, str): | |
| intents[i] = intents.get(i, 0) + 1 | |
| p = r.get("planString") | |
| if isinstance(p, str) and (not plans or plans[-1] != p): | |
| plans.append(p) | |
| return { | |
| "valid": True, | |
| "ticks": len(rows), | |
| "intent_histogram": intents, | |
| "distinct_plans": len(plans), | |
| "plan_timeline": plans[:12], | |
| "grammar_id": GRAMMAR_ID, | |
| } | |
| HAS_SERVER = hasattr(gr, "Server") | |
| if HAS_SERVER: | |
| app = gr.Server() | |
| else: # degrade gracefully; primary fix is pinning sdk_version per README | |
| print("[parry] WARNING: gradio.Server missing — booting FastAPI + mounted Blocks hedge") | |
| from fastapi import FastAPI | |
| app = FastAPI() | |
| app.mount("/static", StaticFiles(directory=str(STATIC)), name="static") | |
| # index.html is served at "/" with relative asset URLs → they resolve to /assets/* | |
| app.mount("/assets", StaticFiles(directory=str(STATIC / "assets")), name="assets") | |
| # pose-mode model + WASM are self-hosted under /pose (the worker fetches them | |
| # by absolute origin URL — an unmounted path returns HTML 404s that die as | |
| # "parse error" in TFJS, the same class of bug as the /assets mount above) | |
| app.mount("/pose", StaticFiles(directory=str(STATIC / "pose")), name="pose") | |
| async def homepage() -> FileResponse: | |
| # no-cache on the HTML only; hashed assets under /static are long-cached | |
| return FileResponse(STATIC / "index.html", headers={"Cache-Control": "no-cache"}) | |
| async def funnel(req: Request) -> JSONResponse: | |
| try: | |
| row = await req.json() | |
| except Exception: # noqa: BLE001 — sendBeacon may post opaque bodies | |
| return JSONResponse({"ok": False}, status_code=400) | |
| if row.get("event") not in FUNNEL_EVENTS: | |
| return JSONResponse({"ok": False, "error": "unknown event"}, status_code=400) | |
| _append_funnel({k: row.get(k) for k in ("v", "event", "session", "ts", "props")}) | |
| return JSONResponse({"ok": True}, status_code=200) | |
| async def healthz() -> JSONResponse: | |
| return JSONResponse({"ok": True, "version": APP_VERSION, "grammar_id": GRAMMAR_ID, "inference": "in-browser"}) | |
| if HAS_SERVER: | |
| # Gradio-queue endpoints: Gradio's engine genuinely doing work (queue, SSE), | |
| # callable via gradio_client. Both are pure-local — no model, no external calls. | |
| def trace_digest_api(trace_json: str) -> dict: | |
| return _trace_digest(trace_json) | |
| def about_api() -> dict: | |
| return _about() | |
| else: | |
| # Hedge boot: mount a minimal Blocks app so Gradio is still in the loop. | |
| with gr.Blocks() as demo: | |
| gr.Markdown("# Parry backend (hedge boot)") | |
| inp = gr.Textbox(label="exported BrainTrace JSON") | |
| out = gr.JSON(label="trace digest") | |
| gr.Button("digest").click(_trace_digest, inp, out) | |
| app = gr.mount_gradio_app(app, demo, path="/gradio") | |
| if __name__ == "__main__": | |
| if HAS_SERVER: | |
| app.launch(show_error=True) | |
| else: | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 7860))) | |