From 42b3720721d708a10f81a244ad5b57edde3764f3 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:08:56 -0500 Subject: [PATCH 01/10] =?UTF-8?q?feat(api):=20config=20+=20role=E2=86=92mo?= =?UTF-8?q?del=20routing=20(qwen3.5=20narrator=20default,=20think=20off)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/config.py | 22 ++++++++++++++++++++++ api/app/routing.py | 25 +++++++++++++++++++++++++ api/tests/test_routing.py | 21 +++++++++++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 api/app/config.py create mode 100644 api/app/routing.py create mode 100644 api/tests/test_routing.py diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..4981f57 --- /dev/null +++ b/api/app/config.py @@ -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 diff --git a/api/app/routing.py b/api/app/routing.py new file mode 100644 index 0000000..98d9dba --- /dev/null +++ b/api/app/routing.py @@ -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}") diff --git a/api/tests/test_routing.py b/api/tests/test_routing.py new file mode 100644 index 0000000..d455598 --- /dev/null +++ b/api/tests/test_routing.py @@ -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") From 4d8a19353e994ffe2ea5c47e045fa99cad0b8893 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:11:57 -0500 Subject: [PATCH 02/10] feat(api): prompt loader (header split) + curated canon-log digest Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/prompts.py | 57 +++++++++++++++++++++++++++++++++++++++ api/tests/test_prompts.py | 35 ++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 api/app/prompts.py create mode 100644 api/tests/test_prompts.py diff --git a/api/app/prompts.py b/api/app/prompts.py new file mode 100644 index 0000000..f334ea5 --- /dev/null +++ b/api/app/prompts.py @@ -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) diff --git a/api/tests/test_prompts.py b/api/tests/test_prompts.py new file mode 100644 index 0000000..092f119 --- /dev/null +++ b/api/tests/test_prompts.py @@ -0,0 +1,35 @@ +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 From be133c4f3a2bf4b805b580e3c05ef003231cd5dc Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:15:45 -0500 Subject: [PATCH 03/10] feat(api): Ollama /api/chat client with one retry + typed ModelError Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/ollama_client.py | 46 ++++++++++++++++++ api/tests/test_ollama_client.py | 83 +++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 api/app/ollama_client.py create mode 100644 api/tests/test_ollama_client.py diff --git a/api/app/ollama_client.py b/api/app/ollama_client.py new file mode 100644 index 0000000..6de1d48 --- /dev/null +++ b/api/app/ollama_client.py @@ -0,0 +1,46 @@ +"""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 as exc: # TimeoutException, TransportError, HTTPStatusError + last = f"{type(exc).__name__}: {exc}" + raise ModelError(last) + finally: + if owns: + http.close() diff --git a/api/tests/test_ollama_client.py b/api/tests/test_ollama_client.py new file mode 100644 index 0000000..8bd14e7 --- /dev/null +++ b/api/tests/test_ollama_client.py @@ -0,0 +1,83 @@ +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)) From 15995c57ee87e9a9531d452a4fc0fce767a82531 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:20:00 -0500 Subject: [PATCH 04/10] =?UTF-8?q?feat(api):=20=C2=A710=20JSON-lines=20call?= =?UTF-8?q?=20logging=20(full=20prompt=20+=20seed,=20injectable=20sink)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/call_log.py | 55 ++++++++++++++++++++++++++++++++++++++ api/tests/test_call_log.py | 34 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 api/app/call_log.py create mode 100644 api/tests/test_call_log.py diff --git a/api/app/call_log.py b/api/app/call_log.py new file mode 100644 index 0000000..8e69a13 --- /dev/null +++ b/api/app/call_log.py @@ -0,0 +1,55 @@ +"""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: + 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") + + +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 diff --git a/api/tests/test_call_log.py b/api/tests/test_call_log.py new file mode 100644 index 0000000..a22a43e --- /dev/null +++ b/api/tests/test_call_log.py @@ -0,0 +1,34 @@ +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 From 2558e2a2a060815ff04892622361f451f4374e00 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:23:31 -0500 Subject: [PATCH 05/10] feat(api): author narrator system prompt (voice, [FACT] rule, no-numbers, no player agency) --- api/prompts/narrator.md | 17 +++++++++++++++-- api/tests/test_prompts.py | 7 +++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/api/prompts/narrator.md b/api/prompts/narrator.md index 4ef7406..98ef907 100644 --- a/api/prompts/narrator.md +++ b/api/prompts/narrator.md @@ -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). --- - +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: ]`, 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. diff --git a/api/tests/test_prompts.py b/api/tests/test_prompts.py index 092f119..940fbeb 100644 --- a/api/tests/test_prompts.py +++ b/api/tests/test_prompts.py @@ -33,3 +33,10 @@ def test_digest_omits_raw_numbers_and_ids(): 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 From 2027e7a9b97ee7b8173743e0ab11df0718070e25 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:27:36 -0500 Subject: [PATCH 06/10] =?UTF-8?q?feat(api):=20narrate=20service=20?= =?UTF-8?q?=E2=80=94=20render=E2=86=92route=E2=86=92call=E2=86=92log,=20re?= =?UTF-8?q?turns=20raw=20prose?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/narrate.py | 37 +++++++++++++++++++++++++++++++++ api/tests/test_narrate.py | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 api/app/narrate.py create mode 100644 api/tests/test_narrate.py diff --git a/api/app/narrate.py b/api/app/narrate.py new file mode 100644 index 0000000..8f61a8b --- /dev/null +++ b/api/app/narrate.py @@ -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 diff --git a/api/tests/test_narrate.py b/api/tests/test_narrate.py new file mode 100644 index 0000000..a9f42d4 --- /dev/null +++ b/api/tests/test_narrate.py @@ -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"] From 5a35802075937a860527e85fa10a1c2e29de7a54 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:31:38 -0500 Subject: [PATCH 07/10] =?UTF-8?q?feat(api):=20wire=20/dm/narrate=20?= =?UTF-8?q?=E2=80=94=20200=20prose=20|=20502=20model=5Ferror=20|=20422=20i?= =?UTF-8?q?nvalid=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- api/app/main.py | 7 +++++- api/tests/test_narrate_endpoint.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 api/tests/test_narrate_endpoint.py diff --git a/api/app/main.py b/api/app/main.py index d04fa7b..9d9fddb 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -12,6 +12,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") @@ -52,7 +54,10 @@ def health() -> dict: @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") diff --git a/api/tests/test_narrate_endpoint.py b/api/tests/test_narrate_endpoint.py new file mode 100644 index 0000000..e3bd86f --- /dev/null +++ b/api/tests/test_narrate_endpoint.py @@ -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"] From 45d5f0360b5010af4b2f4e648b9bb76284fb361d Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:31:42 -0500 Subject: [PATCH 08/10] test(api): keep test_endpoints hermetic after /dm/narrate wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /dm/narrate now makes a real Ollama call (Task 7). The role-endpoint loop tests in test_endpoints.py had no mock, so every default pytest run was silently hitting a live Ollama instance on localhost:11434 — breaking the hermetic-suite constraint. Add an autouse fixture that stubs narrate.ollama_client.chat and narrate.call_log.record so all five endpoints stay hermetic; assertions (200 on valid, 422 on invalid) are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/tests/test_endpoints.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/api/tests/test_endpoints.py b/api/tests/test_endpoints.py index b7db5ee..b3f6d70 100644 --- a/api/tests/test_endpoints.py +++ b/api/tests/test_endpoints.py @@ -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"} From 470ccb29358bf707073ed1d56fc4d642afc048e1 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:36:56 -0500 Subject: [PATCH 09/10] test(api): gated live Ollama smoke (--run-live) + env docs Co-Authored-By: Claude Opus 4.8 (1M context) --- api/.env.example | 7 +++++++ api/conftest.py | 21 +++++++++++++++++++++ api/pytest.ini | 2 ++ api/tests/test_live_smoke.py | 19 +++++++++++++++++++ 4 files changed, 49 insertions(+) create mode 100644 api/conftest.py create mode 100644 api/tests/test_live_smoke.py diff --git a/api/.env.example b/api/.env.example index c7df8f0..0d6b52a 100644 --- a/api/.env.example +++ b/api/.env.example @@ -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= diff --git a/api/conftest.py b/api/conftest.py new file mode 100644 index 0000000..38d7def --- /dev/null +++ b/api/conftest.py @@ -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) diff --git a/api/pytest.ini b/api/pytest.ini index c038626..795b6eb 100644 --- a/api/pytest.ini +++ b/api/pytest.ini @@ -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 diff --git a/api/tests/test_live_smoke.py b/api/tests/test_live_smoke.py new file mode 100644 index 0000000..c76066c --- /dev/null +++ b/api/tests/test_live_smoke.py @@ -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 From f7c51b79dab5b520fb3e2d9cf04826647674c445 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:45:39 -0500 Subject: [PATCH 10/10] =?UTF-8?q?fix(api):=20best-effort=20call=20logging?= =?UTF-8?q?=20+=20non-JSON=20200=20=E2=86=92=20ModelError=20(final-review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- api/app/call_log.py | 21 +++++++++++++++------ api/app/main.py | 9 ++++++--- api/app/ollama_client.py | 6 +++++- api/tests/test_call_log.py | 22 ++++++++++++++++++++++ api/tests/test_ollama_client.py | 12 ++++++++++++ 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/api/app/call_log.py b/api/app/call_log.py index 8e69a13..2ac8d73 100644 --- a/api/app/call_log.py +++ b/api/app/call_log.py @@ -16,12 +16,21 @@ from . import config def _default_write(line: str) -> None: - 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") + """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( diff --git a/api/app/main.py b/api/app/main.py index 9d9fddb..5ac3ffa 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -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 @@ -48,8 +50,9 @@ 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") diff --git a/api/app/ollama_client.py b/api/app/ollama_client.py index 6de1d48..920c7b7 100644 --- a/api/app/ollama_client.py +++ b/api/app/ollama_client.py @@ -38,7 +38,11 @@ def chat( if content.strip(): return content last = "empty response from model" - except httpx.HTTPError as exc: # TimeoutException, TransportError, HTTPStatusError + 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: diff --git a/api/tests/test_call_log.py b/api/tests/test_call_log.py index a22a43e..9ddaa3b 100644 --- a/api/tests/test_call_log.py +++ b/api/tests/test_call_log.py @@ -32,3 +32,25 @@ def test_failure_record_shape(): 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 != "" diff --git a/api/tests/test_ollama_client.py b/api/tests/test_ollama_client.py index 8bd14e7..59a2402 100644 --- a/api/tests/test_ollama_client.py +++ b/api/tests/test_ollama_client.py @@ -81,3 +81,15 @@ def test_empty_content_retried_then_modelerror(): 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="not json") + + with pytest.raises(ModelError): + chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))