From 004cc21724d9d40dbb8fa45d8d4dbf8b3261e574 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 12:18:01 -0500 Subject: [PATCH] =?UTF-8?q?feat(api):=20npc.run=20service=20=E2=80=94=20th?= =?UTF-8?q?in=20drop-in=20on=20the=20model=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app/npc.py | 59 +++++++++++++++++++++++++++++++++++++++++++ api/tests/test_npc.py | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 api/app/npc.py create mode 100644 api/tests/test_npc.py diff --git a/api/app/npc.py b/api/app/npc.py new file mode 100644 index 0000000..7f2499d --- /dev/null +++ b/api/app/npc.py @@ -0,0 +1,59 @@ +"""The NPC role service (charter §5/§6). Loads the NPC's server-only persona + +knowledge, renders the digest, routes the model, calls Ollama with one retry, +logs the call (§10), and returns raw prose — move/fact tags left intact for the +client to extract, validate, and apply (§2/§6). Mirrors narrate.py. +""" + +import random +from time import perf_counter + +from . import call_log, ollama_client, prompts, routing +from .content import load_npc + + +class UnknownNpc(Exception): + """No authored content resolves for the requested npc_id.""" + + +def run( + canon_log: dict, + npc_id: str, + disposition: int, + available_moves: list[str], + utterance: str, +) -> str: + npc = load_npc(npc_id) + if npc is None: + raise UnknownNpc(npc_id) + + cfg = routing.for_role("npc") + options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)} + messages = [ + {"role": "system", "content": prompts.system_prompt("npc")}, + {"role": "user", "content": prompts.render_npc_digest( + canon_log, + persona=npc.get("persona", ""), + knowledge=npc.get("knowledge", []), + disposition=disposition, + available_moves=available_moves, + utterance=utterance, + )}, + ] + + start = perf_counter() + try: + text = ollama_client.chat(cfg.model, messages, options, think=cfg.think) + except ollama_client.ModelError as exc: + call_log.record( + role="npc", model=cfg.model, options=options, messages=messages, + canon_log=canon_log, ok=False, + latency_ms=int((perf_counter() - start) * 1000), error=str(exc), + ) + raise + + call_log.record( + role="npc", model=cfg.model, options=options, messages=messages, + canon_log=canon_log, ok=True, + latency_ms=int((perf_counter() - start) * 1000), response=text, + ) + return text diff --git a/api/tests/test_npc.py b/api/tests/test_npc.py new file mode 100644 index 0000000..c4d4893 --- /dev/null +++ b/api/tests/test_npc.py @@ -0,0 +1,55 @@ +import pytest + +import app.npc as npc +from app.ollama_client import ModelError + +LOG = { + "player": {"name": "Aldric", "class_id": "sellsword", "luck_descriptor": "kind"}, + "location": {"id": "greywater_docks", "name": "Greywater Docks"}, + "party": [], "recent_events": [], "established_facts": [], + "active_quests": [], "humiliations": [], +} +FENN = {"id": "fenn", "name": "Fenn", "persona": "A clerk.", + "knowledge": ["The ledger is gone."], "capabilities": {}} + + +def test_run_returns_prose_and_logs(monkeypatch): + seen = {} + + def fake_chat(model, messages, options, *, think=None): + seen.update(messages=messages, think=think) + return 'Aye. [MOVE: reveal(varrell_twins)] [FACT: the ledger is gone]' + + logs = [] + monkeypatch.setattr(npc, "load_npc", lambda i: FENN) + monkeypatch.setattr(npc.ollama_client, "chat", fake_chat) + monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw)) + + out = npc.run(LOG, "fenn", -10, ["reveal(varrell_twins)", "refuse"], "What happened?") + assert out.startswith("Aye.") + # persona + knowledge reached the model; utterance is present + user_msg = seen["messages"][1]["content"] + assert "A clerk." in user_msg and "The ledger is gone." in user_msg + assert "What happened?" in user_msg + assert seen["think"] is False + assert logs and logs[0]["ok"] is True and logs[0]["role"] == "npc" + + +def test_run_unknown_npc_raises(monkeypatch): + monkeypatch.setattr(npc, "load_npc", lambda i: None) + with pytest.raises(npc.UnknownNpc): + npc.run(LOG, "nobody", 0, [], "hi") + + +def test_run_logs_failure_and_reraises(monkeypatch): + monkeypatch.setattr(npc, "load_npc", lambda i: FENN) + + def boom(*a, **k): + raise ModelError("down") + + logs = [] + monkeypatch.setattr(npc.ollama_client, "chat", boom) + monkeypatch.setattr(npc.call_log, "record", lambda **kw: logs.append(kw)) + with pytest.raises(ModelError): + npc.run(LOG, "fenn", 0, [], "hi") + assert logs and logs[0]["ok"] is False