72 lines
2.8 KiB
Python
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}")
|