"""FastAPI proxy entrypoint — the guarding proxy of charter §4. Skeleton: a health check plus the five role endpoints. Each role endpoint now validates the posted canon log against the contract (charter §11) before doing anything else — an invalid log is rejected with 422 and never reaches a prompt. `/dm/narrate` is fully wired: prompt routing, the Ollama model call, and call-log logging. The other four roles remain stubs until they get the same treatment. """ from fastapi import Depends, FastAPI, HTTPException from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from pydantic import BaseModel from .canon_log import validate_canon_log from .narrate import run as narrate_run from .npc import UnknownNpc, run as npc_run from .ollama_client import ModelError from .version import VERSION app = FastAPI(title="coc-rpg proxy", version=VERSION) @app.exception_handler(RequestValidationError) async def unify_validation_errors(request, exc: RequestValidationError) -> JSONResponse: """Reshape pydantic body-validation failures into the same envelope as a canon-log schema failure, so the client parses ONE 422 shape (charter §11): {"detail": {"canon_log_errors": [...]}}. """ errors = [f"{'.'.join(str(loc) for loc in e['loc'])}: {e['msg']}" for e in exc.errors()] return JSONResponse(status_code=422, content={"detail": {"canon_log_errors": errors}}) class TurnRequest(BaseModel): canon_log: dict def valid_turn(req: TurnRequest) -> TurnRequest: """Shared dependency: reject any request whose canon log breaks the contract.""" errors = validate_canon_log(req.canon_log) if errors: raise HTTPException(status_code=422, detail={"canon_log_errors": errors}) return req class NpcSpeakRequest(BaseModel): canon_log: dict npc_id: str disposition: int available_moves: list[str] utterance: str def valid_npc_turn(req: NpcSpeakRequest) -> NpcSpeakRequest: """Reject any /npc/speak request whose canon log breaks the contract.""" errors = validate_canon_log(req.canon_log) if errors: raise HTTPException(status_code=422, detail={"canon_log_errors": errors}) return req @app.get("/health") def health() -> dict: """Liveness probe for compose / fly.io.""" return {"status": "ok"} @app.get("/version") def version() -> dict: return {"version": VERSION} # ── Role endpoints (charter §4) ────────────────────────────────────────────── # The client knows these paths and nothing about which model or prompt serves # them. Bodies are validated against the canon log contract. /dm/narrate is # fully wired (routing + model call + logging); the other four roles remain # stubs until prompt routing, model selection, and logging land for them too. @app.post("/dm/narrate") def narrate(req: TurnRequest = Depends(valid_turn)) -> dict: try: return {"prose": narrate_run(req.canon_log)} except ModelError as exc: raise HTTPException(status_code=502, detail={"model_error": str(exc)}) @app.post("/dm/adjudicate") def adjudicate(req: TurnRequest = Depends(valid_turn)) -> dict: return {"detail": "not implemented"} @app.post("/dm/improvise") def improvise(req: TurnRequest = Depends(valid_turn)) -> dict: return {"detail": "not implemented"} @app.post("/npc/speak") def npc_speak(req: NpcSpeakRequest = Depends(valid_npc_turn)) -> dict: try: return {"prose": npc_run( canon_log=req.canon_log, npc_id=req.npc_id, disposition=req.disposition, available_moves=req.available_moves, utterance=req.utterance, )} except UnknownNpc as exc: raise HTTPException( status_code=422, detail={"canon_log_errors": [f"unknown npc_id: {exc}"]}) except ModelError as exc: raise HTTPException(status_code=502, detail={"model_error": str(exc)}) @app.post("/party/banter") def banter(req: TurnRequest = Depends(valid_turn)) -> dict: return {"detail": "not implemented"}