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"]