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:
@@ -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)
|
||||
|
||||
@@ -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 1–4 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 1–7 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
|
||||
|
||||
Reference in New Issue
Block a user