Add UserTurnCompletionLLMServiceMixin (#3518)
* Added UserTurnCompletionLLMServiceMixin class * Added 22-filter-incomplete-turns.py foundational example * Removed old 22 natural conversation foundational examples * Added test_user_turn_completion_mixin.py
This commit is contained in:
@@ -567,6 +567,54 @@ class TestLLMAssistantAggregator(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(len(stop_messages), 1)
|
||||
self.assertEqual(stop_messages[0].content, "Hello from Pipecat!")
|
||||
|
||||
async def test_turn_completion_markers_stripped_from_transcript(self):
|
||||
"""Turn completion markers should be stripped from assistant transcript."""
|
||||
from pipecat.turns.user_turn_completion_mixin import (
|
||||
USER_TURN_COMPLETE_MARKER,
|
||||
USER_TURN_INCOMPLETE_SHORT_MARKER,
|
||||
)
|
||||
|
||||
context = LLMContext()
|
||||
aggregator = LLMAssistantAggregator(context)
|
||||
|
||||
stop_messages = []
|
||||
|
||||
@aggregator.event_handler("on_assistant_turn_stopped")
|
||||
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
|
||||
stop_messages.append(message)
|
||||
|
||||
# Send text with a turn completion marker
|
||||
frames_to_send = [
|
||||
LLMFullResponseStartFrame(),
|
||||
LLMTextFrame(f"{USER_TURN_COMPLETE_MARKER} Hello from Pipecat!"),
|
||||
LLMFullResponseEndFrame(),
|
||||
]
|
||||
await run_test(aggregator, frames_to_send=frames_to_send)
|
||||
|
||||
# The marker should be stripped from the transcript
|
||||
self.assertEqual(len(stop_messages), 1)
|
||||
self.assertEqual(stop_messages[0].content, "Hello from Pipecat!")
|
||||
|
||||
# Test incomplete markers are also stripped
|
||||
stop_messages.clear()
|
||||
context2 = LLMContext()
|
||||
aggregator2 = LLMAssistantAggregator(context2)
|
||||
|
||||
@aggregator2.event_handler("on_assistant_turn_stopped")
|
||||
async def on_assistant_turn_stopped2(aggregator, message: AssistantTurnStoppedMessage):
|
||||
stop_messages.append(message)
|
||||
|
||||
frames_to_send = [
|
||||
LLMFullResponseStartFrame(),
|
||||
LLMTextFrame(USER_TURN_INCOMPLETE_SHORT_MARKER),
|
||||
LLMFullResponseEndFrame(),
|
||||
]
|
||||
await run_test(aggregator2, frames_to_send=frames_to_send)
|
||||
|
||||
# The incomplete marker should be stripped (resulting in empty content)
|
||||
self.assertEqual(len(stop_messages), 1)
|
||||
self.assertEqual(stop_messages[0].content, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
117
tests/test_user_turn_completion_mixin.py
Normal file
117
tests/test_user_turn_completion_mixin.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#
|
||||
# Copyright (c) 2024-2026, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from pipecat.frames.frames import LLMTextFrame
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
from pipecat.turns.user_turn_completion_mixin import (
|
||||
USER_TURN_COMPLETE_MARKER,
|
||||
USER_TURN_INCOMPLETE_LONG_MARKER,
|
||||
USER_TURN_INCOMPLETE_SHORT_MARKER,
|
||||
UserTurnCompletionLLMServiceMixin,
|
||||
)
|
||||
|
||||
|
||||
class MockProcessor(UserTurnCompletionLLMServiceMixin, FrameProcessor):
|
||||
"""Simple mock processor using the turn completion mixin."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TestUserUserTurnCompletionLLMServiceMixin(unittest.IsolatedAsyncioTestCase):
|
||||
"""Tests for UserUserTurnCompletionLLMServiceMixin functionality."""
|
||||
|
||||
async def test_complete_marker_pushes_text(self):
|
||||
"""Test that ✓ marker is detected and text after it is pushed normally."""
|
||||
processor = MockProcessor()
|
||||
|
||||
# Capture frames that get pushed
|
||||
pushed_frames = []
|
||||
processor.push_frame = AsyncMock(
|
||||
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
|
||||
)
|
||||
|
||||
# Simulate LLM generating: "✓ Hello there!"
|
||||
await processor._push_turn_text(f"{USER_TURN_COMPLETE_MARKER} Hello there!")
|
||||
|
||||
# Should have 2 text frames: marker (skip_tts) and content (normal)
|
||||
self.assertEqual(len(pushed_frames), 2)
|
||||
|
||||
# First frame should be the marker with skip_tts=True
|
||||
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
|
||||
self.assertEqual(pushed_frames[0].text, USER_TURN_COMPLETE_MARKER)
|
||||
self.assertTrue(pushed_frames[0].skip_tts)
|
||||
|
||||
# Second frame should be the actual text without skip_tts
|
||||
self.assertIsInstance(pushed_frames[1], LLMTextFrame)
|
||||
self.assertEqual(pushed_frames[1].text, "Hello there!")
|
||||
self.assertFalse(pushed_frames[1].skip_tts)
|
||||
|
||||
async def test_incomplete_short_marker_suppresses_text(self):
|
||||
"""Test that ○ marker suppresses text with skip_tts."""
|
||||
processor = MockProcessor()
|
||||
|
||||
pushed_frames = []
|
||||
processor.push_frame = AsyncMock(
|
||||
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
|
||||
)
|
||||
# Mock timeout to avoid needing task manager
|
||||
processor._start_incomplete_timeout = AsyncMock()
|
||||
|
||||
await processor._push_turn_text(USER_TURN_INCOMPLETE_SHORT_MARKER)
|
||||
|
||||
# Should have 1 text frame with skip_tts=True
|
||||
self.assertEqual(len(pushed_frames), 1)
|
||||
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
|
||||
self.assertEqual(pushed_frames[0].text, USER_TURN_INCOMPLETE_SHORT_MARKER)
|
||||
self.assertTrue(pushed_frames[0].skip_tts)
|
||||
|
||||
async def test_incomplete_long_marker_suppresses_text(self):
|
||||
"""Test that ◐ marker suppresses text with skip_tts."""
|
||||
processor = MockProcessor()
|
||||
|
||||
pushed_frames = []
|
||||
processor.push_frame = AsyncMock(
|
||||
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
|
||||
)
|
||||
# Mock timeout to avoid needing task manager
|
||||
processor._start_incomplete_timeout = AsyncMock()
|
||||
|
||||
await processor._push_turn_text(USER_TURN_INCOMPLETE_LONG_MARKER)
|
||||
|
||||
# Should have 1 text frame with skip_tts=True
|
||||
self.assertEqual(len(pushed_frames), 1)
|
||||
self.assertIsInstance(pushed_frames[0], LLMTextFrame)
|
||||
self.assertEqual(pushed_frames[0].text, USER_TURN_INCOMPLETE_LONG_MARKER)
|
||||
self.assertTrue(pushed_frames[0].skip_tts)
|
||||
|
||||
async def test_text_buffered_until_marker_found(self):
|
||||
"""Test that text is buffered until a marker is detected."""
|
||||
processor = MockProcessor()
|
||||
|
||||
pushed_frames = []
|
||||
processor.push_frame = AsyncMock(
|
||||
side_effect=lambda f, *args, **kwargs: pushed_frames.append(f)
|
||||
)
|
||||
|
||||
# Simulate token-by-token streaming without marker
|
||||
await processor._push_turn_text("Hello")
|
||||
await processor._push_turn_text(" there")
|
||||
|
||||
# No frames should be pushed yet (buffering)
|
||||
self.assertEqual(len(pushed_frames), 0)
|
||||
|
||||
# Now send the complete marker
|
||||
await processor._push_turn_text(f" {USER_TURN_COMPLETE_MARKER} How are you?")
|
||||
|
||||
# Now frames should be pushed
|
||||
self.assertEqual(len(pushed_frames), 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user