app.py (11220B)
1 """HTTP API around TheoLeeCJ/SemIf. 2 3 SemIf is used purely as a library (semif_phase1); this package contains no 4 copied scorer logic, so upstream updates apply without a rebase. 5 """ 6 7 from __future__ import annotations 8 9 import asyncio 10 import os 11 import time 12 from contextlib import asynccontextmanager 13 from pathlib import Path 14 15 import uvicorn 16 from fastapi import FastAPI, HTTPException 17 from fastapi.staticfiles import StaticFiles 18 from pydantic import BaseModel 19 from starlette.concurrency import run_in_threadpool 20 21 from semif_phase1.core import load_causal_model, validate_row 22 from .llama import BackendError, DEFAULT_MODEL, LlamaBackend 23 24 MODEL = os.environ.get("SEMIF_MODEL", "Qwen/Qwen3.5-4B") 25 REVISION = os.environ.get("SEMIF_REVISION", "851bf6e806efd8d0a36b00ddf55e13ccb7b8cd0a") 26 MAX_TOKENS = int(os.environ.get("SEMIF_MAX_TOKENS", "4096")) 27 # Note: no planning sampling knobs — plan() pins the model card's 28 # thinking-mode settings (PLAN_SAMPLING), and no max_tokens is sent since it 29 # would cap the reasoning trace too. 30 HOST = os.environ.get("SEMIF_HOST", "127.0.0.1") 31 PORT = int(os.environ.get("SEMIF_PORT", "8321")) 32 33 # The browser UI ships as package data (see pyproject package-data) and is served 34 # by the API itself, so the page is same-origin: no CORS, and it works on a GPU 35 # host with no network access. 36 WEB_DIR = Path(__file__).parent / "web" 37 38 39 class OptionIn(BaseModel): 40 id: str 41 description: str 42 43 44 class DecisionIn(BaseModel): 45 """One decision: evidence state + criterion + declared options.""" 46 47 id: str 48 state: str | dict | list 49 question: str 50 options: list[OptionIn] 51 52 53 class BatchDecisionIn(BaseModel): 54 id: str 55 question: str 56 options: list[OptionIn] 57 58 59 class BatchIn(BaseModel): 60 """Many decisions against one state; execution depends on the backend.""" 61 62 state: str | dict | list 63 decisions: list[BatchDecisionIn] 64 65 66 class ModelIn(BaseModel): 67 model: str 68 69 70 class PlanTurn(BaseModel): 71 """One transcript entry: a completed action and its observed outcome.""" 72 73 role: str 74 content: str 75 76 77 class PlanIn(BaseModel): 78 """A planning request: context note + action history for a reasoning chat. 79 80 Game-agnostic by construction: the caller composes the prompt from facts it 81 owns — the simulation's one-line goal, what triggered planning, and the 82 rules currently in effect — and the transcript of completed actions; the 83 backend runs one chat completion under a universal system prompt and 84 returns the generated rules plus the thinking trace. 85 """ 86 87 id: str 88 prompt: str 89 transcript: list[PlanTurn] = [] 90 91 92 async def _score(function, *args, **kwargs): 93 """Run one scorer call, mapping upstream input failures to 422. 94 95 Upstream raises ValueError for every input problem it detects -- token 96 budget in encode_prompt, answer-slot tokenisation, the shared-state and 97 batch-id rules in score_shared -- and those are contract violations, not 98 server faults. Without this they escape as a bare 500 whose real reason is 99 only in the log, contradicting docs/usage.md. Remote backend failures 100 become 502; unexpected local faults still propagate as 500. 101 """ 102 try: 103 return await run_in_threadpool(function, *args, **kwargs) 104 except ValueError as error: 105 raise HTTPException(status_code=422, detail=str(error)) from error 106 except BackendError as error: 107 raise HTTPException(status_code=502, detail=str(error)) from error 108 109 110 def _row(decision: DecisionIn) -> dict: 111 row = decision.model_dump() 112 try: 113 validate_row(row) 114 except ValueError as error: 115 raise HTTPException(status_code=422, detail=str(error)) from error 116 return row 117 118 119 def create_app() -> FastAPI: 120 backend_name = os.environ.get("SEMIF_BACKEND", "torch") 121 if backend_name not in {"torch", "llama"}: 122 raise ValueError("SEMIF_BACKEND must be torch or llama") 123 124 @asynccontextmanager 125 async def lifespan(app): 126 app.state.lock = asyncio.Lock() 127 app.state.llama = None 128 if backend_name == "llama": 129 app.state.llama = LlamaBackend( 130 os.environ.get("SEMIF_LLAMA_URL", "http://127.0.0.1:8080"), 131 os.environ.get("SEMIF_LLAMA_MODEL", DEFAULT_MODEL), 132 timeout=float(os.environ.get("SEMIF_LLAMA_TIMEOUT", "600")), 133 n_probs=int(os.environ.get("SEMIF_LLAMA_N_PROBS", "1024")), 134 max_n_probs=int(os.environ.get("SEMIF_LLAMA_MAX_N_PROBS", "16384")), 135 max_tokens=MAX_TOKENS, 136 cache_prompt=os.environ.get("SEMIF_LLAMA_CACHE_PROMPT", "true").lower() == "true", 137 ) 138 app.state.metadata = app.state.llama.metadata 139 # The server loads the selected GGUF lazily; the first score confirms it. 140 app.state.model_state = "pending" 141 else: 142 # No torch model or scorer initialization on the llama path. 143 from semif_phase1.direct import score as direct_score 144 from semif_phase1.shared import score_shared 145 app.state.direct_score = direct_score 146 app.state.shared_score = score_shared 147 model, tokenizer, metadata = await run_in_threadpool(load_causal_model, MODEL, REVISION) 148 app.state.model, app.state.tokenizer, app.state.metadata = model, tokenizer, metadata 149 app.state.model_state = "loaded" 150 yield 151 152 app = FastAPI(title="semif-api", version="0.1.0", lifespan=lifespan) 153 app.mount("/ui", StaticFiles(directory=WEB_DIR, html=True), name="ui") 154 155 @app.get("/healthz") 156 def healthz() -> dict: 157 # Liveness, not a remote readiness check: never load a GGUF on polling. 158 return {"status": "ok", "backend": backend_name, "model": app.state.metadata, 159 "max_tokens": MAX_TOKENS, 160 "backend_status": "not_checked" if backend_name == "llama" else "loaded", 161 "model_state": app.state.model_state} 162 163 @app.get("/models") 164 async def models() -> dict: 165 """Served models + the runtime selection. `switching` is false on torch.""" 166 if app.state.llama is None: 167 return {"switching": False, "current": app.state.metadata["source"], 168 "model_state": app.state.model_state, "models": []} 169 try: 170 available = await run_in_threadpool(app.state.llama.list_models) 171 except BackendError as error: 172 raise HTTPException(status_code=502, detail=str(error)) from error 173 return {"switching": True, "current": app.state.llama.model, 174 "model_state": app.state.model_state, "models": available} 175 176 @app.post("/models") 177 async def select_model(selection: ModelIn) -> dict: 178 """Switch the llama backend's model: unload current, lazy-load on first request. 179 180 Serialized on the same lock as scoring, so a switch never interleaves 181 with a score that still needs the old weights. Runtime state only: 182 nothing here changes the process environment or the llama-server config. 183 """ 184 if app.state.llama is None: 185 raise HTTPException(status_code=422, 186 detail="model selection is only available on the llama backend") 187 async with app.state.lock: 188 try: 189 result = await run_in_threadpool(app.state.llama.switch, selection.model) 190 except ValueError as error: 191 raise HTTPException(status_code=422, detail=str(error)) from error 192 except BackendError as error: 193 raise HTTPException(status_code=502, detail=str(error)) from error 194 if not result["unchanged"]: 195 app.state.model_state = "pending" 196 app.state.metadata = app.state.llama.metadata 197 return {"current": app.state.llama.model, "model_state": app.state.model_state, 198 "warning": result["warning"], 199 "status": "selected " + result["selected"] + ( 200 " (no change)" if result["unchanged"] 201 else "; loads on first request")} 202 203 @app.post("/decide") 204 async def decide(decision: DecisionIn) -> dict: 205 row = _row(decision) 206 async with app.state.lock: 207 if app.state.llama is not None: 208 result = await _score(app.state.llama.score, row) 209 app.state.model_state = "loaded" 210 return result 211 return await _score( 212 app.state.direct_score, app.state.model, app.state.tokenizer, row, app.state.metadata, MAX_TOKENS 213 ) 214 215 @app.post("/plan") 216 async def plan(request: PlanIn) -> dict: 217 """Generate environment rules from an action transcript; llama backend only. 218 219 Torch has no text-generation path (it exists purely for logit readout), 220 so planning is refused there rather than silently degraded. 221 """ 222 if app.state.llama is None: 223 raise HTTPException( 224 status_code=422, 225 detail="/plan requires the llama backend (SEMIF_BACKEND=llama); " 226 "the torch backend only scores declared options") 227 async with app.state.lock: 228 result = await _score( 229 app.state.llama.plan, request.id, request.prompt, 230 [turn.model_dump() for turn in request.transcript], 231 ) 232 app.state.model_state = "loaded" 233 return result 234 235 @app.post("/decide-batch") 236 async def decide_batch(batch: BatchIn) -> dict: 237 rows = [ 238 { 239 "id": decision.id, 240 "state": batch.state, 241 "question": decision.question, 242 "options": [option.model_dump() for option in decision.options], 243 } 244 for decision in batch.decisions 245 ] 246 if not rows: 247 raise HTTPException(status_code=422, detail="Shared scoring requires one nonempty exact state") 248 if len({row["id"] for row in rows}) != len(rows): 249 raise HTTPException(status_code=422, detail="Decision IDs must be unique") 250 for row in rows: 251 try: 252 validate_row(row) 253 except ValueError as error: 254 raise HTTPException(status_code=422, detail=str(error)) from error 255 async with app.state.lock: 256 if app.state.llama is not None: 257 started = time.perf_counter() 258 results = [] 259 for row in rows: 260 results.append(await _score(app.state.llama.score, row)) 261 app.state.model_state = "loaded" 262 timing = {"total_seconds": time.perf_counter() - started, 263 "batch_size": len(rows), "mode": "llama-sequential"} 264 else: 265 results, timing = await _score( 266 app.state.shared_score, app.state.model, app.state.tokenizer, 267 rows, app.state.metadata, MAX_TOKENS 268 ) 269 return { 270 "results": [{**result, "shared_timing": timing} for result in results], 271 "timing": timing, 272 } 273 274 return app 275 276 277 app = create_app() 278 279 280 def run() -> None: 281 uvicorn.run(app, host=HOST, port=PORT)