Files
code_of_conquest_dnd/api/app/main.py
Phillip Tarrant f7c51b79da fix(api): best-effort call logging + non-JSON 200 → ModelError (final-review)
- call_log._default_write: swallow any exception from the default file/
  stdout sink and report to stderr, so a log-write failure (disk full,
  bad permissions, misconfigured CALL_LOG_PATH) never turns a successful
  narration into a 500 (charter §13). Injected write= sinks (used by
  tests) are left to surface their own errors.
- ollama_client.chat: catch ValueError alongside httpx.HTTPError in the
  retry loop so a 200 response with a non-JSON body (JSONDecodeError is
  a ValueError subclass) counts as a failed attempt and falls through to
  ModelError after the one retry, instead of escaping chat() uncaught
  (charter §12 — one retry, then ModelError, nothing else escapes).
- main.py: update stale docstrings — /dm/narrate is now fully wired
  (routing + model call + logging); the other four roles remain stubs.

Regression tests added for both fixes (TDD: watched RED, then GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 08:45:39 -05:00

84 lines
3.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 .ollama_client import ModelError
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. /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: 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"}