"""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. Prompt routing, model selection, and logging land later. """ 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 app = FastAPI(title="coc-rpg proxy", version="0.0.1") @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 @app.get("/health") def health() -> dict: """Liveness probe for compose / fly.io.""" return {"status": "ok"} # ── 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; the AI half is a # stub until prompt routing lands. @app.post("/dm/narrate") def narrate(req: TurnRequest = Depends(valid_turn)) -> dict: return {"detail": "not implemented"} @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: TurnRequest = Depends(valid_turn)) -> dict: return {"detail": "not implemented"} @app.post("/party/banter") def banter(req: TurnRequest = Depends(valid_turn)) -> dict: return {"detail": "not implemented"}