From 0c3d64d06860c411963437f1f4a2382fdbdcb128 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 12:22:07 -0500 Subject: [PATCH] feat(api): wire POST /npc/speak (422 unknown npc, 502 model error) --- api/app/main.py | 31 ++++++++++++++++-- api/tests/test_endpoints.py | 20 ++++++++++-- api/tests/test_npc_endpoint.py | 59 ++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 api/tests/test_npc_endpoint.py diff --git a/api/app/main.py b/api/app/main.py index 5ac3ffa..9503394 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -15,6 +15,7 @@ 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 app = FastAPI(title="coc-rpg proxy", version="0.0.1") @@ -42,6 +43,22 @@ def valid_turn(req: TurnRequest) -> TurnRequest: 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.""" @@ -74,8 +91,18 @@ def improvise(req: TurnRequest = Depends(valid_turn)) -> dict: @app.post("/npc/speak") -def npc_speak(req: TurnRequest = Depends(valid_turn)) -> dict: - return {"detail": "not implemented"} +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") diff --git a/api/tests/test_endpoints.py b/api/tests/test_endpoints.py index b3f6d70..0fa84a0 100644 --- a/api/tests/test_endpoints.py +++ b/api/tests/test_endpoints.py @@ -31,7 +31,15 @@ def test_health_ok(): def test_every_role_endpoint_accepts_a_valid_canon_log(): for path in ROLE_ENDPOINTS: - r = client.post(path, json={"canon_log": VALID_LOG}) + if path == "/npc/speak": + # /npc/speak has additional required fields + body = { + "canon_log": VALID_LOG, "npc_id": "brannoc_thane", + "disposition": 0, "available_moves": [], "utterance": "test" + } + else: + body = {"canon_log": VALID_LOG} + r = client.post(path, json=body) assert r.status_code == 200, f"{path} rejected a valid log: {r.text}" @@ -39,7 +47,15 @@ def test_every_role_endpoint_rejects_an_invalid_canon_log(): bad = json.loads(json.dumps(VALID_LOG)) bad["player"]["luck"] = 5 # §7 leak for path in ROLE_ENDPOINTS: - r = client.post(path, json={"canon_log": bad}) + if path == "/npc/speak": + # /npc/speak has additional required fields + body = { + "canon_log": bad, "npc_id": "brannoc_thane", + "disposition": 0, "available_moves": [], "utterance": "test" + } + else: + body = {"canon_log": bad} + r = client.post(path, json=body) assert r.status_code == 422, f"{path} accepted an invalid log" assert "canon_log_errors" in r.json()["detail"] diff --git a/api/tests/test_npc_endpoint.py b/api/tests/test_npc_endpoint.py new file mode 100644 index 0000000..791988d --- /dev/null +++ b/api/tests/test_npc_endpoint.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path + +from fastapi.testclient import TestClient + +import app.main as main +import app.npc as npc + +client = TestClient(main.app) +VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text()) + + +def _body(**over): + b = {"canon_log": VALID, "npc_id": "fenn", "disposition": 0, + "available_moves": ["refuse"], "utterance": "hello"} + b.update(over) + return b + + +def test_valid_request_returns_prose(monkeypatch): + monkeypatch.setattr(main, "npc_run", lambda **k: "Aye, well met.") + r = client.post("/npc/speak", json=_body()) + assert r.status_code == 200 + assert r.json() == {"prose": "Aye, well met."} + + +def test_unknown_npc_maps_to_422(monkeypatch): + def boom(**k): + raise npc.UnknownNpc("nobody") + monkeypatch.setattr(main, "npc_run", boom) + r = client.post("/npc/speak", json=_body(npc_id="nobody")) + assert r.status_code == 422 + assert "canon_log_errors" in r.json()["detail"] + + +def test_model_error_maps_to_502(monkeypatch): + from app.ollama_client import ModelError + + def boom(**k): + raise ModelError("down") + monkeypatch.setattr(main, "npc_run", boom) + r = client.post("/npc/speak", json=_body()) + assert r.status_code == 502 + assert "model_error" in r.json()["detail"] + + +def test_invalid_log_still_422(): + bad = json.loads(json.dumps(VALID)) + bad["player"]["luck"] = 5 # §7 leak — schema rejects + r = client.post("/npc/speak", json=_body(canon_log=bad)) + assert r.status_code == 422 + assert "canon_log_errors" in r.json()["detail"] + + +def test_missing_field_is_422(): + b = _body() + del b["utterance"] + r = client.post("/npc/speak", json=b) + assert r.status_code == 422