Add PairPatternAggregator
This commit is contained in:
262
src/pipecat/utils/text/pattern_pair_aggregator.py
Normal file
262
src/pipecat/utils/text/pattern_pair_aggregator.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Callable, Dict, Optional, Pattern, Tuple, Union
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from pipecat.utils.string import match_endofsentence
|
||||||
|
from pipecat.utils.text.base_text_aggregator import BaseTextAggregator
|
||||||
|
|
||||||
|
|
||||||
|
class PatternMatch:
|
||||||
|
"""Represents a matched pattern pair with its content.
|
||||||
|
|
||||||
|
A PatternMatch object is created when a complete pattern pair is found
|
||||||
|
in the text. It contains information about which pattern was matched,
|
||||||
|
the full matched text (including start and end patterns), and the
|
||||||
|
content between the patterns.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
pattern_id: The identifier of the matched pattern pair.
|
||||||
|
full_match: The complete text including start and end patterns.
|
||||||
|
content: The text content between the start and end patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, pattern_id: str, full_match: str, content: str):
|
||||||
|
"""Initialize a pattern match.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern_id: ID of the pattern pair.
|
||||||
|
full_match: Complete matched text including start and end patterns.
|
||||||
|
content: Content between the start and end patterns.
|
||||||
|
"""
|
||||||
|
self.pattern_id = pattern_id
|
||||||
|
self.full_match = full_match
|
||||||
|
self.content = content
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
"""Return a string representation of the pattern match.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A string describing the pattern match.
|
||||||
|
"""
|
||||||
|
return f"PatternMatch(id={self.pattern_id}, content={self.content})"
|
||||||
|
|
||||||
|
|
||||||
|
class PatternPairAggregator(BaseTextAggregator):
|
||||||
|
"""Aggregator that identifies and processes content between pattern pairs.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The aggregator ensures that patterns spanning multiple text chunks are
|
||||||
|
correctly identified and handles cases where patterns contain sentence
|
||||||
|
boundaries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the pattern pair aggregator.
|
||||||
|
|
||||||
|
Creates an empty aggregator with no patterns or handlers registered.
|
||||||
|
"""
|
||||||
|
self._text = ""
|
||||||
|
self._patterns = {}
|
||||||
|
self._handlers = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
"""Get the currently buffered text.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The current text buffer content.
|
||||||
|
"""
|
||||||
|
return self._text
|
||||||
|
|
||||||
|
def add_pattern_pair(
|
||||||
|
self, pattern_id: str, start_pattern: str, end_pattern: str, remove_match: bool = True
|
||||||
|
) -> "PatternPairAggregator":
|
||||||
|
"""Add a pattern pair to detect in the text.
|
||||||
|
|
||||||
|
Registers a new pattern pair with a unique identifier. The aggregator
|
||||||
|
will look for text that starts with the start pattern and ends with
|
||||||
|
the end pattern, and treat the content between them as a match.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern_id: Unique identifier for this pattern pair.
|
||||||
|
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.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining.
|
||||||
|
"""
|
||||||
|
self._patterns[pattern_id] = {
|
||||||
|
"start": start_pattern,
|
||||||
|
"end": end_pattern,
|
||||||
|
"remove_match": remove_match,
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
|
||||||
|
def on_pattern_match(
|
||||||
|
self, pattern_id: str, handler: Callable[[PatternMatch], 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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pattern_id: ID of the pattern pair to match.
|
||||||
|
handler: Function to call when pattern is matched.
|
||||||
|
The function should accept a PatternMatch object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining.
|
||||||
|
"""
|
||||||
|
self._handlers[pattern_id] = handler
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _process_complete_patterns(self, text: str) -> Tuple[str, bool]:
|
||||||
|
"""Process all complete pattern pairs in the text.
|
||||||
|
|
||||||
|
Searches for all complete pattern pairs in the text, calls the
|
||||||
|
appropriate handlers, and optionally removes the matches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (processed_text, was_modified) where:
|
||||||
|
- processed_text is the text after processing patterns
|
||||||
|
- was_modified indicates whether any changes were made
|
||||||
|
"""
|
||||||
|
processed_text = text
|
||||||
|
modified = False
|
||||||
|
|
||||||
|
for pattern_id, 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"]
|
||||||
|
|
||||||
|
# Create regex to match from start pattern to end pattern
|
||||||
|
# The .*? is non-greedy to handle nested patterns
|
||||||
|
regex = f"{start}(.*?){end}"
|
||||||
|
|
||||||
|
# Find all matches
|
||||||
|
match_iter = re.finditer(regex, processed_text, re.DOTALL)
|
||||||
|
matches = list(match_iter) # Convert to list for safe iteration
|
||||||
|
|
||||||
|
for match in matches:
|
||||||
|
content = match.group(1) # Content between patterns
|
||||||
|
full_match = match.group(0) # Full match including patterns
|
||||||
|
|
||||||
|
# Create pattern match object
|
||||||
|
pattern_match = PatternMatch(
|
||||||
|
pattern_id=pattern_id, full_match=full_match, content=content
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call the appropriate handler if registered
|
||||||
|
if pattern_id in self._handlers:
|
||||||
|
try:
|
||||||
|
self._handlers[pattern_id](pattern_match)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in pattern handler for {pattern_id}: {e}")
|
||||||
|
|
||||||
|
# Remove the pattern from the text if configured
|
||||||
|
if remove_match:
|
||||||
|
processed_text = processed_text.replace(full_match, "", 1)
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
return processed_text, modified
|
||||||
|
|
||||||
|
def _has_incomplete_patterns(self, text: str) -> bool:
|
||||||
|
"""Check if text contains incomplete pattern pairs.
|
||||||
|
|
||||||
|
Determines whether the text contains any start patterns without
|
||||||
|
matching end patterns, which would indicate incomplete content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: The text to check.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if there are incomplete patterns, False otherwise.
|
||||||
|
"""
|
||||||
|
for pattern_id, pattern_info in self._patterns.items():
|
||||||
|
start = pattern_info["start"]
|
||||||
|
end = pattern_info["end"]
|
||||||
|
|
||||||
|
# Count occurrences
|
||||||
|
start_count = text.count(start)
|
||||||
|
end_count = text.count(end)
|
||||||
|
|
||||||
|
# If there are more starts than ends, we have incomplete patterns
|
||||||
|
if start_count > end_count:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def aggregate(self, text: str) -> Optional[str]:
|
||||||
|
"""Aggregate text and process pattern pairs.
|
||||||
|
|
||||||
|
This method adds the new text to the buffer, processes any complete pattern
|
||||||
|
pairs, and returns processed text up to sentence boundaries if possible.
|
||||||
|
If there are incomplete patterns (start without matching end), it will
|
||||||
|
continue buffering text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: New text to add to the buffer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
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 = self._process_complete_patterns(self._text)
|
||||||
|
|
||||||
|
# Only update the buffer if modifications were made
|
||||||
|
if modified:
|
||||||
|
self._text = processed_text
|
||||||
|
|
||||||
|
# Check if we have incomplete patterns
|
||||||
|
if self._has_incomplete_patterns(self._text):
|
||||||
|
# Still waiting for complete patterns
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find sentence boundary if no incomplete patterns
|
||||||
|
eos_marker = match_endofsentence(self._text)
|
||||||
|
if eos_marker:
|
||||||
|
# Extract text up to the sentence boundary
|
||||||
|
result = self._text[:eos_marker]
|
||||||
|
self._text = self._text[eos_marker:]
|
||||||
|
return result
|
||||||
|
|
||||||
|
# No complete sentence found yet
|
||||||
|
return None
|
||||||
|
|
||||||
|
def handle_interruption(self):
|
||||||
|
"""Handle interruptions by clearing the buffer.
|
||||||
|
|
||||||
|
Called when an interruption occurs in the processing pipeline,
|
||||||
|
to reset the state and discard any partially aggregated text.
|
||||||
|
"""
|
||||||
|
self._text = ""
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Clear the internally aggregated text.
|
||||||
|
|
||||||
|
Resets the aggregator to its initial state, discarding any
|
||||||
|
buffered text.
|
||||||
|
"""
|
||||||
|
self._text = ""
|
||||||
Reference in New Issue
Block a user