Feat/enhanced timestamped transcript (#501)

* feat: add additional timestamps in call transcript optionally

* fi: timestamp precision to millisec instead of micro

* fix: address enhanced transcript review issues

* fix: non vad user turn timestamp
This commit is contained in:
Sabiha Khan 2026-07-07 14:47:43 +05:30 committed by GitHub
parent d9b9a1efc8
commit ac01f7775e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 441 additions and 15 deletions

View file

@ -107,6 +107,12 @@ class InMemoryLogsBuffer:
self._turn_counter = 0
self._current_node_id: Optional[str] = None
self._current_node_name: Optional[str] = None
self._user_speech_start_timestamp: Optional[str] = None
self._user_speech_end_timestamp: Optional[str] = None
self._user_speech_start_from_vad = False
self._user_speech_end_from_vad = False
self._bot_speech_start_timestamp: Optional[str] = None
self._bot_speech_end_timestamp: Optional[str] = None
def set_current_node(self, node_id: str, node_name: str):
"""Set the current node ID and name to be injected into subsequent events."""
@ -123,11 +129,126 @@ class InMemoryLogsBuffer:
"""Get the current node name."""
return self._current_node_name
@staticmethod
def _now_iso() -> str:
return datetime.now(UTC).isoformat(timespec="milliseconds")
def mark_user_started_speaking(
self, timestamp: Optional[str] = None, *, from_vad: bool = False
):
"""Record when the user started speaking for the current turn."""
vad_interval_is_open = (
self._user_speech_start_from_vad and self._user_speech_end_timestamp is None
)
if vad_interval_is_open and not from_vad:
return
self._user_speech_start_timestamp = timestamp or self._now_iso()
self._user_speech_end_timestamp = None
self._user_speech_start_from_vad = from_vad
self._user_speech_end_from_vad = False
self._update_latest_payload_start_timestamp(
RealtimeFeedbackType.USER_TRANSCRIPTION.value,
self._user_speech_start_timestamp,
require_final=True,
)
def mark_user_stopped_speaking(
self, timestamp: Optional[str] = None, *, from_vad: bool = False
):
"""Record when the user stopped speaking and update the latest user event."""
if self._user_speech_end_from_vad and not from_vad:
return
self._user_speech_end_timestamp = timestamp or self._now_iso()
self._user_speech_end_from_vad = from_vad
self._update_latest_payload_end_timestamp(
RealtimeFeedbackType.USER_TRANSCRIPTION.value,
self._user_speech_end_timestamp,
require_final=True,
)
def mark_bot_started_speaking(self, timestamp: Optional[str] = None):
"""Record when the bot started speaking for the current assistant turn."""
self._bot_speech_start_timestamp = timestamp or self._now_iso()
self._bot_speech_end_timestamp = None
self._update_latest_payload_start_timestamp(
RealtimeFeedbackType.BOT_TEXT.value,
self._bot_speech_start_timestamp,
)
def mark_bot_stopped_speaking(self, timestamp: Optional[str] = None):
"""Record when the bot stopped speaking and update the latest bot event."""
self._bot_speech_end_timestamp = timestamp or self._now_iso()
self._update_latest_payload_end_timestamp(
RealtimeFeedbackType.BOT_TEXT.value,
self._bot_speech_end_timestamp,
)
def _find_latest_open_payload(
self, event_type: str, *, require_final: bool = False
) -> dict | None:
for event in reversed(self._events):
if event.get("type") != event_type:
continue
payload = event.get("payload")
if not isinstance(payload, dict):
continue
if require_final and payload.get("final") is not True:
continue
if payload.get("end_timestamp"):
continue
return payload
return None
def _update_latest_payload_start_timestamp(
self, event_type: str, start_timestamp: str, *, require_final: bool = False
):
payload = self._find_latest_open_payload(
event_type, require_final=require_final
)
if payload is not None:
payload["timestamp"] = start_timestamp
def _update_latest_payload_end_timestamp(
self, event_type: str, end_timestamp: str, *, require_final: bool = False
):
payload = self._find_latest_open_payload(
event_type, require_final=require_final
)
if payload is not None:
payload["end_timestamp"] = end_timestamp
def _event_with_speech_timestamps(self, event: dict) -> dict:
event_type = event.get("type")
payload = event.get("payload")
if not isinstance(payload, dict):
return event
payload_with_timestamps = dict(payload)
if (
event_type == RealtimeFeedbackType.USER_TRANSCRIPTION.value
and payload.get("final") is True
):
if self._user_speech_start_timestamp:
payload_with_timestamps["timestamp"] = self._user_speech_start_timestamp
if self._user_speech_end_timestamp:
payload_with_timestamps["end_timestamp"] = self._user_speech_end_timestamp
elif event_type == RealtimeFeedbackType.BOT_TEXT.value:
bot_interval_is_active = self._bot_speech_end_timestamp is None
if bot_interval_is_active and self._bot_speech_start_timestamp:
payload_with_timestamps["timestamp"] = self._bot_speech_start_timestamp
if payload_with_timestamps == payload:
return event
return {**event, "payload": payload_with_timestamps}
async def append(self, event: dict):
"""Append a feedback event to the buffer with timestamp and current node."""
event = self._event_with_speech_timestamps(event)
timestamped_event = stamp_realtime_feedback_event(
event,
timestamp=datetime.now(UTC).isoformat(),
timestamp=self._now_iso(),
turn=self._turn_counter,
node_id=self._current_node_id,
node_name=self._current_node_name,
@ -166,13 +287,15 @@ class InMemoryLogsBuffer:
return True
return False
def generate_transcript_text(self) -> str:
def generate_transcript_text(self, *, include_end_timestamps: bool = False) -> str:
"""Generate transcript text from logged events.
Filters for rtf-user-transcription (final) and rtf-bot-text events,
formats them as '[timestamp] user/assistant: text\\n'.
"""
return _generate_transcript_text(self._sorted_events())
return _generate_transcript_text(
self._sorted_events(), include_end_timestamps=include_end_timestamps
)
@property
def is_empty(self) -> bool: