Files
VoiceAgent/journal.py
T
2026-08-07 18:15:36 -04:00

72 lines
2.8 KiB
Python

"""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}")