feat: unify debug runtime timelines
This commit is contained in:
@@ -5,6 +5,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import (
|
||||
@@ -14,6 +16,38 @@ from pipecat.frames.frames import (
|
||||
InterruptionFrame,
|
||||
)
|
||||
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||
from pipecat.utils.time import time_now_iso8601
|
||||
|
||||
|
||||
CALL_END_EVENT_BY_STATE = {
|
||||
"pending": "conversation_end_requested",
|
||||
"speaking": "conversation_end_speaking",
|
||||
"cancelled": "conversation_end_cancelled",
|
||||
"ended": "conversation_ended",
|
||||
}
|
||||
|
||||
|
||||
def call_end_trace_event(
|
||||
state: str,
|
||||
*,
|
||||
reason: str = "",
|
||||
interruptible: bool = False,
|
||||
voice_interruptible: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Build one shared record used by live calls and headless tests."""
|
||||
|
||||
return {
|
||||
"type": "workflow-event",
|
||||
"eventId": f"wfe_{uuid4().hex[:20]}",
|
||||
"event": CALL_END_EVENT_BY_STATE.get(
|
||||
state,
|
||||
"conversation_end_state_changed",
|
||||
),
|
||||
"timestamp": time_now_iso8601(),
|
||||
**({"reason": reason} if reason else {}),
|
||||
"interruptible": interruptible,
|
||||
"voiceInterruptible": voice_interruptible,
|
||||
}
|
||||
|
||||
|
||||
class SpeechPlaybackCompletion:
|
||||
@@ -76,6 +110,7 @@ class CallEndCoordinator:
|
||||
):
|
||||
self._queue_end = queue_end
|
||||
self._on_state_change = on_state_change
|
||||
self._state_tasks: set[asyncio.Task[None]] = set()
|
||||
self._ending = False
|
||||
self._interruptible = False
|
||||
self._armed = False
|
||||
@@ -86,7 +121,7 @@ class CallEndCoordinator:
|
||||
self._tracked_speech_completions: set[SpeechPlaybackCompletion] = set()
|
||||
self._finish_after_tracked_speech = False
|
||||
self._finished = False
|
||||
self._reason = "completed"
|
||||
self._reason = ""
|
||||
|
||||
@property
|
||||
def ending(self) -> bool:
|
||||
@@ -106,6 +141,12 @@ class CallEndCoordinator:
|
||||
"""Whether transport output is currently producing bot speech."""
|
||||
return self._speaking
|
||||
|
||||
@property
|
||||
def reason(self) -> str:
|
||||
"""Current requested ending reason for client feedback and records."""
|
||||
|
||||
return self._reason
|
||||
|
||||
async def wait_until_silent(self) -> None:
|
||||
"""Wait for the transport-owned bot speech boundary."""
|
||||
await self._speech_stopped.wait()
|
||||
@@ -124,6 +165,7 @@ class CallEndCoordinator:
|
||||
"""Cancel one pending ending when its tool explicitly allows it."""
|
||||
if not self.interruptible:
|
||||
return False
|
||||
was_interruptible = self._interruptible
|
||||
self._ending = False
|
||||
self._interruptible = False
|
||||
self._armed = False
|
||||
@@ -132,12 +174,26 @@ class CallEndCoordinator:
|
||||
# Transport speech state remains authoritative: if audio is currently
|
||||
# playing, the interruption path will still emit BotStoppedSpeakingFrame.
|
||||
logger.info("用户打断结束语,已撤销待挂断状态")
|
||||
self._schedule_state("cancelled")
|
||||
self._schedule_state("cancelled", interruptible=was_interruptible)
|
||||
return True
|
||||
|
||||
def _schedule_state(self, state: str) -> None:
|
||||
def _schedule_state(
|
||||
self,
|
||||
state: str,
|
||||
*,
|
||||
interruptible: bool | None = None,
|
||||
) -> None:
|
||||
if self._on_state_change is not None:
|
||||
asyncio.create_task(self._on_state_change(state, self.interruptible))
|
||||
task = asyncio.create_task(
|
||||
self._on_state_change(
|
||||
state,
|
||||
self.interruptible
|
||||
if interruptible is None
|
||||
else interruptible,
|
||||
)
|
||||
)
|
||||
self._state_tasks.add(task)
|
||||
task.add_done_callback(self._state_tasks.discard)
|
||||
|
||||
def begin_response(self) -> None:
|
||||
"""Start tracking speech produced by one LLM response."""
|
||||
@@ -196,6 +252,9 @@ class CallEndCoordinator:
|
||||
return
|
||||
self._finished = True
|
||||
self._interruptible = False
|
||||
pending_states = tuple(self._state_tasks)
|
||||
if pending_states:
|
||||
await asyncio.gather(*pending_states, return_exceptions=True)
|
||||
await self._queue_end(self._reason)
|
||||
|
||||
async def observe(self, frame) -> None:
|
||||
|
||||
@@ -17,6 +17,7 @@ from services.conversation_history import ConversationRecorder
|
||||
from services.pipecat.call_lifecycle import (
|
||||
CallEndCoordinator,
|
||||
EndCallAfterSpeechProcessor,
|
||||
call_end_trace_event,
|
||||
)
|
||||
from services.pipecat.service_factory import (
|
||||
config_with_resource,
|
||||
@@ -208,6 +209,7 @@ def _call_end_state_frame(
|
||||
*,
|
||||
interruptible: bool,
|
||||
voice_interruptible: bool,
|
||||
reason: str = "",
|
||||
) -> OutputTransportMessageUrgentFrame:
|
||||
"""Build the client event used to render the pending call-end state."""
|
||||
return OutputTransportMessageUrgentFrame(
|
||||
@@ -216,10 +218,30 @@ def _call_end_state_frame(
|
||||
"state": state,
|
||||
"interruptible": interruptible,
|
||||
"voice_interruptible": voice_interruptible,
|
||||
**({"reason": reason} if reason else {}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _call_lifecycle_trace_frame(
|
||||
state: str,
|
||||
*,
|
||||
reason: str = "",
|
||||
interruptible: bool = False,
|
||||
voice_interruptible: bool = False,
|
||||
) -> OutputTransportMessageUrgentFrame:
|
||||
"""Build one persistent runtime record for the call-ending lifecycle."""
|
||||
|
||||
return OutputTransportMessageUrgentFrame(
|
||||
message=call_end_trace_event(
|
||||
state,
|
||||
reason=reason,
|
||||
interruptible=interruptible,
|
||||
voice_interruptible=voice_interruptible,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def run_pipeline(
|
||||
transport,
|
||||
cfg: AssistantConfig,
|
||||
@@ -307,6 +329,12 @@ async def run_pipeline(
|
||||
if worker is None:
|
||||
return
|
||||
logger.info(f"结束通话: reason={reason}")
|
||||
await worker.queue_frame(
|
||||
_call_lifecycle_trace_frame(
|
||||
"ended",
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
await worker.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={"type": "call-ended", "reason": reason}
|
||||
@@ -318,13 +346,21 @@ async def run_pipeline(
|
||||
worker = worker_holder.get("worker")
|
||||
if worker is None:
|
||||
return
|
||||
voice_interruptible = interruptible and current_enable_interrupt
|
||||
await worker.queue_frame(
|
||||
_call_end_state_frame(
|
||||
state,
|
||||
interruptible=interruptible,
|
||||
voice_interruptible=(
|
||||
interruptible and current_enable_interrupt
|
||||
),
|
||||
voice_interruptible=voice_interruptible,
|
||||
reason=call_end.reason,
|
||||
)
|
||||
)
|
||||
await worker.queue_frame(
|
||||
_call_lifecycle_trace_frame(
|
||||
state,
|
||||
reason=call_end.reason,
|
||||
interruptible=interruptible,
|
||||
voice_interruptible=voice_interruptible,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1043,6 +1079,12 @@ async def run_realtime_pipeline(
|
||||
if worker is None:
|
||||
return
|
||||
logger.info(f"结束 Realtime 通话: reason={reason}")
|
||||
await worker.queue_frame(
|
||||
_call_lifecycle_trace_frame(
|
||||
"ended",
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
await worker.queue_frame(
|
||||
OutputTransportMessageUrgentFrame(
|
||||
message={"type": "call-ended", "reason": reason}
|
||||
@@ -1054,13 +1096,21 @@ async def run_realtime_pipeline(
|
||||
worker = worker_holder.get("worker")
|
||||
if worker is None:
|
||||
return
|
||||
voice_interruptible = interruptible and cfg.enableInterrupt
|
||||
await worker.queue_frame(
|
||||
_call_end_state_frame(
|
||||
state,
|
||||
interruptible=interruptible,
|
||||
voice_interruptible=(
|
||||
interruptible and cfg.enableInterrupt
|
||||
),
|
||||
voice_interruptible=voice_interruptible,
|
||||
reason=call_end.reason,
|
||||
)
|
||||
)
|
||||
await worker.queue_frame(
|
||||
_call_lifecycle_trace_frame(
|
||||
state,
|
||||
reason=call_end.reason,
|
||||
interruptible=interruptible,
|
||||
voice_interruptible=voice_interruptible,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user