From 24266c238ff316d25403a34ceb660766c6114d92 Mon Sep 17 00:00:00 2001 From: mattie ruth backman Date: Fri, 14 Nov 2025 18:31:58 -0500 Subject: [PATCH] Augmented PatternPairAggregator so that matched patterns can... be treated as their own aggregation, taking advantage of the new ability to assign a type to an aggregation --- CHANGELOG.md | 27 ++ .../35-pattern-pair-voice-switching.py | 16 +- src/pipecat/extensions/ivr/ivr_navigator.py | 18 +- .../utils/text/pattern_pair_aggregator.py | 236 +++++++++++++----- tests/test_pattern_pair_aggregator.py | 74 ++++-- 5 files changed, 283 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 497e97f2c..5ee809beb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,33 @@ Croatian, Hungarian, Malay, Norwegian, Nynorsk, Slovak, Slovenian, Swedish, and produce/consume `Aggregation` objects. - All uses of the above Aggregators have been updated accordingly. +- Augmented the `PatternPairAggregator` so that matched patterns can be treated as their own + aggregation, taking advantage of the new. To that end: + - Introduced a new, preferred version of `add_pattern` to support a new option for treating a + match as a separate aggregation returned from `aggregate()`. This replaces the now + deprecated `add_pattern_pair` method and you provide a `MatchAction` in lieu of the `remove_match` field. + - `MatchAction` enum: `REMOVE`, `KEEP`, `AGGREGATE`, allowing customization for how + a match should be handled. + - `REMOVE`: The text along with its delimiters will be removed from the streaming text. + Sentence aggregation will continue on as if this text did not exist. + - `KEEP`: The delimiters will be removed, but the content between them will be kept. + Sentence aggregation will continue on with the internal text included. + - `AGGREGATE`: The delimiters will be removed and the content between will be treated + as a separate aggregation. Any text before the start of the pattern will be + returned early, whether or not a complete sentence was found. Then the pattern + will be returned. Then the aggregation will continue on sentence matching after + the closing delimiter is found. The content between the delimiters is not + aggregated by sentence. It is aggregated as one single block of text. + - `PatternMatch` now extends `Aggregation` and provides richer info to handlers. + - **BREAKING**: The `PatternMatch` type returned to handlers registered via `on_pattern_match` + has been updated to subclass from the new `Aggregation` type, which means that `content` + has been replaced with `text` and `pattern_id` has been replaced with `type`: + ``` + async dev on_match_tag(match: PatternMatch): + pattern = match.type # instead of match.pattern_id + text = match.text # instead of match.content + ``` + ### Deprecated - The `api_key` parameter in `GeminiTTSService` is deprecated. Use diff --git a/examples/foundational/35-pattern-pair-voice-switching.py b/examples/foundational/35-pattern-pair-voice-switching.py index 7ed9eb268..3a102acfd 100644 --- a/examples/foundational/35-pattern-pair-voice-switching.py +++ b/examples/foundational/35-pattern-pair-voice-switching.py @@ -62,7 +62,11 @@ from pipecat.services.openai.llm import OpenAILLMService from pipecat.transports.base_transport import BaseTransport, TransportParams from pipecat.transports.daily.transport import DailyParams from pipecat.transports.websocket.fastapi import FastAPIWebsocketParams -from pipecat.utils.text.pattern_pair_aggregator import PatternMatch, PatternPairAggregator +from pipecat.utils.text.pattern_pair_aggregator import ( + MatchAction, + PatternMatch, + PatternPairAggregator, +) load_dotenv(override=True) @@ -106,16 +110,16 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): pattern_aggregator = PatternPairAggregator() # Add pattern for voice switching - pattern_aggregator.add_pattern_pair( - pattern_id="voice_tag", + pattern_aggregator.add_pattern( + type="voice", start_pattern="", end_pattern="", - remove_match=True, + action=MatchAction.REMOVE, # Remove tags from final text ) # Register handler for voice switching async def on_voice_tag(match: PatternMatch): - voice_name = match.content.strip().lower() + voice_name = match.text.strip().lower() if voice_name in VOICE_IDS: # First flush any existing audio to finish the current context await tts.flush_audio() @@ -125,7 +129,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments): else: logger.warning(f"Unknown voice: {voice_name}") - pattern_aggregator.on_pattern_match("voice_tag", on_voice_tag) + pattern_aggregator.on_pattern_match("voice", on_voice_tag) stt = DeepgramSTTService(api_key=os.getenv("DEEPGRAM_API_KEY")) diff --git a/src/pipecat/extensions/ivr/ivr_navigator.py b/src/pipecat/extensions/ivr/ivr_navigator.py index 9c59772ad..1ddb41ed8 100644 --- a/src/pipecat/extensions/ivr/ivr_navigator.py +++ b/src/pipecat/extensions/ivr/ivr_navigator.py @@ -31,7 +31,11 @@ from pipecat.pipeline.pipeline import Pipeline from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContextFrame from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.services.llm_service import LLMService -from pipecat.utils.text.pattern_pair_aggregator import PatternMatch, PatternPairAggregator +from pipecat.utils.text.pattern_pair_aggregator import ( + MatchAction, + PatternMatch, + PatternPairAggregator, +) class IVRStatus(Enum): @@ -114,15 +118,15 @@ class IVRProcessor(FrameProcessor): def _setup_xml_patterns(self): """Set up XML pattern detection and handlers.""" # Register DTMF pattern - self._aggregator.add_pattern_pair("dtmf", "", "", remove_match=True) + self._aggregator.add_pattern("dtmf", "", "", action=MatchAction.REMOVE) self._aggregator.on_pattern_match("dtmf", self._handle_dtmf_action) # Register mode pattern - self._aggregator.add_pattern_pair("mode", "", "", remove_match=True) + self._aggregator.add_pattern("mode", "", "", action=MatchAction.REMOVE) self._aggregator.on_pattern_match("mode", self._handle_mode_action) # Register IVR pattern - self._aggregator.add_pattern_pair("ivr", "", "", remove_match=True) + self._aggregator.add_pattern("ivr", "", "", action=MatchAction.REMOVE) self._aggregator.on_pattern_match("ivr", self._handle_ivr_action) async def process_frame(self, frame: Frame, direction: FrameDirection): @@ -159,7 +163,7 @@ class IVRProcessor(FrameProcessor): Args: match: The pattern match containing DTMF content. """ - value = match.content + value = match.text logger.debug(f"DTMF detected: {value}") try: @@ -180,7 +184,7 @@ class IVRProcessor(FrameProcessor): Args: match: The pattern match containing IVR status content. """ - status = match.content + status = match.text logger.trace(f"IVR status detected: {status}") # Convert string to enum, with validation @@ -211,7 +215,7 @@ class IVRProcessor(FrameProcessor): Args: match: The pattern match containing mode content. """ - mode = match.content + mode = match.text logger.debug(f"Mode detected: {mode}") if mode == "conversation": await self._handle_conversation() diff --git a/src/pipecat/utils/text/pattern_pair_aggregator.py b/src/pipecat/utils/text/pattern_pair_aggregator.py index 5c4cafd53..c140e3243 100644 --- a/src/pipecat/utils/text/pattern_pair_aggregator.py +++ b/src/pipecat/utils/text/pattern_pair_aggregator.py @@ -8,11 +8,12 @@ This module provides an aggregator that identifies and processes content between pattern pairs (like XML tags or custom delimiters) in streaming text, with -support for custom handlers and configurable pattern removal. +support for custom handlers and configurable actions for when a pattern is found. """ import re -from typing import Awaitable, Callable, Optional, Tuple +from enum import Enum +from typing import Awaitable, Callable, List, Optional, Tuple from loguru import logger @@ -20,7 +21,28 @@ from pipecat.utils.string import match_endofsentence from pipecat.utils.text.base_text_aggregator import Aggregation, AggregationType, BaseTextAggregator -class PatternMatch: +class MatchAction(Enum): + """Actions to take when a pattern pair is matched. + + Parameters: + REMOVE: The text along with its delimiters will be removed from the streaming text. + Sentence aggregation will continue on as if this text did not exist. + KEEP: The delimiters will be removed, but the content between them will be kept. + Sentence aggregation will continue on with the internal text included. + AGGREGATE: The delimiters will be removed and the content between will be treated + as a separate aggregation. Any text before the start of the pattern will be + returned early, whether or not a complete sentence was found. Then the pattern + will be returned. Then the aggregation will continue on sentence matching after + the closing delimiter is found. The content between the delimiters is not + aggregated by sentence. It is aggregated as one single block of text. + """ + + REMOVE = "remove" + KEEP = "keep" + AGGREGATE = "aggregate" + + +class PatternMatch(Aggregation): """Represents a matched pattern pair with its content. A PatternMatch object is created when a complete pattern pair is found @@ -29,25 +51,25 @@ class PatternMatch: content between the patterns. """ - def __init__(self, pattern_id: str, full_match: str, content: str): + def __init__(self, content: str, type: str, full_match: str): """Initialize a pattern match. Args: - pattern_id: The identifier of the matched pattern pair. + type: The type of the matched pattern pair. It should be representative + of the content type (e.g., 'sentence', 'code', 'speaker', 'custom'). full_match: The complete text including start and end patterns. content: The text content between the start and end patterns. """ - self.pattern_id = pattern_id + super().__init__(text=content, type=type) self.full_match = full_match - self.content = content def __str__(self) -> str: """Return a string representation of the pattern match. Returns: - A descriptive string showing the pattern ID and content. + A descriptive string showing the pattern type and content. """ - return f"PatternMatch(id={self.pattern_id}, content={self.content})" + return f"PatternMatch(type={self.type}, text={self.text}, full_match={self.full_match})" class PatternPairAggregator(BaseTextAggregator): @@ -55,16 +77,21 @@ class PatternPairAggregator(BaseTextAggregator): This aggregator buffers text until it can identify complete pattern pairs (defined by start and end patterns), processes the content between these - patterns using registered handlers, and returns text at sentence boundaries. - It's particularly useful for processing structured content in streaming text, - such as XML tags, markdown formatting, or custom delimiters. + patterns using registered handlers. By default, its aggregation method + returns text at sentence boundaries, and remove the content found between + any matched patterns. However, matched patterns can also be configured to + returned as a separate aggregation object containing the content between + their start and end patterns or left in, so that only the delimiters are + removed and a callback can be triggered. + + This aggregator is particularly useful for processing structured content in + streaming text, such as XML tags, markdown formatting, or custom delimiters. The aggregator ensures that patterns spanning multiple text chunks are - correctly identified and handles cases where patterns contain sentence - boundaries. + correctly identified. """ - def __init__(self): + def __init__(self, **kwargs): """Initialize the pattern pair aggregator. Creates an empty aggregator with no patterns or handlers registered. @@ -76,15 +103,26 @@ class PatternPairAggregator(BaseTextAggregator): @property def text(self) -> Aggregation: - """Get the currently buffered text. + """Get the currently aggregated text. Returns: - The current text buffer content that hasn't been processed yet. + The text that has been accumulated in the buffer. """ - return Aggregation(text=self._text.strip(), type=AggregationType.SENTENCE) + pattern_start = self._match_start_of_pattern(self._text) + stripped_text = self._text.strip() + type = ( + pattern_start[1].get("type", AggregationType.SENTENCE) + if pattern_start + else AggregationType.SENTENCE + ) + return Aggregation(text=stripped_text, type=type) - def add_pattern_pair( - self, pattern_id: str, start_pattern: str, end_pattern: str, remove_match: bool = True + def add_pattern( + self, + type: str, + start_pattern: str, + end_pattern: str, + action: MatchAction = MatchAction.REMOVE, ) -> "PatternPairAggregator": """Add a pattern pair to detect in the text. @@ -93,41 +131,94 @@ class PatternPairAggregator(BaseTextAggregator): the end pattern, and treat the content between them as a match. Args: - pattern_id: Unique identifier for this pattern pair. + type: Identifier for this pattern pair. Should be unique and ideally descriptive. + (e.g., 'code', 'speaker', 'custom'). type can not be 'sentence' or 'word' as + those are reserved for the default behavior. start_pattern: Pattern that marks the beginning of content. end_pattern: Pattern that marks the end of content. - remove_match: Whether to remove the matched content from the text. + action: What to do when a complete pattern is matched: + - MatchAction.REMOVE: Remove the matched pattern from the text. + - MatchAction.KEEP: Keep the matched pattern in the text and treat it as + normal text. This allows you to register handlers for + the pattern without affecting the aggregation logic. + - MatchAction.AGGREGATE: Return the matched pattern as a separate + aggregation object. Returns: Self for method chaining. """ - self._patterns[pattern_id] = { + if type in [AggregationType.SENTENCE, AggregationType.WORD]: + raise ValueError( + f"The aggregation type '{type}' is reserved for default behavior and can not be used for custom patterns." + ) + self._patterns[type] = { "start": start_pattern, "end": end_pattern, - "remove_match": remove_match, + "type": type, + "action": action, } return self + def add_pattern_pair( + self, pattern_id: str, start_pattern: str, end_pattern: str, remove_match: bool = True + ): + """Add a pattern pair to detect in the text. + + .. deprecated:: 0.0.95 + This function is deprecated and will be removed in a future version. + Use `add_pattern` with a type and MatchAction instead. + + This method calls `add_pattern` setting type with the provided pattern_id and action + to either MatchAction.REMOVE or MatchAction.KEEP based on `remove_match`. + + Args: + pattern_id: Identifier for this pattern pair. Should be unique and ideally descriptive. + (e.g., 'code', 'speaker', 'custom'). pattern_id can not be 'sentence' or 'word' + as those arereserved for the default behavior. + start_pattern: Pattern that marks the beginning of content. + end_pattern: Pattern that marks the end of content. + remove_match: If True, the matched pattern will be removed from the text. (Same as MatchAction.REMOVE) + If False, it will be kept and treated as normal text. (Same as MatchAction.KEEP) + """ + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("once") + warnings.warn( + "add_pattern_pair with a pattern_id or remove_match is deprecated and will be" + " removed in a future version. Use add_pattern with a type and MatchAction instead", + DeprecationWarning, + stacklevel=2, + ) + + action = MatchAction.REMOVE if remove_match else MatchAction.KEEP + return self.add_pattern( + type=pattern_id, + start_pattern=start_pattern, + end_pattern=end_pattern, + action=action, + ) + def on_pattern_match( - self, pattern_id: str, handler: Callable[[PatternMatch], Awaitable[None]] + self, type: str, handler: Callable[[PatternMatch], Awaitable[None]] ) -> "PatternPairAggregator": """Register a handler for when a pattern pair is matched. The handler will be called whenever a complete match for the - specified pattern ID is found in the text. + specified type is found in the text. Args: - pattern_id: ID of the pattern pair to match. + type: The type of the pattern pair to trigger the handler. handler: Async function to call when pattern is matched. The function should accept a PatternMatch object. Returns: Self for method chaining. """ - self._handlers[pattern_id] = handler + self._handlers[type] = handler return self - async def _process_complete_patterns(self, text: str) -> Tuple[str, bool]: + async def _process_complete_patterns(self, text: str) -> Tuple[List[PatternMatch], str]: """Process all complete pattern pairs in the text. Searches for all complete pattern pairs in the text, calls the @@ -137,19 +228,19 @@ class PatternPairAggregator(BaseTextAggregator): text: The text to process for pattern matches. Returns: - Tuple of (processed_text, was_modified) where: + Tuple of (all_matches, processed_text) where: - - processed_text is the text after processing patterns - - was_modified indicates whether any changes were made + - all_matches is a list of all pattern matches found. Note: There really should only ever be 1. + - processed_text is the text after processing patterns. If no patterns are found, it will be the same as input text. """ + all_matches = [] processed_text = text - modified = False - for pattern_id, pattern_info in self._patterns.items(): + for type, pattern_info in self._patterns.items(): # Escape special regex characters in the patterns start = re.escape(pattern_info["start"]) end = re.escape(pattern_info["end"]) - remove_match = pattern_info["remove_match"] + action = pattern_info["action"] # Create regex to match from start pattern to end pattern # The .*? is non-greedy to handle nested patterns @@ -165,24 +256,25 @@ class PatternPairAggregator(BaseTextAggregator): # Create pattern match object pattern_match = PatternMatch( - pattern_id=pattern_id, full_match=full_match, content=content + content=content.strip(), type=type, full_match=full_match ) # Call the appropriate handler if registered - if pattern_id in self._handlers: + if type in self._handlers: try: - await self._handlers[pattern_id](pattern_match) + await self._handlers[type](pattern_match) except Exception as e: - logger.error(f"Error in pattern handler for {pattern_id}: {e}") + logger.error(f"Error in pattern handler for {type}: {e}") # Remove the pattern from the text if configured - if remove_match: + if action == MatchAction.REMOVE: processed_text = processed_text.replace(full_match, "", 1) - modified = True + else: + all_matches.append(pattern_match) - return processed_text, modified + return all_matches, processed_text - def _has_incomplete_patterns(self, text: str) -> bool: + def _match_start_of_pattern(self, text: str) -> Optional[Tuple[int, dict]]: """Check if text contains incomplete pattern pairs. Determines whether the text contains any start patterns without @@ -192,9 +284,10 @@ class PatternPairAggregator(BaseTextAggregator): text: The text to check for incomplete patterns. Returns: - True if there are incomplete patterns, False otherwise. + A tuple of (start_index, pattern_info) if an incomplete pattern is found, + or None if no patterns are found or all patterns are complete. """ - for pattern_id, pattern_info in self._patterns.items(): + for type, pattern_info in self._patterns.items(): start = pattern_info["start"] end = pattern_info["end"] @@ -203,12 +296,16 @@ class PatternPairAggregator(BaseTextAggregator): end_count = text.count(end) # If there are more starts than ends, we have incomplete patterns + # Again, this is written generically but there only ever should + # be one pattern active at a time, so the counts should be 0 or 1. + # Which is why we base the return on the first found. if start_count > end_count: - return True + start_index = text.find(start) + return [start_index, pattern_info] - return False + return None - async def aggregate(self, text: str) -> Optional[Aggregation]: + async def aggregate(self, text: str) -> Optional[PatternMatch]: """Aggregate text and process pattern pairs. This method adds the new text to the buffer, processes any complete pattern @@ -220,24 +317,43 @@ class PatternPairAggregator(BaseTextAggregator): text: New text to add to the buffer. Returns: - An Aggregation object containing processed text up to a sentence boundary - and marked as SENTENCE type, or None if more text is needed to form a - complete sentence or pattern. + Processed text up to a sentence boundary, or None if more + text is needed to form a complete sentence or pattern. """ # Add new text to buffer self._text += text # Process any complete patterns in the buffer - processed_text, modified = await self._process_complete_patterns(self._text) + patterns, processed_text = await self._process_complete_patterns(self._text) - # Only update the buffer if modifications were made - if modified: - 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 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] # Check if we have incomplete patterns - if self._has_incomplete_patterns(self._text): - # Still waiting for complete patterns - return None + 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 + ) # Find sentence boundary if no incomplete patterns eos_marker = match_endofsentence(self._text) @@ -245,7 +361,9 @@ class PatternPairAggregator(BaseTextAggregator): # Extract text up to the sentence boundary result = self._text[:eos_marker] self._text = self._text[eos_marker:] - return Aggregation(text=result.strip(), type=AggregationType.SENTENCE) + return PatternMatch( + content=result.strip(), type=AggregationType.SENTENCE, full_match=result + ) # No complete sentence found yet return None diff --git a/tests/test_pattern_pair_aggregator.py b/tests/test_pattern_pair_aggregator.py index 01e45f9bd..20b44a03c 100644 --- a/tests/test_pattern_pair_aggregator.py +++ b/tests/test_pattern_pair_aggregator.py @@ -7,30 +7,42 @@ import unittest from unittest.mock import AsyncMock -from pipecat.utils.text.pattern_pair_aggregator import PatternMatch, PatternPairAggregator +from pipecat.utils.text.pattern_pair_aggregator import ( + MatchAction, + PatternMatch, + PatternPairAggregator, +) class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase): def setUp(self): self.aggregator = PatternPairAggregator() self.test_handler = AsyncMock() + self.code_handler = AsyncMock() # Add a test pattern self.aggregator.add_pattern_pair( pattern_id="test_pattern", start_pattern="", end_pattern="", - remove_match=True, + ) + self.aggregator.add_pattern( + type="code_pattern", + start_pattern="", + end_pattern="", + action=MatchAction.AGGREGATE, ) # Register the mock handler self.aggregator.on_pattern_match("test_pattern", self.test_handler) + self.aggregator.on_pattern_match("code_pattern", self.code_handler) async def test_pattern_match_and_removal(self): # First part doesn't complete the pattern result = await self.aggregator.aggregate("Hello pattern") self.assertIsNone(result) self.assertEqual(self.aggregator.text.text, "Hello pattern") + self.assertEqual(self.aggregator.text.type, "test_pattern") # Second part completes the pattern and includes an exclamation point result = await self.aggregator.aggregate(" content!") @@ -39,9 +51,9 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase): self.test_handler.assert_called_once() call_args = self.test_handler.call_args[0][0] self.assertIsInstance(call_args, PatternMatch) - self.assertEqual(call_args.pattern_id, "test_pattern") + self.assertEqual(call_args.type, "test_pattern") self.assertEqual(call_args.full_match, "pattern content") - self.assertEqual(call_args.content, "pattern content") + self.assertEqual(call_args.text, "pattern content") # The exclamation point should be treated as a sentence boundary, # so the result should include just text up to and including "!" @@ -52,7 +64,35 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase): # should be stripped in the returned Aggregation. result = await self.aggregator.aggregate(" This is another sentence.") self.assertEqual(result.text, "This is another sentence.") + + # Buffer should be empty after returning a complete sentence + self.assertEqual(self.aggregator.text.text, "") + + async def test_pattern_match_and_aggregate(self): + # First part doesn't complete the pattern + result = await self.aggregator.aggregate("Here is code pattern") + self.assertEqual(result.text, "Here is code") + self.assertEqual(self.aggregator.text.text, "pattern") + self.assertEqual(self.aggregator.text.type, "code_pattern") + + # Second part completes the pattern and includes an exclamation point + result = await self.aggregator.aggregate(" content") + + # Verify the handler was called with correct PatternMatch object + self.code_handler.assert_called_once() + call_args = self.code_handler.call_args[0][0] + self.assertIsInstance(call_args, PatternMatch) + self.assertEqual(call_args.type, "code_pattern") + self.assertEqual(call_args.full_match, "pattern content") + self.assertEqual(call_args.text, "pattern content") + self.assertEqual(result.text, "pattern content") + self.assertEqual(result.type, "code_pattern") + + # Next sentence should be processed separately + result = await self.aggregator.aggregate(" This is another sentence.") + self.assertEqual(result.text, "This is another sentence.") self.assertEqual(result.type, "sentence") + # Buffer should be empty after returning a complete sentence self.assertEqual(self.aggregator.text.text, "") @@ -68,6 +108,7 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase): # Buffer should contain the incomplete text self.assertEqual(self.aggregator.text.text, "Hello pattern content") + self.assertEqual(self.aggregator.text.type, "test_pattern") # Reset and confirm buffer is cleared await self.aggregator.reset() @@ -78,15 +119,18 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase): voice_handler = AsyncMock() emphasis_handler = AsyncMock() - self.aggregator.add_pattern_pair( - pattern_id="voice", start_pattern="", end_pattern="", remove_match=True + self.aggregator.add_pattern( + type="voice", + start_pattern="", + end_pattern="", + action=MatchAction.REMOVE, ) - self.aggregator.add_pattern_pair( - pattern_id="emphasis", + self.aggregator.add_pattern( + type="emphasis", start_pattern="", end_pattern="", - remove_match=False, # Keep emphasis tags + action=MatchAction.KEEP, # Keep emphasis tags ) self.aggregator.on_pattern_match("voice", voice_handler) @@ -99,17 +143,16 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase): # Both handlers should be called with correct data voice_handler.assert_called_once() voice_match = voice_handler.call_args[0][0] - self.assertEqual(voice_match.pattern_id, "voice") - self.assertEqual(voice_match.content, "female") + self.assertEqual(voice_match.type, "voice") + self.assertEqual(voice_match.text, "female") emphasis_handler.assert_called_once() emphasis_match = emphasis_handler.call_args[0][0] - self.assertEqual(emphasis_match.pattern_id, "emphasis") - self.assertEqual(emphasis_match.content, "very") + self.assertEqual(emphasis_match.type, "emphasis") + self.assertEqual(emphasis_match.text, "very") # Voice pattern should be removed, emphasis pattern should remain self.assertEqual(result.text, "Hello I am very excited to meet you!") - self.assertEqual(result.type, "sentence") # Buffer should be empty self.assertEqual(self.aggregator.text.text, "") @@ -141,11 +184,10 @@ class TestPatternPairAggregator(unittest.IsolatedAsyncioTestCase): # Handler should be called with entire content self.test_handler.assert_called_once() call_args = self.test_handler.call_args[0][0] - self.assertEqual(call_args.content, "This is sentence one. This is sentence two.") + self.assertEqual(call_args.text, "This is sentence one. This is sentence two.") # Pattern should be removed, resulting in text with sentences merged self.assertEqual(result.text, "Hello Final sentence.") - self.assertEqual(result.type, "sentence") # Buffer should be empty self.assertEqual(self.aggregator.text.text, "")