File size: 4,964 Bytes
66ee87e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d8c255d
66ee87e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
"""DecisionLab: every LightDec (FalconDec) and Arthur model found in the models folder, plus Laya, side by side. Serves the web UI and a small JSON API on port 9910."""
from __future__ import annotations

import os
from contextlib import asynccontextmanager
import platform
import threading
import time
from pathlib import Path
from typing import Any

import torch
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

from .assets import stamp_assets
from .demos import DEMOS, GROUPS
from .models import BACKENDS, gpu_label, load_all
from .security import Gate, SecurityMiddleware
from .validation import validate_models, validate_questions

STATIC = Path(__file__).parent / "static"
MAX_OPTIONS = int(os.getenv("MAX_OPTIONS", "40"))
MAX_BODY_BYTES = int(os.getenv("MAX_BODY_BYTES", str(256 * 1024)))
DECIDE_SLOTS = Gate(int(os.getenv("MAX_PENDING_DECIDES", "4")))      # DL-SA-004

# DL-SA-007: no /docs, /redoc or /openapi.json
VERSION = "2.3.1"
PAGE = stamp_assets((STATIC / "index.html").read_text(encoding="utf-8"), VERSION)


@asynccontextmanager
async def lifespan(_app):
    """Start loading every model in the background when the server starts (replaces the deprecated on_event)."""
    threading.Thread(target=lambda: load_all(), name="model-loader", daemon=True).start()
    yield


app = FastAPI(title="DecisionLab", version=VERSION, docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan)
app.mount("/static", StaticFiles(directory=STATIC), name="static")
# Body limit and security headers on the lab's own routes (no token auth: operator ruling 2026-09-28)
app.add_middleware(SecurityMiddleware, max_body=MAX_BODY_BYTES)


@app.get("/", include_in_schema=False)
def index() -> HTMLResponse:
    # Asset URLs carry the release, so browsers never reuse app.js / app.css from an older release.
    return HTMLResponse(PAGE)


@app.get("/api/health")
def health() -> dict:
    return {"ok": True}


@app.get("/api/status")
def status() -> dict:
    gpu = gpu_label()
    return {
        "models": {k: b.describe() for k, b in BACKENDS.items()},
        "order": list(BACKENDS),
        "env": {"torch": torch.__version__, "python": platform.python_version(), "gpu": gpu,
                "threads": torch.get_num_threads(),
                "models_dir": os.getenv("MODELS_DIR") or "/models"},
    }


@app.get("/api/demos")
def demos(group: str | None = None) -> list[dict]:
    return [d for d in DEMOS if d["group"] == group] if group else DEMOS


@app.get("/api/groups")
def groups() -> list[dict]:
    return [dict(g, demos=sum(d["group"] == g["id"] for d in DEMOS),
                 questions=sum(len(d["questions"]) for d in DEMOS if d["group"] == g["id"])) for g in GROUPS]


@app.post("/api/reload/{key}")
def reload(key: str) -> dict:
    b = BACKENDS.get(key)
    if not b:
        raise HTTPException(404, f"unknown model '{key}'")
    b.start_load()                    # DL-SA-005: a second request while loading starts nothing
    return {"status": "loading"}


class DecideRequest(BaseModel):
    state: Any
    questions: dict[str, dict]
    models: list[str] | None = None   # every model when omitted


class Busy(Exception):
    """Too many decisions are already running."""


def run_decision(state, questions: dict, models: list[str] | None = None) -> dict:
    """One decision, run on the requested models (every model if None). Shared by /api/decide and the Gradio app.
    Raises ValueError for an invalid request and Busy when the server is at its limit."""
    validate_questions(questions, MAX_OPTIONS)
    keys = validate_models(models, list(BACKENDS))                        # DL-SA-008
    if not DECIDE_SLOTS.try_enter():
        raise Busy("DecisionLab is busy with other requests. Try again in a moment.")
    try:
        return RUN_MODELS(state, questions, keys)
    finally:
        DECIDE_SLOTS.leave()


def run_models(state, questions: dict, keys: list[str]) -> dict:
    """Run the models one after another (they never compete for the device); each model times itself."""
    out, t0 = {}, time.perf_counter()
    for key in keys:
        try:
            out[key] = BACKENDS[key].decide(state, questions)
        except Exception as exc:
            out[key] = {"error": f"{type(exc).__name__}: {exc}"[:500]}
    return {"results": out, "server_ms": round((time.perf_counter() - t0) * 1000, 1)}


# The model-running step. The Hugging Face Space replaces it with a @spaces.GPU version (ZeroGPU); the checks and the
# concurrency limit above stay in the main process.
RUN_MODELS = run_models


@app.post("/api/decide")
def decide(req: DecideRequest) -> dict:
    try:
        return run_decision(req.state, req.questions, req.models)
    except ValueError as exc:
        raise HTTPException(422, str(exc)) from None
    except Busy as exc:
        raise HTTPException(429, str(exc)) from None