feat(api): npc.run service — thin drop-in on the model pipeline
This commit is contained in:
59
api/app/npc.py
Normal file
59
api/app/npc.py
Normal file
@@ -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
|
||||||
55
api/tests/test_npc.py
Normal file
55
api/tests/test_npc.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user