oc_chat
Loading...
Searching...
No Matches
logging_presenter.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_ui/terminal/logging_presenter.py
4
5"""
6Decorator presenter that tees every event to a JSON-lines file.
7
8Purpose
9-------
10Wraps any concrete :class:`~oc_ui.presenter_base.Presenter` and emits
11one JSON object per call to *<path>*. The wrapped presenter still
12renders normally — :class:`LoggingPresenter` is a strict pass-through
13that just records.
14
15Use cases:
16
17* **Replay** — drive a different presenter through a recorded session
18 to compare rendering changes without re-running the model.
19* **CI diffing** — capture the event stream for ``ConsolePresenter``
20 and ``RichPresenter`` against the same scripted input and assert
21 they emit the same logical events.
22* **Debugging** — when a user reports a UI glitch, asking them to
23 re-run with ``--log-events sessions/<name>/events.jsonl`` produces
24 a reproducible trace.
25
26The format is intentionally minimal — one ``{event, args, ts}``
27object per line, using :func:`json.dumps` with ``ensure_ascii=False``.
28The file is opened in append mode so multiple turns concatenate into
29the same log; ``flush=True`` after each event keeps the log usable
30from ``tail -f`` for live monitoring.
31
32Responsibilities
33----------------
34- :class:`LoggingPresenter` — implement every :class:`Presenter` hook
35 by writing a JSON line to the configured path *and* delegating to
36 the wrapped presenter.
37- Provide a ``close`` method so callers can flush the log
38 deterministically at session shutdown.
39
40Diagnostics
41-----------
42Domain: OC_UI.TERMINAL
43Levels:
44 L2 — lifecycle events
45 L3 — semantic details
46 L4 — deep tracing
47
48Contracts
49---------
50- The decorator is *strict pass-through*: every event reaches the
51 inner presenter, in order, with unmodified arguments.
52- Each line is a complete JSON object terminated by ``\\n``; partial
53 writes are not permitted.
54- The log file is append-mode — never truncated.
55- Failures while writing the log are surfaced as exceptions; the
56 decorator does not silently drop events.
57
58Dependencies
59------------
60- oc_diagnostics (structured debug logging via _dbg)
61- oc_ui.presenter_base (Presenter ABC contract)
62- oc_chat.core.iteration_stat (lazy; show_iteration_stat imports its JSON and human-line formatters, currently unused)
63- oc_chat.core.session (lazy; ChatSession type hint for show_welcome and bind_session)
64- oc_ui.terminal.statusline (lazy; StatusBarState type hint for bind_status)
65"""
66from __future__ import annotations
67
68import json
69import time
70from contextlib import AbstractContextManager
71from pathlib import Path
72from typing import TYPE_CHECKING, Any, Callable
73
74from oc_diagnostics import _dbg
75
76from oc_ui.presenter_base import Presenter, SpinnerHandle
77
78if TYPE_CHECKING:
79 from oc_chat.core.session import ChatSession
80 from oc_ui.terminal.statusline import StatusBarState
81
82
83class _TeeContext(AbstractContextManager):
84 """Wrap an inner contextmanager + record entry/exit events."""
85
86 def __init__(self, inner: AbstractContextManager,
87 emit, kind: str = "turn") -> None:
88 self._inner = inner
89 self._emit = emit
90 self._kind = kind
91
92 def __enter__(self):
93 self._emit(f"{self._kind}.enter", {})
94 return self._inner.__enter__()
95
96 def __exit__(self, exc_type, exc, tb):
97 self._emit(f"{self._kind}.exit", {
98 "exc_type": exc_type.__name__ if exc_type else None,
99 })
100 return self._inner.__exit__(exc_type, exc, tb)
101
102
104 """A :class:`Presenter` that tees every event to a JSONL log.
105
106 All :class:`Presenter` methods are pass-through to *inner*; in
107 addition, each call appends a JSON line to *path* of the form::
108
109 {"ts": 1714809000.123, "event": "show_content", "args": {"text": "..."}}
110
111 *truncate* opens the log fresh (overwriting any previous content);
112 the default is to append.
113 """
114
115 def __init__(self, inner: Presenter, path: str, truncate: bool = False) -> None:
116 func = "__init__"
117 self._inner = inner
118 self._path = Path(path)
119 self._path.parent.mkdir(parents=True, exist_ok=True)
120 mode = "w" if truncate else "a"
121 # Line-buffered, utf-8 — survives tail -f and never holds a write.
122 self._file = self._path.open(mode, encoding="utf-8", buffering=1)
123 _dbg("OC_UI.TERMINAL", func, f"LoggingPresenter path={path} truncate={truncate}", 2)
124
125 # ── Internal ──────────────────────────────────────────────────────────
126
127 def _emit(self, event: str, args: dict[str, Any]) -> None:
128 record = {
129 "ts": round(time.time(), 3),
130 "event": event,
131 "args": args,
132 }
133 try:
134 line = json.dumps(record, ensure_ascii=False)
135 except (TypeError, ValueError):
136 # Fall back to a repr-based serialisation if a non-JSONable
137 # object slips through. We never want logging to crash the
138 # presenter.
139 safe_args = {k: repr(v) for k, v in args.items()}
140 line = json.dumps({"ts": record["ts"], "event": event,
141 "args": safe_args, "_repr_fallback": True},
142 ensure_ascii=False)
143 self._file.write(line + "\n")
144
145 def close(self) -> None:
146 """Close the underlying log file. Safe to call multiple times."""
147 if not self._file.closed:
148 self._file.close()
149
150 # ── Pass-through with tee ─────────────────────────────────────────────
151
152 def turn(self) -> AbstractContextManager:
153 return _TeeContext(self._inner.turn(), self._emit, kind="turn")
154
155 def start_turn(self) -> None:
156 self._emit("start_turn", {})
157 self._inner.start_turn()
158
159 def end_turn(self) -> None:
160 self._emit("end_turn", {})
161 self._inner.end_turn()
162
163 def show_waiting(self) -> None:
164 self._emit("show_waiting", {})
165 self._inner.show_waiting()
166
167 def show_reasoning(self, text: str) -> None:
168 self._emit("show_reasoning", {"text": text})
169 self._inner.show_reasoning(text)
170
171 def show_content(self, text: str) -> None:
172 self._emit("show_content", {"text": text})
173 self._inner.show_content(text)
174
175 def show_tool_call(self, name: str, arguments: str) -> None:
176 self._emit("show_tool_call", {"name": name, "arguments": arguments})
177 self._inner.show_tool_call(name, arguments)
178
179 def show_tool_progress(self, name: str) -> None:
180 self._emit("show_tool_progress", {"name": name})
181 self._inner.show_tool_progress(name)
182
183 def show_tool_result(self, name: str, result: object) -> None:
184 self._emit("show_tool_result", {"name": name, "result": result})
185 self._inner.show_tool_result(name, result)
186
187 def show_usage(self, usage: dict) -> None:
188 self._emit("show_usage", {"usage": usage})
189 self._inner.show_usage(usage)
190
191 def show_activity(self, label: str, detail: str = "", *, tone: str = "info") -> None:
192 """Record the plan 188 T2 activity line and delegate.
193
194 Strict pass-through like every other hook — the decorator records
195 the logical event and the wrapped presenter renders it.
196 """
197 self._emit("show_activity", {"label": label, "detail": detail, "tone": tone})
198 self._inner.show_activity(label, detail, tone=tone)
199
200 def show_session_start(self) -> None:
201 """Record the plan 218 session-opening rule and delegate.
202
203 Strict pass-through like every other hook: the decorator records
204 the logical event and the wrapped presenter draws the rule.
205 """
206 self._emit("show_session_start", {})
208
209 def show_interrupt_notice(self, stage: str) -> None:
210 """Record the plan 188 T5 Ctrl+C stage and delegate.
211
212 Signal-handler context: ``_emit`` is one buffered line write and the
213 inner presenter owns the terminal write, so this stays within the
214 one-short-write budget the hook's contract sets.
215 """
216 self._emit("show_interrupt_notice", {"stage": stage})
217 self._inner.show_interrupt_notice(stage)
218
219
220 def show_iteration_stat(self, stat: "IterationStat") -> None:
221 """Log and delegate iteration stat."""
222 from oc_chat.core.iteration_stat import iteration_stat_to_json, iteration_stat_human_line
223 self._emit("show_iteration_stat", {"stat": stat})
224 self._inner.show_iteration_stat(stat)
225 def show_turn_summary(self, prompt: int, completion: int, total: int,
226 iterations: int, tool_calls: int,
227 prompt_cached: int | None = None, *,
228 cache_hit_pct: float | None = None,
229 cache_saved_tokens: int | None = None,
230 performance_stats: object = None,
231 model: str | None = None,
232 context_used_pct: float | None = None,
233 duration_ms: int | None = None,
234 turn_cost: object = None) -> None:
235 # Plan 188 T4: record the cost as flat JSON-able fields (the dataclass
236 # itself is not JSON-serialisable) so a replay can audit both the
237 # figure AND the rate-table provenance behind it. ``None`` stays None —
238 # an unpriceable turn must read as absent, not as zero.
239 _cost = None
240 if turn_cost is not None:
241 _cost = {
242 "total_usd": getattr(turn_cost, "total_usd", None),
243 "model": getattr(turn_cost, "model", None),
244 "provider": getattr(turn_cost, "provider", None),
245 "as_of": getattr(turn_cost, "as_of", None),
246 "source": getattr(turn_cost, "source", None),
247 }
248 self._emit("show_turn_summary", {
249 "prompt": prompt, "completion": completion, "total": total,
250 "iterations": iterations, "tool_calls": tool_calls,
251 "prompt_cached": prompt_cached,
252 "cache_hit_pct": cache_hit_pct,
253 "cache_saved_tokens": cache_saved_tokens,
254 # Plan 134 C2: record the honest context gauge + its model too.
255 "model": model,
256 "context_used_pct": context_used_pct,
257 # Plan 147 D1: the real wall-clock turn duration.
258 "duration_ms": duration_ms,
259 # Plan 188 T4: the modelled USD estimate + its price provenance.
260 "turn_cost": _cost,
261 })
263 prompt, completion, total, iterations, tool_calls, prompt_cached,
264 cache_hit_pct=cache_hit_pct, cache_saved_tokens=cache_saved_tokens,
265 performance_stats=performance_stats,
266 model=model, context_used_pct=context_used_pct,
267 duration_ms=duration_ms, turn_cost=turn_cost,
268 )
269
270 def show_welcome(self, session: ChatSession) -> None:
271 self._emit("show_welcome", {"name": getattr(session, "name", "?"),
272 "model": getattr(session, "model", "?")})
273 self._inner.show_welcome(session)
274
275 def show_available_models(self, models: dict) -> None:
276 self._emit("show_available_models", {"count": len(models)})
277 self._inner.show_available_models(models)
278
279 def show_session_list(self, sessions: list, current_name: str | None = None) -> None:
280 self._emit("show_session_list",
281 {"count": len(sessions), "current": current_name})
282 self._inner.show_session_list(sessions, current_name)
283
284 def show_help(self, command_help: dict, tools: list[str] | None = None) -> None:
285 self._emit("show_help",
286 {"command_count": len(command_help),
287 "tool_count": len(tools or [])})
288 self._inner.show_help(command_help, tools)
289
290 def show_compressed(self) -> None:
291 self._emit("show_compressed", {})
293
294 def show_error(self, msg: str) -> None:
295 self._emit("show_error", {"msg": msg})
296 self._inner.show_error(msg)
297
298 def show_info(self, msg: str) -> None:
299 self._emit("show_info", {"msg": msg})
300 self._inner.show_info(msg)
301
302 # ── Progress spinner (Phase H.4) ──────────────────────────────────────
303
304 def spinner_start(self, label: str) -> SpinnerHandle:
305 """Record the spinner_start event and delegate to the wrapped presenter."""
306 self._emit("spinner_start", {"label": label})
307 return self._inner.spinner_start(label)
308
309 def spinner_stop(self, handle: SpinnerHandle) -> None:
310 """Record the spinner_stop event and delegate to the wrapped presenter."""
311 self._emit("spinner_stop", {})
312 self._inner.spinner_stop(handle)
313
314 # ── Pass-through for optional bind_session (Rich-only) ────────────────
315
316 def bind_session(self, session: ChatSession) -> None:
317 """Forward to the wrapped presenter if it implements it.
318
319 Not part of the abstract contract — only Rich/Textual implement
320 this — but :func:`main._select_presenter` checks via ``hasattr``.
321 """
322 self._emit("bind_session", {"name": getattr(session, "name", "?")})
323 if hasattr(self._inner, "bind_session"):
324 self._inner.bind_session(session)
325
326 def bind_status(self, provider: Callable[[], StatusBarState | None]) -> None:
327 """Record the bind_status event and delegate to the wrapped presenter.
328
329 Plan 147 D1: pass-through only — a ``LoggingPresenter`` records
330 logical events and cannot see rendering, so it never calls
331 *provider* itself; it just hands it to ``self._inner``.
332 """
333 self._emit("bind_status", {})
334 self._inner.bind_status(provider)
None __init__(self, AbstractContextManager inner, emit, str kind="turn")
None show_help(self, dict command_help, list[str]|None tools=None)
None show_turn_summary(self, int prompt, int completion, int total, int iterations, int tool_calls, int|None prompt_cached=None, *, float|None cache_hit_pct=None, int|None cache_saved_tokens=None, object performance_stats=None, str|None model=None, float|None context_used_pct=None, int|None duration_ms=None, object turn_cost=None)
None show_iteration_stat(self, "IterationStat" stat)
None show_session_list(self, list sessions, str|None current_name=None)
None show_tool_call(self, str name, str arguments)
None show_activity(self, str label, str detail="", *, str tone="info")
None __init__(self, Presenter inner, str path, bool truncate=False)
None bind_status(self, Callable[[], StatusBarState|None] provider)
None show_tool_result(self, str name, object result)