Make aggregate return an AsyncIterator, other clean up

This commit is contained in:
Mark Backman
2025-12-03 14:46:30 -05:00
parent fa8e7458e1
commit d79dd94019
15 changed files with 259 additions and 378 deletions

View File

@@ -15,18 +15,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved interruption handling to prevent bots from repeating themselves.
LLM services that return multiple sentences in a single response (e.g.,
`GoogleLLMService`) are now split into individual sentences before being sent
to TTS. This ensures interruptions occur at sentence boundaries, preventing
the bot from repeating content after being interrupted during long responses.
- Text Aggregation Improvements:
- Updated `LLMTextProcessor` and `TTSService` to normalize text input by
splitting into individual characters before aggregation. This ensures proper
sentence boundary detection when LLMs return multiple sentences in a single
chunk (e.g., Google Gemini).
- All text aggregators now properly support character-by-character streaming
input.
- **Breaking Change**: `BaseTextAggregator.aggregate()` now returns
`AsyncIterator[Aggregation]` instead of `Optional[Aggregation]`. This
enables the aggregator to return multiple results based on the provided
text.
- Refactored text aggregators to use inheritance: `SkipTagsAggregator` and
`PatternPairAggregator` now inherit from `SimpleTextAggregator`, reusing
its lookahead-based sentence detection logic via
`_check_sentence_with_lookahead()`.
the base class's sentence detection logic.
- Updated `AICFilter` to use Quail STT as the default model
(`AICModelType.QUAIL_STT`). Quail STT is optimized for human-to-machine
@@ -54,6 +57,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed an issue where `LLMTextFrame.skip_tts` was being overwritten by LLM
services.
- Fixed sentence aggregation to correctly handle ambiguous punctuation in
streaming text, such as currency ("$29.95") and abbreviations ("Mr. Smith").
- Fixed bug in `PatternPairAggregator` where pattern handlers could be called
multiple times for `KEEP` or `AGGREGATE` patterns.
- Fixed an issue in `SarvamTTSService` where the last sentence was not being
spoken. Now, audio is flushed when the TTS services receives the
`LLMFullResponseEndFrame` or `EndFrame`.
@@ -66,10 +75,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
voice-ui-kit's conversational panel rending of the LLM output after a
function call.
- Fixed a bug in `PatternPairAggregator` where pattern handlers could be called
multiple times for patterns with `MatchAction.KEEP` or `MatchAction.AGGREGATE`
actions.
## [0.0.96] - 2025-11-26 🦃 "Happy Thanksgiving!" 🦃
### Added

View File

@@ -18,8 +18,10 @@ from loguru import logger
from pipecat.audio.dtmf.types import KeypadEntry
from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.frames.frames import (
EndFrame,
Frame,
LLMContextFrame,
LLMFullResponseEndFrame,
LLMMessagesUpdateFrame,
LLMTextFrame,
OutputDTMFUrgentFrame,
@@ -149,11 +151,18 @@ class IVRProcessor(FrameProcessor):
elif isinstance(frame, LLMTextFrame):
# Process text through the pattern aggregator
result = await self._aggregator.aggregate(frame.text)
if result:
async for result in self._aggregator.aggregate(frame.text):
# Push aggregated text that doesn't contain XML patterns
await self.push_frame(LLMTextFrame(result.text), direction)
elif isinstance(frame, (LLMFullResponseEndFrame, EndFrame)):
# Flush any remaining text from the aggregator
remaining = await self._aggregator.flush()
if remaining:
await self.push_frame(LLMTextFrame(remaining.text), direction)
# Push the end frame
await self.push_frame(frame, direction)
else:
await self.push_frame(frame, direction)

View File

@@ -24,7 +24,6 @@ from pipecat.frames.frames import (
LLMTextFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.utils.string import split_text_by_characters
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
@@ -84,19 +83,13 @@ class LLMTextProcessor(FrameProcessor):
await self._text_aggregator.reset()
async def _handle_llm_text(self, in_frame: LLMTextFrame):
# Split text by characters to normalize LLM output into individual characters
# This ensures consistent aggregation behavior regardless of LLM chunk size
characters = split_text_by_characters(in_frame.text)
for character in characters:
aggregation = await self._text_aggregator.aggregate(character)
if aggregation:
out_frame = AggregatedTextFrame(
text=aggregation.text,
aggregated_by=aggregation.type,
)
out_frame.skip_tts = in_frame.skip_tts
await self.push_frame(out_frame)
async for aggregation in self._text_aggregator.aggregate(in_frame.text):
out_frame = AggregatedTextFrame(
text=aggregation.text,
aggregated_by=aggregation.type,
)
out_frame.skip_tts = in_frame.skip_tts
await self.push_frame(out_frame)
async def _handle_llm_end(self, skip_tts: Optional[bool] = None):
# Flush any remaining text

View File

@@ -51,7 +51,6 @@ from pipecat.processors.frame_processor import FrameDirection
from pipecat.services.ai_service import AIService
from pipecat.services.websocket_service import WebsocketService
from pipecat.transcriptions.language import Language
from pipecat.utils.string import split_text_by_characters
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
from pipecat.utils.text.base_text_filter import BaseTextFilter
from pipecat.utils.text.simple_text_aggregator import SimpleTextAggregator
@@ -544,19 +543,13 @@ class TTSService(AIService):
AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces
)
else:
# Split text by characters to normalize input into individual characters
# This ensures consistent aggregation behavior regardless of input chunk size
characters = split_text_by_characters(frame.text)
for character in characters:
aggregate = await self._text_aggregator.aggregate(character)
if aggregate:
text = aggregate.text
aggregated_by = aggregate.type
logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}")
await self._push_tts_frames(
AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces
)
async for aggregate in self._text_aggregator.aggregate(frame.text):
text = aggregate.text
aggregated_by = aggregate.type
logger.trace(f"Pushing TTS frames for text: {text}, {aggregated_by}")
await self._push_tts_frames(
AggregatedTextFrame(text, aggregated_by), includes_inter_frame_spaces
)
async def _push_tts_frames(
self, src_frame: AggregatedTextFrame, includes_inter_frame_spaces: Optional[bool] = False

View File

@@ -280,27 +280,3 @@ def concatenate_aggregated_text(text_parts: List[TextPartForConcatenation]) -> s
result = result.strip()
return result
def split_text_by_characters(text: str) -> List[str]:
"""Split text into individual characters.
Returns each character as a separate string element, allowing character-by-character
processing while maintaining the ability to reconstruct the original text.
Args:
text: The text to split into characters.
Returns:
A list of individual characters.
Example::
>>> split_text_by_characters("Hello world!")
["H", "e", "l", "l", "o", " ", "w", "o", "r", "l", "d", "!"]
>>> split_text_by_characters("Hi")
["H", "i"]
>>> split_text_by_characters("")
[]
"""
return list(text)

View File

@@ -14,7 +14,7 @@ aggregated text should be sent for speech synthesis.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from typing import AsyncIterator, Optional
class AggregationType(str, Enum):
@@ -80,35 +80,32 @@ class BaseTextAggregator(ABC):
pass
@abstractmethod
async def aggregate(self, text: str) -> Optional[Aggregation]:
"""Aggregate the specified text with the currently accumulated text.
async def aggregate(self, text: str) -> AsyncIterator[Aggregation]:
"""Aggregate the specified text and yield completed aggregations.
This method should be implemented to define how the new text contributes
to the aggregation process. It returns the aggregated text and a string
describing how it was aggregated if it's ready to be processed,
or None otherwise.
This method processes the input text character-by-character internally
and yields Aggregation objects as they complete.
Subclasses should implement their specific logic for:
- How to combine new text with existing accumulated text
- How to process text character-by-character
- When to consider the aggregated text ready for processing
- What criteria determine text completion (e.g., sentence boundaries)
- When a completion occurs, the method should return an Aggregation object
containing the aggregated text and its type. The text should be stripped
of leading/trailing whitespace so that consumers can rely on a consistent
format.
- When a completion occurs, yield an Aggregation object containing the
aggregated text (stripped of leading/trailing whitespace) and its type
Args:
text: The text to be aggregated.
Returns:
An Aggregation object if ready for processing, or None if more
text is needed before the aggregated content is ready. If an Aggregation
object is returned, it should consist of the updated aggregated text,
stripped of leading/trailing whitespace, and a string indicating the
type of aggregation (e.g., 'sentence', 'word', 'token', 'my_custom_aggregation').
Yields:
Aggregation objects as they complete. Each Aggregation consists of
the aggregated text (stripped of leading/trailing whitespace) and
a string indicating the type of aggregation (e.g., 'sentence', 'word',
'token', 'my_custom_aggregation').
"""
pass
# Make this a generator to satisfy type checker
yield # pragma: no cover
@abstractmethod
async def flush(self) -> Optional[Aggregation]:

View File

@@ -13,7 +13,7 @@ support for custom handlers and configurable actions for when a pattern is found
import re
from enum import Enum
from typing import Awaitable, Callable, List, Optional, Tuple
from typing import AsyncIterator, Awaitable, Callable, List, Optional, Tuple
from loguru import logger
@@ -256,20 +256,6 @@ class PatternPairAggregator(SimpleTextAggregator):
matches = list(match_iter) # Convert to list for safe iteration
for match in matches:
# Only process patterns that end at or after last_processed_position
# This ensures we only call handlers once when a pattern completes
if match.end() <= last_processed_position:
# This pattern was already processed in a previous call
if action != MatchAction.REMOVE:
# For KEEP/AGGREGATE patterns, we still need to track them
content = match.group(1)
full_match = match.group(0)
pattern_match = PatternMatch(
content=content.strip(), type=type, full_match=full_match
)
all_matches.append(pattern_match)
continue
content = match.group(1) # Content between patterns
full_match = match.group(0) # Full match including patterns
@@ -278,17 +264,23 @@ class PatternPairAggregator(SimpleTextAggregator):
content=content.strip(), type=type, full_match=full_match
)
# Call the appropriate handler if registered (only for newly complete patterns)
if type in self._handlers:
# Check if this pattern was already processed
already_processed = match.end() <= last_processed_position
# Only call handler for newly completed patterns
if not already_processed and type in self._handlers:
try:
await self._handlers[type](pattern_match)
except Exception as e:
logger.error(f"Error in pattern handler for {type}: {e}")
# Remove the pattern from the text if configured
# Handle pattern based on action
if action == MatchAction.REMOVE:
processed_text = processed_text.replace(full_match, "", 1)
# Remove patterns are only removed once (when newly completed)
if not already_processed:
processed_text = processed_text.replace(full_match, "", 1)
else:
# KEEP/AGGREGATE patterns stay in all_matches
all_matches.append(pattern_match)
return all_matches, processed_text
@@ -324,72 +316,74 @@ class PatternPairAggregator(SimpleTextAggregator):
return None
async def aggregate(self, text: str) -> Optional[PatternMatch]:
async def aggregate(self, text: str) -> AsyncIterator[PatternMatch]:
"""Aggregate text and process pattern pairs.
This method adds the new text to the buffer, processes any complete pattern
pairs, and uses the parent's lookahead logic for sentence detection when
no patterns are active.
Processes the input text character-by-character, handles pattern pairs,
and uses the parent's lookahead logic for sentence detection when no
patterns are active.
Args:
text: New text to add to the buffer.
text: Text to aggregate.
Returns:
Processed text up to a sentence boundary, or None if more
text is needed to form a complete sentence or pattern.
Yields:
PatternMatch objects as patterns complete or sentences are detected.
"""
# Add new text to buffer
self._text += text
# Process text character by character
for char in text:
self._text += char
# Process any newly complete patterns in the buffer
# Only patterns that complete after _last_processed_position will trigger handlers
patterns, processed_text = await self._process_complete_patterns(
self._text, self._last_processed_position
)
# Process any newly complete patterns in the buffer
# Only patterns that complete after _last_processed_position will trigger handlers
patterns, processed_text = await self._process_complete_patterns(
self._text, self._last_processed_position
)
# Update the last processed position before modifying the text
# For REMOVE patterns, the text will be shorter, so we track the original position
self._last_processed_position = len(self._text)
# Update the last processed position to prevent re-processing patterns
# This tracks where in the buffer we've already called handlers, so we
# only trigger handlers once when a pattern completes
self._last_processed_position = len(self._text)
self._text = processed_text
self._text = processed_text
if len(patterns) > 0:
if len(patterns) > 1:
logger.warning(
f"Multiple patterns matched: {[p.type for p in patterns]}. Only the first pattern will be returned."
if len(patterns) > 0:
if len(patterns) > 1:
logger.warning(
f"Multiple patterns matched: {[p.type for p in patterns]}. Only the first pattern will be returned."
)
# If the pattern found is set to be aggregated, return it
action = self._patterns[patterns[0].type].get("action", MatchAction.REMOVE)
if action == MatchAction.AGGREGATE:
self._text = ""
yield patterns[0]
continue
# Check if we have incomplete patterns
pattern_start = self._match_start_of_pattern(self._text)
if pattern_start is not None:
# If the start pattern is at the beginning or should not be separately aggregated, continue
if (
pattern_start[0] == 0
or pattern_start[1].get("action", MatchAction.REMOVE) != MatchAction.AGGREGATE
):
continue
# For AGGREGATE patterns: yield any text before the pattern starts
# This ensures text doesn't get stuck in the buffer waiting for sentence
# boundaries when a pattern begins (e.g., "Here is code <code>..." yields "Here is code")
result = self._text[: pattern_start[0]]
self._text = self._text[pattern_start[0] :]
yield PatternMatch(
content=result.strip(), type=AggregationType.SENTENCE, full_match=result
)
# If the pattern found is set to be aggregated, return it
action = self._patterns[patterns[0].type].get("action", MatchAction.REMOVE)
if action == MatchAction.AGGREGATE:
self._text = ""
return patterns[0]
continue
# Check if we have incomplete patterns
pattern_start = self._match_start_of_pattern(self._text)
if pattern_start is not None:
# If the start pattern is at the beginning or should not be separately aggregated, return None
if (
pattern_start[0] == 0
or pattern_start[1].get("action", MatchAction.REMOVE) != MatchAction.AGGREGATE
):
return None
# Otherwise, strip the text up to the start pattern and return it
result = self._text[: pattern_start[0]]
self._text = self._text[pattern_start[0] :]
return PatternMatch(
content=result.strip(), type=AggregationType.SENTENCE, full_match=result
)
# Use parent's lookahead logic for sentence detection
aggregation = await super()._check_sentence_with_lookahead(text)
if aggregation:
# Convert to PatternMatch for consistency with return type
return PatternMatch(
content=aggregation.text, type=aggregation.type, full_match=aggregation.text
)
# No complete sentence found yet
return None
# Use parent's lookahead logic for sentence detection
aggregation = await super()._check_sentence_with_lookahead(char)
if aggregation:
# Convert to PatternMatch for consistency with return type
yield PatternMatch(
content=aggregation.text, type=aggregation.type, full_match=aggregation.text
)
async def handle_interruption(self):
"""Handle interruptions by clearing the buffer and pattern state.

View File

@@ -11,7 +11,7 @@ until it finds an end-of-sentence marker, making it suitable for basic TTS
text processing scenarios.
"""
from typing import Optional
from typing import AsyncIterator, Optional
from pipecat.utils.string import SENTENCE_ENDING_PUNCTUATION, match_endofsentence
from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator
@@ -42,27 +42,30 @@ class SimpleTextAggregator(BaseTextAggregator):
"""
return Aggregation(text=self._text.strip(), type=AggregationType.SENTENCE)
async def aggregate(self, text: str) -> Optional[Aggregation]:
"""Aggregate text and return completed sentences.
async def aggregate(self, text: str) -> AsyncIterator[Aggregation]:
"""Aggregate text and yield completed sentences.
Adds the new text to the buffer. When sentence-ending punctuation is
detected, it waits for non-whitespace lookahead before calling NLTK.
This prevents false positives like "$29." being detected as a sentence
when it's actually "$29.95", and avoids unnecessary NLTK calls.
Processes the input text character-by-character. When sentence-ending
punctuation is detected, it waits for non-whitespace lookahead before
calling NLTK. This prevents false positives like "$29." being detected
as a sentence when it's actually "$29.95".
Args:
text: New text to add to the aggregation buffer.
text: Text to aggregate.
Returns:
A complete sentence if an end-of-sentence marker is confirmed,
or None if more text is needed.
Yields:
Complete sentences as Aggregation objects.
"""
# Add new text to buffer
self._text += text
# Process text character by character
for char in text:
self._text += char
return await self._check_sentence_with_lookahead(text)
# Check for sentence with lookahead
result = await self._check_sentence_with_lookahead(char)
if result:
yield result
async def _check_sentence_with_lookahead(self, text: str) -> Optional[Aggregation]:
async def _check_sentence_with_lookahead(self, char: str) -> Optional[Aggregation]:
"""Check for sentence boundaries using lookahead logic.
This method implements the core sentence detection logic with lookahead.
@@ -77,7 +80,7 @@ class SimpleTextAggregator(BaseTextAggregator):
while adding their own logic (e.g., tag handling, pattern matching).
Args:
text: The most recently added text (used for lookahead check).
char: The most recently added character (used for lookahead check).
Returns:
Aggregation if sentence found, None otherwise.
@@ -85,7 +88,7 @@ class SimpleTextAggregator(BaseTextAggregator):
# If we need lookahead, check if we now have non-whitespace
if self._needs_lookahead:
# Check if the new character is non-whitespace
if text.strip():
if char.strip():
# We have meaningful lookahead, call NLTK
self._needs_lookahead = False
eos_marker = match_endofsentence(self._text)
@@ -101,7 +104,7 @@ class SimpleTextAggregator(BaseTextAggregator):
return None
# Check if we just added sentence-ending punctuation
if self._text[-1] in SENTENCE_ENDING_PUNCTUATION:
if self._text and self._text[-1] in SENTENCE_ENDING_PUNCTUATION:
# Mark that we need lookahead (don't call NLTK yet)
self._needs_lookahead = True

View File

@@ -11,7 +11,7 @@ between specified start/end tag pairs, ensuring that tagged content is processed
as a unit regardless of internal punctuation.
"""
from typing import Optional, Sequence
from typing import AsyncIterator, Optional, Sequence
from pipecat.utils.string import StartEndTags, parse_start_end_tags
from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType
@@ -43,35 +43,37 @@ class SkipTagsAggregator(SimpleTextAggregator):
self._current_tag: Optional[StartEndTags] = None
self._current_tag_index: int = 0
async def aggregate(self, text: str) -> Optional[Aggregation]:
async def aggregate(self, text: str) -> AsyncIterator[Aggregation]:
"""Aggregate text while respecting tag boundaries.
This method adds the new text to the buffer, updates tag state,
and uses the parent's lookahead logic for sentence detection when
not inside tags.
Processes the input text character-by-character, updates tag state, and
uses the parent's lookahead logic for sentence detection when not
inside tags.
Args:
text: New text to add to the buffer.
text: Text to aggregate.
Returns:
An Aggregation object containing text up to a sentence boundary and
marked as SENTENCE type or None if more text is needed to complete a
sentence or close tags.
Yields:
Aggregation objects containing text up to a sentence boundary,
marked as SENTENCE type.
"""
# Add new text to buffer
self._text += text
# Process text character by character
for char in text:
self._text += char
# Update tag state
(self._current_tag, self._current_tag_index) = parse_start_end_tags(
self._text, self._tags, self._current_tag, self._current_tag_index
)
# Update tag state
(self._current_tag, self._current_tag_index) = parse_start_end_tags(
self._text, self._tags, self._current_tag, self._current_tag_index
)
# If inside tags, don't check for sentences
if self._current_tag:
return None
# If inside tags, don't check for sentences
if self._current_tag:
continue
# Otherwise, use parent's lookahead logic for sentence detection
return await super()._check_sentence_with_lookahead(text)
# Otherwise, use parent's lookahead logic for sentence detection
result = await super()._check_sentence_with_lookahead(char)
if result:
yield result
async def handle_interruption(self):
"""Handle interruptions by clearing the buffer and tag state.

View File

@@ -10,6 +10,7 @@ from unittest.mock import AsyncMock
from pipecat.audio.vad.vad_analyzer import VADParams
from pipecat.extensions.ivr.ivr_navigator import IVRProcessor
from pipecat.frames.frames import (
LLMFullResponseEndFrame,
LLMMessagesUpdateFrame,
LLMTextFrame,
OutputDTMFUrgentFrame,
@@ -334,10 +335,12 @@ class TestIVRNavigation(unittest.IsolatedAsyncioTestCase):
frames_to_send = [
LLMTextFrame(text="Hello, I'm trying to reach billing."),
LLMFullResponseEndFrame(),
]
expected_down_frames = [
LLMTextFrame, # Should pass through unchanged
LLMFullResponseEndFrame,
]
expected_up_frames = [

View File

@@ -38,14 +38,8 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.aggregator.on_pattern_match("code_pattern", self.code_handler)
async def test_pattern_match_and_removal(self):
# Feed text character by character
full_text = "Hello <test>pattern content</test>!"
results = []
for char in full_text:
result = await self.aggregator.aggregate(char)
if result:
results.append(result)
text = "Hello <test>pattern content</test>!"
results = [result async for result in self.aggregator.aggregate(text)]
# Verify the handler was called with correct PatternMatch object
self.test_handler.assert_called_once()
@@ -59,10 +53,8 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(len(results), 0)
# Next sentence should provide the lookahead and trigger the previous sentence
for char in " This is another sentence.":
result = await self.aggregator.aggregate(char)
if result:
results.append(result)
async for result in self.aggregator.aggregate(" This is another sentence."):
results.append(result)
# First result should be "Hello !" triggered by the space lookahead
self.assertEqual(len(results), 1)
@@ -77,14 +69,9 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.aggregator.text.text, "")
async def test_pattern_match_and_aggregate(self):
# Feed text character by character, collecting all results
results = []
full_text = "Here is code <code>pattern content</code> This is another sentence."
text = "Here is code <code>pattern content</code> This is another sentence."
for char in full_text:
result = await self.aggregator.aggregate(char)
if result:
results.append(result)
results = [result async for result in self.aggregator.aggregate(text)]
# First result should be "Here is code" when pattern starts
self.assertEqual(results[0].text, "Here is code")
@@ -111,15 +98,10 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.aggregator.text.text, "")
async def test_incomplete_pattern(self):
# Feed text character by character with incomplete pattern
result = None
for char in "Hello <test>pattern content":
result = await self.aggregator.aggregate(char)
if result:
break
text = "Hello <test>pattern content"
results = [result async for result in self.aggregator.aggregate(text)]
# No complete pattern yet, so nothing should be returned
self.assertIsNone(result)
self.assertEqual(len(results), 0)
# The handler should not be called yet
self.test_handler.assert_not_called()
@@ -154,13 +136,8 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.aggregator.on_pattern_match("voice", voice_handler)
self.aggregator.on_pattern_match("emphasis", emphasis_handler)
# Feed text character by character
text = "Hello <voice>female</voice> I am <em>very</em> excited to meet you!"
result = None
for char in text:
result = await self.aggregator.aggregate(char)
if result:
break
results = [result async for result in self.aggregator.aggregate(text)]
# Both handlers should be called with correct data
voice_handler.assert_called_once()
@@ -174,7 +151,7 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(emphasis_match.text, "very")
# With lookahead, we need to flush to get the final sentence
self.assertIsNone(result) # Waiting for lookahead after "!"
self.assertEqual(len(results), 0) # Waiting for lookahead after "!"
result = await self.aggregator.flush()
# Voice pattern should be removed, emphasis pattern should remain
@@ -184,14 +161,9 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(self.aggregator.text.text, "")
async def test_handle_interruption(self):
# Feed text character by character with incomplete pattern
result = None
for char in "Hello <test>pattern":
result = await self.aggregator.aggregate(char)
if result:
break
self.assertIsNone(result)
text = "Hello <test>pattern"
results = [result async for result in self.aggregator.aggregate(text)]
self.assertEqual(len(results), 0)
# Simulate interruption
await self.aggregator.handle_interruption()
@@ -203,14 +175,8 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.test_handler.assert_not_called()
async def test_pattern_across_sentences(self):
# Feed text character by character - pattern spans multiple sentences
full_text = "Hello <test>This is sentence one. This is sentence two.</test> Final sentence."
result = None
for char in full_text:
result = await self.aggregator.aggregate(char)
if result:
break
text = "Hello <test>This is sentence one. This is sentence two.</test> Final sentence."
results = [result async for result in self.aggregator.aggregate(text)]
# Handler should be called with entire content
self.test_handler.assert_called_once()
@@ -218,7 +184,7 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase):
self.assertEqual(call_args.text, "This is sentence one. This is sentence two.")
# With lookahead, we need to flush to get the final sentence
self.assertIsNone(result) # Waiting for lookahead after "."
self.assertEqual(len(results), 0) # Waiting for lookahead after "."
result = await self.aggregator.flush()
# Pattern should be removed, resulting in text with sentences merged

View File

@@ -14,19 +14,22 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
self.aggregator = SimpleTextAggregator()
async def test_reset_aggregations(self):
# Feed character-by-character
for char in "Hello ":
assert await self.aggregator.aggregate(char) == None
text = "Hello "
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet
assert len(results) == 0
assert self.aggregator.text.text == "Hello"
await self.aggregator.reset()
assert self.aggregator.text.text == ""
async def test_simple_sentence(self):
# Feed character-by-character: "Hello Pipecat!"
for char in "Hello Pipecat":
assert await self.aggregator.aggregate(char) == None
# After "!", lookahead waits for confirmation
assert await self.aggregator.aggregate("!") == None
text = "Hello Pipecat!"
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after "!")
assert len(results) == 0
# Flush to get the pending sentence
aggregate = await self.aggregator.flush()
assert aggregate.text == "Hello Pipecat!"
@@ -34,81 +37,58 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
assert self.aggregator.text.text == ""
async def test_multiple_sentences(self):
# Feed character-by-character: "Hello Pipecat! How are you?"
for char in "Hello Pipecat":
assert await self.aggregator.aggregate(char) == None
# Hit "!" - wait for lookahead
assert await self.aggregator.aggregate("!") == None
# Space is whitespace - keep waiting
assert await self.aggregator.aggregate(" ") == None
# "H" confirms sentence end
result = await self.aggregator.aggregate("H")
assert result.text == "Hello Pipecat!"
# Continue with second sentence
for char in "ow are you":
assert await self.aggregator.aggregate(char) == None
# Hit "?" - wait for lookahead
assert await self.aggregator.aggregate("?") == None
text = "Hello Pipecat! How are you?"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence should be complete (lookahead from "H" confirmed it)
assert len(results) == 1
assert results[0].text == "Hello Pipecat!"
# Flush to get the pending sentence
result = await self.aggregator.flush()
assert result.text == "How are you?"
async def test_lookahead_decimal_number(self):
"""Test that $29.95 is not split at $29."""
# Feed character by character: "Ask me for only $29.95/month."
for char in "Ask me for only $29":
assert await self.aggregator.aggregate(char) == None
# When we hit ".", it looks like end of sentence, but should wait for lookahead
assert await self.aggregator.aggregate(".") == None
# Next character "9" confirms it's not end of sentence (NLTK changes boundary)
for char in "95/month":
assert await self.aggregator.aggregate(char) == None
# Now we hit the real end of sentence - wait for lookahead
assert await self.aggregator.aggregate(".") == None
text = "Ask me for only $29.95/month."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after final ".")
assert len(results) == 0
# Can use flush() to get the pending sentence at end of stream
result = await self.aggregator.flush()
assert result.text == "Ask me for only $29.95/month."
async def test_lookahead_abbreviation(self):
"""Test that Mr. Smith is not split at Mr."""
# Feed character by character: "Hello Mr. Smith."
for char in "Hello Mr":
assert await self.aggregator.aggregate(char) == None
# When we hit ".", it looks like end of sentence, but should wait for lookahead
assert await self.aggregator.aggregate(".") == None
# Space alone is not enough
assert await self.aggregator.aggregate(" ") == None
# "S" confirms it's not end of sentence (NLTK changes boundary detection)
for char in "Smith":
assert await self.aggregator.aggregate(char) == None
# Now we hit the real end of sentence - wait for lookahead
assert await self.aggregator.aggregate(".") == None
text = "Hello Mr. Smith."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead after final ".")
assert len(results) == 0
# Can use flush() to get the pending sentence at end of stream
result = await self.aggregator.flush()
assert result.text == "Hello Mr. Smith."
async def test_lookahead_actual_sentence_end(self):
"""Test that a real sentence end is detected after lookahead."""
# Feed character by character: "Hello world. Next sentence"
for char in "Hello world":
assert await self.aggregator.aggregate(char) == None
# Hit period - should wait for lookahead
assert await self.aggregator.aggregate(".") == None
# Space alone is not enough - need non-whitespace for meaningful lookahead
assert await self.aggregator.aggregate(" ") == None
# Capital letter confirms sentence end (NLTK detects boundary at same position)
result = await self.aggregator.aggregate("N")
assert result.text == "Hello world."
# Continue with next sentence
assert await self.aggregator.aggregate("e") == None
text = "Hello world. Next sentence"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence should be complete (lookahead from "N" confirmed it)
assert len(results) == 1
assert results[0].text == "Hello world."
async def test_flush_pending_sentence(self):
"""Test that flush() returns pending sentence waiting for lookahead."""
# Feed up to a period
for char in "Hello world":
assert await self.aggregator.aggregate(char) == None
assert await self.aggregator.aggregate(".") == None
# At this point, "Hello world." is pending lookahead
text = "Hello world."
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences yet (waiting for lookahead)
assert len(results) == 0
# Call flush to get it
result = await self.aggregator.flush()
assert result is not None
@@ -118,7 +98,12 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
async def test_flush_with_no_pending(self):
"""Test that flush() returns any remaining text in buffer."""
assert await self.aggregator.aggregate("Hello") == None
text = "Hello"
results = [agg async for agg in self.aggregator.aggregate(text)]
# No complete sentences
assert len(results) == 0
result = await self.aggregator.flush()
# flush() now returns any remaining text, not just pending lookahead
assert result is not None
@@ -128,13 +113,13 @@ class TestSimpleTextAggregator(unittest.IsolatedAsyncioTestCase):
async def test_flush_after_lookahead_confirmed(self):
"""Test flush after lookahead has already confirmed sentence."""
for char in "Hello.":
await self.aggregator.aggregate(char)
# Space alone is not enough - still waiting
assert await self.aggregator.aggregate(" ") == None
# Non-whitespace lookahead confirms it's a sentence
result = await self.aggregator.aggregate("W")
assert result.text == "Hello."
text = "Hello. W"
results = [agg async for agg in self.aggregator.aggregate(text)]
# First sentence should be complete (lookahead from "W" confirmed it)
assert len(results) == 1
assert results[0].text == "Hello."
# flush() returns any remaining text (the "W" in this case)
result = await self.aggregator.flush()
assert result.text == "W"

View File

@@ -17,15 +17,11 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
await self.aggregator.reset()
# No tags involved, aggregate at end of sentence.
# Feed text character by character
result = None
for char in "Hello Pipecat!":
result = await self.aggregator.aggregate(char)
if result:
break
text = "Hello Pipecat!"
results = [agg async for agg in self.aggregator.aggregate(text)]
# Should still be waiting for lookahead after "!"
self.assertIsNone(result)
self.assertEqual(len(results), 0)
# Flush to get the pending sentence
result = await self.aggregator.flush()
@@ -37,15 +33,11 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
await self.aggregator.reset()
# Tags involved, avoid aggregation during tags.
# Feed text character by character
result = None
for char in "My email is <spell>foo@pipecat.ai</spell>.":
result = await self.aggregator.aggregate(char)
if result:
break
text = "My email is <spell>foo@pipecat.ai</spell>."
results = [agg async for agg in self.aggregator.aggregate(text)]
# Should still be waiting for lookahead after "."
self.assertIsNone(result)
self.assertEqual(len(results), 0)
# Flush to get the pending sentence
result = await self.aggregator.flush()
@@ -56,22 +48,17 @@ class TestSkipTagsAggregator(unittest.IsolatedAsyncioTestCase):
async def test_streaming_tags(self):
await self.aggregator.reset()
# Tags involved, feed character by character
full_text = "My email is <spell>foo.bar@pipecat.ai</spell>."
result = None
for char in full_text:
result = await self.aggregator.aggregate(char)
if result:
break
# Tags involved
text = "My email is <spell>foo.bar@pipecat.ai</spell>."
results = [agg async for agg in self.aggregator.aggregate(text)]
# Should still be waiting for lookahead after "."
self.assertIsNone(result)
self.assertEqual(self.aggregator.text.text, full_text)
self.assertEqual(len(results), 0)
self.assertEqual(self.aggregator.text.text, text)
self.assertEqual(self.aggregator.text.type, "sentence")
# Flush to get the pending sentence
result = await self.aggregator.flush()
self.assertEqual(result.text, full_text)
self.assertEqual(result.text, text)
self.assertEqual(self.aggregator.text.text, "")
self.assertEqual(self.aggregator.text.type, "sentence")

View File

@@ -6,7 +6,7 @@
import unittest
from pipecat.utils.string import match_endofsentence, parse_start_end_tags, split_text_by_characters
from pipecat.utils.string import match_endofsentence, parse_start_end_tags
class TestUtilsString(unittest.IsolatedAsyncioTestCase):
@@ -232,35 +232,3 @@ class TestStartEndTags(unittest.IsolatedAsyncioTestCase):
("<a>", "</a>"),
41,
)
async def test_split_text_by_characters(self):
"""Test splitting text into individual characters."""
# Basic sentence
assert split_text_by_characters("Hello world!") == [
"H",
"e",
"l",
"l",
"o",
" ",
"w",
"o",
"r",
"l",
"d",
"!",
]
# Single word
assert split_text_by_characters("Hi") == ["H", "i"]
# Empty string
assert split_text_by_characters("") == []
# With spaces
assert split_text_by_characters("A B") == ["A", " ", "B"]
# Concatenation test - characters should concatenate back to original
characters = split_text_by_characters("Hello world!")
concatenated = "".join(characters)
assert concatenated == "Hello world!"

6
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.13'",
@@ -4710,7 +4710,6 @@ requires-dist = [
{ name = "numba", specifier = "==0.61.2" },
{ name = "numpy", specifier = ">=1.26.4,<3" },
{ name = "nvidia-riva-client", marker = "extra == 'nvidia'", specifier = "~=2.21.1" },
{ name = "nvidia-riva-client", marker = "extra == 'riva'", specifier = "~=2.21.1" },
{ name = "onnxruntime", marker = "extra == 'local-smart-turn-v3'", specifier = ">=1.20.1,<2" },
{ name = "onnxruntime", marker = "extra == 'silero'", specifier = ">=1.20.1,<2" },
{ name = "openai", specifier = ">=1.74.0,<3" },
@@ -4721,6 +4720,7 @@ requires-dist = [
{ name = "opentelemetry-sdk", marker = "extra == 'tracing'", specifier = ">=1.33.0" },
{ name = "ormsgpack", marker = "extra == 'fish'", specifier = "~=1.7.0" },
{ name = "pillow", specifier = ">=11.1.0,<12" },
{ name = "pipecat-ai", extras = ["nvidia"], marker = "extra == 'riva'" },
{ name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'assemblyai'" },
{ name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'asyncai'" },
{ name = "pipecat-ai", extras = ["websockets-base"], marker = "extra == 'aws'" },
@@ -4771,7 +4771,7 @@ requires-dist = [
{ name = "wait-for2", marker = "python_full_version < '3.12'", specifier = ">=0.4.1" },
{ name = "websockets", marker = "extra == 'websockets-base'", specifier = ">=13.1,<16.0" },
]
provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "krisp", "langchain", "livekit", "lmnt", "local", "local-smart-turn", "local-smart-turn-v3", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "nim", "noisereduce", "openai", "openpipe", "openrouter", "perplexity", "playht", "qwen", "remote-smart-turn", "rime", "riva", "nvidia", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"]
provides-extras = ["aic", "anthropic", "assemblyai", "asyncai", "aws", "aws-nova-sonic", "azure", "cartesia", "cerebras", "daily", "deepgram", "deepseek", "elevenlabs", "fal", "fireworks", "fish", "gladia", "google", "grok", "groq", "gstreamer", "heygen", "hume", "inworld", "koala", "krisp", "langchain", "livekit", "lmnt", "local", "local-smart-turn", "local-smart-turn-v3", "mcp", "mem0", "mistral", "mlx-whisper", "moondream", "neuphonic", "noisereduce", "nvidia", "openai", "openpipe", "openrouter", "perplexity", "playht", "qwen", "remote-smart-turn", "rime", "riva", "runner", "sagemaker", "sambanova", "sarvam", "sentry", "silero", "simli", "soniox", "soundfile", "speechmatics", "strands", "tavus", "together", "tracing", "ultravox", "webrtc", "websocket", "websockets-base", "whisper"]
[package.metadata.requires-dev]
dev = [