core/chat/telemetry.py
Shay 417f71917c feat(adr-0041): core chat --show-verdicts + FanOutSink
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.
2026-05-17 21:47:47 -07:00

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