oc_chat
Loading...
Searching...
No Matches
replay.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/scripts/replay.py
4
5"""
6CLI shell for offline session replay over the event NDJSON — M2.7.
7
8Purpose
9-------
10A thin ``argparse`` front-end over :mod:`oc_chat.core.replay`. Resolves an event
11directory, reads + reconstructs the session tree, and prints it as text or
12``replay/v0`` JSON. Three subcommands: ``session`` (full tree), ``turn`` (one
13turn by id or ≥4-char prefix), ``summary`` (per-kind counts only). Never calls a
14provider; never mutates the log.
15
16Responsibilities
17----------------
18- :func:`main` — parse args, dispatch, map outcomes to exit codes (0 ok,
19 1 unexpected error, 2 user error) with a one-line ``error:`` on stderr.
20- :func:`_resolve_turn` — turn-id prefix matching with ambiguity handling.
21
22Diagnostics
23-----------
24Domain: OC_CHAT.REPLAY
25Levels:
26 L2 — CLI invocation + chosen subcommand.
27 L3 — resolution detail (event count, matched turn).
28 L4 — module-load coverage marker.
29
30Contracts
31---------
32- Exit 0 on success, 2 on user error (missing dir / bad prefix / bad args),
33 1 on unexpected exception; all errors print ``error: …`` to stderr first.
34- Read-only: no provider calls, no mutation of the event log.
35
36Dependencies
37------------
38- oc_diagnostics (structured debug logging via _dbg)
39- oc_chat.core.replay (read_events_from_dir, build_replay_tree, renderers)
40"""
41from __future__ import annotations
42
43import argparse
44import sys
45from pathlib import Path
46from typing import Optional, Sequence
47
48from oc_diagnostics import _dbg
49
50from oc_chat.core.replay import (
51 ReplayTree,
52 build_replay_tree,
53 read_events_from_dir,
54 render_tree_json,
55 render_tree_text,
56)
57
58_dbg("OC_CHAT.REPLAY", "replay_cli", "module", 4)
59
60_MIN_PREFIX = 4
61
62
63class _UserError(Exception):
64 """Raised for user-facing errors that map to exit code 2."""
65
66
67def _load_tree(event_dir: str) -> ReplayTree:
68 """Read *event_dir* and build the replay tree; raise :class:`_UserError` if missing."""
69 base = Path(event_dir)
70 if not base.exists():
71 raise _UserError(f"event directory not found: {event_dir}")
72 events, skipped = read_events_from_dir(base)
73 tree = build_replay_tree(events, skipped=skipped)
74 tree.source_dir = str(base)
75 return tree
76
77
78def _resolve_turn(tree: ReplayTree, prefix: str) -> str:
79 """Return the single ``turn_id`` matching *prefix* (≥4 chars); raise on miss/ambiguity."""
80 if len(prefix) < _MIN_PREFIX:
81 raise _UserError(f"turn-id prefix must be at least {_MIN_PREFIX} characters")
82 matches = [t.turn_id for t in tree.turns if t.turn_id.startswith(prefix)]
83 if not matches:
84 raise _UserError(f"no turn matches prefix '{prefix}'")
85 if len(matches) > 1:
86 raise _UserError(f"prefix '{prefix}' matches {len(matches)} turns; please narrow to a longer prefix")
87 return matches[0]
88
89
90def _build_parser() -> argparse.ArgumentParser:
91 """Build the ``replay`` argument parser with its three subcommands."""
92 parser = argparse.ArgumentParser(
93 prog="python -m oc_chat.scripts.replay",
94 description="Offline replay of an OC Chat session from its event NDJSON (no provider calls).",
95 )
96 sub = parser.add_subparsers(dest="cmd", required=True)
97
98 p_session = sub.add_parser("session", help="render the full session tree")
99 p_session.add_argument("event_dir")
100 p_session.add_argument("--json", action="store_true", help="emit canonical replay/v0 JSON")
101 p_session.add_argument("--limit", type=int, default=None, help="render only the last N turns")
102
103 p_turn = sub.add_parser("turn", help="render one turn by id or >=4-char prefix")
104 p_turn.add_argument("event_dir")
105 p_turn.add_argument("turn_id")
106 p_turn.add_argument("--json", action="store_true", help="emit canonical replay/v0 JSON")
107
108 p_summary = sub.add_parser("summary", help="print per-kind counts + turn count only")
109 p_summary.add_argument("event_dir")
110 p_summary.add_argument("--json", action="store_true", help="emit canonical replay/v0 JSON")
111 return parser
112
113
114def _run_summary(tree: ReplayTree, as_json: bool) -> None:
115 """Print the cheap per-kind count view for *tree*."""
116 if as_json:
117 print(render_tree_json(ReplayTree(
118 session_id=tree.session_id, turn_count=tree.turn_count,
119 event_count=tree.event_count, skipped_count=tree.skipped_count,
120 source_dir=tree.source_dir, event_counts=tree.event_counts, turns=[],
121 )))
122 return
123 print(f"── Replay summary: session {(tree.session_id or '-')[:8]} ──")
124 print(f" events: {tree.event_count} turns: {tree.turn_count} skipped: {tree.skipped_count}")
125 for kind in sorted(tree.event_counts):
126 print(f" {kind:<24} {tree.event_counts[kind]}")
127
128
129def main(argv: Optional[Sequence[str]] = None) -> int:
130 """Entry point. Returns the process exit code (0 ok, 1 error, 2 user error)."""
131 func = "main"
132 for _stream in (sys.stdout, sys.stderr):
133 try:
134 _stream.reconfigure(errors="replace")
135 except (AttributeError, ValueError):
136 pass # non-reconfigurable stream (tests, pipes wrapped by harnesses)
137 parser = _build_parser()
138 args = parser.parse_args(argv)
139 try:
140 _dbg("OC_CHAT.REPLAY", func, f"cmd={args.cmd} dir={args.event_dir}", 2)
141 tree = _load_tree(args.event_dir)
142 if args.cmd == "summary":
143 _run_summary(tree, args.json)
144 elif args.cmd == "turn":
145 turn_id = _resolve_turn(tree, args.turn_id)
146 print(render_tree_json(tree, single_turn_id=turn_id) if args.json
147 else render_tree_text(tree, single_turn_id=turn_id))
148 else: # session
149 if args.limit is not None and args.limit >= 0:
150 tree.turns = tree.turns[-args.limit:]
151 print(render_tree_json(tree) if args.json else render_tree_text(tree))
152 return 0
153 except _UserError as exc:
154 print(f"error: {exc}", file=sys.stderr)
155 return 2
156 except Exception as exc: # noqa: BLE001 — CLI boundary: no stack trace to the user
157 _dbg("OC_CHAT.REPLAY", func, f"SYSTEM_ERROR: {type(exc).__name__}: {exc}", 1, override=True)
158 print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr)
159 return 1
160
161
162if __name__ == "__main__": # pragma: no cover
163 sys.exit(main())
ReplayTree _load_tree(str event_dir)
Definition replay.py:67
argparse.ArgumentParser _build_parser()
Definition replay.py:90
str _resolve_turn(ReplayTree tree, str prefix)
Definition replay.py:78
None _run_summary(ReplayTree tree, bool as_json)
Definition replay.py:114