Merge feature/narrator-ollama-pipeline into dev

Narrator/Ollama pipeline (server): shared model-call pipeline (config, role→model
routing, prompt loader + curated canon-log digest, Ollama /api/chat client with one
retry, §10 JSON-lines call logging) wired through the Narrator role — /dm/narrate is
now real (200 prose | 502 model_error | 422 invalid log). 49 passed/1 skipped; live
wire proven against qwen3.5. Final-review fixes: best-effort logging, non-JSON 200 →
ModelError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 08:52:34 -05:00
19 changed files with 638 additions and 6 deletions

View File

@@ -9,3 +9,10 @@ REPLICATE_API_TOKEN=
# Port the proxy binds. compose / fly.io override this.
PORT=8000
# Narrator model (charter §5 "the good model"). qwen3.x runs with thinking OFF.
OLLAMA_NARRATOR_MODEL=qwen3.5:latest
# Per-attempt model-call timeout (seconds).
OLLAMA_TIMEOUT_SECONDS=30
# Optional: append call logs (JSON lines, §10) to this file instead of stdout.
CALL_LOG_PATH=

64
api/app/call_log.py Normal file
View File

@@ -0,0 +1,64 @@
"""Structured JSON-lines logging of every model call (charter §10 — the seed and
the full prompt logged with every call; §4 — free eval/replay infrastructure).
One JSON object per line: everything needed to replay the call (canon_log +
messages + model + seed) against a candidate model. No secrets to redact — the
client never sends keys, the canon log carries no PII, and the prompt is exactly
what we want on record.
"""
import json
import sys
from datetime import datetime, timezone
from typing import Callable
from . import config
def _default_write(line: str) -> None:
"""Best-effort (charter §13): a good model call must never become a 500
because the log write failed (disk full, bad permissions, a misconfigured
CALL_LOG_PATH). Any failure here is swallowed and reported to stderr —
never stdout, since stdout may itself be the default sink and must stay
clean JSON-lines for downstream tooling.
"""
try:
path = config.call_log_path()
if path:
with open(path, "a", encoding="utf-8") as handle:
handle.write(line + "\n")
else:
sys.stdout.write(line + "\n")
except Exception as exc: # noqa: BLE001 - logging must never break a request
print(f"call_log: failed to write log line: {exc}", file=sys.stderr)
def record(
*,
role: str,
model: str,
options: dict,
messages: list[dict],
canon_log: dict,
ok: bool,
latency_ms: int,
response: str | None = None,
error: str | None = None,
write: Callable[[str], None] = _default_write,
) -> dict:
rec: dict = {
"ts": datetime.now(timezone.utc).isoformat(),
"role": role,
"model": model,
"options": options,
"messages": messages,
"canon_log": canon_log,
"ok": ok,
"latency_ms": latency_ms,
}
if ok:
rec["response"] = response
else:
rec["error"] = error
write(json.dumps(rec, ensure_ascii=False))
return rec

22
api/app/config.py Normal file
View File

@@ -0,0 +1,22 @@
"""Server config, read from the environment at call time so tests can override
without reimport. Charter §4 — all of this lives server-side; the client never
sees it.
"""
import os
def ollama_base_url() -> str:
return os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
def ollama_timeout_seconds() -> float:
return float(os.environ.get("OLLAMA_TIMEOUT_SECONDS", "30"))
def narrator_model() -> str:
return os.environ.get("OLLAMA_NARRATOR_MODEL", "qwen3.5:latest")
def call_log_path() -> str | None:
return os.environ.get("CALL_LOG_PATH") or None

View File

@@ -3,7 +3,9 @@
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.
Prompt routing, model selection, and logging land later.
`/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
@@ -12,6 +14,8 @@ 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")
@@ -46,13 +50,17 @@ def health() -> dict:
# ── 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; the AI half is a
# stub until prompt routing lands.
# 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:
return {"detail": "not implemented"}
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")

37
api/app/narrate.py Normal file
View File

@@ -0,0 +1,37 @@
"""The Narrator role service (charter §5). Renders the digest, routes the model,
calls Ollama with one retry, logs the call (§10), and returns raw prose — tags
left intact for the client's TagExtractor to harvest (§2/§6). Raises ModelError
on failure for the handler to map to a 502.
"""
import random
from time import perf_counter
from . import call_log, ollama_client, prompts, routing
def run(canon_log: dict) -> str:
cfg = routing.for_role("narrator")
options = {**cfg.options, "seed": random.randint(0, 2**31 - 1)}
messages = [
{"role": "system", "content": prompts.system_prompt("narrator")},
{"role": "user", "content": prompts.render_digest(canon_log)},
]
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="narrator", 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="narrator", model=cfg.model, options=options, messages=messages,
canon_log=canon_log, ok=True,
latency_ms=int((perf_counter() - start) * 1000), response=text,
)
return text

50
api/app/ollama_client.py Normal file
View File

@@ -0,0 +1,50 @@
"""Call Ollama's /api/chat (non-streaming) with a single retry. Callers see
success or ModelError (charter §12/§13 — one retry, then fail; the client
degrades on any non-200). Built so a streaming mode is an additive path later.
"""
import httpx
from . import config
class ModelError(Exception):
"""The model call failed after one retry (transport / timeout / non-2xx / empty)."""
def chat(
model: str,
messages: list[dict],
options: dict,
*,
think: bool | None = None,
client: httpx.Client | None = None,
) -> str:
payload: dict = {"model": model, "messages": messages, "stream": False, "options": options}
if think is not None:
payload["think"] = think
owns = client is None
http = client or httpx.Client(
base_url=config.ollama_base_url(), timeout=config.ollama_timeout_seconds()
)
try:
last = "no attempt made"
for _ in range(2): # one try + one retry (§12: never more than once)
try:
resp = http.post("/api/chat", json=payload)
resp.raise_for_status()
content = resp.json().get("message", {}).get("content", "")
if content.strip():
return content
last = "empty response from model"
except (httpx.HTTPError, ValueError) as exc:
# httpx.HTTPError: TimeoutException, TransportError, HTTPStatusError.
# ValueError: resp.json() raises json.JSONDecodeError (a ValueError
# subclass) on a 200 with a non-JSON body (e.g. a proxy error page) —
# treat that as a failed attempt too, not an uncaught escape (§12/§13).
last = f"{type(exc).__name__}: {exc}"
raise ModelError(last)
finally:
if owns:
http.close()

57
api/app/prompts.py Normal file
View File

@@ -0,0 +1,57 @@
"""Load a role's system prompt (§16 — prompts are source code) and render the
canon log into a curated digest for the user message (charter §11).
The digest is a projection, not a dump: it surfaces narrative context and
deliberately omits raw integers — companion dispositions render as names only,
luck as its descriptor, and ids/schema_version are dropped (charter §7/§2).
"""
from functools import lru_cache
from pathlib import Path
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "prompts"
@lru_cache(maxsize=None)
def system_prompt(role: str) -> str:
text = (_PROMPT_DIR / f"{role}.md").read_text(encoding="utf-8")
# Everything below the first '---' line is the model-facing prompt; the
# header above it is human/dev front-matter.
parts = text.split("\n---\n", 1)
body = parts[1] if len(parts) == 2 else text
return body.strip()
def render_digest(canon_log: dict) -> str:
lines: list[str] = []
location = canon_log.get("location", {})
lines.append(f"Location: {location.get('name', 'an unknown place')}")
events = canon_log.get("recent_events", [])
if events:
lines.append("Recent events:")
lines += [f"- {e}" for e in events]
facts = canon_log.get("established_facts", [])
if facts:
lines.append("Established facts:")
lines += [f"- {f}" for f in facts]
party = canon_log.get("party", [])
if party:
lines.append("Companions present: " + ", ".join(m.get("name", "") for m in party))
for quest in canon_log.get("active_quests", []):
if quest.get("status") == "active":
lines.append(f"Active quest: {quest.get('name', '')}{quest.get('objective', '')}")
player = canon_log.get("player", {})
descriptor = player.get("luck_descriptor")
if descriptor:
lines.append(f"Fortune: {descriptor}")
lines.append(f"Player: {player.get('name', '')}, a {player.get('class_id', '')}")
lines.append("")
lines.append("Describe the scene as it is now.")
return "\n".join(lines)

25
api/app/routing.py Normal file
View File

@@ -0,0 +1,25 @@
"""Role → model routing (charter §4/§5). Model choice is config, not law — a
deploy, not a client patch. Read at call time so an env override needs no reimport.
The provider abstraction (Ollama → Replicate) slots in here later (YAGNI now).
"""
from dataclasses import dataclass
from . import config
@dataclass(frozen=True)
class RoleConfig:
model: str
options: dict
think: bool | None = None # Ollama top-level `think`; None omits it
def for_role(role: str) -> RoleConfig:
if role == "narrator":
return RoleConfig(
model=config.narrator_model(),
options={"temperature": 0.8, "top_p": 0.9, "num_predict": 300},
think=False, # qwen3.x thinking OFF; harmless on non-thinking models
)
raise KeyError(f"no routing for role: {role}")

21
api/conftest.py Normal file
View File

@@ -0,0 +1,21 @@
"""Gate `@pytest.mark.live` tests (they hit a real Ollama) behind --run-live so
the default suite stays hermetic. Run the live layer with: pytest --run-live
"""
import pytest
def pytest_addoption(parser):
parser.addoption(
"--run-live", action="store_true", default=False,
help="run tests marked @pytest.mark.live (require a real Ollama)",
)
def pytest_collection_modifyitems(config, items):
if config.getoption("--run-live"):
return
skip_live = pytest.mark.skip(reason="needs --run-live (real Ollama)")
for item in items:
if "live" in item.keywords:
item.add_marker(skip_live)

View File

@@ -2,11 +2,24 @@
**Role:** Describe scenes, outcomes, transitions. **Quality matters most — use the good model** (charter §5).
**Input:** scene state + canon log (§11).
**Input:** scene state + canon log (§11), rendered as a labeled digest in the user message.
**Output:** prose with tags. Never invent a proper noun without emitting `[FACT: ...]` so code can harvest it into `established_facts` (§11).
**Voice:** dry, gritty not grim, plays the world straight. Never winks. Nothing is "hilariously" anything (§3).
---
<!-- Prompt body TBD. This file is source code — version and review changes. -->
You are the Narrator of a gritty high-fantasy RPG. You describe the world; you never control the player.
Given the current scene state below, describe the scene as it is right now, in the second person ("you"), present tense. Cover what the player sees, hears, and smells, and what the people present are doing. Three to five sentences, then stop.
Voice:
- Dry and economical. Gritty, not grim. Play the world straight — never wink, never point out that a thing is funny. Nothing is "hilariously" or "comically" anything.
- Profanity and ugliness are allowed when the moment earns them, never as decoration.
Hard rules:
- Do not introduce a proper noun — a person's name, a place name, a named object — unless it already appears below. If you must introduce one, append a tag on its own: `[FACT: <the new fact as a short clause>]`, one per new proper noun, so the game can record it.
- Never contradict anything under "Established facts".
- Never state a number, a statistic, a hit-point total, or a Luck value. "Fortune" is a feeling, never a figure.
- Never decide, narrate, or assume the player's choices, words, or actions. Describe the world and the other characters; leave the player's next move to the player.
- Output prose only, plus any `[FACT: ...]` tags. No JSON, no lists, no headings, no notes about what you are doing.

View File

@@ -3,3 +3,5 @@ pythonpath = .
testpaths = tests
filterwarnings =
ignore:Using `httpx` with `starlette.testclient` is deprecated
markers =
live: exercises a real Ollama; skipped unless --run-live

View File

@@ -0,0 +1,56 @@
import json
from app.call_log import record
def test_success_record_shape():
captured = []
rec = record(
role="narrator", model="qwen3.5:latest", options={"seed": 7},
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
ok=True, latency_ms=120, response="prose", write=captured.append,
)
assert len(captured) == 1
parsed = json.loads(captured[0])
assert parsed["ok"] is True
assert parsed["response"] == "prose"
assert parsed["options"]["seed"] == 7
assert parsed["role"] == "narrator"
assert parsed["canon_log"] == {"a": 1}
assert "error" not in parsed
assert "ts" in parsed
assert rec["response"] == "prose"
def test_failure_record_shape():
captured = []
record(
role="narrator", model="m", options={}, messages=[], canon_log={},
ok=False, latency_ms=5, error="ModelError: down", write=captured.append,
)
parsed = json.loads(captured[0])
assert parsed["ok"] is False
assert parsed["error"] == "ModelError: down"
assert "response" not in parsed
def test_default_write_failure_does_not_propagate(monkeypatch, capsys):
"""§13 — a good narration must never become a 500 because the log write
failed. Point CALL_LOG_PATH at a path whose parent directory does not
exist, so the default sink's `open()` raises. record() must swallow it,
report to stderr (never stdout — stdout may be the log sink itself), and
still return the record.
"""
monkeypatch.setenv(
"CALL_LOG_PATH", "/nonexistent-dir-for-test/does/not/exist.log"
)
rec = record(
role="narrator", model="qwen3.5:latest", options={"seed": 7},
messages=[{"role": "user", "content": "x"}], canon_log={"a": 1},
ok=True, latency_ms=120, response="prose",
)
assert rec["response"] == "prose"
assert rec["ok"] is True
out, err = capsys.readouterr()
assert out == ""
assert err != ""

View File

@@ -1,8 +1,10 @@
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import app.narrate as narrate
from app.main import app
client = TestClient(app)
@@ -14,6 +16,15 @@ ROLE_ENDPOINTS = [
]
@pytest.fixture(autouse=True)
def _stub_narrate_model_call(monkeypatch):
# /dm/narrate is wired to a real Ollama call (Task 7). These tests exercise
# canon-log validation across ALL role endpoints, not model behavior, so
# stub the model boundary — keeps the default suite hermetic (no network).
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "stubbed prose")
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
def test_health_ok():
assert client.get("/health").json() == {"status": "ok"}

View File

@@ -0,0 +1,19 @@
import json
from pathlib import Path
import pytest
import app.narrate as narrate
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
@pytest.mark.live
def test_narrator_returns_real_prose():
"""Hits the real Ollama (OLLAMA_BASE_URL). Proves the wire end-to-end:
prompt assembly → model → non-empty prose. Not asserted: tag content
(nondeterministic). Run with: pytest --run-live
"""
text = narrate.run(VALID)
assert isinstance(text, str)
assert len(text.strip()) > 40 # real prose, not a blip

43
api/tests/test_narrate.py Normal file
View File

@@ -0,0 +1,43 @@
import json
from pathlib import Path
import pytest
import app.narrate as narrate
from app.ollama_client import ModelError
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_run_returns_prose_and_logs_success(monkeypatch):
seen = {}
def fake_chat(model, messages, options, *, think=None):
seen.update(model=model, messages=messages, options=options, think=think)
return "You stand on the wharf. [FACT: the gulls have gone quiet]"
logs = []
monkeypatch.setattr(narrate.ollama_client, "chat", fake_chat)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
out = narrate.run(VALID)
assert out.startswith("You stand on the wharf.")
assert seen["model"] == "qwen3.5:latest"
assert seen["think"] is False
assert seen["messages"][0]["role"] == "system"
assert seen["messages"][1]["role"] == "user"
assert "seed" in seen["options"]
assert logs and logs[0]["ok"] is True and logs[0]["response"] == out
def test_run_logs_failure_and_reraises(monkeypatch):
def boom(*args, **kwargs):
raise ModelError("upstream down")
logs = []
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: logs.append(kw))
with pytest.raises(ModelError):
narrate.run(VALID)
assert logs and logs[0]["ok"] is False and "down" in logs[0]["error"]

View File

@@ -0,0 +1,39 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
import app.main as main
import app.narrate as narrate
from app.ollama_client import ModelError
client = TestClient(main.app)
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_valid_log_returns_prose(monkeypatch):
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "You stand on the wharf.")
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None) # keep test output pristine
r = client.post("/dm/narrate", json={"canon_log": VALID})
assert r.status_code == 200
assert r.json() == {"prose": "You stand on the wharf."}
def test_model_error_maps_to_502(monkeypatch):
def boom(*a, **k):
raise ModelError("upstream down")
monkeypatch.setattr(narrate.ollama_client, "chat", boom)
monkeypatch.setattr(narrate.call_log, "record", lambda **kw: None)
r = client.post("/dm/narrate", json={"canon_log": VALID})
assert r.status_code == 502
assert "model_error" in r.json()["detail"]
def test_invalid_log_still_422(monkeypatch):
monkeypatch.setattr(narrate.ollama_client, "chat", lambda *a, **k: "x")
bad = json.loads(json.dumps(VALID))
bad["player"]["luck"] = 5 # §7 leak — schema rejects
r = client.post("/dm/narrate", json={"canon_log": bad})
assert r.status_code == 422
assert "canon_log_errors" in r.json()["detail"]

View File

@@ -0,0 +1,95 @@
import json
import httpx
import pytest
from app.ollama_client import ModelError, chat
def _client(handler):
return httpx.Client(transport=httpx.MockTransport(handler), base_url="http://ollama")
def test_success_returns_content_and_sends_payload():
seen = {}
def handler(request):
seen["body"] = json.loads(request.content)
return httpx.Response(200, json={"message": {"content": "You stand on the wharf."}})
out = chat("qwen3.5:latest", [{"role": "user", "content": "hi"}], {"seed": 7},
think=False, client=_client(handler))
assert out == "You stand on the wharf."
assert seen["body"]["model"] == "qwen3.5:latest"
assert seen["body"]["stream"] is False
assert seen["body"]["options"]["seed"] == 7
assert seen["body"]["think"] is False
def test_think_omitted_when_none():
seen = {}
def handler(request):
seen["body"] = json.loads(request.content)
return httpx.Response(200, json={"message": {"content": "ok"}})
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
assert "think" not in seen["body"]
def test_retries_once_then_succeeds():
calls = {"n": 0}
def handler(request):
calls["n"] += 1
if calls["n"] == 1:
raise httpx.ConnectError("boom")
return httpx.Response(200, json={"message": {"content": "recovered"}})
out = chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
assert out == "recovered"
assert calls["n"] == 2
def test_two_transport_failures_raise_modelerror():
def handler(request):
raise httpx.ConnectError("down")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_timeout_exhausted_raises_modelerror():
def handler(request):
raise httpx.TimeoutException("slow")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_non_2xx_retried_then_modelerror():
def handler(request):
return httpx.Response(500, json={"error": "nope"})
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_empty_content_retried_then_modelerror():
def handler(request):
return httpx.Response(200, json={"message": {"content": " "}})
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
def test_non_json_200_retried_then_modelerror():
"""A 200 response with a non-JSON body (e.g. a proxy error page) must
degrade to ModelError after the one retry, not escape as a bare
JSONDecodeError/ValueError (charter §12/§13).
"""
def handler(request):
return httpx.Response(200, text="<html>not json</html>")
with pytest.raises(ModelError):
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))

42
api/tests/test_prompts.py Normal file
View File

@@ -0,0 +1,42 @@
import json
from pathlib import Path
from app.prompts import render_digest, system_prompt
VALID = json.loads((Path(__file__).parent / "fixtures" / "canon_log_valid.json").read_text())
def test_system_prompt_strips_the_header():
body = system_prompt("narrator")
assert isinstance(body, str) and body.strip() != ""
# The human-facing markdown title above the '---' is not part of the prompt.
assert "# Narrator prompt" not in body
def test_digest_has_the_narrative_context():
d = render_digest(VALID)
assert "Location: the Greywater docks" in d
assert "Arrived at Greywater by barge before dawn" in d
assert "Established facts:" in d
assert "the harbourmaster is named Oda Fenn" in d
assert "Companions present: Brannoc Thane, Cadwyn Vell" in d
assert "Active quest: The Missing Ledger — Find who took Fenn's ledger" in d
assert "Fortune: Fortune spits on you" in d
assert "Player: Aldric, a sellsword" in d
assert d.rstrip().endswith("Describe the scene as it is now.")
def test_digest_omits_raw_numbers_and_ids():
d = render_digest(VALID)
# §7/§2: no disposition integers, no ids, no schema_version leak into the prompt.
assert "disposition" not in d
assert "40" not in d and "15" not in d # the deserter dispositions
assert "brannoc_thane" not in d # ids stay out; names only
assert "schema_version" not in d
def test_narrator_body_is_authored():
body = system_prompt.__wrapped__("narrator") # bypass lru_cache in case of prior load
assert "TBD" not in body
assert "[FACT:" in body # the tag instruction is present
assert "second person" in body # the core task is present

21
api/tests/test_routing.py Normal file
View File

@@ -0,0 +1,21 @@
import pytest
from app.routing import for_role
def test_narrator_defaults():
cfg = for_role("narrator")
assert cfg.model == "qwen3.5:latest"
assert cfg.think is False
assert cfg.options["num_predict"] == 300
assert cfg.options["temperature"] == 0.8
def test_env_overrides_model(monkeypatch):
monkeypatch.setenv("OLLAMA_NARRATOR_MODEL", "llama3.1:latest")
assert for_role("narrator").model == "llama3.1:latest"
def test_unknown_role_raises():
with pytest.raises(KeyError):
for_role("wizard")