Files
code_of_conquest_dnd/api/app/main.py

117 lines
4.0 KiB
Python

"""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"}