"""Record what was said and what came back, one line of JSON per turn. Every improvement so far has come from measuring something rather than guessing at it, and there is no record of real conversations to measure. This writes one so questions like "which words does it mishear most often" have an answer that isn't synthesised audio. The two halves arrive by different routes, which is the awkward part. A transcript reaches this processor as a frame, but replies never do: the user aggregator *consumes* TranscriptionFrames rather than forwarding them, so a processor placed late enough to see LLMTextFrames is already too late to see the transcript. Sitting early and taking the reply through the LLM's own callback is the only placement that sees both. Deliberately not in git: it is append-only, grows without bound, and is a verbatim record of everything said near the microphone. """ import json from datetime import datetime from pathlib import Path from loguru import logger from pipecat.frames.frames import CancelFrame, EndFrame, Frame, TranscriptionFrame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor class Journal(FrameProcessor): """Log each turn as JSONL. Place it just after the transcript repair.""" def __init__(self, path: Path, **kwargs): super().__init__(**kwargs) self._path = path self._heard: str | None = None async def process_frame(self, frame: Frame, direction: FrameDirection): await super().process_frame(frame, direction) if isinstance(frame, TranscriptionFrame): # A second transcript before any reply means the previous turn was # abandoned — record it anyway, since an unanswered utterance is # exactly the kind of failure worth being able to find later. if self._heard: self._write(self._heard, None) self._heard = frame.text elif isinstance(frame, (EndFrame, CancelFrame)) and self._heard: self._write(self._heard, None) self._heard = None await self.push_frame(frame, direction) def record_reply(self, reply: str): """Called by the LLM with each completed answer.""" self._write(self._heard, reply) self._heard = None def _write(self, heard: str | None, reply: str | None): if not heard and not reply: return entry = { "at": datetime.now().isoformat(timespec="seconds"), "heard": heard, "reply": reply, } try: self._path.parent.mkdir(parents=True, exist_ok=True) with self._path.open("a") as f: f.write(json.dumps(entry) + "\n") except OSError as e: logger.debug(f"Could not write the journal: {e}")