feat(api): narrate service — render→route→call→log, returns raw prose
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
37
api/app/narrate.py
Normal file
37
api/app/narrate.py
Normal 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
|
||||||
43
api/tests/test_narrate.py
Normal file
43
api/tests/test_narrate.py
Normal 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"]
|
||||||
Reference in New Issue
Block a user