Two thin layers closing the audit story end-to-end: - core chat --show-verdicts prints format_verdict_summary(verdicts) to stderr after each turn. Stdout stays clean for piped consumers. Format is dense and terse; designed to skim, not machine-parseable (the JSONL sink owns that contract). - FanOutSink forwards every emitted line to N sinks in declaration order. Fail-fast on first error — consistent with ADR-0040's single-sink contract (audit failures surface). Composes with any combination of JsonlFileSink / JsonlBufferSink / future sinks. Two formatters, one bundle: format_turn_event_jsonl (machine, ADR-0040) and format_verdict_summary (operator, ADR-0041) both consume the same TurnVerdicts. No risk of drift. Summary format: [identity=0.83 safety=ok ethics=VIOLATED:foo refusal=- hedge=YES] Audit story now reads end-to-end: - TurnVerdicts bundle (ADR-0039) - Machine JSONL sink (ADR-0040) - Fan-out + operator CLI (ADR-0041) Files: - chat/telemetry.py — FanOutSink dataclass, format_verdict_summary, _format_verdict_short helper - core/cli.py — --show-verdicts on chat subparser; cmd_chat prints summary to stderr when set - tests/test_telemetry_fanout_and_summary.py (new) — 13 tests - docs/decisions/ADR-0041-cli-verdicts-and-fanout.md (new) Verification: - Combined pack-layer + telemetry suite: 212 green (was 199; +13) - CLI suites unchanged: smoke 67, runtime 19, cognition 121 - core eval cognition: intent 100%, versor_closure 100% (baseline) - Manual smoke: echo "light is" | core chat --show-verdicts prints expected bracketed audit line to stderr alongside response.
273 lines
9.2 KiB
Python
273 lines
9.2 KiB
Python
"""ADR-0040 — structured-logging sink for turn-event audit.
|
|
|
|
Consumes ``TurnEvent`` records that ADR-0039 makes uniform across
|
|
main and stub paths. Emits one JSON-line per turn with deterministic
|
|
field ordering, suitable for log aggregation, replay, and offline
|
|
audit pipelines.
|
|
|
|
Trust boundary (per CLAUDE.md):
|
|
|
|
* **Metadata-only by default.** Surface text and input tokens are
|
|
redacted unless the caller explicitly opts in via
|
|
``include_content=True``. Audit needs counts, ids, and flags —
|
|
not raw content — and the redact-by-default stance prevents
|
|
accidental PII leakage when sinks point at shared log stores.
|
|
* **No implicit wall-clock.** Timestamps are caller-provided so
|
|
emission is reproducible under replay. The runtime never reaches
|
|
for ``datetime.now()`` here.
|
|
* **Append-only file paths.** ``JsonlFileSink`` opens the target in
|
|
append mode and never truncates. Path is fixed at construction;
|
|
the sink does not interpret user-controlled paths at emit time.
|
|
* **Idempotent flush.** Each ``emit()`` flushes immediately so a
|
|
crashed turn loop still has its prior turns durable on disk.
|
|
|
|
See ``docs/decisions/ADR-0040-telemetry-sink.md``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import IO, Protocol
|
|
|
|
|
|
_UNKNOWN_DOMAIN_SURFACE = "I don't know — insufficient grounding for that yet."
|
|
|
|
|
|
# ---------- pure serializer ----------
|
|
|
|
|
|
def serialize_turn_event(
|
|
event,
|
|
*,
|
|
safety_pack_id: str = "",
|
|
ethics_pack_id: str = "",
|
|
identity_pack_id: str = "",
|
|
include_content: bool = False,
|
|
timestamp: str | None = None,
|
|
) -> dict[str, object]:
|
|
"""Produce a JSON-safe audit dict from a ``TurnEvent``.
|
|
|
|
Pack ids are passed as kwargs because ``TurnEvent`` does not
|
|
carry them — the runtime knows them. Fields are typed
|
|
deliberately at the boundary so an upstream change to
|
|
``TurnEvent`` doesn't silently break the wire format; missing or
|
|
differently-typed values fall back to safe defaults.
|
|
"""
|
|
verdicts = getattr(event, "verdicts", None)
|
|
out: dict[str, object] = {
|
|
"turn": int(getattr(event, "turn", 0)),
|
|
"safety_pack_id": str(safety_pack_id),
|
|
"ethics_pack_id": str(ethics_pack_id),
|
|
"identity_pack_id": str(identity_pack_id),
|
|
"refusal_emitted": bool(getattr(verdicts, "refusal_emitted", False)),
|
|
"hedge_injected": bool(getattr(verdicts, "hedge_injected", False)),
|
|
"versor_condition": float(getattr(event, "versor_condition", 0.0)),
|
|
"vault_hits": int(getattr(event, "vault_hits", 0)),
|
|
"cycle_cost_total": float(getattr(event, "cycle_cost_total", 0.0)),
|
|
"flagged": bool(getattr(event, "flagged", False)),
|
|
"stub_path": getattr(event, "walk_surface", "") == _UNKNOWN_DOMAIN_SURFACE,
|
|
"dialogue_role": str(getattr(event, "dialogue_role", "")),
|
|
}
|
|
safety = getattr(event, "safety_verdict", None)
|
|
if safety is not None:
|
|
out["safety_violated"] = sorted(
|
|
getattr(safety, "violated_boundaries", ()) or ()
|
|
)
|
|
out["safety_runtime_checkable_count"] = int(
|
|
getattr(safety, "runtime_checkable_count", 0)
|
|
)
|
|
out["safety_upheld"] = bool(getattr(safety, "upheld", True))
|
|
ethics = getattr(event, "ethics_verdict", None)
|
|
if ethics is not None:
|
|
out["ethics_violated"] = sorted(
|
|
getattr(ethics, "violated_commitments", ()) or ()
|
|
)
|
|
out["ethics_runtime_checkable_count"] = int(
|
|
getattr(ethics, "runtime_checkable_count", 0)
|
|
)
|
|
out["ethics_upheld"] = bool(getattr(ethics, "upheld", True))
|
|
identity_score = getattr(event, "identity_score", None)
|
|
if identity_score is not None:
|
|
out["identity_alignment"] = float(
|
|
getattr(identity_score, "alignment", 0.0)
|
|
)
|
|
out["identity_flagged"] = bool(getattr(identity_score, "flagged", False))
|
|
out["identity_deviation_axes"] = sorted(
|
|
getattr(identity_score, "deviation_axes", ()) or ()
|
|
)
|
|
if include_content:
|
|
out["input_tokens"] = list(getattr(event, "input_tokens", ()))
|
|
out["surface"] = str(getattr(event, "surface", ""))
|
|
out["walk_surface"] = str(getattr(event, "walk_surface", ""))
|
|
out["articulation_surface"] = str(
|
|
getattr(event, "articulation_surface", "")
|
|
)
|
|
if timestamp is not None:
|
|
out["timestamp"] = str(timestamp)
|
|
return out
|
|
|
|
|
|
def format_turn_event_jsonl(event, **kwargs) -> str:
|
|
"""Serialize a turn event as one deterministic JSONL line.
|
|
|
|
Field order is alphabetical (``sort_keys=True``) so two emissions
|
|
of the same logical event produce byte-identical lines. No
|
|
trailing newline — the sink owns line termination.
|
|
"""
|
|
payload = serialize_turn_event(event, **kwargs)
|
|
return json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
|
|
|
|
# ---------- sink protocol ----------
|
|
|
|
|
|
class TurnEventSink(Protocol):
|
|
"""Minimal sink contract.
|
|
|
|
Sinks receive one already-serialized JSONL line per turn. The
|
|
runtime calls ``emit()`` after each ``turn_log.append()`` — see
|
|
``ChatRuntime._emit_turn_event``.
|
|
"""
|
|
|
|
def emit(self, line: str) -> None: ...
|
|
|
|
|
|
# ---------- concrete sinks ----------
|
|
|
|
|
|
@dataclass
|
|
class JsonlBufferSink:
|
|
"""In-memory sink that captures every emitted line.
|
|
|
|
Useful for tests, replay diffing, and small-volume audit where
|
|
persistence is the caller's responsibility.
|
|
"""
|
|
|
|
lines: list[str] = field(default_factory=list)
|
|
|
|
def emit(self, line: str) -> None:
|
|
self.lines.append(line)
|
|
|
|
|
|
class JsonlFileSink:
|
|
"""Append-only JSONL file sink with eager flush.
|
|
|
|
The path is fixed at construction. Each ``emit()`` flushes
|
|
immediately so a crashed runtime still has its prior turns
|
|
durable on disk. Supports context-manager usage.
|
|
"""
|
|
|
|
def __init__(self, path: str | Path) -> None:
|
|
self._path = Path(path)
|
|
self._fh: IO[str] | None = None
|
|
|
|
def emit(self, line: str) -> None:
|
|
if self._fh is None:
|
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._fh = self._path.open("a", encoding="utf-8")
|
|
self._fh.write(line)
|
|
self._fh.write("\n")
|
|
self._fh.flush()
|
|
|
|
def close(self) -> None:
|
|
if self._fh is not None:
|
|
self._fh.close()
|
|
self._fh = None
|
|
|
|
def __enter__(self) -> "JsonlFileSink":
|
|
return self
|
|
|
|
def __exit__(self, *exc_info) -> None:
|
|
self.close()
|
|
|
|
|
|
# ---------- fan-out ----------
|
|
|
|
|
|
@dataclass
|
|
class FanOutSink:
|
|
"""Forward every emitted line to N sinks in declaration order.
|
|
|
|
ADR-0041. Composes with any combination of sinks — typically
|
|
``JsonlFileSink`` (durable) + ``JsonlBufferSink`` (in-memory
|
|
audit), or two file sinks (local + shadow).
|
|
|
|
**Error semantics:** fail-fast. If sink *i* raises, sinks *i+1..*
|
|
are NOT called and the exception propagates to the caller. This
|
|
is consistent with the single-sink contract: telemetry failures
|
|
surface, never silently drop audit signal. Callers wanting
|
|
partial-success semantics wrap individual sinks in their own
|
|
error-tolerant shim.
|
|
"""
|
|
|
|
sinks: tuple = () # tuple[TurnEventSink, ...]
|
|
|
|
def emit(self, line: str) -> None:
|
|
for sink in self.sinks:
|
|
sink.emit(line)
|
|
|
|
|
|
# ---------- operator-facing summary formatter ----------
|
|
|
|
|
|
def format_verdict_summary(verdicts) -> str:
|
|
"""ADR-0041 — one-line human-readable summary of a TurnVerdicts bundle.
|
|
|
|
Used by ``core chat --show-verdicts`` to print a per-turn audit
|
|
line to the operator. Distinct from ``format_turn_event_jsonl``
|
|
(machine-facing): this is dense, terse, and skims the high-signal
|
|
fields. Empty string when ``verdicts`` is None.
|
|
|
|
Format::
|
|
|
|
[identity=0.83 safety=ok ethics=ok refusal=- hedge=-]
|
|
[identity=0.42 safety=VIOLATED:preserve_versor_closure ethics=ok refusal=YES hedge=-]
|
|
"""
|
|
if verdicts is None:
|
|
return ""
|
|
parts: list[str] = []
|
|
identity = getattr(verdicts, "identity_score", None)
|
|
if identity is not None:
|
|
alignment = float(getattr(identity, "alignment", 0.0))
|
|
parts.append(f"identity={alignment:.2f}")
|
|
else:
|
|
parts.append("identity=-")
|
|
safety = getattr(verdicts, "safety_verdict", None)
|
|
parts.append(_format_verdict_short(
|
|
safety, "safety", id_attr="violated_boundaries",
|
|
))
|
|
ethics = getattr(verdicts, "ethics_verdict", None)
|
|
parts.append(_format_verdict_short(
|
|
ethics, "ethics", id_attr="violated_commitments",
|
|
))
|
|
parts.append(
|
|
"refusal=YES" if getattr(verdicts, "refusal_emitted", False)
|
|
else "refusal=-"
|
|
)
|
|
parts.append(
|
|
"hedge=YES" if getattr(verdicts, "hedge_injected", False)
|
|
else "hedge=-"
|
|
)
|
|
return "[" + " ".join(parts) + "]"
|
|
|
|
|
|
def _format_verdict_short(verdict, label: str, *, id_attr: str) -> str:
|
|
if verdict is None:
|
|
return f"{label}=-"
|
|
violated = sorted(getattr(verdict, id_attr, ()) or ())
|
|
if not violated:
|
|
return f"{label}=ok"
|
|
return f"{label}=VIOLATED:{','.join(violated)}"
|
|
|
|
|
|
__all__ = [
|
|
"FanOutSink",
|
|
"JsonlBufferSink",
|
|
"JsonlFileSink",
|
|
"TurnEventSink",
|
|
"format_turn_event_jsonl",
|
|
"format_verdict_summary",
|
|
"serialize_turn_event",
|
|
]
|