返回 ppt-master
workflow_transcript.py
根目录 / skills / ppt-master / scripts / workflow_transcript.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Automatic Workflow Transcript
4
5 Internal runtime helper that records a project-scoped Python tool's command
6 envelope and material text outcomes in an existing project workflow log.
7
8 Dependencies:
9 None (standard library only)
10 """
11
12 from __future__ import annotations
13
14 import atexit
15 import json
16 import os
17 import sys
18 import threading
19 import time
20 import uuid
21 from collections import deque
22 from collections.abc import Iterable
23 from datetime import datetime, timezone
24 from pathlib import Path
25 from typing import TextIO
26
27 WORKFLOW_LOG_RELATIVE_PATH = Path("validation/workflow.log")
28 PROJECT_PATH_ENV = "PPT_MASTER_PROJECT_PATH"
29 DISABLE_TRANSCRIPT_ENV = "PPT_MASTER_DISABLE_WORKFLOW_TRANSCRIPT"
30 _EXCLUDED_ENTRYPOINTS = {"workflow_log.py"}
31 _CRITICAL_MARKERS = ("[ERROR]", "[FAIL]")
32 _ALWAYS_RETAIN_MARKERS = (
33 "[POSTFLIGHT]",
34 "[PPTX]",
35 "[REPORT]",
36 "[Done]",
37 "[OK] Done",
38 )
39 _SUMMARY_MARKER = "[SUMMARY]"
40 _SUMMARY_STOP_MARKERS = ("[TIP]",)
41 _WARNING_MARKERS = ("[WARN]", "[WARNING]")
42 _OK_MARKER = "[OK]"
43 _WARNING_SAMPLE_LIMIT = 4
44 _OK_SAMPLE_LIMIT = 1
45 _STDERR_SAMPLE_LIMIT = 8
46 _STDERR_TAIL_LIMIT = 4
47 _CRITICAL_CONTEXT_LIMIT = 4
48 _SUMMARY_CONTEXT_LIMIT = 12
49 _ACTIVE_TRANSCRIPT: _AutomaticTranscript | None = None
50
51
52 def _utc_timestamp() -> str:
53 """Return a compact UTC timestamp."""
54 return (
55 datetime.now(timezone.utc)
56 .isoformat(timespec="milliseconds")
57 .replace("+00:00", "Z")
58 )
59
60
61 def _candidate_directory(value: str) -> Path | None:
62 """Resolve one CLI value to an existing directory candidate."""
63 if not value or value.startswith("-") or "://" in value:
64 return None
65 try:
66 raw_path = Path(value)
67 except (OSError, ValueError):
68 return None
69 if not raw_path.exists() and raw_path.parent == Path("."):
70 return None
71 path = raw_path
72 if not path.is_absolute():
73 path = Path.cwd() / path
74 while not path.exists() and path != path.parent:
75 path = path.parent
76 if path.is_file():
77 path = path.parent
78 return path.resolve()
79
80
81 def _find_project_root(argv: list[str]) -> Path | None:
82 """Find the first ancestor that already owns a workflow log."""
83 explicit_project = os.environ.get(PROJECT_PATH_ENV, "").strip()
84 values = [explicit_project, *argv[1:], str(Path.cwd())]
85 for value in values:
86 directory = _candidate_directory(value)
87 if directory is None:
88 continue
89 for candidate in (directory, *directory.parents):
90 if (candidate / WORKFLOW_LOG_RELATIVE_PATH).is_file():
91 return candidate
92 return None
93
94
95 class _AutomaticTranscript:
96 """Append a bounded command/outcome audit without changing the owning tool."""
97
98 def __init__(
99 self,
100 handle: TextIO,
101 original_stderr: TextIO,
102 argv: list[str],
103 ) -> None:
104 self.handle = handle
105 self.original_stderr = original_stderr
106 self.argv = argv
107 self.run_id = uuid.uuid4().hex[:12]
108 self.started_clock = time.monotonic()
109 self.pending = {"stdout": "", "stderr": ""}
110 self.observed_lines = 0
111 self.retained_lines = 0
112 self.omitted_lines = 0
113 self.warning_samples = 0
114 self.ok_samples = 0
115 self.stderr_samples = 0
116 self.stderr_tail: deque[str] = deque(maxlen=_STDERR_TAIL_LIMIT)
117 self.critical_context_remaining = 0
118 self.summary_context_remaining = 0
119 self.current_record_stream: str | None = None
120 self.lock = threading.RLock()
121 self.enabled = True
122
123 def _disable(self, exc: OSError | UnicodeError | ValueError) -> None:
124 """Stop transcript writes without changing tool execution."""
125 self.enabled = False
126 try:
127 self.original_stderr.write(
128 f"[WARN] Workflow audit recording stopped: {exc}\n"
129 )
130 self.original_stderr.flush()
131 except (OSError, ValueError):
132 pass
133
134 def _write(self, text: str) -> None:
135 """Append and flush one complete audit record."""
136 if not self.enabled:
137 return
138 try:
139 self.handle.write(text)
140 self.handle.flush()
141 except (OSError, UnicodeError, ValueError) as exc:
142 self._disable(exc)
143
144 def start(self) -> None:
145 """Write the Python-command envelope."""
146 self._write(
147 f"\n=== {_utc_timestamp()} PYTHON run={self.run_id} ===\n"
148 f"cwd: {Path.cwd()}\n"
149 f"argv: {json.dumps(self.argv, ensure_ascii=False)}\n"
150 )
151
152 @staticmethod
153 def _is_decoration(line: str) -> bool:
154 """Return whether a non-empty line is only visual separator glyphs."""
155 stripped = line.strip()
156 return bool(stripped) and set(stripped) <= {"-", "=", "_"}
157
158 def _should_retain(self, stream_name: str, line: str) -> bool:
159 """Select a bounded set of explicit outcomes for the cold audit log."""
160 stripped = line.strip()
161 if not stripped or self._is_decoration(stripped):
162 return False
163
164 if _SUMMARY_MARKER in stripped:
165 self.summary_context_remaining = _SUMMARY_CONTEXT_LIMIT
166 return True
167 if any(marker in stripped for marker in _SUMMARY_STOP_MARKERS):
168 self.summary_context_remaining = 0
169 return False
170 if self.summary_context_remaining > 0:
171 self.summary_context_remaining -= 1
172 return True
173 if any(marker in stripped for marker in _CRITICAL_MARKERS):
174 self.critical_context_remaining = _CRITICAL_CONTEXT_LIMIT
175 return True
176 if self.critical_context_remaining > 0:
177 self.critical_context_remaining -= 1
178 return True
179 if any(marker in stripped for marker in _ALWAYS_RETAIN_MARKERS):
180 return True
181 if any(marker in stripped for marker in _WARNING_MARKERS):
182 if self.warning_samples < _WARNING_SAMPLE_LIMIT:
183 self.warning_samples += 1
184 return True
185 return False
186 if _OK_MARKER in stripped:
187 if self.ok_samples < _OK_SAMPLE_LIMIT:
188 self.ok_samples += 1
189 return True
190 return False
191 if stream_name == "stderr" and self.stderr_samples < _STDERR_SAMPLE_LIMIT:
192 self.stderr_samples += 1
193 return True
194 return False
195
196 def _record_line(self, stream_name: str, line: str) -> None:
197 """Record one material logical line or account for its omission."""
198 if not line.strip():
199 return
200 self.observed_lines += 1
201 if not self._should_retain(stream_name, line):
202 self.omitted_lines += 1
203 if stream_name == "stderr" and not self._is_decoration(line):
204 self.stderr_tail.append(line)
205 return
206 if stream_name == "stderr":
207 self.stderr_tail.clear()
208 self.retained_lines += 1
209 if self.current_record_stream != stream_name:
210 self._write(f"{stream_name}:\n")
211 self.current_record_stream = stream_name
212 self._write(f" {line}\n")
213
214 def write_stream(self, stream_name: str, text: str) -> None:
215 """Buffer fragments and write complete logical lines."""
216 if not text or not self.enabled:
217 return
218 with self.lock:
219 pending = self.pending[stream_name] + text
220 while "\n" in pending:
221 line, pending = pending.split("\n", 1)
222 self._record_line(stream_name, line)
223 self.pending[stream_name] = pending
224
225 def flush_stream(self, stream_name: str) -> None:
226 """Flush a partial logical line when the owning stream flushes."""
227 with self.lock:
228 if not self.enabled:
229 self.pending[stream_name] = ""
230 return
231 pending = self.pending[stream_name]
232 if pending:
233 self._record_line(stream_name, pending)
234 self.pending[stream_name] = ""
235
236 def close(self) -> None:
237 """Flush pending output and close the command envelope."""
238 with self.lock:
239 if not self.enabled:
240 return
241 self.flush_stream("stdout")
242 self.flush_stream("stderr")
243 if self.stderr_tail:
244 self._write("stderr-tail:\n")
245 for line in self.stderr_tail:
246 self._write(f" {line}\n")
247 self.retained_lines += len(self.stderr_tail)
248 self.omitted_lines -= len(self.stderr_tail)
249 elapsed_ms = int((time.monotonic() - self.started_clock) * 1000)
250 self._write(
251 f"=== {_utc_timestamp()} END run={self.run_id} "
252 f"elapsed_ms={elapsed_ms} output_lines={self.observed_lines} "
253 f"retained={self.retained_lines} omitted={self.omitted_lines} ===\n"
254 )
255 self.enabled = False
256 try:
257 self.handle.close()
258 except (OSError, ValueError) as exc:
259 self._disable(exc)
260
261
262 class _TeeTextIO:
263 """Forward text writes while offering them to the audit recorder."""
264
265 def __init__(
266 self,
267 primary: TextIO,
268 transcript: _AutomaticTranscript,
269 stream_name: str,
270 ) -> None:
271 self.primary = primary
272 self.transcript = transcript
273 self.stream_name = stream_name
274 self.lock = threading.RLock()
275
276 def write(self, text: str) -> int:
277 """Write to the original stream, then mirror the same text."""
278 with self.lock:
279 written = self.primary.write(text)
280 self.transcript.write_stream(self.stream_name, text)
281 return written
282
283 def writelines(self, lines: Iterable[str]) -> None:
284 """Forward a sequence through the ordinary write path."""
285 for line in lines:
286 self.write(line)
287
288 def flush(self) -> None:
289 """Flush the original stream without splitting a logical log line."""
290 with self.lock:
291 self.primary.flush()
292
293 def __getattr__(self, name: str) -> object:
294 """Delegate the remaining text-stream interface."""
295 return getattr(self.primary, name)
296
297
298 def install_auto_transcript(argv: list[str] | None = None) -> Path | None:
299 """Record a project-scoped Python command when its project log exists."""
300 global _ACTIVE_TRANSCRIPT
301
302 effective_argv = list(sys.argv if argv is None else argv)
303 if _ACTIVE_TRANSCRIPT is not None:
304 return None
305 if os.environ.get(DISABLE_TRANSCRIPT_ENV):
306 return None
307 if Path(effective_argv[0]).name in _EXCLUDED_ENTRYPOINTS:
308 return None
309
310 project_root = _find_project_root(effective_argv)
311 if project_root is None:
312 return None
313 log_path = project_root / WORKFLOW_LOG_RELATIVE_PATH
314 original_stdout = sys.stdout
315 original_stderr = sys.stderr
316 try:
317 handle = log_path.open(
318 "a",
319 encoding="utf-8",
320 errors="replace",
321 newline="",
322 )
323 except OSError as exc:
324 try:
325 original_stderr.write(
326 f"[WARN] Workflow audit unavailable ({log_path}): {exc}\n"
327 )
328 original_stderr.flush()
329 except (OSError, ValueError):
330 pass
331 return None
332
333 transcript = _AutomaticTranscript(
334 handle,
335 original_stderr,
336 [sys.executable, *effective_argv],
337 )
338 transcript.start()
339 _ACTIVE_TRANSCRIPT = transcript
340 sys.stdout = _TeeTextIO(original_stdout, transcript, "stdout")
341 sys.stderr = _TeeTextIO(original_stderr, transcript, "stderr")
342 atexit.register(transcript.close)
343 return log_path
344
344 lines PYTHON