From 15995c57ee87e9a9531d452a4fc0fce767a82531 Mon Sep 17 00:00:00 2001 From: Phillip Tarrant Date: Fri, 10 Jul 2026 08:20:00 -0500 Subject: [PATCH] =?UTF-8?q?feat(api):=20=C2=A710=20JSON-lines=20call=20log?= =?UTF-8?q?ging=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