Compare commits
28 Commits
v0.0.82
...
mb/fix-qui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6531858970 | ||
|
|
64486ef50b | ||
|
|
802c5d04f4 | ||
|
|
83b90da53a | ||
|
|
1f49de5cdf | ||
|
|
7cf099eae7 | ||
|
|
93a8ea3cb2 | ||
|
|
776aafddfb | ||
|
|
d56762262a | ||
|
|
bbcf35d657 | ||
|
|
972546b24f | ||
|
|
8b351f5bec | ||
|
|
bd7d9346b7 | ||
|
|
81325be4f3 | ||
|
|
399f8de6ef | ||
|
|
60c070e077 | ||
|
|
b5a644dd6f | ||
|
|
25b595e125 | ||
|
|
edc8cc1e69 | ||
|
|
633dd69dee | ||
|
|
1a1d5a1081 | ||
|
|
c1b8d2acab | ||
|
|
ea368e4c5f | ||
|
|
f03deb6ecc | ||
|
|
0e01ac8ef6 | ||
|
|
5787743ab3 | ||
|
|
79be0695dd | ||
|
|
a5c5e069ba |
67
CHANGELOG.md
67
CHANGELOG.md
@@ -5,6 +5,73 @@ All notable changes to **Pipecat** will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- Added `pipecat.extensions.ivr` for automated IVR system navigation with
|
||||
configurable goals and conversation handling. Supports DTMF input, verbal
|
||||
responses, and intelligent menu traversal.
|
||||
|
||||
Basic usage:
|
||||
|
||||
```python
|
||||
from pipecat.extensions.ivr.ivr_navigator import IVRNavigator
|
||||
|
||||
# Create IVR navigator with your goal
|
||||
ivr_navigator = IVRNavigator(
|
||||
llm=llm_service,
|
||||
ivr_prompt="Navigate to billing department to dispute a charge"
|
||||
)
|
||||
|
||||
# Handle different outcomes
|
||||
@ivr_navigator.event_handler("on_conversation_detected")
|
||||
async def on_conversation(processor, conversation_history):
|
||||
# Switch to normal conversation mode
|
||||
pass
|
||||
|
||||
@ivr_navigator.event_handler("on_ivr_status_changed")
|
||||
async def on_ivr_status(processor, status):
|
||||
if status == IVRStatus.COMPLETED:
|
||||
# End pipeline, transfer call, or start bot conversation
|
||||
elif status == IVRStatus.STUCK:
|
||||
# Handle navigation failure
|
||||
```
|
||||
|
||||
- `BaseOutputTransport` now implements `write_dtmf()` by loading DTMF audio and
|
||||
sending it through the transport. This makes sending DTMF generic across all
|
||||
output transports.
|
||||
|
||||
- Added new config parameters to `GladiaSTTService`.
|
||||
- PreProcessingConfig > `audio_enhancer` to enhance audio quality.
|
||||
- CustomVocabularyItem > `pronunciations` and `language` to specify special pronunciations and in which language it will be pronounced.
|
||||
|
||||
## Changed
|
||||
|
||||
- `pipecat.frames.frames.KeypadEntry` is deprecated and has been moved to
|
||||
`pipecat.audio.dtmf.types.KeypadEntry`.
|
||||
|
||||
## Removed
|
||||
|
||||
- Remove `StopInterruptionFrame`. This was a legacy frame that was not being
|
||||
used really anywhere and it didn't provide any useful meaning. It was only
|
||||
pushed after `UserStoppedSpeakingFrame`, so developers can just use
|
||||
`UserStoppedSpeakingFrame`.
|
||||
|
||||
- `DailyTransport.write_dtmf()` has been removed in favor of the generic
|
||||
`BaseOutputTransport.write_dtmf()`.
|
||||
|
||||
- Remove deprecated `DailyTransport.send_dtmf()`.
|
||||
|
||||
## Deprecated
|
||||
|
||||
- `pipecat.frames.frames.KeypadEntry` is deprecated use
|
||||
`pipecat.audio.dtmf.types.KeypadEntry` instead.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Fixed an issue where `PipelineTask` was not cleaning up the observers.
|
||||
|
||||
## [0.0.82] - 2025-08-28
|
||||
|
||||
### Added
|
||||
|
||||
@@ -21,6 +21,7 @@ Quick Links
|
||||
Adapters <api/pipecat.adapters>
|
||||
Audio <api/pipecat.audio>
|
||||
Clocks <api/pipecat.clocks>
|
||||
Extensions <api/pipecat.extensions>
|
||||
Frames <api/pipecat.frames>
|
||||
Metrics <api/pipecat.metrics>
|
||||
Observers <api/pipecat.observers>
|
||||
|
||||
@@ -12,9 +12,8 @@ from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.frames.frames import (
|
||||
BotInterruptionFrame,
|
||||
LLMRunFrame,
|
||||
StopInterruptionFrame,
|
||||
StartInterruptionFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
)
|
||||
@@ -98,11 +97,11 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
|
||||
@stt.event_handler("on_speech_started")
|
||||
async def on_speech_started(stt, *args, **kwargs):
|
||||
await task.queue_frames([BotInterruptionFrame(), UserStartedSpeakingFrame()])
|
||||
await task.queue_frames([StartInterruptionFrame(), UserStartedSpeakingFrame()])
|
||||
|
||||
@stt.event_handler("on_utterance_end")
|
||||
async def on_utterance_end(stt, *args, **kwargs):
|
||||
await task.queue_frames([StopInterruptionFrame(), UserStoppedSpeakingFrame()])
|
||||
await task.queue_frames([UserStoppedSpeakingFrame()])
|
||||
|
||||
@transport.event_handler("on_client_connected")
|
||||
async def on_client_connected(transport, client):
|
||||
|
||||
@@ -21,7 +21,6 @@ from pipecat.frames.frames import (
|
||||
LLMRunFrame,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
StopInterruptionFrame,
|
||||
SystemFrame,
|
||||
TextFrame,
|
||||
TranscriptionFrame,
|
||||
@@ -234,7 +233,6 @@ class TurnDetectionLLM(Pipeline):
|
||||
return (
|
||||
isinstance(frame, OpenAILLMContextFrame)
|
||||
or isinstance(frame, StartInterruptionFrame)
|
||||
or isinstance(frame, StopInterruptionFrame)
|
||||
or isinstance(frame, FunctionCallInProgressFrame)
|
||||
or isinstance(frame, FunctionCallResultFrame)
|
||||
)
|
||||
|
||||
@@ -21,7 +21,6 @@ from pipecat.frames.frames import (
|
||||
LLMRunFrame,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
StopInterruptionFrame,
|
||||
SystemFrame,
|
||||
TextFrame,
|
||||
TranscriptionFrame,
|
||||
@@ -428,7 +427,6 @@ class TurnDetectionLLM(Pipeline):
|
||||
return (
|
||||
isinstance(frame, OpenAILLMContextFrame)
|
||||
or isinstance(frame, StartInterruptionFrame)
|
||||
or isinstance(frame, StopInterruptionFrame)
|
||||
or isinstance(frame, FunctionCallInProgressFrame)
|
||||
or isinstance(frame, FunctionCallResultFrame)
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from loguru import logger
|
||||
from pipecat.frames.frames import LLMRunFrame
|
||||
|
||||
print("🚀 Starting Pipecat bot...")
|
||||
print("⏳ Loading models and imports (20 seconds first run only)\n")
|
||||
@@ -102,7 +101,7 @@ async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
|
||||
logger.info(f"Client connected")
|
||||
# Kick off the conversation.
|
||||
messages.append({"role": "system", "content": "Say hello and briefly introduce yourself."})
|
||||
await task.queue_frames([LLMRunFrame()])
|
||||
await task.queue_frames([context_aggregator.user().get_context_frame()])
|
||||
|
||||
@transport.event_handler("on_client_disconnected")
|
||||
async def on_client_disconnected(transport, client):
|
||||
|
||||
@@ -20,6 +20,7 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence"
|
||||
]
|
||||
dependencies = [
|
||||
"aiofiles>=24.1.0,<25",
|
||||
"aiohttp>=3.11.12,<4",
|
||||
"audioop-lts~=0.2.1; python_version>='3.13'",
|
||||
"docstring_parser~=0.16",
|
||||
@@ -114,11 +115,11 @@ dev = [
|
||||
"grpcio-tools~=1.67.1",
|
||||
"pip-tools~=7.4.1",
|
||||
"pre-commit~=4.2.0",
|
||||
"pyright~=1.1.402",
|
||||
"pyright>=1.1.404,<1.2",
|
||||
"pytest~=8.4.1",
|
||||
"pytest-asyncio~=1.1.0",
|
||||
"pytest-aiohttp==1.1.0",
|
||||
"ruff~=0.12.1",
|
||||
"ruff>=0.12.11,<1",
|
||||
"setuptools~=78.1.1",
|
||||
"setuptools_scm~=8.3.1",
|
||||
"python-dotenv>=1.0.1,<2.0.0",
|
||||
@@ -137,6 +138,20 @@ where = ["src"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"pipecat" = ["py.typed"]
|
||||
"pipecat.audio.dtmf" = [
|
||||
"src/pipecat/audio/dtmf/dtmf-0.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-1.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-2.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-3.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-4.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-5.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-6.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-7.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-8.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-9.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-pound.wav",
|
||||
"src/pipecat/audio/dtmf/dtmf-star.wav",
|
||||
]
|
||||
"pipecat.services.aws_nova_sonic" = ["src/pipecat/services/aws_nova_sonic/ready.wav"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
|
||||
35
scripts/dtmf/generate_dtmf.sh
Executable file
35
scripts/dtmf/generate_dtmf.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
|
||||
# DTMF frequency map (low, high)
|
||||
declare -A DTMF=(
|
||||
[1]="697 1209"
|
||||
[2]="697 1336"
|
||||
[3]="697 1477"
|
||||
[4]="770 1209"
|
||||
[5]="770 1336"
|
||||
[6]="770 1477"
|
||||
[7]="852 1209"
|
||||
[8]="852 1336"
|
||||
[9]="852 1477"
|
||||
["star"]="941 1209"
|
||||
[0]="941 1336"
|
||||
["pound"]="941 1477"
|
||||
)
|
||||
|
||||
# Tone duration (seconds) + gap after
|
||||
DURATION=0.3
|
||||
GAP=0.2
|
||||
SAMPLERATE=8000
|
||||
|
||||
for key in "${!DTMF[@]}"; do
|
||||
freqs=(${DTMF[$key]})
|
||||
low=${freqs[0]}
|
||||
high=${freqs[1]}
|
||||
echo "Generating DTMF tone for $key ($low Hz + $high Hz)"
|
||||
ffmpeg -hide_banner -loglevel error -y \
|
||||
-f lavfi -i "sine=frequency=$low:duration=$DURATION:sample_rate=$SAMPLERATE" \
|
||||
-f lavfi -i "sine=frequency=$high:duration=$DURATION:sample_rate=$SAMPLERATE" \
|
||||
-f lavfi -i "anullsrc=r=$SAMPLERATE:cl=mono:d=$GAP" \
|
||||
-filter_complex "[0][1]amix=2[a];[a][2]concat=n=2:v=0:a=1[out]" \
|
||||
-map "[out]" -c:a pcm_s16le -ar $SAMPLERATE "dtmf-${key}.wav"
|
||||
done
|
||||
0
src/pipecat/audio/dtmf/__init__.py
Normal file
0
src/pipecat/audio/dtmf/__init__.py
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-0.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-0.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-1.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-1.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-2.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-2.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-3.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-3.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-4.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-4.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-5.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-5.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-6.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-6.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-7.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-7.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-8.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-8.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-9.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-9.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-pound.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-pound.wav
Normal file
Binary file not shown.
BIN
src/pipecat/audio/dtmf/dtmf-star.wav
Normal file
BIN
src/pipecat/audio/dtmf/dtmf-star.wav
Normal file
Binary file not shown.
47
src/pipecat/audio/dtmf/types.py
Normal file
47
src/pipecat/audio/dtmf/types.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# Copyright (c) 2024–2025, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""This module defines generic type for DTMS.
|
||||
|
||||
It defines the `KeypadEntry` enumeration, representing dual-tone multi-frequency
|
||||
(DTMF) keypad entries for phone system integration. Each entry corresponds to a
|
||||
key on the telephone keypad, facilitating the handling of input in
|
||||
telecommunication applications.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class KeypadEntry(str, Enum):
|
||||
"""DTMF keypad entries for phone system integration.
|
||||
|
||||
Parameters:
|
||||
ONE: Number key 1.
|
||||
TWO: Number key 2.
|
||||
THREE: Number key 3.
|
||||
FOUR: Number key 4.
|
||||
FIVE: Number key 5.
|
||||
SIX: Number key 6.
|
||||
SEVEN: Number key 7.
|
||||
EIGHT: Number key 8.
|
||||
NINE: Number key 9.
|
||||
ZERO: Number key 0.
|
||||
POUND: Pound/hash key (#).
|
||||
STAR: Star/asterisk key (*).
|
||||
"""
|
||||
|
||||
ONE = "1"
|
||||
TWO = "2"
|
||||
THREE = "3"
|
||||
FOUR = "4"
|
||||
FIVE = "5"
|
||||
SIX = "6"
|
||||
SEVEN = "7"
|
||||
EIGHT = "8"
|
||||
NINE = "9"
|
||||
ZERO = "0"
|
||||
|
||||
POUND = "#"
|
||||
STAR = "*"
|
||||
70
src/pipecat/audio/dtmf/utils.py
Normal file
70
src/pipecat/audio/dtmf/utils.py
Normal file
@@ -0,0 +1,70 @@
|
||||
#
|
||||
# Copyright (c) 2024–2025, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""DTMF audio utilities.
|
||||
|
||||
This module provides functionality to load DTMF (Dual-Tone Multi-Frequency)
|
||||
audio files corresponding to phone keypad entries. Audio data is cached
|
||||
in-memory after first load to improve performance on subsequent accesses.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import wave
|
||||
from importlib.resources import files
|
||||
from typing import Dict, Optional
|
||||
|
||||
import aiofiles
|
||||
|
||||
from pipecat.audio.dtmf.types import KeypadEntry
|
||||
from pipecat.audio.resamplers.base_audio_resampler import BaseAudioResampler
|
||||
from pipecat.audio.utils import create_file_resampler
|
||||
|
||||
__DTMF_LOCK__ = asyncio.Lock()
|
||||
__DTMF_AUDIO__: Dict[KeypadEntry, bytes] = {}
|
||||
__DTMF_RESAMPLER__: Optional[BaseAudioResampler] = None
|
||||
|
||||
__DTMF_FILE_NAME = {
|
||||
KeypadEntry.POUND: "dtmf-pound.wav",
|
||||
KeypadEntry.STAR: "dtmf-star.wav",
|
||||
}
|
||||
|
||||
|
||||
async def load_dtmf_audio(button: KeypadEntry, *, sample_rate: int = 8000) -> bytes:
|
||||
"""Load audio for DTMF tones associated with the given button.
|
||||
|
||||
Args:
|
||||
button (KeypadEntry): The button for which the DTMF audio is to be loaded.
|
||||
sample_rate (int, optional): The sample rate for the audio. Defaults to 8000.
|
||||
|
||||
Returns:
|
||||
bytes: The audio data for the DTMF tone as bytes.
|
||||
"""
|
||||
global __DTMF_AUDIO__, __DTMF_RESAMPLER__
|
||||
|
||||
async with __DTMF_LOCK__:
|
||||
if button in __DTMF_AUDIO__:
|
||||
return __DTMF_AUDIO__[button]
|
||||
|
||||
if not __DTMF_RESAMPLER__:
|
||||
__DTMF_RESAMPLER__ = create_file_resampler()
|
||||
|
||||
dtmf_file_name = __DTMF_FILE_NAME.get(button, f"dtmf-{button.value}.wav")
|
||||
dtmf_file_path = files("pipecat.audio.dtmf").joinpath(dtmf_file_name)
|
||||
|
||||
async with aiofiles.open(dtmf_file_path, "rb") as f:
|
||||
data = await f.read()
|
||||
|
||||
with io.BytesIO(data) as buffer:
|
||||
with wave.open(buffer, "rb") as wf:
|
||||
audio = wf.readframes(wf.getnframes())
|
||||
in_sample_rate = wf.getframerate()
|
||||
resampled_audio = await __DTMF_RESAMPLER__.resample(
|
||||
audio, in_sample_rate, sample_rate
|
||||
)
|
||||
__DTMF_AUDIO__[button] = resampled_audio
|
||||
|
||||
return __DTMF_AUDIO__[button]
|
||||
@@ -41,13 +41,15 @@ def create_default_resampler(**kwargs) -> BaseAudioResampler:
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"`create_default_resampler` is deprecated. "
|
||||
"Use `create_stream_resampler` for real-time processing scenarios or "
|
||||
"`create_file_resampler` for batch processing of complete audio files.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"`create_default_resampler` is deprecated. "
|
||||
"Use `create_stream_resampler` for real-time processing scenarios or "
|
||||
"`create_file_resampler` for batch processing of complete audio files.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return SOXRAudioResampler(**kwargs)
|
||||
|
||||
|
||||
|
||||
0
src/pipecat/extensions/ivr/__init__.py
Normal file
0
src/pipecat/extensions/ivr/__init__.py
Normal file
452
src/pipecat/extensions/ivr/ivr_navigator.py
Normal file
452
src/pipecat/extensions/ivr/ivr_navigator.py
Normal file
@@ -0,0 +1,452 @@
|
||||
#
|
||||
# Copyright (c) 2024–2025, Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
"""Interactive Voice Response (IVR) navigation components.
|
||||
|
||||
This module provides classes for automated navigation of IVR phone systems
|
||||
using LLM-based decision making and DTMF tone generation.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from pipecat.audio.dtmf.types import KeypadEntry
|
||||
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||
from pipecat.frames.frames import (
|
||||
Frame,
|
||||
LLMContextFrame,
|
||||
LLMMessagesUpdateFrame,
|
||||
LLMTextFrame,
|
||||
OutputDTMFUrgentFrame,
|
||||
StartFrame,
|
||||
TextFrame,
|
||||
VADParamsUpdateFrame,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class IVRStatus(Enum):
|
||||
"""Enumeration of IVR navigation status values.
|
||||
|
||||
These statuses are used to communicate the current state of IVR navigation
|
||||
between the LLM and the IVR processing system.
|
||||
"""
|
||||
|
||||
DETECTED = "detected"
|
||||
COMPLETED = "completed"
|
||||
STUCK = "stuck"
|
||||
WAIT = "wait"
|
||||
|
||||
|
||||
class IVRProcessor(FrameProcessor):
|
||||
"""Processes LLM responses for IVR navigation commands.
|
||||
|
||||
Aggregates XML-tagged commands from LLM text streams and executes
|
||||
corresponding actions like DTMF tone generation and mode switching.
|
||||
|
||||
Supported features:
|
||||
|
||||
- DTMF command processing (`<dtmf>1</dtmf>`)
|
||||
- IVR state management (see IVRStatus enum: `<ivr>detected</ivr>`, `<ivr>completed</ivr>`, `<ivr>stuck</ivr>`, `<ivr>wait</ivr>`)
|
||||
- Automatic prompt and VAD parameter switching
|
||||
- Event emission via on_ivr_status_changed for detected, completed, and stuck states
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
classifier_prompt: str,
|
||||
ivr_prompt: str,
|
||||
ivr_vad_params: Optional[VADParams] = None,
|
||||
):
|
||||
"""Initialize the IVR processor.
|
||||
|
||||
Args:
|
||||
classifier_prompt: System prompt for classifying IVR or conversation.
|
||||
ivr_prompt: System prompt for IVR navigation mode.
|
||||
ivr_vad_params: VAD parameters for IVR navigation mode. If None, defaults to VADParams(stop_secs=2.0).
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
self._ivr_prompt = ivr_prompt
|
||||
self._ivr_vad_params = ivr_vad_params or VADParams(stop_secs=2.0)
|
||||
self._classifier_prompt = classifier_prompt
|
||||
|
||||
# Store saved context messages
|
||||
self._saved_messages: List[dict] = []
|
||||
|
||||
# XML pattern aggregation
|
||||
self._aggregator = PatternPairAggregator()
|
||||
self._setup_xml_patterns()
|
||||
|
||||
# Register IVR events
|
||||
self._register_event_handler("on_conversation_detected")
|
||||
self._register_event_handler("on_ivr_status_changed")
|
||||
|
||||
def update_saved_messages(self, messages: List[dict]) -> None:
|
||||
"""Update the saved context messages.
|
||||
|
||||
Sets the messages that are saved when switching between
|
||||
conversation and IVR navigation modes.
|
||||
|
||||
Args:
|
||||
messages: List of message dictionaries to save.
|
||||
"""
|
||||
self._saved_messages = messages
|
||||
|
||||
def _get_conversation_history(self) -> List[dict]:
|
||||
"""Get saved context messages without the system message.
|
||||
|
||||
Returns:
|
||||
List of message dictionaries excluding the first system message.
|
||||
"""
|
||||
return self._saved_messages[1:] if self._saved_messages else []
|
||||
|
||||
def _setup_xml_patterns(self):
|
||||
"""Set up XML pattern detection and handlers."""
|
||||
# Register DTMF pattern
|
||||
self._aggregator.add_pattern_pair("dtmf", "<dtmf>", "</dtmf>", remove_match=True)
|
||||
self._aggregator.on_pattern_match("dtmf", self._handle_dtmf_action)
|
||||
|
||||
# Register mode pattern
|
||||
self._aggregator.add_pattern_pair("mode", "<mode>", "</mode>", remove_match=True)
|
||||
self._aggregator.on_pattern_match("mode", self._handle_mode_action)
|
||||
|
||||
# Register IVR pattern
|
||||
self._aggregator.add_pattern_pair("ivr", "<ivr>", "</ivr>", remove_match=True)
|
||||
self._aggregator.on_pattern_match("ivr", self._handle_ivr_action)
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
"""Process frames and aggregate XML tag content.
|
||||
|
||||
Args:
|
||||
frame: The frame to process.
|
||||
direction: The direction of frame flow in the pipeline.
|
||||
"""
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
if isinstance(frame, StartFrame):
|
||||
# Push the StartFrame right away
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
# Set the classifier prompt and push it upstream
|
||||
messages = [{"role": "system", "content": self._classifier_prompt}]
|
||||
llm_update_frame = LLMMessagesUpdateFrame(messages=messages)
|
||||
await self.push_frame(llm_update_frame, FrameDirection.UPSTREAM)
|
||||
|
||||
elif isinstance(frame, LLMTextFrame):
|
||||
# Process text through the pattern aggregator
|
||||
result = await self._aggregator.aggregate(frame.text)
|
||||
if result:
|
||||
# Push aggregated text that doesn't contain XML patterns
|
||||
await self.push_frame(LLMTextFrame(result), direction)
|
||||
|
||||
else:
|
||||
await self.push_frame(frame, direction)
|
||||
|
||||
async def _handle_dtmf_action(self, match: PatternMatch):
|
||||
"""Handle DTMF action by creating and pushing DTMF frame.
|
||||
|
||||
Args:
|
||||
match: The pattern match containing DTMF content.
|
||||
"""
|
||||
value = match.content
|
||||
logger.debug(f"DTMF detected: {value}")
|
||||
|
||||
try:
|
||||
# Convert the value to a KeypadEntry
|
||||
keypad_entry = KeypadEntry(value)
|
||||
dtmf_frame = OutputDTMFUrgentFrame(button=keypad_entry)
|
||||
await self.push_frame(dtmf_frame)
|
||||
# Push a TextFrame to add DTMF message to the context
|
||||
text_frame = TextFrame(text=f"<dtmf>{value}</dtmf>")
|
||||
text_frame.skip_tts = True
|
||||
await self.push_frame(text_frame)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid DTMF value: {value}. Must be 0-9, *, or #")
|
||||
|
||||
async def _handle_ivr_action(self, match: PatternMatch):
|
||||
"""Handle IVR status action.
|
||||
|
||||
Args:
|
||||
match: The pattern match containing IVR status content.
|
||||
"""
|
||||
status = match.content
|
||||
logger.trace(f"IVR status detected: {status}")
|
||||
|
||||
# Convert string to enum, with validation
|
||||
try:
|
||||
ivr_status = IVRStatus(status)
|
||||
except ValueError:
|
||||
logger.warning(f"Unknown IVR status: {status}")
|
||||
return
|
||||
|
||||
match ivr_status:
|
||||
case IVRStatus.DETECTED:
|
||||
await self._handle_ivr_detected()
|
||||
case IVRStatus.COMPLETED:
|
||||
await self._handle_ivr_completed()
|
||||
case IVRStatus.STUCK:
|
||||
await self._handle_ivr_stuck()
|
||||
case IVRStatus.WAIT:
|
||||
await self._handle_ivr_wait()
|
||||
|
||||
# Push a TextFrame to add the IVR detected signal to the context
|
||||
ivr_text_frame = TextFrame(text=f"<ivr>{status}</ivr>")
|
||||
ivr_text_frame.skip_tts = True
|
||||
await self.push_frame(ivr_text_frame)
|
||||
|
||||
async def _handle_mode_action(self, match: PatternMatch):
|
||||
"""Handle mode action by switching to the appropriate mode.
|
||||
|
||||
Args:
|
||||
match: The pattern match containing mode content.
|
||||
"""
|
||||
mode = match.content
|
||||
logger.debug(f"Mode detected: {mode}")
|
||||
if mode == "conversation":
|
||||
await self._handle_conversation()
|
||||
elif mode == "ivr":
|
||||
await self._handle_ivr_detected()
|
||||
|
||||
# No TextFrame is pushed for the mode selection, as the mode
|
||||
# selection conversation is ephemeral and the system message
|
||||
# is removed after the mode is detected.
|
||||
|
||||
async def _handle_conversation(self):
|
||||
"""Handle conversation mode by switching to conversation mode.
|
||||
|
||||
Emit an on_conversation_detected event with saved conversation history.
|
||||
"""
|
||||
logger.debug("Conversation detected - emitting on_conversation_detected event")
|
||||
|
||||
# Extract conversation history for the event handler
|
||||
conversation_history = self._get_conversation_history()
|
||||
|
||||
await self._call_event_handler("on_conversation_detected", conversation_history)
|
||||
|
||||
async def _handle_ivr_detected(self):
|
||||
"""Handle IVR detection by switching to IVR mode.
|
||||
|
||||
Allows bidirectional switching for error recovery and complex IVR flows.
|
||||
Saves previous messages from the conversation context when available.
|
||||
"""
|
||||
logger.debug("IVR detected - switching to IVR navigation mode")
|
||||
|
||||
# Create new context with IVR system prompt and saved messages
|
||||
messages = [{"role": "system", "content": self._ivr_prompt}]
|
||||
|
||||
# Add saved conversation history if available
|
||||
conversation_history = self._get_conversation_history()
|
||||
if conversation_history:
|
||||
messages.extend(conversation_history)
|
||||
|
||||
# Push the messages upstream and run the LLM with the new context
|
||||
llm_update_frame = LLMMessagesUpdateFrame(messages=messages, run_llm=True)
|
||||
await self.push_frame(llm_update_frame, FrameDirection.UPSTREAM)
|
||||
|
||||
# Update VAD parameters for IVR response timing
|
||||
vad_update_frame = VADParamsUpdateFrame(params=self._ivr_vad_params)
|
||||
await self.push_frame(vad_update_frame, FrameDirection.UPSTREAM)
|
||||
|
||||
# Emit status changed event
|
||||
await self._call_event_handler("on_ivr_status_changed", IVRStatus.DETECTED)
|
||||
|
||||
async def _handle_ivr_completed(self):
|
||||
"""Handle IVR completion by triggering the status changed event.
|
||||
|
||||
Emits on_ivr_status_changed with IVRStatus.COMPLETED.
|
||||
"""
|
||||
logger.debug("IVR navigation completed - triggering status change event")
|
||||
|
||||
await self._call_event_handler("on_ivr_status_changed", IVRStatus.COMPLETED)
|
||||
|
||||
async def _handle_ivr_stuck(self):
|
||||
"""Handle IVR stuck state by triggering the status changed event.
|
||||
|
||||
Emits on_ivr_status_changed with IVRStatus.STUCK for external handling of stuck scenarios.
|
||||
"""
|
||||
logger.debug("IVR navigation stuck - triggering status change event")
|
||||
|
||||
await self._call_event_handler("on_ivr_status_changed", IVRStatus.STUCK)
|
||||
|
||||
async def _handle_ivr_wait(self):
|
||||
"""Handle IVR wait state when transcription is incomplete.
|
||||
|
||||
The LLM is indicating it needs more information to make a decision.
|
||||
This is a no-op since the system will continue to provide more transcription.
|
||||
"""
|
||||
logger.debug("IVR waiting for more complete transcription")
|
||||
|
||||
|
||||
class IVRNavigator(Pipeline):
|
||||
"""Pipeline for automated IVR system navigation.
|
||||
|
||||
Orchestrates LLM-based IVR navigation by combining an LLM service with
|
||||
IVR processing capabilities. Starts with mode classification to classify input
|
||||
as conversation or IVR system.
|
||||
|
||||
Navigation behavior:
|
||||
|
||||
- Detects conversation vs IVR systems automatically
|
||||
- Navigates IVR menus using DTMF tones and verbal responses
|
||||
- Provides event hooks for mode classification and status changes (on_conversation_detected, on_ivr_status_changed)
|
||||
- Developers control conversation handling via on_conversation_detected event
|
||||
"""
|
||||
|
||||
CLASSIFIER_PROMPT = """You are an IVR detection classifier. Analyze the transcribed text to determine if it's an automated IVR system or a live human conversation.
|
||||
|
||||
IVR SYSTEM (respond `<mode>ivr</mode>`):
|
||||
- Menu options: "Press 1 for billing", "Press 2 for technical support", "Press 0 to speak to an agent"
|
||||
- Automated instructions: "Please enter your account number", "Say or press your selection", "Enter your phone number followed by the pound key"
|
||||
- System prompts: "Thank you for calling [company]", "Your call is important to us", "Please hold while we connect you"
|
||||
- Scripted introductions: "Welcome to [company] customer service", "For faster service, have your account number ready"
|
||||
- Navigation phrases: "To return to the main menu", "Press star to repeat", "Say 'agent' or press 0"
|
||||
- Hold messages: "Please continue to hold", "Your estimated wait time is", "Thank you for your patience"
|
||||
- Carrier messages: "All circuits are busy", "Due to high call volume"
|
||||
|
||||
HUMAN CONVERSATION (respond `<mode>conversation</mode>`):
|
||||
- Personal greetings: "Hello, this is Sarah", "Good morning, how can I help you?", "Customer service, this is Mike"
|
||||
- Interactive responses: "Who am I speaking with?", "What can I do for you today?", "How are you calling about?"
|
||||
- Natural speech patterns: hesitations, informal language, conversational flow
|
||||
- Direct engagement: "I see you're calling about...", "Let me look that up for you", "Can you spell that for me?"
|
||||
- Spontaneous responses: "Oh, I can help with that", "Sure, no problem", "Hmm, let me check"
|
||||
|
||||
RESPOND ONLY with either:
|
||||
- `<mode>ivr</mode>` for IVR system
|
||||
- `<mode>conversation</mode>` for human conversation"""
|
||||
|
||||
IVR_NAVIGATION_BASE = """You are navigating an Interactive Voice Response (IVR) system to accomplish a specific goal. You receive text transcriptions of the IVR system's audio prompts and menu options.
|
||||
|
||||
YOUR NAVIGATION GOAL:
|
||||
{goal}
|
||||
|
||||
NAVIGATION RULES:
|
||||
1. When you see menu options with keypress instructions (e.g., "Press 1 for...", "Press 2 for..."), ONLY respond with a keypress if one of the options aligns with your navigation goal
|
||||
2. If an option closely matches your goal, respond with: `<dtmf>NUMBER</dtmf>` (e.g., `<dtmf>1</dtmf>`)
|
||||
3. For sequences of numbers (dates, account numbers, phone numbers), enter each digit separately: `<dtmf>1</dtmf><dtmf>2</dtmf><dtmf>3</dtmf>` for "123"
|
||||
4. When the system asks for verbal responses (e.g., "Say Yes or No", "Please state your name", "What department?"), respond with natural language text ending with punctuation
|
||||
5. If multiple options seem relevant, choose the most specific or direct path
|
||||
6. If NO options are relevant to your goal, respond with `<ivr>wait</ivr>` - the system may present more options
|
||||
7. If the transcription is incomplete or unclear, respond with `<ivr>wait</ivr>` to indicate you need more information
|
||||
|
||||
COMPLETION CRITERIA - Respond with `<ivr>completed</ivr>` when:
|
||||
- You see "Please hold while I transfer you" or similar transfer language
|
||||
- You see "You're being connected to..." or "Connecting you to..."
|
||||
- The system says "One moment please" after selecting your final option
|
||||
- The system indicates you've reached the target department/service
|
||||
- You've successfully navigated to your goal and are being transferred to a human
|
||||
|
||||
WAIT CRITERIA - Respond with `<ivr>wait</ivr>` when:
|
||||
- NONE of the presented options are relevant to your navigation goal
|
||||
- The transcription appears to be cut off mid-sentence
|
||||
- You can see partial menu options but the list seems incomplete
|
||||
- The transcription is unclear or garbled
|
||||
- You suspect there are more options that weren't captured in the transcription
|
||||
- The system presents options for specific user types that don't apply to your goal
|
||||
|
||||
IMPORTANT: Do NOT feel pressured to select an option if none match your goal. Waiting is often the correct response when the IVR system is presenting partial menus or options intended for different user types.
|
||||
|
||||
STUCK CRITERIA - Respond with `<ivr>stuck</ivr>` when:
|
||||
- You've been through the same menu options 3+ times without progress
|
||||
- No available options relate to your goal after careful consideration
|
||||
- You encounter an error message or "invalid selection" repeatedly
|
||||
- The system asks for information you don't have (account numbers, PINs, etc.)
|
||||
- You reach a dead end with no relevant options and no way back
|
||||
|
||||
STRATEGY TIPS:
|
||||
- Look for keywords in menu options that match your goal
|
||||
- Try general options like "Customer Service" or "Other Services" if specific options aren't available
|
||||
- Pay attention to sub-menus. Sometimes the path requires multiple steps through different menu layers
|
||||
- If you see "For all other inquiries, press..." that's often a good fallback option
|
||||
- Remember that reaching your goal may require navigating through several menu levels
|
||||
- Be patient - IVR systems often present options in waves, and waiting for the right option is better than selecting the wrong one
|
||||
|
||||
SEQUENCE INPUT EXAMPLES:
|
||||
- For date of birth "01/15/1990": `<dtmf>0</dtmf><dtmf>1</dtmf><dtmf>1</dtmf><dtmf>5</dtmf><dtmf>1</dtmf><dtmf>9</dtmf><dtmf>9</dtmf><dtmf>0</dtmf>`
|
||||
- For account number "12345": `<dtmf>1</dtmf><dtmf>2</dtmf><dtmf>3</dtmf><dtmf>4</dtmf><dtmf>5</dtmf>`
|
||||
- For phone number last 4 digits "6789": `<dtmf>6</dtmf><dtmf>7</dtmf><dtmf>8</dtmf><dtmf>9</dtmf>`
|
||||
|
||||
VERBAL RESPONSE EXAMPLES:
|
||||
- "Is your date of birth 01/15/1990? Say Yes or No" → "Yes."
|
||||
- "Please state your first and last name" → "John Smith."
|
||||
- "What department are you trying to reach?" → "Billing."
|
||||
- "Are you calling about an existing order? Please say Yes or No" → "No."
|
||||
- "Did I hear that correctly? Please say Yes or No" → "Yes."
|
||||
|
||||
Remember: Respond with `<dtmf>NUMBER</dtmf>` (single or multiple for sequences), `<ivr>completed</ivr>`, `<ivr>stuck</ivr>`, `<ivr>wait</ivr>`, OR natural language text when verbal responses are requested. No other response types."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
llm: LLMService,
|
||||
ivr_prompt: str,
|
||||
ivr_vad_params: Optional[VADParams] = None,
|
||||
):
|
||||
"""Initialize the IVR navigator.
|
||||
|
||||
Args:
|
||||
llm: LLM service for text generation and decision making.
|
||||
ivr_prompt: Navigation goal prompt integrated with IVR navigation instructions.
|
||||
ivr_vad_params: VAD parameters for IVR navigation mode. If None, defaults to VADParams(stop_secs=2.0).
|
||||
"""
|
||||
self._llm = llm
|
||||
self._ivr_prompt = self.IVR_NAVIGATION_BASE.format(goal=ivr_prompt)
|
||||
self._ivr_vad_params = ivr_vad_params or VADParams(stop_secs=2.0)
|
||||
self._classifier_prompt = self.CLASSIFIER_PROMPT
|
||||
|
||||
self._ivr_processor = IVRProcessor(
|
||||
classifier_prompt=self._classifier_prompt,
|
||||
ivr_prompt=self._ivr_prompt,
|
||||
ivr_vad_params=self._ivr_vad_params,
|
||||
)
|
||||
|
||||
# Add the IVR processor to the pipeline
|
||||
super().__init__([self._llm, self._ivr_processor])
|
||||
|
||||
# Register IVR events
|
||||
self._register_event_handler("on_conversation_detected")
|
||||
self._register_event_handler("on_ivr_status_changed")
|
||||
|
||||
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||
"""Process frames at the pipeline level to intercept context frames.
|
||||
|
||||
Args:
|
||||
frame: The frame to process.
|
||||
direction: The direction of frame flow in the pipeline.
|
||||
"""
|
||||
if isinstance(frame, (OpenAILLMContextFrame, LLMContextFrame)):
|
||||
# Extract messages and pass to IVR processor
|
||||
all_messages = frame.context.get_messages()
|
||||
|
||||
# Store messages in the IVR processor for mode switching
|
||||
self._ivr_processor.update_saved_messages(all_messages)
|
||||
|
||||
# Let the pipeline handle normal frame processing
|
||||
await super().process_frame(frame, direction)
|
||||
|
||||
def add_event_handler(self, event_name: str, handler):
|
||||
"""Add event handler for IVR navigation events.
|
||||
|
||||
Args:
|
||||
event_name: Event name ("on_conversation_detected", "on_ivr_status_changed").
|
||||
handler: Async function called when event occurs.
|
||||
- on_conversation_detected: Receives IVRProcessor instance and conversation_history list
|
||||
- on_ivr_status_changed: Receives IVRProcessor instance and IVRStatus enum value
|
||||
"""
|
||||
if event_name in (
|
||||
"on_conversation_detected",
|
||||
"on_ivr_status_changed",
|
||||
):
|
||||
self._ivr_processor.add_event_handler(event_name, handler)
|
||||
else:
|
||||
super().add_event_handler(event_name, handler)
|
||||
@@ -12,7 +12,6 @@ and LLM processing.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
@@ -28,6 +27,7 @@ from typing import (
|
||||
)
|
||||
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
from pipecat.audio.dtmf.types import KeypadEntry as NewKeypadEntry
|
||||
from pipecat.audio.interruptions.base_interruption_strategy import BaseInterruptionStrategy
|
||||
from pipecat.audio.turn.smart_turn.base_smart_turn import SmartTurnParams
|
||||
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||
@@ -41,9 +41,13 @@ if TYPE_CHECKING:
|
||||
from pipecat.processors.frame_processor import FrameProcessor
|
||||
|
||||
|
||||
class KeypadEntry(str, Enum):
|
||||
class DeprecatedKeypadEntry:
|
||||
"""DTMF keypad entries for phone system integration.
|
||||
|
||||
.. deprecated:: 0.0.82
|
||||
This class is deprecated and will be removed in a future version.
|
||||
Instead, use `audio.dtmf.types.KeypadEntry`.
|
||||
|
||||
Parameters:
|
||||
ONE: Number key 1.
|
||||
TWO: Number key 2.
|
||||
@@ -59,18 +63,38 @@ class KeypadEntry(str, Enum):
|
||||
STAR: Star/asterisk key (*).
|
||||
"""
|
||||
|
||||
ONE = "1"
|
||||
TWO = "2"
|
||||
THREE = "3"
|
||||
FOUR = "4"
|
||||
FIVE = "5"
|
||||
SIX = "6"
|
||||
SEVEN = "7"
|
||||
EIGHT = "8"
|
||||
NINE = "9"
|
||||
ZERO = "0"
|
||||
POUND = "#"
|
||||
STAR = "*"
|
||||
_enum = NewKeypadEntry
|
||||
|
||||
@classmethod
|
||||
def _warn(cls):
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"`pipecat.frames.frames.KeypadEntry` is deprecated and will be removed in a future version. "
|
||||
"Use `pipecat.audio.dtmf.types.KeypadEntry` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
||||
"""Allow the instance to be called as a function."""
|
||||
self._warn()
|
||||
return self._enum(*args, **kwargs)
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Retrieve an attribute from the underlying enum."""
|
||||
self._warn()
|
||||
return getattr(self._enum, name)
|
||||
|
||||
def __getitem__(self, name):
|
||||
"""Retrieve an item from the underlying enum."""
|
||||
self._warn()
|
||||
return self._enum[name]
|
||||
|
||||
|
||||
KeypadEntry = DeprecatedKeypadEntry()
|
||||
|
||||
|
||||
def format_pts(pts: Optional[int]):
|
||||
@@ -526,15 +550,16 @@ class LLMMessagesFrame(DataFrame):
|
||||
super().__post_init__()
|
||||
import warnings
|
||||
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"LLMMessagesFrame is deprecated and will be removed in a future version. "
|
||||
"Instead, use either "
|
||||
"`LLMMessagesUpdateFrame` with `run_llm=True`, or "
|
||||
"`OpenAILLMContextFrame` with desired messages in a new context",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"LLMMessagesFrame is deprecated and will be removed in a future version. "
|
||||
"Instead, use either "
|
||||
"`LLMMessagesUpdateFrame` with `run_llm=True`, or "
|
||||
"`OpenAILLMContextFrame` with desired messages in a new context",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -668,7 +693,7 @@ class DTMFFrame:
|
||||
button: The DTMF keypad entry that was pressed.
|
||||
"""
|
||||
|
||||
button: KeypadEntry
|
||||
button: NewKeypadEntry
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -844,19 +869,6 @@ class StartInterruptionFrame(SystemFrame):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class StopInterruptionFrame(SystemFrame):
|
||||
"""Frame indicating user stopped speaking (interruption ended).
|
||||
|
||||
Emitted by the BaseInputTransport to indicate that a user has stopped
|
||||
speaking (i.e. no more interruptions). This is similar to
|
||||
UserStoppedSpeakingFrame except that it should be pushed concurrently
|
||||
with other frames (so the order is not guaranteed).
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserStartedSpeakingFrame(SystemFrame):
|
||||
"""Frame indicating user has started speaking.
|
||||
|
||||
@@ -571,6 +571,10 @@ class PipelineTask(BasePipelineTask):
|
||||
# Cleanup base object.
|
||||
await self.cleanup()
|
||||
|
||||
# Cleanup observers.
|
||||
if self._observer:
|
||||
await self._observer.cleanup()
|
||||
|
||||
# End conversation tracing if it's active - this will also close any active turn span
|
||||
if self._enable_tracing and hasattr(self, "_turn_trace_observer"):
|
||||
self._turn_trace_observer.end_conversation_tracing()
|
||||
|
||||
@@ -119,6 +119,16 @@ class TaskObserver(BaseObserver):
|
||||
for proxy in self._proxies.values():
|
||||
await self._task_manager.cancel_task(proxy.task)
|
||||
|
||||
async def cleanup(self):
|
||||
"""Cleanup all proxy observers."""
|
||||
await super().cleanup()
|
||||
|
||||
if not self._proxies:
|
||||
return
|
||||
|
||||
for proxy in self._proxies:
|
||||
await proxy.cleanup()
|
||||
|
||||
async def on_process_frame(self, data: FramePushed):
|
||||
"""Queue frame data for all managed observers.
|
||||
|
||||
|
||||
@@ -14,13 +14,13 @@ for downstream processing by LLM context aggregators.
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from pipecat.audio.dtmf.types import KeypadEntry
|
||||
from pipecat.frames.frames import (
|
||||
BotInterruptionFrame,
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
Frame,
|
||||
InputDTMFFrame,
|
||||
KeypadEntry,
|
||||
StartFrame,
|
||||
TranscriptionFrame,
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ LLM processing, and text-to-speech components in conversational AI pipelines.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import warnings
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Literal, Optional, Set
|
||||
@@ -326,11 +325,15 @@ class LLMContextResponseAggregator(BaseLLMResponseAggregator):
|
||||
Returns:
|
||||
LLMContextFrame containing the current context.
|
||||
"""
|
||||
warnings.warn(
|
||||
"get_context_frame() is deprecated and will be removed in a future version. To trigger an LLM response, use LLMRunFrame instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"get_context_frame() is deprecated and will be removed in a future version. To trigger an LLM response, use LLMRunFrame instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return self._get_context_frame()
|
||||
|
||||
def _get_context_frame(self) -> OpenAILLMContextFrame:
|
||||
@@ -1035,12 +1038,16 @@ class LLMUserResponseAggregator(LLMUserContextAggregator):
|
||||
params: Configuration parameters for aggregation behavior.
|
||||
**kwargs: Additional arguments passed to parent class.
|
||||
"""
|
||||
warnings.warn(
|
||||
"LLMUserResponseAggregator is deprecated and will be removed in a future version. "
|
||||
"Use LLMUserContextAggregator or another LLM-specific subclass instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"LLMUserResponseAggregator is deprecated and will be removed in a future version. "
|
||||
"Use LLMUserContextAggregator or another LLM-specific subclass instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
super().__init__(context=OpenAILLMContext(messages), params=params, **kwargs)
|
||||
|
||||
async def _process_aggregation(self):
|
||||
@@ -1078,12 +1085,16 @@ class LLMAssistantResponseAggregator(LLMAssistantContextAggregator):
|
||||
params: Configuration parameters for aggregation behavior.
|
||||
**kwargs: Additional arguments passed to parent class.
|
||||
"""
|
||||
warnings.warn(
|
||||
"LLMAssistantResponseAggregator is deprecated and will be removed in a future version. "
|
||||
"Use LLMAssistantContextAggregator or another LLM-specific subclass instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"LLMAssistantResponseAggregator is deprecated and will be removed in a future version. "
|
||||
"Use LLMAssistantContextAggregator or another LLM-specific subclass instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
super().__init__(context=OpenAILLMContext(messages), params=params, **kwargs)
|
||||
|
||||
async def push_aggregation(self):
|
||||
|
||||
@@ -27,7 +27,6 @@ from pipecat.frames.frames import (
|
||||
InterimTranscriptionFrame,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
StopInterruptionFrame,
|
||||
STTMuteFrame,
|
||||
TranscriptionFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
@@ -206,7 +205,6 @@ class STTMuteFilter(FrameProcessor):
|
||||
frame,
|
||||
(
|
||||
StartInterruptionFrame,
|
||||
StopInterruptionFrame,
|
||||
VADUserStartedSpeakingFrame,
|
||||
VADUserStoppedSpeakingFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
|
||||
@@ -30,7 +30,6 @@ from pipecat.frames.frames import (
|
||||
FrameProcessorResumeUrgentFrame,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
StopInterruptionFrame,
|
||||
SystemFrame,
|
||||
)
|
||||
from pipecat.metrics.metrics import LLMTokenUsage, MetricsData
|
||||
@@ -444,7 +443,7 @@ class FrameProcessor(BaseObject):
|
||||
|
||||
.. deprecated:: 0.0.81
|
||||
This function is deprecated, use `await task` or
|
||||
`await asyncio.wait_for(task, timeout) instead.
|
||||
`await asyncio.wait_for(task, timeout)` instead.
|
||||
|
||||
Args:
|
||||
task: The task to wait for.
|
||||
@@ -452,12 +451,14 @@ class FrameProcessor(BaseObject):
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"`FrameProcessor.wait_for_task()` is deprecated. "
|
||||
"Use `await task` or `await asyncio.wait_for(task, timeout)` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"`FrameProcessor.wait_for_task()` is deprecated. "
|
||||
"Use `await task` or `await asyncio.wait_for(task, timeout)` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if timeout:
|
||||
await asyncio.wait_for(task, timeout)
|
||||
@@ -585,8 +586,6 @@ class FrameProcessor(BaseObject):
|
||||
elif isinstance(frame, StartInterruptionFrame):
|
||||
await self._start_interruption()
|
||||
await self.stop_all_metrics()
|
||||
elif isinstance(frame, StopInterruptionFrame):
|
||||
self._should_report_ttfb = True
|
||||
elif isinstance(frame, CancelFrame):
|
||||
await self.__cancel(frame)
|
||||
elif isinstance(frame, (FrameProcessorPauseFrame, FrameProcessorPauseUrgentFrame)):
|
||||
|
||||
@@ -122,11 +122,13 @@ async def configure_with_args(aiohttp_session: aiohttp.ClientSession, parser=Non
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"configure_with_args is deprecated. Use configure() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"configure_with_args is deprecated. Use configure() instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
room_url, token = await configure(aiohttp_session)
|
||||
return (room_url, token, None)
|
||||
|
||||
@@ -13,13 +13,13 @@ from typing import Optional
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pipecat.audio.dtmf.types import KeypadEntry
|
||||
from pipecat.audio.utils import create_stream_resampler
|
||||
from pipecat.frames.frames import (
|
||||
AudioRawFrame,
|
||||
Frame,
|
||||
InputAudioRawFrame,
|
||||
InputDTMFFrame,
|
||||
KeypadEntry,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
TransportMessageFrame,
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Optional
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pipecat.audio.dtmf.types import KeypadEntry
|
||||
from pipecat.audio.utils import create_stream_resampler, pcm_to_ulaw, ulaw_to_pcm
|
||||
from pipecat.frames.frames import (
|
||||
AudioRawFrame,
|
||||
@@ -21,7 +22,6 @@ from pipecat.frames.frames import (
|
||||
Frame,
|
||||
InputAudioRawFrame,
|
||||
InputDTMFFrame,
|
||||
KeypadEntry,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
TransportMessageFrame,
|
||||
|
||||
@@ -14,6 +14,7 @@ import aiohttp
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pipecat.audio.dtmf.types import KeypadEntry
|
||||
from pipecat.audio.utils import (
|
||||
alaw_to_pcm,
|
||||
create_stream_resampler,
|
||||
@@ -28,7 +29,6 @@ from pipecat.frames.frames import (
|
||||
Frame,
|
||||
InputAudioRawFrame,
|
||||
InputDTMFFrame,
|
||||
KeypadEntry,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from typing import Optional
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from pipecat.audio.dtmf.types import KeypadEntry
|
||||
from pipecat.audio.utils import create_stream_resampler, pcm_to_ulaw, ulaw_to_pcm
|
||||
from pipecat.frames.frames import (
|
||||
AudioRawFrame,
|
||||
@@ -21,7 +22,6 @@ from pipecat.frames.frames import (
|
||||
Frame,
|
||||
InputAudioRawFrame,
|
||||
InputDTMFFrame,
|
||||
KeypadEntry,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
TransportMessageFrame,
|
||||
|
||||
@@ -11,11 +11,11 @@ _warned_modules = set()
|
||||
|
||||
|
||||
def _warn_deprecated_access(globals: Dict[str, Any], attr, old: str, new: str):
|
||||
import warnings
|
||||
|
||||
# Only warn once per old->new module pair
|
||||
module_key = (old, new)
|
||||
if module_key not in _warned_modules:
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
|
||||
@@ -271,11 +271,13 @@ class CartesiaTTSService(AudioContextWordTTSService):
|
||||
voice_config["id"] = self._voice_id
|
||||
|
||||
if self._settings["emotion"]:
|
||||
warnings.warn(
|
||||
"The 'emotion' parameter in __experimental_controls is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"The 'emotion' parameter in __experimental_controls is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
voice_config["__experimental_controls"] = {}
|
||||
if self._settings["emotion"]:
|
||||
voice_config["__experimental_controls"]["emotion"] = self._settings["emotion"]
|
||||
@@ -606,11 +608,13 @@ class CartesiaHttpTTSService(TTSService):
|
||||
voice_config = {"mode": "id", "id": self._voice_id}
|
||||
|
||||
if self._settings["emotion"]:
|
||||
warnings.warn(
|
||||
"The 'emotion' parameter in voice.__experimental_controls is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"The 'emotion' parameter in voice.__experimental_controls is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
voice_config["__experimental_controls"] = {"emotion": self._settings["emotion"]}
|
||||
|
||||
await self.start_ttfb_metrics()
|
||||
|
||||
@@ -120,12 +120,14 @@ class FishAudioTTSService(InterruptibleTTSService):
|
||||
if model:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"Parameter 'model' is deprecated and will be removed in a future version. "
|
||||
"Use 'reference_id' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"Parameter 'model' is deprecated and will be removed in a future version. "
|
||||
"Use 'reference_id' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
reference_id = model
|
||||
|
||||
self._api_key = api_key
|
||||
|
||||
@@ -29,9 +29,11 @@ class PreProcessingConfig(BaseModel):
|
||||
"""Configuration for audio pre-processing options.
|
||||
|
||||
Parameters:
|
||||
audio_enhancer: Apply pre-processing to the audio stream to enhance quality
|
||||
speech_threshold: Sensitivity for speech detection (0-1)
|
||||
"""
|
||||
|
||||
audio_enhancer: Optional[bool] = None
|
||||
speech_threshold: Optional[float] = None
|
||||
|
||||
|
||||
@@ -41,10 +43,14 @@ class CustomVocabularyItem(BaseModel):
|
||||
Parameters:
|
||||
value: The vocabulary word or phrase
|
||||
intensity: The bias intensity for this vocabulary item (0-1)
|
||||
pronunciations: The pronunciations used in the transcription.
|
||||
language: Specify the language in which it will be pronounced when sound comparison occurs. Default to transcription language.
|
||||
"""
|
||||
|
||||
value: str
|
||||
intensity: float
|
||||
pronunciations: Optional[List[str]] = None
|
||||
language: Optional[str] = None
|
||||
|
||||
|
||||
class CustomVocabularyConfig(BaseModel):
|
||||
@@ -170,7 +176,7 @@ class GladiaInputParams(BaseModel):
|
||||
channels: Optional[int] = 1
|
||||
custom_metadata: Optional[Dict[str, Any]] = None
|
||||
endpointing: Optional[float] = None
|
||||
maximum_duration_without_endpointing: Optional[int] = 10
|
||||
maximum_duration_without_endpointing: Optional[int] = 5
|
||||
language: Optional[Language] = None # Deprecated
|
||||
language_config: Optional[LanguageConfig] = None
|
||||
pre_processing: Optional[PreProcessingConfig] = None
|
||||
|
||||
@@ -13,12 +13,12 @@ supporting multiple languages, custom vocabulary, and various audio processing o
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import warnings
|
||||
from typing import Any, AsyncGenerator, Dict, Literal, Optional
|
||||
|
||||
import aiohttp
|
||||
from loguru import logger
|
||||
|
||||
from pipecat import __version__ as pipecat_version
|
||||
from pipecat.frames.frames import (
|
||||
CancelFrame,
|
||||
EndFrame,
|
||||
@@ -173,12 +173,16 @@ class _InputParamsDescriptor:
|
||||
"""Descriptor for backward compatibility with deprecation warning."""
|
||||
|
||||
def __get__(self, obj, objtype=None):
|
||||
warnings.warn(
|
||||
"GladiaSTTService.InputParams is deprecated and will be removed in a future version. "
|
||||
"Import and use GladiaInputParams directly instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"GladiaSTTService.InputParams is deprecated and will be removed in a future version. "
|
||||
"Import and use GladiaInputParams directly instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return GladiaInputParams
|
||||
|
||||
|
||||
@@ -234,12 +238,14 @@ class GladiaSTTService(STTService):
|
||||
|
||||
# Warn about deprecated language parameter if it's used
|
||||
if params.language is not None:
|
||||
warnings.warn(
|
||||
"The 'language' parameter is deprecated and will be removed in a future version. "
|
||||
"Use 'language_config' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"The 'language' parameter is deprecated and will be removed in a future version. "
|
||||
"Use 'language_config' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
self._api_key = api_key
|
||||
self._region = region
|
||||
@@ -298,8 +304,8 @@ class GladiaSTTService(STTService):
|
||||
}
|
||||
|
||||
# Add custom_metadata if provided
|
||||
if self._params.custom_metadata:
|
||||
settings["custom_metadata"] = self._params.custom_metadata
|
||||
settings["custom_metadata"] = dict(self._params.custom_metadata or {})
|
||||
settings["custom_metadata"]["pipecat"] = pipecat_version
|
||||
|
||||
# Add endpointing parameters if provided
|
||||
if self._params.endpointing is not None:
|
||||
@@ -425,6 +431,7 @@ class GladiaSTTService(STTService):
|
||||
response = await self._setup_gladia(settings)
|
||||
self._session_url = response["url"]
|
||||
self._reconnection_attempts = 0
|
||||
logger.info(f"Session URL : {self._session_url}")
|
||||
|
||||
# Connect with automatic reconnection
|
||||
async with websocket_connect(self._session_url) as websocket:
|
||||
|
||||
@@ -65,12 +65,14 @@ class GoogleLLMOpenAIBetaService(OpenAILLMService):
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"GoogleLLMOpenAIBetaService is deprecated and will be removed in a future version. "
|
||||
"Use GoogleLLMService instead for better integration with Google's native API.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"GoogleLLMOpenAIBetaService is deprecated and will be removed in a future version. "
|
||||
"Use GoogleLLMService instead for better integration with Google's native API.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
super().__init__(api_key=api_key, base_url=base_url, model=model, **kwargs)
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ class Mem0MemoryService(FrameProcessor):
|
||||
try:
|
||||
logger.debug(f"Storing {len(messages)} messages in Mem0")
|
||||
params = {
|
||||
"async_mode": True,
|
||||
"messages": messages,
|
||||
"metadata": {"platform": "pipecat"},
|
||||
"output_format": "v1.1",
|
||||
@@ -163,7 +164,7 @@ class Mem0MemoryService(FrameProcessor):
|
||||
("run_id", self.run_id),
|
||||
]
|
||||
clauses = [{name: value} for name, value in id_pairs if value is not None]
|
||||
filters = {"AND": clauses} if clauses else {}
|
||||
filters = {"OR": clauses} if clauses else {}
|
||||
results = self.memory_client.search(
|
||||
query=query,
|
||||
filters=filters,
|
||||
|
||||
@@ -32,7 +32,6 @@ from pipecat.frames.frames import (
|
||||
LLMUpdateSettingsFrame,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
StopInterruptionFrame,
|
||||
TranscriptionFrame,
|
||||
TTSAudioRawFrame,
|
||||
TTSStartedFrame,
|
||||
@@ -653,7 +652,6 @@ class OpenAIRealtimeBetaLLMService(LLMService):
|
||||
await self.start_ttfb_metrics()
|
||||
await self.start_processing_metrics()
|
||||
await self._stop_interruption()
|
||||
await self.push_frame(StopInterruptionFrame())
|
||||
await self.push_frame(UserStoppedSpeakingFrame())
|
||||
|
||||
async def _maybe_handle_evt_retrieve_conversation_item_error(self, evt: events.ErrorEvent):
|
||||
|
||||
@@ -14,7 +14,6 @@ import io
|
||||
import json
|
||||
import struct
|
||||
import uuid
|
||||
import warnings
|
||||
from typing import AsyncGenerator, Optional
|
||||
|
||||
import aiohttp
|
||||
@@ -455,11 +454,15 @@ class PlayHTHttpTTSService(TTSService):
|
||||
|
||||
# Warn about deprecated protocol parameter if explicitly provided
|
||||
if protocol:
|
||||
warnings.warn(
|
||||
"The 'protocol' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"The 'protocol' parameter is deprecated and will be removed in a future version.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
params = params or PlayHTHttpTTSService.InputParams()
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import warnings
|
||||
from typing import Any, AsyncGenerator, Mapping, Optional
|
||||
|
||||
import aiohttp
|
||||
@@ -333,8 +332,10 @@ class SarvamTTSService(InterruptibleTTSService):
|
||||
voice_id: Voice identifier for synthesis (default "anushka").
|
||||
url: WebSocket URL for connecting to the TTS backend (default production URL).
|
||||
aiohttp_session: Optional shared aiohttp session. To maintain backward compatibility.
|
||||
|
||||
.. deprecated:: 0.0.81
|
||||
aiohttp_session is no longer used. This parameter will be removed in a future version.
|
||||
|
||||
aggregate_sentences: Whether to merge multiple sentences into one audio chunk (default True).
|
||||
sample_rate: Desired sample rate for the output audio in Hz (overrides default if set).
|
||||
params: Optional input parameters to override global configuration.
|
||||
@@ -356,11 +357,15 @@ class SarvamTTSService(InterruptibleTTSService):
|
||||
)
|
||||
params = params or SarvamTTSService.InputParams()
|
||||
if aiohttp_session is not None:
|
||||
warnings.warn(
|
||||
"The 'aiohttp_session' parameter is deprecated and will be removed in a future version. ",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"The 'aiohttp_session' parameter is deprecated and will be removed in a future version. ",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
# WebSocket endpoint URL
|
||||
self._websocket_url = f"{url}?model={model}"
|
||||
self._api_key = api_key
|
||||
|
||||
@@ -10,7 +10,6 @@ import asyncio
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
import warnings
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, AsyncGenerator
|
||||
@@ -1107,6 +1106,8 @@ def _check_deprecated_args(kwargs: dict, params: SpeechmaticsSTTService.InputPar
|
||||
|
||||
# Show deprecation warnings
|
||||
def _deprecation_warning(old: str, new: str | None = None):
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
if new:
|
||||
|
||||
@@ -276,11 +276,13 @@ class TTSService(AIService):
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"`TTSService.say()` is deprecated. Push a `TTSSpeakFrame` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"`TTSService.say()` is deprecated. Push a `TTSSpeakFrame` instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
await self.queue_frame(TTSSpeakFrame(text))
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@ from pipecat.frames.frames import (
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
StopFrame,
|
||||
StopInterruptionFrame,
|
||||
SystemFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
@@ -374,7 +373,6 @@ class BaseInputTransport(FrameProcessor):
|
||||
await self.push_frame(frame)
|
||||
if self.interruptions_allowed:
|
||||
await self._stop_interruption()
|
||||
await self.push_frame(StopInterruptionFrame())
|
||||
|
||||
#
|
||||
# Handle bot speaking state
|
||||
|
||||
@@ -19,6 +19,7 @@ from typing import Any, AsyncGenerator, Dict, List, Mapping, Optional
|
||||
from loguru import logger
|
||||
from PIL import Image
|
||||
|
||||
from pipecat.audio.dtmf.utils import load_dtmf_audio
|
||||
from pipecat.audio.mixers.base_audio_mixer import BaseAudioMixer
|
||||
from pipecat.audio.utils import create_stream_resampler, is_silence
|
||||
from pipecat.frames.frames import (
|
||||
@@ -38,7 +39,6 @@ from pipecat.frames.frames import (
|
||||
SpriteFrame,
|
||||
StartFrame,
|
||||
StartInterruptionFrame,
|
||||
StopInterruptionFrame,
|
||||
SystemFrame,
|
||||
TransportMessageFrame,
|
||||
TransportMessageUrgentFrame,
|
||||
@@ -223,7 +223,12 @@ class BaseOutputTransport(FrameProcessor):
|
||||
Args:
|
||||
frame: The DTMF frame to write.
|
||||
"""
|
||||
pass
|
||||
dtmf_audio = await load_dtmf_audio(frame.button, sample_rate=self._sample_rate)
|
||||
dtmf_audio_frame = OutputAudioRawFrame(
|
||||
audio=dtmf_audio, sample_rate=self._sample_rate, num_channels=1
|
||||
)
|
||||
dtmf_audio_frame.transport_destination = frame.transport_destination
|
||||
await self.write_audio_frame(dtmf_audio_frame)
|
||||
|
||||
async def send_audio(self, frame: OutputAudioRawFrame):
|
||||
"""Send an audio frame downstream.
|
||||
@@ -267,7 +272,7 @@ class BaseOutputTransport(FrameProcessor):
|
||||
elif isinstance(frame, CancelFrame):
|
||||
await self.cancel(frame)
|
||||
await self.push_frame(frame, direction)
|
||||
elif isinstance(frame, (StartInterruptionFrame, StopInterruptionFrame)):
|
||||
elif isinstance(frame, StartInterruptionFrame):
|
||||
await self.push_frame(frame, direction)
|
||||
await self._handle_frame(frame)
|
||||
elif isinstance(frame, TransportMessageUrgentFrame):
|
||||
|
||||
@@ -31,8 +31,6 @@ from pipecat.frames.frames import (
|
||||
InputAudioRawFrame,
|
||||
InterimTranscriptionFrame,
|
||||
OutputAudioRawFrame,
|
||||
OutputDTMFFrame,
|
||||
OutputDTMFUrgentFrame,
|
||||
OutputImageRawFrame,
|
||||
SpriteFrame,
|
||||
StartFrame,
|
||||
@@ -1676,7 +1674,7 @@ class DailyInputTransport(BaseInputTransport):
|
||||
class DailyOutputTransport(BaseOutputTransport):
|
||||
"""Handles outgoing media streams and events to Daily calls.
|
||||
|
||||
Manages sending audio, video, DTMF tones, and other data to Daily calls,
|
||||
Manages sending audio, video and other data to Daily calls,
|
||||
including audio destination registration and message transmission.
|
||||
"""
|
||||
|
||||
@@ -1783,19 +1781,6 @@ class DailyOutputTransport(BaseOutputTransport):
|
||||
"""
|
||||
await self._client.register_audio_destination(destination)
|
||||
|
||||
async def write_dtmf(self, frame: OutputDTMFFrame | OutputDTMFUrgentFrame):
|
||||
"""Write DTMF tones to the call.
|
||||
|
||||
Args:
|
||||
frame: The DTMF frame containing tone information.
|
||||
"""
|
||||
await self._client.send_dtmf(
|
||||
{
|
||||
"sessionId": frame.transport_destination,
|
||||
"tones": frame.button.value,
|
||||
}
|
||||
)
|
||||
|
||||
async def write_audio_frame(self, frame: OutputAudioRawFrame):
|
||||
"""Write an audio frame to the Daily call.
|
||||
|
||||
@@ -2022,25 +2007,6 @@ class DailyTransport(BaseTransport):
|
||||
"""
|
||||
await self._client.stop_dialout(participant_id)
|
||||
|
||||
async def send_dtmf(self, settings):
|
||||
"""Send DTMF tones during a call (deprecated).
|
||||
|
||||
.. deprecated:: 0.0.69
|
||||
Push an `OutputDTMFFrame` or an `OutputDTMFUrgentFrame` instead.
|
||||
|
||||
Args:
|
||||
settings: DTMF settings including tones and target session.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("always")
|
||||
warnings.warn(
|
||||
"`DailyTransport.send_dtmf()` is deprecated, push an `OutputDTMFFrame` or an `OutputDTMFUrgentFrame` instead.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
await self._client.send_dtmf(settings)
|
||||
|
||||
async def sip_call_transfer(self, settings):
|
||||
"""Transfer a SIP call to another destination.
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
|
||||
import unittest
|
||||
|
||||
from pipecat.audio.dtmf.types import KeypadEntry
|
||||
from pipecat.frames.frames import (
|
||||
EndFrame,
|
||||
InputDTMFFrame,
|
||||
KeypadEntry,
|
||||
TranscriptionFrame,
|
||||
)
|
||||
from pipecat.processors.aggregators.dtmf_aggregator import DTMFAggregator
|
||||
|
||||
352
tests/test_ivr_navigation.py
Normal file
352
tests/test_ivr_navigation.py
Normal file
@@ -0,0 +1,352 @@
|
||||
#
|
||||
# Copyright (c) 2024-2025 Daily
|
||||
#
|
||||
# SPDX-License-Identifier: BSD 2-Clause License
|
||||
#
|
||||
|
||||
import unittest
|
||||
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 (
|
||||
LLMMessagesUpdateFrame,
|
||||
LLMTextFrame,
|
||||
OutputDTMFUrgentFrame,
|
||||
TextFrame,
|
||||
VADParamsUpdateFrame,
|
||||
)
|
||||
from pipecat.services.llm_service import LLMService
|
||||
from pipecat.tests.utils import run_test
|
||||
|
||||
|
||||
class TestIVRNavigation(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
# Create a mock LLM service
|
||||
self.mock_llm = AsyncMock(spec=LLMService)
|
||||
|
||||
# Test prompts
|
||||
self.classifier_prompt = "Classify as IVR or conversation"
|
||||
self.ivr_prompt = "Navigate to the billing department"
|
||||
|
||||
# VAD parameters
|
||||
self.ivr_vad_params = VADParams(stop_secs=2.0)
|
||||
|
||||
async def test_switch_to_ivr_mode(self):
|
||||
"""Test switching to IVR mode from conversation mode."""
|
||||
# Create just the IVR processor to test in isolation
|
||||
processor = IVRProcessor(
|
||||
classifier_prompt=self.classifier_prompt,
|
||||
ivr_prompt=self.ivr_prompt,
|
||||
ivr_vad_params=self.ivr_vad_params,
|
||||
)
|
||||
|
||||
frames_to_send = [
|
||||
LLMTextFrame(text="<mode>ivr</mode>"),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
# No frames expected - mode selection doesn't push TextFrame
|
||||
]
|
||||
|
||||
expected_up_frames = [
|
||||
LLMMessagesUpdateFrame, # Initialize the mode detection prompt
|
||||
LLMMessagesUpdateFrame, # Switch to the ivr prompt
|
||||
VADParamsUpdateFrame, # Switch to the ivr VAD parameters
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
expected_up_frames=expected_up_frames,
|
||||
)
|
||||
|
||||
async def test_switch_to_conversation_mode(self):
|
||||
"""Test switching to conversation mode when conversation is detected."""
|
||||
# Create just the IVR processor to test in isolation
|
||||
processor = IVRProcessor(
|
||||
classifier_prompt=self.classifier_prompt,
|
||||
ivr_prompt=self.ivr_prompt,
|
||||
ivr_vad_params=self.ivr_vad_params,
|
||||
)
|
||||
|
||||
# Mock event handler
|
||||
conversation_handler_called = False
|
||||
received_processor = None
|
||||
received_history = None
|
||||
|
||||
async def mock_conversation_handler(ivr_processor, conversation_history):
|
||||
nonlocal conversation_handler_called, received_processor, received_history
|
||||
conversation_handler_called = True
|
||||
received_processor = ivr_processor
|
||||
received_history = conversation_history
|
||||
|
||||
# Register the event handler
|
||||
processor.add_event_handler("on_conversation_detected", mock_conversation_handler)
|
||||
|
||||
frames_to_send = [
|
||||
LLMTextFrame(text="<mode>conversation</mode>"),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
# No frames expected - mode selection doesn't push TextFrame
|
||||
]
|
||||
|
||||
expected_up_frames = [
|
||||
LLMMessagesUpdateFrame, # Initialize the mode detection prompt
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
expected_up_frames=expected_up_frames,
|
||||
)
|
||||
|
||||
# Verify the event handler was called
|
||||
self.assertTrue(
|
||||
conversation_handler_called,
|
||||
"on_conversation_detected event handler should have been called",
|
||||
)
|
||||
self.assertEqual(
|
||||
received_processor, processor, "Event handler should receive the IVRProcessor instance"
|
||||
)
|
||||
self.assertEqual(
|
||||
received_history,
|
||||
[],
|
||||
"Event handler should receive empty conversation history initially",
|
||||
)
|
||||
|
||||
async def test_basic_dtmf_navigation(self):
|
||||
"""Test basic DTMF tone generation from LLM responses."""
|
||||
# Create just the IVR processor to test in isolation
|
||||
processor = IVRProcessor(
|
||||
classifier_prompt=self.classifier_prompt,
|
||||
ivr_prompt=self.ivr_prompt,
|
||||
ivr_vad_params=self.ivr_vad_params,
|
||||
)
|
||||
|
||||
frames_to_send = [
|
||||
LLMTextFrame(text="<dtmf>1</dtmf>"),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
OutputDTMFUrgentFrame,
|
||||
TextFrame, # Context frame with skip_tts=True
|
||||
]
|
||||
|
||||
expected_up_frames = [
|
||||
LLMMessagesUpdateFrame, # Initialize the mode detection prompt
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
expected_up_frames=expected_up_frames,
|
||||
)
|
||||
|
||||
async def test_multiple_dtmf_navigation(self):
|
||||
"""Test basic DTMF tone generation from LLM responses."""
|
||||
# Create just the IVR processor to test in isolation
|
||||
processor = IVRProcessor(
|
||||
classifier_prompt=self.classifier_prompt,
|
||||
ivr_prompt=self.ivr_prompt,
|
||||
ivr_vad_params=self.ivr_vad_params,
|
||||
)
|
||||
|
||||
frames_to_send = [
|
||||
LLMTextFrame(text="<dtmf>1</dtmf>"),
|
||||
LLMTextFrame(text="<dtmf>2</dtmf>"),
|
||||
LLMTextFrame(text="<dtmf>3</dtmf>"),
|
||||
LLMTextFrame(text="<dtmf>4</dtmf>"),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
OutputDTMFUrgentFrame,
|
||||
TextFrame, # Context frame with skip_tts=True
|
||||
OutputDTMFUrgentFrame,
|
||||
TextFrame, # Context frame with skip_tts=True
|
||||
OutputDTMFUrgentFrame,
|
||||
TextFrame, # Context frame with skip_tts=True
|
||||
OutputDTMFUrgentFrame,
|
||||
TextFrame, # Context frame with skip_tts=True
|
||||
]
|
||||
|
||||
expected_up_frames = [
|
||||
LLMMessagesUpdateFrame, # Initialize the mode detection prompt
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
expected_up_frames=expected_up_frames,
|
||||
)
|
||||
|
||||
async def test_ivr_wait(self):
|
||||
"""Test basic DTMF tone generation from LLM responses."""
|
||||
# Create just the IVR processor to test in isolation
|
||||
processor = IVRProcessor(
|
||||
classifier_prompt=self.classifier_prompt,
|
||||
ivr_prompt=self.ivr_prompt,
|
||||
ivr_vad_params=self.ivr_vad_params,
|
||||
)
|
||||
|
||||
frames_to_send = [
|
||||
LLMTextFrame(text="<ivr>wait</ivr>"),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
TextFrame, # Context frame with skip_tts=True
|
||||
]
|
||||
|
||||
expected_up_frames = [
|
||||
LLMMessagesUpdateFrame, # Initialize the mode detection prompt
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
expected_up_frames=expected_up_frames,
|
||||
)
|
||||
|
||||
async def test_ivr_stuck(self):
|
||||
"""Test that on_ivr_status_changed event handler is called when stuck."""
|
||||
# Create just the IVR processor to test in isolation
|
||||
processor = IVRProcessor(
|
||||
classifier_prompt=self.classifier_prompt,
|
||||
ivr_prompt=self.ivr_prompt,
|
||||
ivr_vad_params=self.ivr_vad_params,
|
||||
)
|
||||
|
||||
# Mock event handler
|
||||
stuck_handler_called = False
|
||||
received_processor = None
|
||||
received_status = None
|
||||
|
||||
async def mock_status_handler(ivr_processor, status):
|
||||
nonlocal stuck_handler_called, received_processor, received_status
|
||||
if status.value == "stuck":
|
||||
stuck_handler_called = True
|
||||
received_processor = ivr_processor
|
||||
received_status = status
|
||||
|
||||
# Register the event handler
|
||||
processor.add_event_handler("on_ivr_status_changed", mock_status_handler)
|
||||
|
||||
frames_to_send = [
|
||||
LLMTextFrame(text="<ivr>stuck</ivr>"),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
TextFrame, # Context frame with skip_tts=True
|
||||
]
|
||||
|
||||
expected_up_frames = [
|
||||
LLMMessagesUpdateFrame, # Initialize the mode detection prompt
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
expected_up_frames=expected_up_frames,
|
||||
)
|
||||
|
||||
# Verify the event handler was called
|
||||
self.assertTrue(
|
||||
stuck_handler_called,
|
||||
"on_ivr_status_changed event handler should have been called for stuck status",
|
||||
)
|
||||
self.assertEqual(
|
||||
received_processor, processor, "Event handler should receive the IVRProcessor instance"
|
||||
)
|
||||
self.assertEqual(
|
||||
received_status.value, "stuck", "Event handler should receive IVRStatus.STUCK"
|
||||
)
|
||||
|
||||
async def test_ivr_completed(self):
|
||||
"""Test that on_ivr_status_changed event handler is called when completed."""
|
||||
# Create just the IVR processor to test in isolation
|
||||
processor = IVRProcessor(
|
||||
classifier_prompt=self.classifier_prompt,
|
||||
ivr_prompt=self.ivr_prompt,
|
||||
ivr_vad_params=self.ivr_vad_params,
|
||||
)
|
||||
|
||||
# Mock event handler
|
||||
completed_handler_called = False
|
||||
received_processor = None
|
||||
received_status = None
|
||||
|
||||
async def mock_status_handler(ivr_processor, status):
|
||||
nonlocal completed_handler_called, received_processor, received_status
|
||||
if status.value == "completed":
|
||||
completed_handler_called = True
|
||||
received_processor = ivr_processor
|
||||
received_status = status
|
||||
|
||||
# Register the event handler
|
||||
processor.add_event_handler("on_ivr_status_changed", mock_status_handler)
|
||||
|
||||
frames_to_send = [
|
||||
LLMTextFrame(text="<ivr>completed</ivr>"),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
TextFrame, # Context frame with skip_tts=True
|
||||
]
|
||||
|
||||
expected_up_frames = [
|
||||
LLMMessagesUpdateFrame, # Initialize the mode detection prompt
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
expected_up_frames=expected_up_frames,
|
||||
)
|
||||
|
||||
# Verify the event handler was called
|
||||
self.assertTrue(
|
||||
completed_handler_called,
|
||||
"on_ivr_status_changed event handler should have been called for completed status",
|
||||
)
|
||||
self.assertEqual(
|
||||
received_processor, processor, "Event handler should receive the IVRProcessor instance"
|
||||
)
|
||||
self.assertEqual(
|
||||
received_status.value, "completed", "Event handler should receive IVRStatus.COMPLETED"
|
||||
)
|
||||
|
||||
async def test_normal_text_passthrough(self):
|
||||
"""Test that normal LLM text (no XML) passes through unchanged."""
|
||||
processor = IVRProcessor(
|
||||
classifier_prompt=self.classifier_prompt,
|
||||
ivr_prompt=self.ivr_prompt,
|
||||
ivr_vad_params=self.ivr_vad_params,
|
||||
)
|
||||
|
||||
frames_to_send = [
|
||||
LLMTextFrame(text="Hello, I'm trying to reach billing."),
|
||||
]
|
||||
|
||||
expected_down_frames = [
|
||||
LLMTextFrame, # Should pass through unchanged
|
||||
]
|
||||
|
||||
expected_up_frames = [
|
||||
LLMMessagesUpdateFrame, # Initialize the mode detection prompt
|
||||
]
|
||||
|
||||
await run_test(
|
||||
processor,
|
||||
frames_to_send=frames_to_send,
|
||||
expected_down_frames=expected_down_frames,
|
||||
expected_up_frames=expected_up_frames,
|
||||
)
|
||||
51
uv.lock
generated
51
uv.lock
generated
@@ -4189,6 +4189,7 @@ wheels = [
|
||||
name = "pipecat-ai"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "aiohttp" },
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "docstring-parser" },
|
||||
@@ -4409,6 +4410,7 @@ docs = [
|
||||
requires-dist = [
|
||||
{ name = "accelerate", marker = "extra == 'moondream'", specifier = "~=1.10.0" },
|
||||
{ name = "aioboto3", marker = "extra == 'aws'", specifier = "~=15.0.0" },
|
||||
{ name = "aiofiles", specifier = ">=24.1.0,<25" },
|
||||
{ name = "aiohttp", specifier = ">=3.11.12,<4" },
|
||||
{ name = "aiortc", marker = "extra == 'webrtc'", specifier = "~=1.11.0" },
|
||||
{ name = "anthropic", marker = "extra == 'anthropic'", specifier = "~=0.49.0" },
|
||||
@@ -4507,12 +4509,12 @@ dev = [
|
||||
{ name = "grpcio-tools", specifier = "~=1.67.1" },
|
||||
{ name = "pip-tools", specifier = "~=7.4.1" },
|
||||
{ name = "pre-commit", specifier = "~=4.2.0" },
|
||||
{ name = "pyright", specifier = "~=1.1.402" },
|
||||
{ name = "pyright", specifier = ">=1.1.404,<1.2" },
|
||||
{ name = "pytest", specifier = "~=8.4.1" },
|
||||
{ name = "pytest-aiohttp", specifier = "==1.1.0" },
|
||||
{ name = "pytest-asyncio", specifier = "~=1.1.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.1,<2.0.0" },
|
||||
{ name = "ruff", specifier = "~=0.12.1" },
|
||||
{ name = "ruff", specifier = ">=0.12.11,<1" },
|
||||
{ name = "setuptools", specifier = "~=78.1.1" },
|
||||
{ name = "setuptools-scm", specifier = "~=8.3.1" },
|
||||
]
|
||||
@@ -5295,15 +5297,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.403"
|
||||
version = "1.1.404"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526, upload-time = "2025-07-09T07:15:52.882Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/6e/026be64c43af681d5632722acd100b06d3d39f383ec382ff50a71a6d5bce/pyright-1.1.404.tar.gz", hash = "sha256:455e881a558ca6be9ecca0b30ce08aa78343ecc031d37a198ffa9a7a1abeb63e", size = 4065679, upload-time = "2025-08-20T18:46:14.029Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504, upload-time = "2025-07-09T07:15:50.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/30/89aa7f7d7a875bbb9a577d4b1dc5a3e404e3d2ae2657354808e905e358e0/pyright-1.1.404-py3-none-any.whl", hash = "sha256:c7b7ff1fdb7219c643079e4c3e7d4125f0dafcc19d253b47e898d130ea426419", size = 5902951, upload-time = "2025-08-20T18:46:12.096Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6044,27 +6046,28 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.12.8"
|
||||
version = "0.12.11"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/da/5bd7565be729e86e1442dad2c9a364ceeff82227c2dece7c29697a9795eb/ruff-0.12.8.tar.gz", hash = "sha256:4cb3a45525176e1009b2b64126acf5f9444ea59066262791febf55e40493a033", size = 5242373, upload-time = "2025-08-07T19:05:47.268Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/55/16ab6a7d88d93001e1ae4c34cbdcfb376652d761799459ff27c1dc20f6fa/ruff-0.12.11.tar.gz", hash = "sha256:c6b09ae8426a65bbee5425b9d0b82796dbb07cb1af045743c79bfb163001165d", size = 5347103, upload-time = "2025-08-28T13:59:08.87Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/1e/c843bfa8ad1114fab3eb2b78235dda76acd66384c663a4e0415ecc13aa1e/ruff-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:63cb5a5e933fc913e5823a0dfdc3c99add73f52d139d6cd5cc8639d0e0465513", size = 11675315, upload-time = "2025-08-07T19:05:06.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/ee/af6e5c2a8ca3a81676d5480a1025494fd104b8896266502bb4de2a0e8388/ruff-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9a9bbe28f9f551accf84a24c366c1aa8774d6748438b47174f8e8565ab9dedbc", size = 12456653, upload-time = "2025-08-07T19:05:09.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/9d/e91f84dfe3866fa648c10512904991ecc326fd0b66578b324ee6ecb8f725/ruff-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2fae54e752a3150f7ee0e09bce2e133caf10ce9d971510a9b925392dc98d2fec", size = 11659690, upload-time = "2025-08-07T19:05:12.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ac/a363d25ec53040408ebdd4efcee929d48547665858ede0505d1d8041b2e5/ruff-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c0acbcf01206df963d9331b5838fb31f3b44fa979ee7fa368b9b9057d89f4a53", size = 11896923, upload-time = "2025-08-07T19:05:14.821Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/9f/ea356cd87c395f6ade9bb81365bd909ff60860975ca1bc39f0e59de3da37/ruff-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ae3e7504666ad4c62f9ac8eedb52a93f9ebdeb34742b8b71cd3cccd24912719f", size = 11477612, upload-time = "2025-08-07T19:05:16.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/46/92e8fa3c9dcfd49175225c09053916cb97bb7204f9f899c2f2baca69e450/ruff-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb82efb5d35d07497813a1c5647867390a7d83304562607f3579602fa3d7d46f", size = 13182745, upload-time = "2025-08-07T19:05:18.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/c4/f2176a310f26e6160deaf661ef60db6c3bb62b7a35e57ae28f27a09a7d63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dbea798fc0065ad0b84a2947b0aff4233f0cb30f226f00a2c5850ca4393de609", size = 14206885, upload-time = "2025-08-07T19:05:21.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/9d/98e162f3eeeb6689acbedbae5050b4b3220754554526c50c292b611d3a63/ruff-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:49ebcaccc2bdad86fd51b7864e3d808aad404aab8df33d469b6e65584656263a", size = 13639381, upload-time = "2025-08-07T19:05:23.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/4e/1b7478b072fcde5161b48f64774d6edd59d6d198e4ba8918d9f4702b8043/ruff-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ac9c570634b98c71c88cb17badd90f13fc076a472ba6ef1d113d8ed3df109fb", size = 12613271, upload-time = "2025-08-07T19:05:25.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/67/0c3c9179a3ad19791ef1b8f7138aa27d4578c78700551c60d9260b2c660d/ruff-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560e0cd641e45591a3e42cb50ef61ce07162b9c233786663fdce2d8557d99818", size = 12847783, upload-time = "2025-08-07T19:05:28.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/2a/0b6ac3dd045acf8aa229b12c9c17bb35508191b71a14904baf99573a21bd/ruff-0.12.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:71c83121512e7743fba5a8848c261dcc454cafb3ef2934a43f1b7a4eb5a447ea", size = 11702672, upload-time = "2025-08-07T19:05:30.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ee/f9fdc9f341b0430110de8b39a6ee5fa68c5706dc7c0aa940817947d6937e/ruff-0.12.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:de4429ef2ba091ecddedd300f4c3f24bca875d3d8b23340728c3cb0da81072c3", size = 11440626, upload-time = "2025-08-07T19:05:32.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/fb/b3aa2d482d05f44e4d197d1de5e3863feb13067b22c571b9561085c999dc/ruff-0.12.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a2cab5f60d5b65b50fba39a8950c8746df1627d54ba1197f970763917184b161", size = 12462162, upload-time = "2025-08-07T19:05:34.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/9f/5c5d93e1d00d854d5013c96e1a92c33b703a0332707a7cdbd0a4880a84fb/ruff-0.12.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:45c32487e14f60b88aad6be9fd5da5093dbefb0e3e1224131cb1d441d7cb7d46", size = 12913212, upload-time = "2025-08-07T19:05:36.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/13/ab9120add1c0e4604c71bfc2e4ef7d63bebece0cfe617013da289539cef8/ruff-0.12.8-py3-none-win32.whl", hash = "sha256:daf3475060a617fd5bc80638aeaf2f5937f10af3ec44464e280a9d2218e720d3", size = 11694382, upload-time = "2025-08-07T19:05:38.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/dc/a2873b7c5001c62f46266685863bee2888caf469d1edac84bf3242074be2/ruff-0.12.8-py3-none-win_amd64.whl", hash = "sha256:7209531f1a1fcfbe8e46bcd7ab30e2f43604d8ba1c49029bb420b103d0b5f76e", size = 12740482, upload-time = "2025-08-07T19:05:40.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/5c/799a1efb8b5abab56e8a9f2a0b72d12bd64bb55815e9476c7d0a2887d2f7/ruff-0.12.8-py3-none-win_arm64.whl", hash = "sha256:c90e1a334683ce41b0e7a04f41790c429bf5073b62c1ae701c9dc5b3d14f0749", size = 11884718, upload-time = "2025-08-07T19:05:42.866Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/a2/3b3573e474de39a7a475f3fbaf36a25600bfeb238e1a90392799163b64a0/ruff-0.12.11-py3-none-linux_armv6l.whl", hash = "sha256:93fce71e1cac3a8bf9200e63a38ac5c078f3b6baebffb74ba5274fb2ab276065", size = 11979885, upload-time = "2025-08-28T13:58:26.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/e4/235ad6d1785a2012d3ded2350fd9bc5c5af8c6f56820e696b0118dfe7d24/ruff-0.12.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b8e33ac7b28c772440afa80cebb972ffd823621ded90404f29e5ab6d1e2d4b93", size = 12742364, upload-time = "2025-08-28T13:58:30.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/0d/15b72c5fe6b1e402a543aa9d8960e0a7e19dfb079f5b0b424db48b7febab/ruff-0.12.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d69fb9d4937aa19adb2e9f058bc4fbfe986c2040acb1a4a9747734834eaa0bfd", size = 11920111, upload-time = "2025-08-28T13:58:33.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/c0/f66339d7893798ad3e17fa5a1e587d6fd9806f7c1c062b63f8b09dda6702/ruff-0.12.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:411954eca8464595077a93e580e2918d0a01a19317af0a72132283e28ae21bee", size = 12160060, upload-time = "2025-08-28T13:58:35.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/69/9870368326db26f20c946205fb2d0008988aea552dbaec35fbacbb46efaa/ruff-0.12.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a2c0a2e1a450f387bf2c6237c727dd22191ae8c00e448e0672d624b2bbd7fb0", size = 11799848, upload-time = "2025-08-28T13:58:38.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/8c/dd2c7f990e9b3a8a55eee09d4e675027d31727ce33cdb29eab32d025bdc9/ruff-0.12.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ca4c3a7f937725fd2413c0e884b5248a19369ab9bdd850b5781348ba283f644", size = 13536288, upload-time = "2025-08-28T13:58:40.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/30/d5496fa09aba59b5e01ea76775a4c8897b13055884f56f1c35a4194c2297/ruff-0.12.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4d1df0098124006f6a66ecf3581a7f7e754c4df7644b2e6704cd7ca80ff95211", size = 14490633, upload-time = "2025-08-28T13:58:42.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/2f/81f998180ad53445d403c386549d6946d0748e536d58fce5b5e173511183/ruff-0.12.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a8dd5f230efc99a24ace3b77e3555d3fbc0343aeed3fc84c8d89e75ab2ff793", size = 13888430, upload-time = "2025-08-28T13:58:44.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/71/23a0d1d5892a377478c61dbbcffe82a3476b050f38b5162171942a029ef3/ruff-0.12.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4dc75533039d0ed04cd33fb8ca9ac9620b99672fe7ff1533b6402206901c34ee", size = 12913133, upload-time = "2025-08-28T13:58:47.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/22/3c6cef96627f89b344c933781ed38329bfb87737aa438f15da95907cbfd5/ruff-0.12.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc58f9266d62c6eccc75261a665f26b4ef64840887fc6cbc552ce5b29f96cc8", size = 13169082, upload-time = "2025-08-28T13:58:49.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/b5/68b3ff96160d8b49e8dd10785ff3186be18fd650d356036a3770386e6c7f/ruff-0.12.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5a0113bd6eafd545146440225fe60b4e9489f59eb5f5f107acd715ba5f0b3d2f", size = 13139490, upload-time = "2025-08-28T13:58:51.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/b9/050a3278ecd558f74f7ee016fbdf10591d50119df8d5f5da45a22c6afafc/ruff-0.12.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0d737b4059d66295c3ea5720e6efc152623bb83fde5444209b69cd33a53e2000", size = 11958928, upload-time = "2025-08-28T13:58:53.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/bc/93be37347db854806904a43b0493af8d6873472dfb4b4b8cbb27786eb651/ruff-0.12.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:916fc5defee32dbc1fc1650b576a8fed68f5e8256e2180d4d9855aea43d6aab2", size = 11764513, upload-time = "2025-08-28T13:58:55.976Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/a1/1471751e2015a81fd8e166cd311456c11df74c7e8769d4aabfbc7584c7ac/ruff-0.12.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c984f07d7adb42d3ded5be894fb4007f30f82c87559438b4879fe7aa08c62b39", size = 12745154, upload-time = "2025-08-28T13:58:58.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/2542b14890d0f4872dd81b7b2a6aed3ac1786fae1ce9b17e11e6df9e31e3/ruff-0.12.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e07fbb89f2e9249f219d88331c833860489b49cdf4b032b8e4432e9b13e8a4b9", size = 13227653, upload-time = "2025-08-28T13:59:00.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/16/2fbfc61047dbfd009c58a28369a693a1484ad15441723be1cd7fe69bb679/ruff-0.12.11-py3-none-win32.whl", hash = "sha256:c792e8f597c9c756e9bcd4d87cf407a00b60af77078c96f7b6366ea2ce9ba9d3", size = 11944270, upload-time = "2025-08-28T13:59:02.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a5/34276984705bfe069cd383101c45077ee029c3fe3b28225bf67aa35f0647/ruff-0.12.11-py3-none-win_amd64.whl", hash = "sha256:a3283325960307915b6deb3576b96919ee89432ebd9c48771ca12ee8afe4a0fd", size = 13046600, upload-time = "2025-08-28T13:59:04.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/a8/001d4a7c2b37623a3fd7463208267fb906df40ff31db496157549cfd6e72/ruff-0.12.11-py3-none-win_arm64.whl", hash = "sha256:bae4d6e6a2676f8fb0f98b74594a048bae1b944aab17e9f5d504062303c6dbea", size = 12135290, upload-time = "2025-08-28T13:59:06.933Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user