docs: mark Narrator/Ollama pipeline complete (plan steps + roadmap)

Plan status → COMPLETE, all 8 tasks/44 steps checked. Roadmap: Narrator pipeline
, Client HTTP loop promoted to ▶ next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 09:00:34 -05:00
parent ddc00d42da
commit 1189d2e24d
2 changed files with 47 additions and 45 deletions

View File

@@ -31,8 +31,8 @@ Status: ✅ done · ▶ next · ○ planned. Each item is its own spec → plan
-**Client canon log engine** — typed GDScript model (invariants in the model), new-game construction (origin + world + creation → canon log), turn-to-turn maintenance mutators, pure `[FACT]`/`[MOVE]`/`[ADJUST_DISPOSITION]` tag extractor (Plan B).
### Proving the loop — the POC's one question
- **Narrator / Ollama pipeline (server)** — the shared server-side model-call pipeline (Ollama httpx client, role→model routing config, prompt loader + canon-log digest renderer, §10 call logging, single-retry → typed 502) wired through `/dm/narrate`. Non-streaming. Two phases: **(1) pipeline foundation**, **(2) Narrator online** (authored `narrator.md` body + gated live smoke). Server returns raw prose; the client extracts tags. The other four roles become thin drop-ins on this pipeline.
- **Client HTTP loop** — client posts its canon log to `/dm/narrate`, displays the prose, harvests `[FACT]` via its `TagExtractor` into its own log, and shows the degraded-DM authored fallback (§13) on any non-200. Closes the first end-to-end Narrator scene.
- **Narrator / Ollama pipeline (server)** — the shared server-side model-call pipeline (Ollama httpx client, role→model routing config, prompt loader + canon-log digest renderer, §10 call logging, single-retry → typed 502) wired through `/dm/narrate`. Non-streaming. Both phases landed: **(1) pipeline foundation** (`config`/`routing`/`prompts`/`ollama_client`/`call_log`), **(2) Narrator online** (authored `narrator.md` body + `narrate` service + gated live smoke). Server returns raw prose; the client extracts tags. The other four roles become thin drop-ins on this pipeline. Live wire proven against qwen3.5; 49 tests. Merged to `dev` (`ddc00d4`).
- **Client HTTP loop** — client posts its canon log to `/dm/narrate`, displays the prose, harvests `[FACT]` via its `TagExtractor` into its own log, and shows the degraded-DM authored fallback (§13) on any non-200. Closes the first end-to-end Narrator scene.
-**Bounded NPC conversation**`/npc/speak` with persona + `knowledge[]` + `available_moves[]`; client validates and applies moves, silently drops the invalid, keeps the prose (§6). **The core experiment.**
### Fills out the POC (charter §17)

View File

@@ -1,5 +1,7 @@
# Narrator / Ollama Pipeline (server) Implementation Plan
> **Status: ✅ COMPLETE (2026-07-10).** All 8 tasks landed via subagent-driven development (per-task spec+quality review + opus whole-branch review). Merged `--no-ff` to `dev` as `ddc00d4` (branch `feature/narrator-ollama-pipeline`, commits `42b3720..f7c51b7`, deleted post-merge). Suite: 49 passed / 1 skipped (live smoke, `--run-live`), pristine. Live wire proven against real qwen3.5. Two conflicts caught in execution: the Plan-A `test_endpoints` loop was made hermetic after wiring (Task 7), and `pytest.ini` was merged not replaced to keep `filterwarnings`. Opus review found + fixed two seam-level robustness gaps: best-effort call logging (§13) and non-JSON 200 → `ModelError` (§12). Deferred follow-ups for later plans: `prompts.system_prompt` should raise on a missing `---` delimiter; `render_digest`'s trailing narrator instruction should move into the system prompt when a second role reuses the digest.
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `/dm/narrate` real — assemble the Narrator prompt + a curated canon-log digest, call Ollama, return the prose, log every call (§10), and signal failure honestly — building the reusable server-side model-call pipeline the other four roles later drop into.
@@ -37,7 +39,7 @@ The reusable machinery, each module mock/unit-tested. The endpoint is untouched
**Interfaces:**
- Produces: `config.ollama_base_url() -> str`, `config.ollama_timeout_seconds() -> float`, `config.narrator_model() -> str`, `config.call_log_path() -> str | None` (all read env at call time). `routing.RoleConfig(model: str, options: dict, think: bool | None)`; `routing.for_role(role: str) -> RoleConfig` (raises `KeyError` for an unknown role).
- [ ] **Step 1: Write the failing test**
- [x] **Step 1: Write the failing test**
Create `api/tests/test_routing.py`:
@@ -65,12 +67,12 @@ def test_unknown_role_raises():
for_role("wizard")
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_routing.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.routing'`.
- [ ] **Step 3: Write `config.py`**
- [x] **Step 3: Write `config.py`**
Create `api/app/config.py`:
@@ -99,7 +101,7 @@ def call_log_path() -> str | None:
return os.environ.get("CALL_LOG_PATH") or None
```
- [ ] **Step 4: Write `routing.py`**
- [x] **Step 4: Write `routing.py`**
Create `api/app/routing.py`:
@@ -131,12 +133,12 @@ def for_role(role: str) -> RoleConfig:
raise KeyError(f"no routing for role: {role}")
```
- [ ] **Step 5: Run test to verify it passes**
- [x] **Step 5: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_routing.py -v`
Expected: PASS — 3 passed.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add api/app/config.py api/app/routing.py api/tests/test_routing.py
@@ -155,7 +157,7 @@ git commit -m "feat(api): config + role→model routing (qwen3.5 narrator defaul
- Consumes: the fixture `api/tests/fixtures/canon_log_valid.json`; the existing `api/prompts/narrator.md`.
- Produces: `prompts.system_prompt(role: str) -> str` (returns the file content **below the first `\n---\n`** — the header above it is human-facing front-matter; cached). `prompts.render_digest(canon_log: dict) -> str` (curated labeled digest; omits integers).
- [ ] **Step 1: Write the failing test**
- [x] **Step 1: Write the failing test**
Create `api/tests/test_prompts.py`:
@@ -197,12 +199,12 @@ def test_digest_omits_raw_numbers_and_ids():
assert "schema_version" not in d
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_prompts.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.prompts'`.
- [ ] **Step 3: Write `prompts.py`**
- [x] **Step 3: Write `prompts.py`**
Create `api/app/prompts.py`:
@@ -266,12 +268,12 @@ def render_digest(canon_log: dict) -> str:
return "\n".join(lines)
```
- [ ] **Step 4: Run test to verify it passes**
- [x] **Step 4: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_prompts.py -v`
Expected: PASS — 3 passed. (`system_prompt` returns whatever is below `---` in the current `narrator.md`; its body is authored in Task 5.)
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add api/app/prompts.py api/tests/test_prompts.py
@@ -290,7 +292,7 @@ git commit -m "feat(api): prompt loader (header split) + curated canon-log diges
- Consumes: `config.ollama_base_url`, `config.ollama_timeout_seconds`.
- Produces: `ollama_client.ModelError` (Exception); `ollama_client.chat(model: str, messages: list[dict], options: dict, *, think: bool | None = None, client: httpx.Client | None = None) -> str` — returns the assistant content, raises `ModelError` after one retry. Sends `{"model", "messages", "stream": False, "options"}` and, only when `think is not None`, a top-level `"think"`.
- [ ] **Step 1: Write the failing test**
- [x] **Step 1: Write the failing test**
Create `api/tests/test_ollama_client.py`:
@@ -380,12 +382,12 @@ def test_empty_content_retried_then_modelerror():
chat("m", [{"role": "user", "content": "x"}], {}, client=_client(handler))
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_ollama_client.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.ollama_client'`.
- [ ] **Step 3: Write `ollama_client.py`**
- [x] **Step 3: Write `ollama_client.py`**
Create `api/app/ollama_client.py`:
@@ -438,12 +440,12 @@ def chat(
http.close()
```
- [ ] **Step 4: Run test to verify it passes**
- [x] **Step 4: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_ollama_client.py -v`
Expected: PASS — 7 passed.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add api/app/ollama_client.py api/tests/test_ollama_client.py
@@ -462,7 +464,7 @@ git commit -m "feat(api): Ollama /api/chat client with one retry + typed ModelEr
- Consumes: `config.call_log_path`.
- Produces: `call_log.record(*, role, model, options, messages, canon_log, ok, latency_ms, response=None, error=None, write=<default>) -> dict` — builds the record, writes one JSON line via `write` (default: `CALL_LOG_PATH` file if set, else stdout), returns the record. `response` present only when `ok`; `error` present only when not.
- [ ] **Step 1: Write the failing test**
- [x] **Step 1: Write the failing test**
Create `api/tests/test_call_log.py`:
@@ -503,12 +505,12 @@ def test_failure_record_shape():
assert "response" not in parsed
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_call_log.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.call_log'`.
- [ ] **Step 3: Write `call_log.py`**
- [x] **Step 3: Write `call_log.py`**
Create `api/app/call_log.py`:
@@ -570,17 +572,17 @@ def record(
return rec
```
- [ ] **Step 4: Run test to verify it passes**
- [x] **Step 4: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_call_log.py -v`
Expected: PASS — 2 passed.
- [ ] **Step 5: Run the whole Phase 1 suite**
- [x] **Step 5: Run the whole Phase 1 suite**
Run: `cd api && python -m pytest -v`
Expected: PASS — all Task 14 tests plus the pre-existing Plan A suite green.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add api/app/call_log.py api/tests/test_call_log.py
@@ -602,7 +604,7 @@ Author the prompt, wire the service and endpoint, prove the wire against a real
**Interfaces:**
- Consumes: `prompts.system_prompt` (Task 2). No new interface.
- [ ] **Step 1: Author the prompt body**
- [x] **Step 1: Author the prompt body**
Replace the entire contents of `api/prompts/narrator.md` with (header above `---` stays human-facing; everything below is the model-facing system prompt):
@@ -634,7 +636,7 @@ Hard rules:
- Output prose only, plus any `[FACT: ...]` tags. No JSON, no lists, no headings, no notes about what you are doing.
```
- [ ] **Step 2: Append the body-authored assertion**
- [x] **Step 2: Append the body-authored assertion**
Add to `api/tests/test_prompts.py`:
@@ -646,12 +648,12 @@ def test_narrator_body_is_authored():
assert "second person" in body # the core task is present
```
- [ ] **Step 3: Run the prompt tests**
- [x] **Step 3: Run the prompt tests**
Run: `cd api && python -m pytest tests/test_prompts.py -v`
Expected: PASS — 4 passed (the 3 from Task 2 + the new body assertion). The `.__wrapped__` call bypasses `lru_cache` so the freshly authored file is read.
- [ ] **Step 4: Commit**
- [x] **Step 4: Commit**
```bash
git add api/prompts/narrator.md api/tests/test_prompts.py
@@ -670,7 +672,7 @@ git commit -m "feat(api): author narrator system prompt (voice, [FACT] rule, no-
- Consumes: `routing.for_role`, `prompts.system_prompt`/`render_digest`, `ollama_client.chat`/`ModelError`, `call_log.record` (all imported as modules so tests can monkeypatch the attribute).
- Produces: `narrate.run(canon_log: dict) -> str` — renders digest, routes model, calls Ollama with a per-call seed and `think`, logs success or failure, returns raw prose or re-raises `ModelError`.
- [ ] **Step 1: Write the failing test**
- [x] **Step 1: Write the failing test**
Create `api/tests/test_narrate.py`:
@@ -720,12 +722,12 @@ def test_run_logs_failure_and_reraises(monkeypatch):
assert logs and logs[0]["ok"] is False and "down" in logs[0]["error"]
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_narrate.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'app.narrate'`.
- [ ] **Step 3: Write `narrate.py`**
- [x] **Step 3: Write `narrate.py`**
Create `api/app/narrate.py`:
@@ -769,12 +771,12 @@ def run(canon_log: dict) -> str:
return text
```
- [ ] **Step 4: Run test to verify it passes**
- [x] **Step 4: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_narrate.py -v`
Expected: PASS — 2 passed.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add api/app/narrate.py api/tests/test_narrate.py
@@ -793,7 +795,7 @@ git commit -m "feat(api): narrate service — render→route→call→log, retur
- Consumes: `narrate.run`, `ollama_client.ModelError`.
- Produces: `POST /dm/narrate` → 200 `{"prose": text}` on success; 502 `{"detail": {"model_error": "<reason>"}}` on `ModelError`; 422 (existing) on an invalid canon log. The other four role endpoints stay stubbed.
- [ ] **Step 1: Write the failing test**
- [x] **Step 1: Write the failing test**
Create `api/tests/test_narrate_endpoint.py`:
@@ -839,12 +841,12 @@ def test_invalid_log_still_422(monkeypatch):
assert "canon_log_errors" in r.json()["detail"]
```
- [ ] **Step 2: Run test to verify it fails**
- [x] **Step 2: Run test to verify it fails**
Run: `cd api && python -m pytest tests/test_narrate_endpoint.py -v`
Expected: FAIL — the current `/dm/narrate` returns 200 `{"detail": "not implemented"}`, so `test_valid_log_returns_prose` and `test_model_error_maps_to_502` fail.
- [ ] **Step 3: Wire the endpoint**
- [x] **Step 3: Wire the endpoint**
In `api/app/main.py`, add these imports near the existing imports:
@@ -874,12 +876,12 @@ def narrate(req: TurnRequest = Depends(valid_turn)) -> dict:
Leave `/dm/adjudicate`, `/dm/improvise`, `/npc/speak`, `/party/banter` returning `{"detail": "not implemented"}`.
- [ ] **Step 4: Run test to verify it passes**
- [x] **Step 4: Run test to verify it passes**
Run: `cd api && python -m pytest tests/test_narrate_endpoint.py -v`
Expected: PASS — 3 passed.
- [ ] **Step 5: Commit**
- [x] **Step 5: Commit**
```bash
git add api/app/main.py api/tests/test_narrate_endpoint.py
@@ -899,7 +901,7 @@ git commit -m "feat(api): wire /dm/narrate — 200 prose | 502 model_error | 422
**Interfaces:**
- Consumes: `narrate.run`. Produces: a `live` marker + `--run-live` gate; the live test is skipped by default and hits the real Ollama when opted in.
- [ ] **Step 1: Add the `--run-live` gate**
- [x] **Step 1: Add the `--run-live` gate**
Create `api/conftest.py`:
@@ -927,7 +929,7 @@ def pytest_collection_modifyitems(config, items):
item.add_marker(skip_live)
```
- [ ] **Step 2: Register the marker**
- [x] **Step 2: Register the marker**
Replace `api/pytest.ini` with:
@@ -939,7 +941,7 @@ markers =
live: exercises a real Ollama; skipped unless --run-live
```
- [ ] **Step 3: Write the live smoke test**
- [x] **Step 3: Write the live smoke test**
Create `api/tests/test_live_smoke.py`:
@@ -965,7 +967,7 @@ def test_narrator_returns_real_prose():
assert len(text.strip()) > 40 # real prose, not a blip
```
- [ ] **Step 4: Verify the default suite SKIPS it, then run it live**
- [x] **Step 4: Verify the default suite SKIPS it, then run it live**
Run (default — must skip): `cd api && python -m pytest tests/test_live_smoke.py -v`
Expected: 1 skipped (`needs --run-live`).
@@ -973,7 +975,7 @@ Expected: 1 skipped (`needs --run-live`).
Run (live — real model): `cd api && python -m pytest tests/test_live_smoke.py -v --run-live`
Expected: PASS — 1 passed; a JSON call-log line is emitted to stdout, and the returned prose is a few sentences of scene description. This proves the whole loop against the real qwen3.5.
- [ ] **Step 5: Document the new env vars**
- [x] **Step 5: Document the new env vars**
Append to `api/.env.example`:
@@ -986,12 +988,12 @@ OLLAMA_TIMEOUT_SECONDS=30
CALL_LOG_PATH=
```
- [ ] **Step 6: Run the whole suite (default, hermetic)**
- [x] **Step 6: Run the whole suite (default, hermetic)**
Run: `cd api && python -m pytest -v`
Expected: PASS — every unit test from Tasks 17 green, the live smoke **skipped**, and the pre-existing Plan A suite green.
- [ ] **Step 7: Commit**
- [x] **Step 7: Commit**
```bash
git add api/conftest.py api/pytest.ini api/tests/test_live_smoke.py api/.env.example