mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge branch 'main' into sslip-trusted-https
This commit is contained in:
commit
53b8ba08fd
87 changed files with 3220 additions and 1145 deletions
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
".": "1.37.0"
|
||||
".": "1.38.0"
|
||||
}
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -1,5 +1,30 @@
|
|||
# Changelog
|
||||
|
||||
## 1.38.0 (2026-06-25)
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
||||
## What's Changed
|
||||
### Features
|
||||
* feat(scripts): generate REDIS_PASSWORD on setup, plumb through compose by @tecnomanu in https://github.com/dograh-hq/dograh/pull/458
|
||||
* feat(storage): support custom S3 endpoint, signature version, and addressing style by @skymoore in https://github.com/dograh-hq/dograh/pull/461
|
||||
* feat(twilio): add Answering Machine Detection (AMD) support via telephony config by @nuthalapativarun in https://github.com/dograh-hq/dograh/pull/443
|
||||
### Bug Fixes
|
||||
* fix: support Gemini JSON schema tools by @snvtac in https://github.com/dograh-hq/dograh/pull/463
|
||||
### Documentation
|
||||
* docs: update Tuner integration to use Dograh provider by @mohamedsalem-bot in https://github.com/dograh-hq/dograh/pull/457
|
||||
### Other Changes
|
||||
* style(docs): add custom green scrollbar by @Gurkirat-Singh-bit in https://github.com/dograh-hq/dograh/pull/434
|
||||
* Add Hostinger (managed-Traefik) deployment files by @a6kme in https://github.com/dograh-hq/dograh/pull/459
|
||||
|
||||
## New Contributors
|
||||
* @Gurkirat-Singh-bit made their first contribution in https://github.com/dograh-hq/dograh/pull/434
|
||||
* @tecnomanu made their first contribution in https://github.com/dograh-hq/dograh/pull/458
|
||||
* @skymoore made their first contribution in https://github.com/dograh-hq/dograh/pull/461
|
||||
* @snvtac made their first contribution in https://github.com/dograh-hq/dograh/pull/463
|
||||
|
||||
**Full Changelog**: https://github.com/dograh-hq/dograh/compare/dograh-v1.37.0...dograh-v1.38.0
|
||||
|
||||
## 1.37.0 (2026-06-19)
|
||||
|
||||
<!-- Release notes generated using configuration in .github/release.yml at main -->
|
||||
|
|
|
|||
|
|
@ -18,6 +18,13 @@ ENABLE_AWS_S3="false"
|
|||
# AWS_SECRET_ACCESS_KEY=""
|
||||
# S3_BUCKET=""
|
||||
# S3_REGION=""
|
||||
# --- S3-compatible servers (MinIO, rustfs, Ceph, ...) ---
|
||||
# Use the S3 backend (ENABLE_AWS_S3=true) against a non-AWS, S3-compatible
|
||||
# server by overriding the endpoint and signing. Unlike the MinIO backend, the
|
||||
# S3 backend emits real presigned URLs, so the bucket can stay private.
|
||||
# S3_ENDPOINT_URL="" # e.g. https://s3.example.com (blank = AWS default)
|
||||
# S3_SIGNATURE_VERSION="" # blank = botocore default; set "s3v4" if the server requires SigV4
|
||||
# S3_ADDRESSING_STYLE="" # blank = auto; set "path" if the server / TLS cert requires path-style
|
||||
|
||||
# MinIO Configuration if using containerised MinIO instead of
|
||||
# AWS S3
|
||||
|
|
|
|||
|
|
@ -53,6 +53,17 @@ MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true"
|
|||
# AWS S3 Configuration
|
||||
S3_BUCKET = os.environ.get("S3_BUCKET")
|
||||
S3_REGION = os.environ.get("S3_REGION", "us-east-1")
|
||||
# Optional overrides for S3-compatible backends (e.g. MinIO, rustfs, Ceph).
|
||||
# S3_ENDPOINT_URL: full URL of a custom S3 endpoint (e.g. "https://s3.example.com").
|
||||
# Leave unset to use AWS's default endpoint resolution.
|
||||
# S3_SIGNATURE_VERSION: botocore signature version used to sign requests and
|
||||
# presigned URLs. Defaults to None (botocore's default, currently SigV2 for
|
||||
# presigned URLs). Set to "s3v4" for S3-compatible servers that require SigV4.
|
||||
# S3_ADDRESSING_STYLE: "auto" (default), "path", or "virtual". Many S3-compatible
|
||||
# servers and TLS setups require "path".
|
||||
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL")
|
||||
S3_SIGNATURE_VERSION = os.environ.get("S3_SIGNATURE_VERSION")
|
||||
S3_ADDRESSING_STYLE = os.environ.get("S3_ADDRESSING_STYLE")
|
||||
|
||||
# Sentry configuration
|
||||
SENTRY_DSN = os.getenv("SENTRY_DSN")
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from pathlib import Path
|
|||
from typing import List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
|
|
@ -300,6 +300,31 @@ class KnowledgeBaseClient(BaseDBClient):
|
|||
logger.info(f"Created {len(chunks)} chunks")
|
||||
return chunks
|
||||
|
||||
async def replace_chunks_for_document(
|
||||
self,
|
||||
document_id: int,
|
||||
organization_id: int,
|
||||
chunks: List[KnowledgeBaseChunkModel],
|
||||
) -> List[KnowledgeBaseChunkModel]:
|
||||
"""Replace all chunks for a document with a new precomputed batch."""
|
||||
async with self.async_session() as session:
|
||||
await session.execute(
|
||||
delete(KnowledgeBaseChunkModel).where(
|
||||
KnowledgeBaseChunkModel.document_id == document_id,
|
||||
KnowledgeBaseChunkModel.organization_id == organization_id,
|
||||
)
|
||||
)
|
||||
session.add_all(chunks)
|
||||
await session.commit()
|
||||
|
||||
for chunk in chunks:
|
||||
await session.refresh(chunk)
|
||||
|
||||
logger.info(
|
||||
f"Replaced chunks for document {document_id}: {len(chunks)} chunks"
|
||||
)
|
||||
return chunks
|
||||
|
||||
async def get_chunks_for_document(
|
||||
self,
|
||||
document_id: int,
|
||||
|
|
|
|||
28
api/enums.py
28
api/enums.py
|
|
@ -17,6 +17,32 @@ class CallType(Enum):
|
|||
OUTBOUND = "outbound"
|
||||
|
||||
|
||||
class TelephonyCallStatus(str, Enum):
|
||||
INITIATED = "initiated"
|
||||
RINGING = "ringing"
|
||||
IN_PROGRESS = "in-progress"
|
||||
ANSWERED = "answered"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
BUSY = "busy"
|
||||
NO_ANSWER = "no-answer"
|
||||
CANCELED = "canceled"
|
||||
ERROR = "error"
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, value: object) -> "TelephonyCallStatus | None":
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
|
||||
if value in (None, ""):
|
||||
return None
|
||||
|
||||
try:
|
||||
return cls(str(value).lower())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
class WorkflowRunMode(Enum):
|
||||
ARI = "ari"
|
||||
PLIVO = "plivo"
|
||||
|
|
@ -77,8 +103,6 @@ class WorkflowRunStatus(Enum):
|
|||
|
||||
|
||||
class OrganizationConfigurationKey(Enum):
|
||||
DISPOSITION_CODE_MAPPING = "DISPOSITION_CODE_MAPPING"
|
||||
DISPOSITION_MESSAGE_TEMPLATE = "DISPOSITION_MESSAGE_TEMPLATE"
|
||||
CONCURRENT_CALL_LIMIT = "CONCURRENT_CALL_LIMIT"
|
||||
TELEPHONY_CONFIGURATION = (
|
||||
"TELEPHONY_CONFIGURATION" # Stores all providers + active one
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[project]
|
||||
name = "dograh-api"
|
||||
version = "1.37.0"
|
||||
version = "1.38.0"
|
||||
description = "Backend API for Dograh voice AI platform"
|
||||
requires-python = ">=3.13,<3.14"
|
||||
|
|
|
|||
|
|
@ -373,11 +373,7 @@ async def search_chunks(
|
|||
apply_managed_embeddings_base_url,
|
||||
get_resolved_ai_model_configuration,
|
||||
)
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.gen_ai import (
|
||||
AzureOpenAIEmbeddingService,
|
||||
OpenAIEmbeddingService,
|
||||
)
|
||||
from api.services.gen_ai import build_embedding_service
|
||||
|
||||
# Try to get user's embeddings configuration
|
||||
resolved_config = await get_resolved_ai_model_configuration(
|
||||
|
|
@ -405,22 +401,20 @@ async def search_chunks(
|
|||
effective_config.embeddings, "api_version", None
|
||||
)
|
||||
|
||||
# Initialize embedding service based on provider
|
||||
if embeddings_provider == ServiceProviders.AZURE.value and embeddings_endpoint:
|
||||
embedding_service = AzureOpenAIEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=embeddings_api_key,
|
||||
endpoint=embeddings_endpoint,
|
||||
model_id=embeddings_model or "text-embedding-3-small",
|
||||
api_version=embeddings_api_version or "2024-02-15-preview",
|
||||
)
|
||||
else:
|
||||
embedding_service = OpenAIEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=embeddings_api_key,
|
||||
model_id=embeddings_model or "text-embedding-3-small",
|
||||
base_url=embeddings_base_url,
|
||||
)
|
||||
# Manual search runs outside any workflow run, so resolve the MPS
|
||||
# correlation id here (mint only for orgs already on v2; never create one).
|
||||
embedding_service = await build_embedding_service(
|
||||
db_client=db_client,
|
||||
provider=embeddings_provider,
|
||||
api_key=embeddings_api_key,
|
||||
model=embeddings_model,
|
||||
base_url=embeddings_base_url,
|
||||
endpoint=embeddings_endpoint,
|
||||
api_version=embeddings_api_version,
|
||||
organization_id=user.selected_organization_id,
|
||||
created_by=str(user.provider_id),
|
||||
resolve_correlation=True,
|
||||
)
|
||||
|
||||
# Perform search
|
||||
results = await embedding_service.search_similar_chunks(
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from api.services.configuration.check_validity import UserConfigurationValidator
|
|||
from api.services.configuration.defaults import DEFAULT_SERVICE_PROVIDERS
|
||||
from api.services.configuration.masking import is_mask_of, mask_key, mask_user_config
|
||||
from api.services.configuration.registry import (
|
||||
DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES,
|
||||
DOGRAH_STT_LANGUAGES,
|
||||
REGISTRY,
|
||||
DograhTTSService,
|
||||
|
|
@ -274,6 +275,7 @@ async def get_model_configuration_v2_defaults(
|
|||
"step": DOGRAH_SPEED_STEP,
|
||||
},
|
||||
"languages": DOGRAH_STT_LANGUAGES,
|
||||
"multilingual_languages": DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES,
|
||||
"defaults": {
|
||||
"voice": DOGRAH_DEFAULT_VOICE,
|
||||
"speed": 1.0,
|
||||
|
|
|
|||
|
|
@ -388,9 +388,18 @@ class VoiceInfo(BaseModel):
|
|||
preview_url: Optional[str] = None
|
||||
|
||||
|
||||
class VoiceFacets(BaseModel):
|
||||
"""Distinct selector values across a provider's full voice catalog."""
|
||||
|
||||
genders: List[str] = []
|
||||
accents: List[str] = []
|
||||
languages: List[str] = []
|
||||
|
||||
|
||||
class VoicesResponse(BaseModel):
|
||||
provider: str
|
||||
voices: List[VoiceInfo]
|
||||
facets: Optional[VoiceFacets] = None
|
||||
|
||||
|
||||
@router.get("/configurations/voices/{provider}")
|
||||
|
|
@ -398,6 +407,9 @@ async def get_voices(
|
|||
provider: TTSProvider,
|
||||
model: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
gender: Optional[str] = None,
|
||||
accent: Optional[str] = None,
|
||||
user: UserModel = Depends(get_user),
|
||||
) -> VoicesResponse:
|
||||
"""Get available voices for a TTS provider."""
|
||||
|
|
@ -406,12 +418,16 @@ async def get_voices(
|
|||
provider=provider,
|
||||
model=model,
|
||||
language=language,
|
||||
q=q,
|
||||
gender=gender,
|
||||
accent=accent,
|
||||
organization_id=user.selected_organization_id,
|
||||
created_by=user.provider_id,
|
||||
)
|
||||
return VoicesResponse(
|
||||
provider=result.get("provider", provider),
|
||||
voices=[VoiceInfo(**voice) for voice in result.get("voices", [])],
|
||||
facets=result.get("facets"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch voices for {provider}: {e}")
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ def _compile_dograh_configuration(
|
|||
embeddings=DograhEmbeddingsConfiguration(
|
||||
provider=ServiceProviders.DOGRAH,
|
||||
api_key=configuration.api_key,
|
||||
model="default",
|
||||
model="dograh_embedding_v1",
|
||||
),
|
||||
is_realtime=False,
|
||||
managed_service_version=2,
|
||||
|
|
|
|||
|
|
@ -457,6 +457,11 @@ async def create_user_configuration_with_mps_key(
|
|||
"api_key": [service_key],
|
||||
"model": "default",
|
||||
},
|
||||
"embeddings": {
|
||||
"provider": ServiceProviders.DOGRAH.value,
|
||||
"api_key": [service_key],
|
||||
"model": "dograh_embedding_v1",
|
||||
},
|
||||
}
|
||||
effective_config = EffectiveAIModelConfiguration(**configuration)
|
||||
return effective_config
|
||||
|
|
|
|||
|
|
@ -316,6 +316,7 @@ def convert_legacy_ai_model_configuration_to_v2(
|
|||
|
||||
|
||||
def dograh_embeddings_base_url() -> str:
|
||||
# AsyncOpenAI appends "/embeddings"; MPS exposes that under /api/v1/llm.
|
||||
return f"{MPS_API_URL}/api/v1/llm"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
GOOGLE_MODELS = (
|
||||
"gemini-2.0-flash",
|
||||
"gemini-2.0-flash-lite",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-2.5-flash-lite",
|
||||
"gemini-3.5-flash",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from api.services.configuration.options import (
|
|||
AZURE_SPEECH_TTS_LANGUAGES,
|
||||
AZURE_SPEECH_TTS_VOICES,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES,
|
||||
DEEPGRAM_LANGUAGES,
|
||||
DEEPGRAM_STT_MODELS,
|
||||
GLADIA_STT_LANGUAGES,
|
||||
|
|
@ -315,7 +316,6 @@ OPENROUTER_MODELS = [
|
|||
"openai/gpt-4.1-mini",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"google/gemini-2.5-flash",
|
||||
"google/gemini-2.0-flash",
|
||||
"meta-llama/llama-3.3-70b-instruct",
|
||||
"deepseek/deepseek-chat-v3-0324",
|
||||
]
|
||||
|
|
@ -350,7 +350,7 @@ class GoogleLLMService(BaseLLMConfiguration):
|
|||
model_config = GOOGLE_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.GOOGLE] = ServiceProviders.GOOGLE
|
||||
model: str = Field(
|
||||
default="gemini-2.0-flash",
|
||||
default="gemini-2.5-flash",
|
||||
description="Gemini model on Google AI Studio (not Vertex).",
|
||||
json_schema_extra={"examples": GOOGLE_MODELS, "allow_custom_input": True},
|
||||
)
|
||||
|
|
@ -1397,6 +1397,10 @@ class GoogleSTTConfiguration(BaseSTTConfiguration):
|
|||
# Dograh STT Service
|
||||
DOGRAH_STT_MODELS = ["default"]
|
||||
DOGRAH_STT_LANGUAGES = DEEPGRAM_LANGUAGES
|
||||
# Languages auto-detected when the Dograh STT language is "multi". Dograh STT runs
|
||||
# Deepgram Flux multilingual under the hood, which only auto-detects this subset —
|
||||
# not the full DOGRAH_STT_LANGUAGES list offered for explicit single-language selection.
|
||||
DOGRAH_MULTILINGUAL_AUTODETECT_LANGUAGES = DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGES
|
||||
|
||||
|
||||
@register_stt
|
||||
|
|
@ -1722,7 +1726,7 @@ class AzureOpenAIEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
|
|||
)
|
||||
|
||||
|
||||
DOGRAH_EMBEDDING_MODELS = ["default"]
|
||||
DOGRAH_EMBEDDING_MODELS = ["dograh_embedding_v1"]
|
||||
|
||||
|
||||
@register_embeddings
|
||||
|
|
@ -1730,7 +1734,7 @@ class DograhEmbeddingsConfiguration(BaseEmbeddingsConfiguration):
|
|||
model_config = DOGRAH_PROVIDER_MODEL_CONFIG
|
||||
provider: Literal[ServiceProviders.DOGRAH] = ServiceProviders.DOGRAH
|
||||
model: str = Field(
|
||||
default="default",
|
||||
default="dograh_embedding_v1",
|
||||
description="Dograh-managed embedding model.",
|
||||
json_schema_extra={"examples": DOGRAH_EMBEDDING_MODELS},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from typing import Any, BinaryIO, Dict, Optional
|
||||
|
||||
import aioboto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from .base import BaseFileSystem
|
||||
|
|
@ -9,22 +10,56 @@ from .base import BaseFileSystem
|
|||
class S3FileSystem(BaseFileSystem):
|
||||
"""S3 implementation of the filesystem interface."""
|
||||
|
||||
def __init__(self, bucket_name: str, region_name: str = "us-east-1"):
|
||||
def __init__(
|
||||
self,
|
||||
bucket_name: str,
|
||||
region_name: str = "us-east-1",
|
||||
endpoint_url: Optional[str] = None,
|
||||
signature_version: Optional[str] = None,
|
||||
addressing_style: Optional[str] = None,
|
||||
):
|
||||
"""Initialize S3 filesystem.
|
||||
|
||||
Args:
|
||||
bucket_name: Name of the S3 bucket
|
||||
region_name: AWS region name
|
||||
endpoint_url: Optional custom S3 endpoint (e.g. for MinIO/rustfs).
|
||||
``None`` uses AWS's default endpoint resolution.
|
||||
signature_version: Optional botocore signature version (e.g.
|
||||
``"s3v4"``). ``None`` keeps botocore's default signing behavior.
|
||||
addressing_style: Optional S3 addressing style (``"path"`` /
|
||||
``"virtual"`` / ``"auto"``). ``None`` keeps botocore's default.
|
||||
"""
|
||||
self.bucket_name = bucket_name
|
||||
self.region_name = region_name
|
||||
self.endpoint_url = endpoint_url
|
||||
self.session = aioboto3.Session()
|
||||
|
||||
# Build a botocore Config only when an override is requested so that the
|
||||
# default behavior is byte-for-byte unchanged when no env vars are set.
|
||||
config_kwargs: Dict[str, Any] = {}
|
||||
if signature_version:
|
||||
config_kwargs["signature_version"] = signature_version
|
||||
if addressing_style:
|
||||
config_kwargs["s3"] = {"addressing_style": addressing_style}
|
||||
self._config = Config(**config_kwargs) if config_kwargs else None
|
||||
|
||||
def _client_kwargs(self) -> Dict[str, Any]:
|
||||
"""Common kwargs for every ``session.client("s3", ...)`` call.
|
||||
|
||||
Only includes ``endpoint_url`` / ``config`` when configured, so default
|
||||
deployments behave exactly as before.
|
||||
"""
|
||||
kwargs: Dict[str, Any] = {"region_name": self.region_name}
|
||||
if self.endpoint_url:
|
||||
kwargs["endpoint_url"] = self.endpoint_url
|
||||
if self._config is not None:
|
||||
kwargs["config"] = self._config
|
||||
return kwargs
|
||||
|
||||
async def acreate_file(self, file_path: str, content: BinaryIO) -> bool:
|
||||
try:
|
||||
async with self.session.client(
|
||||
"s3", region_name=self.region_name
|
||||
) as s3_client:
|
||||
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
|
||||
await s3_client.put_object(
|
||||
Bucket=self.bucket_name, Key=file_path, Body=await content.read()
|
||||
)
|
||||
|
|
@ -34,9 +69,7 @@ class S3FileSystem(BaseFileSystem):
|
|||
|
||||
async def aupload_file(self, local_path: str, destination_path: str) -> bool:
|
||||
try:
|
||||
async with self.session.client(
|
||||
"s3", region_name=self.region_name
|
||||
) as s3_client:
|
||||
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
|
||||
await s3_client.upload_file(
|
||||
local_path, self.bucket_name, destination_path
|
||||
)
|
||||
|
|
@ -59,9 +92,7 @@ class S3FileSystem(BaseFileSystem):
|
|||
disposition on the response.
|
||||
"""
|
||||
try:
|
||||
async with self.session.client(
|
||||
"s3", region_name=self.region_name
|
||||
) as s3_client:
|
||||
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
|
||||
params = {"Bucket": self.bucket_name, "Key": file_path}
|
||||
|
||||
# Make artifacts viewable inline in the browser when requested
|
||||
|
|
@ -100,9 +131,7 @@ class S3FileSystem(BaseFileSystem):
|
|||
async def aget_file_metadata(self, file_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get S3 object metadata."""
|
||||
try:
|
||||
async with self.session.client(
|
||||
"s3", region_name=self.region_name
|
||||
) as s3_client:
|
||||
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
|
||||
response = await s3_client.head_object(
|
||||
Bucket=self.bucket_name, Key=file_path
|
||||
)
|
||||
|
|
@ -126,9 +155,7 @@ class S3FileSystem(BaseFileSystem):
|
|||
) -> Optional[str]:
|
||||
"""Generate a presigned PUT URL for direct file upload."""
|
||||
try:
|
||||
async with self.session.client(
|
||||
"s3", region_name=self.region_name
|
||||
) as s3_client:
|
||||
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
|
||||
url = await s3_client.generate_presigned_url(
|
||||
"put_object",
|
||||
Params={
|
||||
|
|
@ -145,9 +172,7 @@ class S3FileSystem(BaseFileSystem):
|
|||
async def adownload_file(self, source_path: str, local_path: str) -> bool:
|
||||
"""Download a file from S3 to local path."""
|
||||
try:
|
||||
async with self.session.client(
|
||||
"s3", region_name=self.region_name
|
||||
) as s3_client:
|
||||
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
|
||||
await s3_client.download_file(self.bucket_name, source_path, local_path)
|
||||
return True
|
||||
except ClientError:
|
||||
|
|
@ -156,9 +181,7 @@ class S3FileSystem(BaseFileSystem):
|
|||
async def acopy_file(self, source_path: str, destination_path: str) -> bool:
|
||||
"""Copy a file within S3 (server-side copy)."""
|
||||
try:
|
||||
async with self.session.client(
|
||||
"s3", region_name=self.region_name
|
||||
) as s3_client:
|
||||
async with self.session.client("s3", **self._client_kwargs()) as s3_client:
|
||||
await s3_client.copy_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=destination_path,
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@ from .embedding import (
|
|||
AzureEmbeddingAPIKeyNotConfiguredError,
|
||||
AzureOpenAIEmbeddingService,
|
||||
BaseEmbeddingService,
|
||||
DograhEmbeddingService,
|
||||
EmbeddingAPIKeyNotConfiguredError,
|
||||
OpenAIEmbeddingService,
|
||||
build_embedding_service,
|
||||
resolve_embedding_correlation_id,
|
||||
)
|
||||
from .json_parser import parse_llm_json
|
||||
|
||||
|
|
@ -13,7 +16,10 @@ __all__ = [
|
|||
"AzureEmbeddingAPIKeyNotConfiguredError",
|
||||
"AzureOpenAIEmbeddingService",
|
||||
"BaseEmbeddingService",
|
||||
"DograhEmbeddingService",
|
||||
"EmbeddingAPIKeyNotConfiguredError",
|
||||
"OpenAIEmbeddingService",
|
||||
"build_embedding_service",
|
||||
"resolve_embedding_correlation_id",
|
||||
"parse_llm_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ from .azure_openai_service import (
|
|||
AzureOpenAIEmbeddingService,
|
||||
)
|
||||
from .base import BaseEmbeddingService
|
||||
from .dograh_service import DograhEmbeddingService
|
||||
from .factory import build_embedding_service, resolve_embedding_correlation_id
|
||||
from .openai_service import EmbeddingAPIKeyNotConfiguredError, OpenAIEmbeddingService
|
||||
|
||||
__all__ = [
|
||||
"AzureEmbeddingAPIKeyNotConfiguredError",
|
||||
"AzureOpenAIEmbeddingService",
|
||||
"BaseEmbeddingService",
|
||||
"DograhEmbeddingService",
|
||||
"EmbeddingAPIKeyNotConfiguredError",
|
||||
"OpenAIEmbeddingService",
|
||||
"build_embedding_service",
|
||||
"resolve_embedding_correlation_id",
|
||||
]
|
||||
|
|
|
|||
69
api/services/gen_ai/embedding/dograh_service.py
Normal file
69
api/services/gen_ai/embedding/dograh_service.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Dograh-managed embedding service.
|
||||
|
||||
Routes embeddings through Dograh's managed proxy (MPS). This mirrors the managed
|
||||
voice services (``DograhLLMService`` / ``DograhTTSService``): when a server-minted
|
||||
MPS correlation id is present, it forwards the MPS billing v2 protocol
|
||||
(``correlation_id`` + ``mps_billing_version``) in the request body so MPS can
|
||||
authorize and attribute the call. With no correlation id (e.g. a v1 org) it
|
||||
behaves like a plain OpenAI-compatible call, which MPS accepts.
|
||||
|
||||
Keeping this in a subclass keeps ``OpenAIEmbeddingService`` a generic
|
||||
OpenAI-compatible client; only the managed path carries MPS-specific metadata,
|
||||
so BYOK OpenAI/Azure requests never ship MPS fields to the real provider.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from api.db.db_client import DBClient
|
||||
|
||||
from .openai_service import DEFAULT_MODEL_ID, OpenAIEmbeddingService
|
||||
|
||||
# Protocol contract with MPS (see model_services
|
||||
# api/services/model_service_correlations.py). Kept local to avoid coupling the
|
||||
# app layer to the pipecat package, which defines its own copy for voice.
|
||||
MPS_BILLING_VERSION_KEY = "mps_billing_version"
|
||||
MPS_BILLING_VERSION_V2 = "2"
|
||||
|
||||
|
||||
class DograhEmbeddingService(OpenAIEmbeddingService):
|
||||
"""OpenAI-compatible embedding client pointed at Dograh's managed proxy."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_client: DBClient,
|
||||
api_key: Optional[str] = None,
|
||||
model_id: str = DEFAULT_MODEL_ID,
|
||||
base_url: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None,
|
||||
):
|
||||
"""Initialize the managed embedding service.
|
||||
|
||||
Args:
|
||||
db_client: Database client for vector similarity search.
|
||||
api_key: Dograh-managed MPS service key.
|
||||
model_id: Embedding model/tier id (default: text-embedding-3-small).
|
||||
base_url: MPS embeddings base URL.
|
||||
correlation_id: Server-minted MPS correlation id. When set, the MPS
|
||||
billing v2 protocol is forwarded with each request. When None,
|
||||
requests are sent without the protocol (valid for v1 orgs).
|
||||
"""
|
||||
super().__init__(
|
||||
db_client=db_client,
|
||||
api_key=api_key,
|
||||
model_id=model_id,
|
||||
base_url=base_url,
|
||||
)
|
||||
self._correlation_id = correlation_id
|
||||
|
||||
def _request_kwargs(self) -> Dict[str, Any]:
|
||||
"""Forward the MPS billing v2 protocol when a correlation id is present."""
|
||||
if not self._correlation_id:
|
||||
return {}
|
||||
return {
|
||||
"extra_body": {
|
||||
"metadata": {
|
||||
"correlation_id": self._correlation_id,
|
||||
MPS_BILLING_VERSION_KEY: MPS_BILLING_VERSION_V2,
|
||||
}
|
||||
}
|
||||
}
|
||||
137
api/services/gen_ai/embedding/factory.py
Normal file
137
api/services/gen_ai/embedding/factory.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""Factory for embedding services, including the Dograh-managed (MPS) path.
|
||||
|
||||
Centralizes the provider branching (Azure BYOK / Dograh-managed / OpenAI-compatible
|
||||
BYOK) that was previously duplicated across document ingestion, the search route,
|
||||
and the RAG tool, and resolves the MPS billing v2 protocol the same way the voice
|
||||
path does: attach it only for orgs already on v2, and never create a billing
|
||||
account to do so.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from api.db.db_client import DBClient
|
||||
|
||||
from .azure_openai_service import AzureOpenAIEmbeddingService
|
||||
from .base import BaseEmbeddingService
|
||||
from .dograh_service import DograhEmbeddingService
|
||||
from .openai_service import OpenAIEmbeddingService
|
||||
|
||||
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_AZURE_API_VERSION = "2024-02-15-preview"
|
||||
|
||||
|
||||
async def resolve_embedding_correlation_id(
|
||||
*,
|
||||
organization_id: Optional[int],
|
||||
service_key: Optional[str],
|
||||
created_by: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve an MPS correlation id for a managed embedding call made outside a run.
|
||||
|
||||
Mirrors the voice path's gating:
|
||||
|
||||
- OSS deployments use a pasted hosted v2 key (v2 by definition), so mint
|
||||
directly via the bearer endpoint — matching ``_authorize_oss_managed_v2_correlation``.
|
||||
- Hosted/SaaS: read the org's billing mode (no side effects) and mint only when
|
||||
it is already v2. Minting for an already-v2 org is a no-op on the account.
|
||||
|
||||
Returns ``None`` when the call should be sent without the protocol; MPS accepts
|
||||
un-gated embedding calls from v1 orgs. Never creates a v2 billing account.
|
||||
"""
|
||||
if not service_key:
|
||||
return None
|
||||
|
||||
# Imported lazily to avoid import-time cycles between the gen_ai and service
|
||||
# layers (matches the inline-import convention used elsewhere in the app).
|
||||
from api.constants import DEPLOYMENT_MODE
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
|
||||
try:
|
||||
if DEPLOYMENT_MODE == "oss":
|
||||
minted = await mps_service_key_client.create_correlation_id(
|
||||
service_key=service_key
|
||||
)
|
||||
return minted.get("correlation_id")
|
||||
|
||||
if organization_id is None:
|
||||
return None
|
||||
|
||||
status = await mps_service_key_client.get_billing_account_status(
|
||||
organization_id, created_by=created_by
|
||||
)
|
||||
if not status or status.get("billing_mode") != "v2":
|
||||
return None
|
||||
|
||||
minted = await mps_service_key_client.create_correlation_id(
|
||||
service_key=service_key
|
||||
)
|
||||
return minted.get("correlation_id")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not resolve MPS correlation id for managed embeddings; "
|
||||
"sending without v2 protocol: {}",
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def build_embedding_service(
|
||||
*,
|
||||
db_client: DBClient,
|
||||
provider: Optional[str],
|
||||
api_key: Optional[str],
|
||||
model: Optional[str],
|
||||
base_url: Optional[str] = None,
|
||||
endpoint: Optional[str] = None,
|
||||
api_version: Optional[str] = None,
|
||||
correlation_id: Optional[str] = None,
|
||||
organization_id: Optional[int] = None,
|
||||
created_by: Optional[str] = None,
|
||||
resolve_correlation: bool = False,
|
||||
) -> BaseEmbeddingService:
|
||||
"""Construct the right embedding service for a provider/config.
|
||||
|
||||
Args:
|
||||
correlation_id: A correlation id already available in context (e.g. the
|
||||
running workflow's MPS correlation id). Used for the Dograh provider.
|
||||
resolve_correlation: When True and no ``correlation_id`` is supplied, resolve
|
||||
one for the Dograh provider via ``resolve_embedding_correlation_id``
|
||||
(for calls made outside a workflow run: ingestion, manual search).
|
||||
"""
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
|
||||
model_id = model or DEFAULT_EMBEDDING_MODEL
|
||||
|
||||
if provider == ServiceProviders.AZURE.value and endpoint:
|
||||
return AzureOpenAIEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=api_key,
|
||||
endpoint=endpoint,
|
||||
model_id=model_id,
|
||||
api_version=api_version or DEFAULT_AZURE_API_VERSION,
|
||||
)
|
||||
|
||||
if provider == ServiceProviders.DOGRAH.value:
|
||||
cid = correlation_id
|
||||
if cid is None and resolve_correlation:
|
||||
cid = await resolve_embedding_correlation_id(
|
||||
organization_id=organization_id,
|
||||
service_key=api_key,
|
||||
created_by=created_by,
|
||||
)
|
||||
return DograhEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=api_key,
|
||||
model_id=model_id,
|
||||
base_url=base_url,
|
||||
correlation_id=cid,
|
||||
)
|
||||
|
||||
return OpenAIEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=api_key,
|
||||
model_id=model_id,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
|
@ -85,6 +85,14 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
|
|||
if not self._api_key_configured or self.client is None:
|
||||
raise EmbeddingAPIKeyNotConfiguredError()
|
||||
|
||||
def _request_kwargs(self) -> Dict[str, Any]:
|
||||
"""Extra kwargs merged into every embeddings.create() call.
|
||||
|
||||
Override hook for subclasses (e.g. DograhEmbeddingService injects the MPS
|
||||
billing protocol here). The base service adds nothing.
|
||||
"""
|
||||
return {}
|
||||
|
||||
async def embed_texts(self, texts: List[str]) -> List[List[float]]:
|
||||
"""Embed a batch of texts using OpenAI API.
|
||||
|
||||
|
|
@ -97,6 +105,7 @@ class OpenAIEmbeddingService(BaseEmbeddingService):
|
|||
response = await self.client.embeddings.create(
|
||||
input=texts,
|
||||
model=self.model_id,
|
||||
**self._request_kwargs(),
|
||||
)
|
||||
return [item.embedding for item in response.data]
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -720,6 +720,9 @@ class MPSServiceKeyClient:
|
|||
provider: str,
|
||||
model: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
gender: Optional[str] = None,
|
||||
accent: Optional[str] = None,
|
||||
organization_id: Optional[int] = None,
|
||||
created_by: Optional[str] = None,
|
||||
) -> dict:
|
||||
|
|
@ -745,6 +748,12 @@ class MPSServiceKeyClient:
|
|||
params["model"] = model
|
||||
if language:
|
||||
params["language"] = language
|
||||
if q:
|
||||
params["q"] = q
|
||||
if gender:
|
||||
params["gender"] = gender
|
||||
if accent:
|
||||
params["accent"] = accent
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/v1/voice-proxy/{provider}/voices",
|
||||
headers=self._get_headers(organization_id, created_by),
|
||||
|
|
|
|||
39
api/services/pipecat/gemini_json_schema_adapter.py
Normal file
39
api/services/pipecat/gemini_json_schema_adapter.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Dograh-specific Gemini adapter customizations."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pipecat.adapters.schemas.tools_schema import AdapterType, ToolsSchema
|
||||
from pipecat.adapters.services.gemini_adapter import GeminiLLMAdapter
|
||||
|
||||
|
||||
class DograhGeminiJSONSchemaAdapter(GeminiLLMAdapter):
|
||||
"""Use Gemini's full JSON Schema tool parameter field.
|
||||
|
||||
Pipecat's default Gemini adapter maps ``FunctionSchema.parameters`` into
|
||||
``FunctionDeclaration.parameters``, which is backed by Google GenAI's
|
||||
stricter OpenAPI-style ``Schema`` model. MCP and imported tools may contain
|
||||
valid JSON Schema keywords such as ``const`` and ``not`` that are rejected
|
||||
by that model. ``parameters_json_schema`` is the Google GenAI field intended
|
||||
for full JSON Schema payloads.
|
||||
"""
|
||||
|
||||
def to_provider_tools_format(
|
||||
self, tools_schema: ToolsSchema
|
||||
) -> list[dict[str, Any]]:
|
||||
functions_schema = tools_schema.standard_tools
|
||||
if functions_schema:
|
||||
formatted_functions = []
|
||||
for func in functions_schema:
|
||||
func_dict = func.to_default_dict()
|
||||
parameters = func_dict.pop("parameters")
|
||||
func_dict["parameters_json_schema"] = parameters
|
||||
formatted_functions.append(func_dict)
|
||||
formatted_standard_tools = [{"function_declarations": formatted_functions}]
|
||||
else:
|
||||
formatted_standard_tools = []
|
||||
|
||||
custom_gemini_tools = []
|
||||
if tools_schema.custom_tools:
|
||||
custom_gemini_tools = tools_schema.custom_tools.get(AdapterType.GEMINI, [])
|
||||
|
||||
return formatted_standard_tools + custom_gemini_tools
|
||||
|
|
@ -22,6 +22,9 @@ from typing import Any
|
|||
|
||||
from loguru import logger
|
||||
|
||||
from api.services.pipecat.gemini_json_schema_adapter import (
|
||||
DograhGeminiJSONSchemaAdapter,
|
||||
)
|
||||
from pipecat.frames.frames import (
|
||||
BotStoppedSpeakingFrame,
|
||||
Frame,
|
||||
|
|
@ -39,6 +42,13 @@ from pipecat.utils.tracing.service_decorators import traced_gemini_live
|
|||
class DograhGeminiLiveLLMService(GeminiLiveLLMService):
|
||||
"""Gemini Live with Dograh engine integration quirks. See module docstring."""
|
||||
|
||||
# Route tool schemas through Gemini's ``parameters_json_schema`` field so
|
||||
# MCP/imported tools that use JSON Schema keywords (``const``, ``not``,
|
||||
# nested ``anyOf``) rejected by the strict ``Schema`` model are accepted.
|
||||
# Mirrors the non-realtime ``DograhGoogleLLMService`` fix;
|
||||
# ``DograhGeminiLiveVertexLLMService`` inherits this via MRO.
|
||||
adapter_class = DograhGeminiJSONSchemaAdapter
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
# User-mute state, driven by broadcast UserMute{Started,Stopped}Frames.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from loguru import logger
|
|||
|
||||
from api.db import db_client
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.configuration.options import DEEPGRAM_FLUX_MODELS
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.integrations import (
|
||||
IntegrationRuntimeContext,
|
||||
|
|
@ -47,6 +46,7 @@ from api.services.pipecat.service_factory import (
|
|||
create_realtime_llm_service,
|
||||
create_stt_service,
|
||||
create_tts_service,
|
||||
stt_uses_flux_turns,
|
||||
)
|
||||
from api.services.pipecat.tracing_config import (
|
||||
ensure_tracing,
|
||||
|
|
@ -626,14 +626,10 @@ async def _run_pipeline(
|
|||
user_config.realtime.provider
|
||||
)
|
||||
else:
|
||||
# Deepgram Flux uses external turn detection (VAD + External start/stop)
|
||||
# Other models use configurable turn detection strategy
|
||||
is_deepgram_flux = (
|
||||
user_config.stt.provider == ServiceProviders.DEEPGRAM.value
|
||||
and user_config.stt.model in DEEPGRAM_FLUX_MODELS
|
||||
)
|
||||
|
||||
if is_deepgram_flux:
|
||||
# Deepgram Flux and supported Dograh managed Flux languages emit their
|
||||
# own turn boundaries, so the aggregator follows those external signals.
|
||||
# Other models use configurable turn detection.
|
||||
if stt_uses_flux_turns(user_config):
|
||||
user_turn_strategies = UserTurnStrategies(
|
||||
start=[
|
||||
VADUserTurnStartStrategy(),
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ from fastapi import HTTPException
|
|||
from loguru import logger
|
||||
|
||||
from api.constants import MPS_API_URL
|
||||
from api.services.configuration.options import DEEPGRAM_FLUX_MODELS
|
||||
from api.services.configuration.options import (
|
||||
DEEPGRAM_FLUX_MODELS,
|
||||
DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS,
|
||||
)
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.pipecat.gemini_json_schema_adapter import (
|
||||
DograhGeminiJSONSchemaAdapter,
|
||||
)
|
||||
from api.services.pipecat.minimax_tts import MiniMaxOwnedSessionTTSService
|
||||
from api.utils.url_security import validate_user_configured_service_url
|
||||
from pipecat.services.assemblyai.stt import AssemblyAISTTService, AssemblyAISTTSettings
|
||||
|
|
@ -27,6 +33,7 @@ from pipecat.services.deepgram.flux.stt import (
|
|||
)
|
||||
from pipecat.services.deepgram.stt import DeepgramSTTService, DeepgramSTTSettings
|
||||
from pipecat.services.deepgram.tts import DeepgramTTSService, DeepgramTTSSettings
|
||||
from pipecat.services.dograh.flux.stt import DograhFluxSTTService
|
||||
from pipecat.services.dograh.llm import DograhLLMService
|
||||
from pipecat.services.dograh.stt import DograhSTTService, DograhSTTSettings
|
||||
from pipecat.services.dograh.tts import DograhTTSService, DograhTTSSettings
|
||||
|
|
@ -94,6 +101,27 @@ DEEPGRAM_FLUX_LANGUAGE_HINTS = {
|
|||
}
|
||||
|
||||
|
||||
def dograh_stt_uses_flux_language(language: str | None) -> bool:
|
||||
language = language or "multi"
|
||||
return language in DEEPGRAM_FLUX_MULTILINGUAL_LANGUAGE_OPTIONS
|
||||
|
||||
|
||||
def stt_uses_flux_turns(user_config) -> bool:
|
||||
if user_config.stt.provider == ServiceProviders.DEEPGRAM.value:
|
||||
return user_config.stt.model in DEEPGRAM_FLUX_MODELS
|
||||
if user_config.stt.provider == ServiceProviders.DOGRAH.value:
|
||||
return dograh_stt_uses_flux_language(getattr(user_config.stt, "language", None))
|
||||
return False
|
||||
|
||||
|
||||
class DograhGoogleLLMService(GoogleLLMService):
|
||||
adapter_class = DograhGeminiJSONSchemaAdapter
|
||||
|
||||
|
||||
class DograhGoogleVertexLLMService(GoogleVertexLLMService):
|
||||
adapter_class = DograhGeminiJSONSchemaAdapter
|
||||
|
||||
|
||||
def _validate_runtime_service_url(url: str, field_name: str) -> None:
|
||||
try:
|
||||
validate_user_configured_service_url(
|
||||
|
|
@ -193,6 +221,29 @@ def create_stt_service(
|
|||
elif user_config.stt.provider == ServiceProviders.DOGRAH.value:
|
||||
base_url = MPS_API_URL.replace("http://", "ws://").replace("https://", "wss://")
|
||||
language = getattr(user_config.stt, "language", None) or "multi"
|
||||
|
||||
if dograh_stt_uses_flux_language(language):
|
||||
# Dograh's Flux proxy only supports multilingual auto-detect and the
|
||||
# same language hint subset as Deepgram Flux multilingual.
|
||||
settings_kwargs = {
|
||||
"model": "flux-general-multi",
|
||||
"eot_timeout_ms": 3000,
|
||||
"eot_threshold": 0.7,
|
||||
"eager_eot_threshold": 0.5,
|
||||
"keyterm": keyterms or [],
|
||||
}
|
||||
language_hint = DEEPGRAM_FLUX_LANGUAGE_HINTS.get(language)
|
||||
if language_hint:
|
||||
settings_kwargs["language_hints"] = [language_hint]
|
||||
return DograhFluxSTTService(
|
||||
base_url=base_url,
|
||||
api_key=user_config.stt.api_key,
|
||||
correlation_id=correlation_id,
|
||||
settings=DeepgramFluxSTTSettings(**settings_kwargs),
|
||||
should_interrupt=False, # external turn strategies own interruption
|
||||
sample_rate=audio_config.transport_in_sample_rate,
|
||||
)
|
||||
|
||||
return DograhSTTService(
|
||||
base_url=base_url,
|
||||
api_key=user_config.stt.api_key,
|
||||
|
|
@ -679,6 +730,19 @@ def create_tts_service(
|
|||
)
|
||||
|
||||
|
||||
def _migrate_deprecated_google_model(model: str) -> str:
|
||||
"""Google removed the ``gemini-2.0-flash*`` models. Transparently upgrade
|
||||
any stored config that still references them to the 2.5 equivalent so old
|
||||
user configurations keep working instead of failing at runtime."""
|
||||
if model and model.startswith("gemini-2.0-flash"):
|
||||
migrated = model.replace("gemini-2.0-", "gemini-2.5-", 1)
|
||||
logger.warning(
|
||||
f"Google model '{model}' is no longer supported; using '{migrated}' instead"
|
||||
)
|
||||
return migrated
|
||||
return model
|
||||
|
||||
|
||||
def create_llm_service_from_provider(
|
||||
provider: str,
|
||||
model: str,
|
||||
|
|
@ -736,12 +800,13 @@ def create_llm_service_from_provider(
|
|||
**kwargs,
|
||||
)
|
||||
elif provider == ServiceProviders.GOOGLE.value:
|
||||
return GoogleLLMService(
|
||||
model = _migrate_deprecated_google_model(model)
|
||||
return DograhGoogleLLMService(
|
||||
api_key=api_key,
|
||||
settings=GoogleLLMSettings(model=model, temperature=0.1),
|
||||
)
|
||||
elif provider == ServiceProviders.GOOGLE_VERTEX.value:
|
||||
return GoogleVertexLLMService(
|
||||
return DograhGoogleVertexLLMService(
|
||||
credentials=credentials,
|
||||
project_id=project_id,
|
||||
location=location or "us-east4",
|
||||
|
|
|
|||
|
|
@ -9,8 +9,11 @@ from api.constants import (
|
|||
MINIO_PUBLIC_ENDPOINT,
|
||||
MINIO_SECRET_KEY,
|
||||
MINIO_SECURE,
|
||||
S3_ADDRESSING_STYLE,
|
||||
S3_BUCKET,
|
||||
S3_ENDPOINT_URL,
|
||||
S3_REGION,
|
||||
S3_SIGNATURE_VERSION,
|
||||
)
|
||||
from api.enums import Environment, StorageBackend
|
||||
|
||||
|
|
@ -57,7 +60,13 @@ def get_storage_for_backend(backend: str) -> BaseFileSystem:
|
|||
logger.info(
|
||||
f"Initializing {backend} storage with bucket '{bucket}' in region '{region}'"
|
||||
)
|
||||
return S3FileSystem(bucket, region)
|
||||
return S3FileSystem(
|
||||
bucket_name=bucket,
|
||||
region_name=region,
|
||||
endpoint_url=S3_ENDPOINT_URL,
|
||||
signature_version=S3_SIGNATURE_VERSION,
|
||||
addressing_style=S3_ADDRESSING_STYLE,
|
||||
)
|
||||
|
||||
# Future backend implementations can be added here:
|
||||
# elif backend == StorageBackend.GCS: # Code 3
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ class NormalizedInboundData:
|
|||
raw_data: Dict[str, Any] = field(default_factory=dict) # Original webhook data
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnsweringMachineDetectionResult:
|
||||
"""Standardized answering-machine detection result across providers."""
|
||||
|
||||
call_id: str
|
||||
answered_by: str
|
||||
raw_data: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class TelephonyProvider(ABC):
|
||||
"""
|
||||
Abstract base class for telephony providers.
|
||||
|
|
@ -192,6 +201,23 @@ class TelephonyProvider(ABC):
|
|||
"""
|
||||
pass
|
||||
|
||||
def supports_answering_machine_detection(self) -> bool:
|
||||
"""Return whether this provider can request answering-machine detection."""
|
||||
return False
|
||||
|
||||
def apply_answering_machine_detection_call_params(
|
||||
self,
|
||||
data: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""Add provider-specific AMD parameters to an outbound call request."""
|
||||
return data
|
||||
|
||||
def parse_answering_machine_detection_result(
|
||||
self, data: Dict[str, Any]
|
||||
) -> Optional[AnsweringMachineDetectionResult]:
|
||||
"""Parse provider-specific callback data into a normalized AMD result."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
async def handle_websocket(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from fastapi import HTTPException
|
|||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunMode
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
|
|
@ -205,12 +205,12 @@ class ARIProvider(TelephonyProvider):
|
|||
"""
|
||||
# Map ARI channel states to common status format
|
||||
state_map = {
|
||||
"Up": "answered",
|
||||
"Down": "completed",
|
||||
"Ringing": "ringing",
|
||||
"Ring": "ringing",
|
||||
"Busy": "busy",
|
||||
"Unavailable": "failed",
|
||||
"Up": TelephonyCallStatus.ANSWERED,
|
||||
"Down": TelephonyCallStatus.COMPLETED,
|
||||
"Ringing": TelephonyCallStatus.RINGING,
|
||||
"Ring": TelephonyCallStatus.RINGING,
|
||||
"Busy": TelephonyCallStatus.BUSY,
|
||||
"Unavailable": TelephonyCallStatus.FAILED,
|
||||
}
|
||||
|
||||
channel_state = data.get("channel", {}).get("state", "")
|
||||
|
|
@ -218,11 +218,11 @@ class ARIProvider(TelephonyProvider):
|
|||
|
||||
# Determine status from event type
|
||||
if event_type == "StasisStart":
|
||||
status = "answered"
|
||||
status = TelephonyCallStatus.ANSWERED
|
||||
elif event_type == "StasisEnd":
|
||||
status = "completed"
|
||||
status = TelephonyCallStatus.COMPLETED
|
||||
elif event_type == "ChannelDestroyed":
|
||||
status = "completed"
|
||||
status = TelephonyCallStatus.COMPLETED
|
||||
else:
|
||||
status = state_map.get(channel_state, channel_state.lower())
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from fastapi import HTTPException
|
|||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunMode
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
|
|
@ -348,15 +348,15 @@ class CloudonixProvider(TelephonyProvider):
|
|||
# Map Cloudonix status values to common format
|
||||
# These mappings may need adjustment based on actual Cloudonix callback format
|
||||
status_map = {
|
||||
"initiated": "initiated",
|
||||
"ringing": "ringing",
|
||||
"answered": "answered",
|
||||
"completed": "completed",
|
||||
"failed": "failed",
|
||||
"busy": "busy",
|
||||
"no-answer": "no-answer",
|
||||
"canceled": "canceled",
|
||||
"error": "error",
|
||||
"initiated": TelephonyCallStatus.INITIATED,
|
||||
"ringing": TelephonyCallStatus.RINGING,
|
||||
"answered": TelephonyCallStatus.ANSWERED,
|
||||
"completed": TelephonyCallStatus.COMPLETED,
|
||||
"failed": TelephonyCallStatus.FAILED,
|
||||
"busy": TelephonyCallStatus.BUSY,
|
||||
"no-answer": TelephonyCallStatus.NO_ANSWER,
|
||||
"canceled": TelephonyCallStatus.CANCELED,
|
||||
"error": TelephonyCallStatus.ERROR,
|
||||
}
|
||||
|
||||
call_status = data.get("status", "")
|
||||
|
|
@ -374,6 +374,33 @@ class CloudonixProvider(TelephonyProvider):
|
|||
"extra": data, # Include all original data
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_cdr_status_callback(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Parse Cloudonix CDR data into generic status callback format."""
|
||||
disposition_map = {
|
||||
"ANSWER": TelephonyCallStatus.COMPLETED,
|
||||
"BUSY": TelephonyCallStatus.BUSY,
|
||||
"CANCEL": TelephonyCallStatus.CANCELED,
|
||||
"FAILED": TelephonyCallStatus.FAILED,
|
||||
"CONGESTION": TelephonyCallStatus.FAILED,
|
||||
"NOANSWER": TelephonyCallStatus.NO_ANSWER,
|
||||
}
|
||||
|
||||
disposition = data.get("disposition") or ""
|
||||
session = data.get("session")
|
||||
billsec = data.get("billsec")
|
||||
|
||||
return {
|
||||
"call_id": session.get("token") if isinstance(session, dict) else "",
|
||||
"status": disposition_map.get(disposition.upper(), disposition.lower()),
|
||||
"from_number": data.get("from"),
|
||||
"to_number": data.get("to"),
|
||||
"duration": str(
|
||||
billsec if billsec is not None else (data.get("duration") or 0)
|
||||
),
|
||||
"extra": data,
|
||||
}
|
||||
|
||||
async def get_webhook_response(
|
||||
self, workflow_id: int, user_id: int, workflow_run_id: int
|
||||
) -> str:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from pipecat.utils.run_context import set_current_run_id
|
|||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider_for_run
|
||||
from api.services.telephony.providers.cloudonix.provider import CloudonixProvider
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
|
|
@ -120,8 +121,15 @@ async def handle_cloudonix_cdr(request: Request):
|
|||
set_current_run_id(workflow_run_id)
|
||||
logger.info(f"[run {workflow_run_id}] Processing Cloudonix CDR for call {call_id}")
|
||||
|
||||
# Convert CDR to status update using StatusCallbackRequest
|
||||
status_update = StatusCallbackRequest.from_cloudonix_cdr(cdr_data)
|
||||
parsed_data = CloudonixProvider.parse_cdr_status_callback(cdr_data)
|
||||
status_update = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
from_number=parsed_data.get("from_number"),
|
||||
to_number=parsed_data.get("to_number"),
|
||||
duration=parsed_data.get("duration"),
|
||||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from fastapi import HTTPException
|
|||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunMode
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
|
|
@ -281,17 +281,17 @@ class PlivoProvider(TelephonyProvider):
|
|||
|
||||
def parse_status_callback(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
status_map = {
|
||||
"in-progress": "answered",
|
||||
"ringing": "ringing",
|
||||
"ring": "ringing",
|
||||
"completed": "completed",
|
||||
"hangup": "completed",
|
||||
"stopstream": "completed",
|
||||
"busy": "busy",
|
||||
"no-answer": "no-answer",
|
||||
"cancel": "canceled",
|
||||
"cancelled": "canceled",
|
||||
"timeout": "no-answer",
|
||||
"in-progress": TelephonyCallStatus.ANSWERED,
|
||||
"ringing": TelephonyCallStatus.RINGING,
|
||||
"ring": TelephonyCallStatus.RINGING,
|
||||
"completed": TelephonyCallStatus.COMPLETED,
|
||||
"hangup": TelephonyCallStatus.COMPLETED,
|
||||
"stopstream": TelephonyCallStatus.COMPLETED,
|
||||
"busy": TelephonyCallStatus.BUSY,
|
||||
"no-answer": TelephonyCallStatus.NO_ANSWER,
|
||||
"cancel": TelephonyCallStatus.CANCELED,
|
||||
"cancelled": TelephonyCallStatus.CANCELED,
|
||||
"timeout": TelephonyCallStatus.NO_ANSWER,
|
||||
}
|
||||
|
||||
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ TELNYX_TIMESTAMP_TOLERANCE_SECONDS = 300
|
|||
TELNYX_PUBLIC_KEY_BYTES = 32
|
||||
TELNYX_SIGNATURE_BYTES = 64
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunMode
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
|
|
@ -305,23 +305,25 @@ class TelnyxProvider(TelephonyProvider):
|
|||
}
|
||||
|
||||
@staticmethod
|
||||
def _resolve_status(event_type: str, payload: Dict[str, Any]) -> str:
|
||||
def _resolve_status(
|
||||
event_type: str, payload: Dict[str, Any]
|
||||
) -> TelephonyCallStatus | str:
|
||||
"""Map a Telnyx event type (and hangup cause) to a normalized status."""
|
||||
EVENT_STATUS = {
|
||||
"call.initiated": "initiated",
|
||||
"call.answered": "in-progress",
|
||||
"call.hangup": "completed",
|
||||
"call.initiated": TelephonyCallStatus.INITIATED,
|
||||
"call.answered": TelephonyCallStatus.IN_PROGRESS,
|
||||
"call.hangup": TelephonyCallStatus.COMPLETED,
|
||||
"call.machine.detection.ended": "machine-detected",
|
||||
"streaming.started": "streaming-started",
|
||||
"streaming.stopped": "streaming-stopped",
|
||||
}
|
||||
|
||||
HANGUP_STATUS = {
|
||||
"busy": "busy",
|
||||
"no_answer": "no-answer",
|
||||
"timeout": "no-answer",
|
||||
"call_rejected": "failed",
|
||||
"unallocated_number": "failed",
|
||||
"busy": TelephonyCallStatus.BUSY,
|
||||
"no_answer": TelephonyCallStatus.NO_ANSWER,
|
||||
"timeout": TelephonyCallStatus.NO_ANSWER,
|
||||
"call_rejected": TelephonyCallStatus.FAILED,
|
||||
"unallocated_number": TelephonyCallStatus.FAILED,
|
||||
}
|
||||
|
||||
status = EVENT_STATUS.get(event_type, event_type)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"account_sid": value.get("account_sid"),
|
||||
"auth_token": value.get("auth_token"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
"amd_enabled": value.get("amd_enabled", False),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -47,6 +48,15 @@ _UI_METADATA = ProviderUIMetadata(
|
|||
type="string-array",
|
||||
description="E.164-formatted Twilio phone numbers used for outbound calls",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="amd_enabled",
|
||||
label="Answering Machine Detection",
|
||||
type="boolean",
|
||||
description=(
|
||||
"Detect whether outbound calls are answered by a person or "
|
||||
"machine. Twilio may bill AMD as an additional per-call feature."
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,13 @@ class TwilioConfigurationRequest(BaseModel):
|
|||
from_numbers: List[str] = Field(
|
||||
default_factory=list, description="List of Twilio phone numbers"
|
||||
)
|
||||
amd_enabled: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Detect whether outbound calls are answered by a person or machine. "
|
||||
"Twilio may bill AMD as an additional per-call feature."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TwilioConfigurationResponse(BaseModel):
|
||||
|
|
@ -25,3 +32,4 @@ class TwilioConfigurationResponse(BaseModel):
|
|||
account_sid: str # Masked (e.g., "****************def0")
|
||||
auth_token: str # Masked (e.g., "****************abc1")
|
||||
from_numbers: List[str]
|
||||
amd_enabled: bool = False
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ from fastapi import HTTPException
|
|||
from loguru import logger
|
||||
from twilio.request_validator import RequestValidator
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunMode
|
||||
from api.services.telephony.base import (
|
||||
AnsweringMachineDetectionResult,
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
ProviderSyncResult,
|
||||
|
|
@ -47,6 +48,7 @@ class TwilioProvider(TelephonyProvider):
|
|||
self.account_sid = config.get("account_sid")
|
||||
self.auth_token = config.get("auth_token")
|
||||
self.from_numbers = config.get("from_numbers", [])
|
||||
self.amd_enabled: bool = bool(config.get("amd_enabled", False))
|
||||
|
||||
# Handle both single number (string) and multiple numbers (list)
|
||||
if isinstance(self.from_numbers, str):
|
||||
|
|
@ -96,6 +98,8 @@ class TwilioProvider(TelephonyProvider):
|
|||
}
|
||||
)
|
||||
|
||||
data = self.apply_answering_machine_detection_call_params(data)
|
||||
|
||||
data.update(kwargs)
|
||||
|
||||
# Make the API request
|
||||
|
|
@ -230,9 +234,10 @@ class TwilioProvider(TelephonyProvider):
|
|||
"""
|
||||
Parse Twilio status callback data into generic format.
|
||||
"""
|
||||
call_status = data.get("CallStatus", "")
|
||||
return {
|
||||
"call_id": data.get("CallSid", ""),
|
||||
"status": data.get("CallStatus", ""),
|
||||
"status": TelephonyCallStatus.from_raw(call_status) or call_status,
|
||||
"from_number": data.get("From"),
|
||||
"to_number": data.get("To"),
|
||||
"direction": data.get("Direction"),
|
||||
|
|
@ -240,6 +245,31 @@ class TwilioProvider(TelephonyProvider):
|
|||
"extra": data, # Include all original data
|
||||
}
|
||||
|
||||
def supports_answering_machine_detection(self) -> bool:
|
||||
"""Twilio supports AMD through the Voice Calls API."""
|
||||
return True
|
||||
|
||||
def apply_answering_machine_detection_call_params(
|
||||
self,
|
||||
data: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
if self.amd_enabled:
|
||||
data["MachineDetection"] = "Enable"
|
||||
return data
|
||||
|
||||
def parse_answering_machine_detection_result(
|
||||
self, data: Dict[str, Any]
|
||||
) -> Optional[AnsweringMachineDetectionResult]:
|
||||
answered_by = data.get("AnsweredBy")
|
||||
if not answered_by:
|
||||
return None
|
||||
|
||||
return AnsweringMachineDetectionResult(
|
||||
call_id=data.get("CallSid", ""),
|
||||
answered_by=answered_by,
|
||||
raw_data=data,
|
||||
)
|
||||
|
||||
async def handle_websocket(
|
||||
self,
|
||||
websocket: "WebSocket",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from pipecat.utils.run_context import set_current_run_id
|
|||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.base import TelephonyProvider
|
||||
from api.services.telephony.factory import get_telephony_provider_for_run
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
|
|
@ -21,6 +22,28 @@ from api.services.telephony.status_processor import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
async def _persist_amd_result_if_present(
|
||||
*,
|
||||
provider: TelephonyProvider,
|
||||
workflow_run_id: int,
|
||||
callback_data: dict,
|
||||
) -> None:
|
||||
amd_result = provider.parse_answering_machine_detection_result(callback_data)
|
||||
if not amd_result:
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] AMD result: AnsweredBy={amd_result.answered_by}"
|
||||
)
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
gathered_context={"answered_by": amd_result.answered_by},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(f"[run {workflow_run_id}] Failed to persist AMD result: {exc}")
|
||||
|
||||
|
||||
@router.post("/twiml", include_in_schema=False)
|
||||
async def handle_twiml_webhook(
|
||||
workflow_id: int,
|
||||
|
|
@ -49,6 +72,12 @@ async def handle_twiml_webhook(
|
|||
)
|
||||
raise HTTPException(status_code=401, detail="Invalid webhook signature")
|
||||
|
||||
await _persist_amd_result_if_present(
|
||||
provider=provider,
|
||||
workflow_run_id=workflow_run_id,
|
||||
callback_data=callback_data,
|
||||
)
|
||||
|
||||
response_content = await provider.get_webhook_response(
|
||||
workflow_id, user_id, workflow_run_id
|
||||
)
|
||||
|
|
@ -111,6 +140,12 @@ async def handle_twilio_status_callback(
|
|||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
await _persist_amd_result_if_present(
|
||||
provider=provider,
|
||||
workflow_run_id=workflow_run_id,
|
||||
callback_data=callback_data,
|
||||
)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import aiohttp
|
|||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunMode
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
|
|
@ -335,9 +335,10 @@ class VobizProvider(TelephonyProvider):
|
|||
- call_uuid (instead of CallSid)
|
||||
- status, from, to, duration, etc.
|
||||
"""
|
||||
call_status = data.get("CallStatus", "")
|
||||
return {
|
||||
"call_id": data.get("CallUUID", ""),
|
||||
"status": data.get("CallStatus", ""),
|
||||
"status": TelephonyCallStatus.from_raw(call_status) or call_status,
|
||||
"from_number": data.get("From"),
|
||||
"to_number": data.get("To"),
|
||||
"direction": data.get("Direction"),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import jwt
|
|||
from fastapi import HTTPException, Response
|
||||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunMode
|
||||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
|
|
@ -291,14 +291,14 @@ class VonageProvider(TelephonyProvider):
|
|||
"""
|
||||
# Map Vonage status to common format
|
||||
status_map = {
|
||||
"started": "initiated",
|
||||
"ringing": "ringing",
|
||||
"answered": "answered",
|
||||
"complete": "completed",
|
||||
"failed": "failed",
|
||||
"busy": "busy",
|
||||
"timeout": "no-answer",
|
||||
"rejected": "busy",
|
||||
"started": TelephonyCallStatus.INITIATED,
|
||||
"ringing": TelephonyCallStatus.RINGING,
|
||||
"answered": TelephonyCallStatus.ANSWERED,
|
||||
"complete": TelephonyCallStatus.COMPLETED,
|
||||
"failed": TelephonyCallStatus.FAILED,
|
||||
"busy": TelephonyCallStatus.BUSY,
|
||||
"timeout": TelephonyCallStatus.NO_ANSWER,
|
||||
"rejected": TelephonyCallStatus.BUSY,
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ class ProviderUIField:
|
|||
|
||||
name: str # Must match the Pydantic field name on config_request_cls
|
||||
label: str
|
||||
type: str # "text" | "password" | "textarea" | "string-array" | "number"
|
||||
# "text" | "password" | "textarea" | "string-array" | "number" | "boolean"
|
||||
type: str
|
||||
required: bool = True
|
||||
sensitive: bool = False # If true, mask when displaying stored value
|
||||
description: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -12,25 +12,96 @@ from loguru import logger
|
|||
from pydantic import BaseModel
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import WorkflowRunState
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunState
|
||||
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
|
||||
from api.services.campaign.campaign_event_publisher import (
|
||||
get_campaign_event_publisher,
|
||||
)
|
||||
from api.services.campaign.circuit_breaker import circuit_breaker
|
||||
from api.tasks.arq import enqueue_job
|
||||
from api.tasks.function_names import FunctionNames
|
||||
|
||||
TERMINAL_NOT_CONNECTED_STATUSES = frozenset(
|
||||
{
|
||||
TelephonyCallStatus.FAILED,
|
||||
TelephonyCallStatus.BUSY,
|
||||
TelephonyCallStatus.NO_ANSWER,
|
||||
TelephonyCallStatus.CANCELED,
|
||||
TelephonyCallStatus.ERROR,
|
||||
}
|
||||
)
|
||||
IN_FLIGHT_STATUSES = frozenset(
|
||||
{
|
||||
TelephonyCallStatus.INITIATED,
|
||||
TelephonyCallStatus.RINGING,
|
||||
TelephonyCallStatus.IN_PROGRESS,
|
||||
TelephonyCallStatus.ANSWERED,
|
||||
}
|
||||
)
|
||||
RETRYABLE_NOT_CONNECTED_STATUSES = frozenset(
|
||||
{TelephonyCallStatus.BUSY, TelephonyCallStatus.NO_ANSWER}
|
||||
)
|
||||
FAILURE_NOT_CONNECTED_STATUSES = frozenset(
|
||||
{TelephonyCallStatus.ERROR, TelephonyCallStatus.FAILED}
|
||||
)
|
||||
|
||||
|
||||
def _status_value(value: object) -> str:
|
||||
status = TelephonyCallStatus.from_raw(value)
|
||||
if status is not None:
|
||||
return status.value
|
||||
|
||||
return str(value or "").lower()
|
||||
|
||||
|
||||
def _duration_seconds(duration: str | None) -> int | float:
|
||||
if duration in (None, ""):
|
||||
return 0
|
||||
|
||||
try:
|
||||
parsed = float(duration)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
return int(parsed) if parsed.is_integer() else parsed
|
||||
|
||||
|
||||
def _append_unique_tags(existing_tags: object, new_tags: list[str]) -> list[str]:
|
||||
tags = existing_tags if isinstance(existing_tags, list) else []
|
||||
merged = list(tags)
|
||||
for tag in new_tags:
|
||||
if tag not in merged:
|
||||
merged.append(tag)
|
||||
return merged
|
||||
|
||||
|
||||
async def _enqueue_integrations_for_unconnected_run(
|
||||
workflow_run_id: int,
|
||||
status: str,
|
||||
) -> None:
|
||||
"""Fire post-call integrations (e.g. webhooks) when a call ends before the
|
||||
Pipecat pipeline ever starts.
|
||||
|
||||
Enqueues integrations only -- deliberately *not*
|
||||
``PROCESS_WORKFLOW_COMPLETION`` -- so an unconnected call still triggers the
|
||||
configured webhooks without incurring platform-usage billing.
|
||||
"""
|
||||
await enqueue_job(FunctionNames.RUN_INTEGRATIONS_POST_WORKFLOW_RUN, workflow_run_id)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Enqueued post-call integrations after terminal "
|
||||
f"telephony status: {status}"
|
||||
)
|
||||
|
||||
|
||||
class StatusCallbackRequest(BaseModel):
|
||||
"""Normalized status callback shape used across all telephony providers.
|
||||
|
||||
Per-provider converters live as classmethods (``from_twilio``, ``from_plivo``,
|
||||
``from_vonage``, ``from_cloudonix_cdr``) so the route handler for each
|
||||
provider can map raw webhook payloads into this shape and hand off to
|
||||
:func:`_process_status_update`.
|
||||
Provider-specific route handlers map raw webhook payloads into this shape,
|
||||
then hand it off to :func:`_process_status_update`.
|
||||
"""
|
||||
|
||||
call_id: str
|
||||
status: str
|
||||
status: TelephonyCallStatus | str
|
||||
from_number: Optional[str] = None
|
||||
to_number: Optional[str] = None
|
||||
direction: Optional[str] = None
|
||||
|
|
@ -38,102 +109,14 @@ class StatusCallbackRequest(BaseModel):
|
|||
|
||||
extra: dict = {}
|
||||
|
||||
@classmethod
|
||||
def from_twilio(cls, data: dict):
|
||||
"""Convert Twilio callback to generic format."""
|
||||
return cls(
|
||||
call_id=data.get("CallSid", ""),
|
||||
status=data.get("CallStatus", ""),
|
||||
from_number=data.get("From"),
|
||||
to_number=data.get("To"),
|
||||
direction=data.get("Direction"),
|
||||
duration=data.get("CallDuration") or data.get("Duration"),
|
||||
extra=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_plivo(cls, data: dict):
|
||||
"""Convert Plivo callback to generic format."""
|
||||
status_map = {
|
||||
"in-progress": "answered",
|
||||
"ringing": "ringing",
|
||||
"ring": "ringing",
|
||||
"completed": "completed",
|
||||
"hangup": "completed",
|
||||
"stopstream": "completed",
|
||||
"busy": "busy",
|
||||
"no-answer": "no-answer",
|
||||
"cancel": "canceled",
|
||||
"cancelled": "canceled",
|
||||
"timeout": "no-answer",
|
||||
}
|
||||
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()
|
||||
return cls(
|
||||
call_id=data.get("CallUUID", "") or data.get("RequestUUID", ""),
|
||||
status=status_map.get(call_status, call_status),
|
||||
from_number=data.get("From"),
|
||||
to_number=data.get("To"),
|
||||
direction=data.get("Direction"),
|
||||
duration=data.get("Duration"),
|
||||
extra=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_vonage(cls, data: dict):
|
||||
"""Convert Vonage event to generic format."""
|
||||
status_map = {
|
||||
"started": "initiated",
|
||||
"ringing": "ringing",
|
||||
"answered": "answered",
|
||||
"complete": "completed",
|
||||
"failed": "failed",
|
||||
"busy": "busy",
|
||||
"timeout": "no-answer",
|
||||
"rejected": "busy",
|
||||
}
|
||||
|
||||
return cls(
|
||||
call_id=data.get("uuid", ""),
|
||||
status=status_map.get(data.get("status", ""), data.get("status", "")),
|
||||
from_number=data.get("from"),
|
||||
to_number=data.get("to"),
|
||||
direction=data.get("direction"),
|
||||
duration=data.get("duration"),
|
||||
extra=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cloudonix_cdr(cls, data: dict):
|
||||
"""Convert Cloudonix CDR to generic format."""
|
||||
disposition_map = {
|
||||
"ANSWER": "completed",
|
||||
"BUSY": "busy",
|
||||
"CANCEL": "canceled",
|
||||
"FAILED": "failed",
|
||||
"CONGESTION": "failed",
|
||||
"NOANSWER": "no-answer",
|
||||
}
|
||||
|
||||
disposition = data.get("disposition") or ""
|
||||
status = disposition_map.get(disposition.upper(), disposition.lower())
|
||||
session = data.get("session")
|
||||
call_id = session.get("token") if isinstance(session, dict) else ""
|
||||
|
||||
return cls(
|
||||
call_id=call_id or "",
|
||||
status=status,
|
||||
from_number=data.get("from"),
|
||||
to_number=data.get("to"),
|
||||
duration=str(data.get("billsec") or data.get("duration") or 0),
|
||||
extra=data,
|
||||
)
|
||||
|
||||
|
||||
async def _process_status_update(workflow_run_id: int, status: StatusCallbackRequest):
|
||||
"""Process status updates from telephony providers.
|
||||
|
||||
Idempotent: handles repeated callbacks (e.g. from both webhook and CDR).
|
||||
"""
|
||||
normalized_status = TelephonyCallStatus.from_raw(status.status)
|
||||
status_value = _status_value(status.status)
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
|
|
@ -143,7 +126,7 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
|
|||
|
||||
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
|
||||
telephony_callback_log = {
|
||||
"status": status.status,
|
||||
"status": status_value,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"call_id": status.call_id,
|
||||
"duration": status.duration,
|
||||
|
|
@ -156,7 +139,7 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
|
|||
logs={"telephony_status_callbacks": telephony_callback_logs},
|
||||
)
|
||||
|
||||
if status.status == "completed":
|
||||
if normalized_status == TelephonyCallStatus.COMPLETED:
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
|
||||
)
|
||||
|
|
@ -174,26 +157,29 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
|
|||
state=WorkflowRunState.COMPLETED.value,
|
||||
)
|
||||
|
||||
elif status.status in ["failed", "busy", "no-answer", "canceled", "error"]:
|
||||
elif normalized_status in TERMINAL_NOT_CONNECTED_STATUSES:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Call failed with status: {status.status}"
|
||||
f"[run {workflow_run_id}] Call failed with status: {normalized_status.value}"
|
||||
)
|
||||
|
||||
if workflow_run.campaign_id:
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
is_failure = status.status in ("error", "failed")
|
||||
is_failure = normalized_status in FAILURE_NOT_CONNECTED_STATUSES
|
||||
await circuit_breaker.record_and_evaluate(
|
||||
workflow_run.campaign_id,
|
||||
is_failure=is_failure,
|
||||
workflow_run_id=workflow_run_id if is_failure else None,
|
||||
reason=status.status if is_failure else None,
|
||||
reason=normalized_status.value if is_failure else None,
|
||||
)
|
||||
|
||||
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
|
||||
if (
|
||||
normalized_status in RETRYABLE_NOT_CONNECTED_STATUSES
|
||||
and workflow_run.campaign_id
|
||||
):
|
||||
publisher = await get_campaign_event_publisher()
|
||||
await publisher.publish_retry_needed(
|
||||
workflow_run_id=workflow_run_id,
|
||||
reason=status.status.replace("-", "_"),
|
||||
reason=normalized_status.value.replace("-", "_"),
|
||||
campaign_id=workflow_run.campaign_id,
|
||||
queued_run_id=workflow_run.queued_run_id,
|
||||
)
|
||||
|
|
@ -203,15 +189,42 @@ async def _process_status_update(workflow_run_id: int, status: StatusCallbackReq
|
|||
if workflow_run.gathered_context
|
||||
else []
|
||||
)
|
||||
call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"])
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
is_completed=True,
|
||||
state=WorkflowRunState.COMPLETED.value,
|
||||
gathered_context={"call_tags": call_tags},
|
||||
call_tags = _append_unique_tags(
|
||||
call_tags,
|
||||
["not_connected", f"telephony_{normalized_status.value}"],
|
||||
)
|
||||
elif status.status in ["in-progress", "initiated", "ringing"]:
|
||||
|
||||
gathered_context = {
|
||||
"call_tags": call_tags,
|
||||
"call_disposition": normalized_status.value,
|
||||
"mapped_call_disposition": normalized_status.value,
|
||||
}
|
||||
if status.call_id:
|
||||
gathered_context["call_id"] = status.call_id
|
||||
|
||||
should_run_post_call_integrations = (
|
||||
workflow_run.state == WorkflowRunState.INITIALIZED.value
|
||||
and not workflow_run.is_completed
|
||||
)
|
||||
|
||||
update_kwargs = {
|
||||
"run_id": workflow_run_id,
|
||||
"is_completed": True,
|
||||
"state": WorkflowRunState.COMPLETED.value,
|
||||
"gathered_context": gathered_context,
|
||||
}
|
||||
if should_run_post_call_integrations:
|
||||
update_kwargs["usage_info"] = {
|
||||
"call_duration_seconds": _duration_seconds(status.duration)
|
||||
}
|
||||
|
||||
await db_client.update_workflow_run(**update_kwargs)
|
||||
|
||||
if should_run_post_call_integrations:
|
||||
await _enqueue_integrations_for_unconnected_run(
|
||||
workflow_run_id, normalized_status.value
|
||||
)
|
||||
elif normalized_status in IN_FLIGHT_STATUSES:
|
||||
# No-op while the call is in flight.
|
||||
pass
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
"""Utility module for applying disposition code mapping."""
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
|
||||
|
||||
async def apply_disposition_mapping(value: str, organization_id: int | None) -> str:
|
||||
"""Apply disposition code mapping if configured.
|
||||
|
||||
Args:
|
||||
value: The original disposition value to map
|
||||
organization_id: The organization ID
|
||||
|
||||
Returns:
|
||||
The mapped value if found in configuration, otherwise the original value
|
||||
"""
|
||||
if not organization_id or not value:
|
||||
return value
|
||||
|
||||
try:
|
||||
disposition_mapping = await db_client.get_configuration_value(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.DISPOSITION_CODE_MAPPING.value,
|
||||
default={},
|
||||
)
|
||||
|
||||
if not disposition_mapping:
|
||||
return value
|
||||
|
||||
# Return mapped value if exists, otherwise original
|
||||
# DISPOSITION_CODE_MAPPING looks like {"user_idle_max_duration_exceeded": "DAIR"} etc.
|
||||
mapped_value = disposition_mapping.get(value, value)
|
||||
|
||||
if mapped_value != value:
|
||||
logger.debug(
|
||||
f"Mapped disposition code from '{value}' to '{mapped_value}' "
|
||||
f"for organization {organization_id}"
|
||||
)
|
||||
|
||||
return mapped_value
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying disposition mapping: {e}")
|
||||
return value
|
||||
|
|
@ -19,7 +19,6 @@ from pipecat.utils.enums import EndTaskReason
|
|||
from api.db import db_client
|
||||
from api.enums import ToolCategory
|
||||
from api.services.pipecat.audio_playback import play_audio
|
||||
from api.services.workflow.disposition_mapper import apply_disposition_mapping
|
||||
from api.services.workflow.workflow_graph import Node, WorkflowGraph
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -751,38 +750,21 @@ class PipecatEngine:
|
|||
CancelFrame(reason=reason) if abort_immediately else EndFrame(reason=reason)
|
||||
)
|
||||
|
||||
# Apply disposition mapping - first try call_disposition if it is,
|
||||
# extracted from the call conversation then fall back to reason
|
||||
call_disposition = self._gathered_context.get("call_disposition", "")
|
||||
organization_id = await self._get_organization_id()
|
||||
# Record the call disposition: prefer one extracted from the conversation,
|
||||
# otherwise fall back to the disconnect reason.
|
||||
call_disposition = self._gathered_context.get("call_disposition", "") or reason
|
||||
self._gathered_context["call_disposition"] = call_disposition
|
||||
self._gathered_context["mapped_call_disposition"] = call_disposition
|
||||
|
||||
if call_disposition:
|
||||
# If call_disposition exists, map it
|
||||
mapped_disposition = await apply_disposition_mapping(
|
||||
call_disposition, organization_id
|
||||
)
|
||||
# Store the original and mapped values
|
||||
self._gathered_context["extracted_call_disposition"] = call_disposition
|
||||
self._gathered_context["call_disposition"] = call_disposition
|
||||
self._gathered_context["mapped_call_disposition"] = mapped_disposition
|
||||
else:
|
||||
# Otherwise, map the disconnect reason
|
||||
mapped_disposition = await apply_disposition_mapping(
|
||||
reason, organization_id
|
||||
)
|
||||
# Store the mapped disconnect reason
|
||||
self._gathered_context["call_disposition"] = reason
|
||||
self._gathered_context["mapped_call_disposition"] = mapped_disposition
|
||||
|
||||
effective_disposition = self._gathered_context.get("call_disposition", "")
|
||||
if effective_disposition:
|
||||
call_tags = self._gathered_context.get("call_tags", [])
|
||||
if effective_disposition not in call_tags:
|
||||
call_tags.append(effective_disposition)
|
||||
if call_disposition not in call_tags:
|
||||
call_tags.append(call_disposition)
|
||||
self._gathered_context["call_tags"] = call_tags
|
||||
|
||||
logger.debug(
|
||||
f"Finishing run with reason: {reason}, disposition: {mapped_disposition} queueing frame {frame_to_push}"
|
||||
f"Finishing run with reason: {reason}, disposition: {call_disposition} "
|
||||
f"queueing frame {frame_to_push}"
|
||||
)
|
||||
await self.task.queue_frame(frame_to_push)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from datetime import UTC, datetime
|
|||
from typing import Any
|
||||
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from loguru import logger
|
||||
from pipecat.bus.serializers.json import JSONMessageSerializer
|
||||
from pipecat.frames.frames import (
|
||||
BotStoppedSpeakingFrame,
|
||||
CancelFrame,
|
||||
|
|
@ -56,6 +56,46 @@ TEXT_CHAT_IDLE_SETTLE_SECONDS = 0.2
|
|||
TEXT_CHAT_INTERNAL_CANCEL_REASON = "text_chat_turn_complete"
|
||||
|
||||
|
||||
def _pipecat_type_tag(type_: type) -> str:
|
||||
return f"{type_.__module__}.{type_.__name__}"
|
||||
|
||||
|
||||
def _pipecat_json_serializer() -> JSONMessageSerializer:
|
||||
return JSONMessageSerializer()
|
||||
|
||||
|
||||
def _serialize_text_chat_checkpoint_messages(messages: list[Any]) -> list[Any]:
|
||||
"""Serialize Pipecat context messages for JSONB checkpoint storage."""
|
||||
# Pipecat's bus JSON serializer already knows how to preserve LLMContext,
|
||||
# LLMSpecificMessage, and binary provider fields such as Gemini signatures.
|
||||
# Keep the serializer shape dependency contained to these checkpoint helpers.
|
||||
encoded_context = _pipecat_json_serializer()._serialize_value(
|
||||
LLMContext(messages=list(messages))
|
||||
)
|
||||
encoded_data = (
|
||||
encoded_context.get("__data__") if isinstance(encoded_context, dict) else None
|
||||
)
|
||||
encoded_messages = (
|
||||
encoded_data.get("messages") if isinstance(encoded_data, dict) else None
|
||||
)
|
||||
if not isinstance(encoded_messages, list):
|
||||
raise TypeError("Pipecat LLMContext serializer returned an unexpected shape")
|
||||
return encoded_messages
|
||||
|
||||
|
||||
def _deserialize_text_chat_checkpoint_messages(messages: list[Any]) -> list[Any]:
|
||||
"""Restore JSONB checkpoint messages to Pipecat context message objects."""
|
||||
restored_context = _pipecat_json_serializer()._deserialize_value(
|
||||
{
|
||||
"__type__": _pipecat_type_tag(LLMContext),
|
||||
"__data__": {"messages": list(messages)},
|
||||
}
|
||||
)
|
||||
if not isinstance(restored_context, LLMContext):
|
||||
raise TypeError("Pipecat LLMContext deserializer returned an unexpected type")
|
||||
return restored_context.get_messages()
|
||||
|
||||
|
||||
def text_chat_trace_id(workflow_run_id: int) -> str:
|
||||
"""Deterministic Langfuse trace id for a text-chat session.
|
||||
|
||||
|
|
@ -391,7 +431,6 @@ async def execute_text_chat_pending_turn(
|
|||
if pending_turn.get("user_message") is not None
|
||||
else None
|
||||
)
|
||||
|
||||
workflow_run, _ = await db_client.get_workflow_run_with_context(workflow_run_id)
|
||||
if not workflow_run or workflow_run.workflow_id != workflow_id:
|
||||
raise ValueError("Workflow run not found for text chat execution")
|
||||
|
|
@ -405,7 +444,6 @@ async def execute_text_chat_pending_turn(
|
|||
)
|
||||
if workflow is None:
|
||||
raise ValueError("Workflow not found for text chat execution")
|
||||
|
||||
# Stamp the async context so OTEL spans are tagged with this org and routed
|
||||
# to its Langfuse project (the voice paths do this in run_pipeline /
|
||||
# webrtc_signaling; the text path previously skipped it, so its spans never
|
||||
|
|
@ -426,7 +464,6 @@ async def execute_text_chat_pending_turn(
|
|||
)
|
||||
if user_config.llm is None:
|
||||
raise ValueError("Text chat requires an LLM configuration")
|
||||
|
||||
from api.services.managed_model_services import (
|
||||
MPS_CORRELATION_ID_CONTEXT_KEY,
|
||||
ensure_mps_correlation_id,
|
||||
|
|
@ -462,9 +499,10 @@ async def execute_text_chat_pending_turn(
|
|||
skip_instance_constraints_for={"trigger"},
|
||||
)
|
||||
base_checkpoint = _resolve_checkpoint_for_pending_turn(session_data, checkpoint)
|
||||
|
||||
context = LLMContext()
|
||||
context.set_messages(base_checkpoint["messages"])
|
||||
context.set_messages(
|
||||
_deserialize_text_chat_checkpoint_messages(base_checkpoint["messages"])
|
||||
)
|
||||
response_window = _ResponseWindowState()
|
||||
capture_processor = _TextChatCaptureProcessor(response_window, context)
|
||||
|
||||
|
|
@ -511,7 +549,6 @@ async def execute_text_chat_pending_turn(
|
|||
context_compaction_enabled = (workflow.workflow_configurations or {}).get(
|
||||
"context_compaction_enabled", False
|
||||
)
|
||||
|
||||
engine = PipecatEngine(
|
||||
llm=llm,
|
||||
inference_llm=inference_llm,
|
||||
|
|
@ -557,7 +594,6 @@ async def execute_text_chat_pending_turn(
|
|||
trace_span_attributes = {
|
||||
"langfuse.trace.name": workflow_run.name or f"text-chat-{workflow_run_id}"
|
||||
}
|
||||
|
||||
pipeline = Pipeline(
|
||||
[
|
||||
llm,
|
||||
|
|
@ -634,20 +670,13 @@ async def execute_text_chat_pending_turn(
|
|||
activity_marker=generation_marker,
|
||||
)
|
||||
finally:
|
||||
if not task.has_finished():
|
||||
await task.cancel(reason=TEXT_CHAT_INTERNAL_CANCEL_REASON)
|
||||
try:
|
||||
if not task.has_finished():
|
||||
await task.cancel(reason=TEXT_CHAT_INTERNAL_CANCEL_REASON)
|
||||
await runner_task
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Transportless text chat pipeline failed while closing run {}",
|
||||
workflow_run_id,
|
||||
)
|
||||
finally:
|
||||
await engine.close_mcp_sessions()
|
||||
await engine.cleanup()
|
||||
raise
|
||||
await engine.close_mcp_sessions()
|
||||
await engine.cleanup()
|
||||
|
||||
gathered_context = await engine.get_gathered_context()
|
||||
assistant_text = (
|
||||
|
|
@ -658,29 +687,36 @@ async def execute_text_chat_pending_turn(
|
|||
assistant_created_at = datetime.now(UTC).isoformat()
|
||||
usage = pipeline_metrics_aggregator.get_all_usage_metrics_serialized()
|
||||
current_node = getattr(engine, "_current_node", None)
|
||||
context_messages = context.get_messages()
|
||||
encoded_messages = _serialize_text_chat_checkpoint_messages(context_messages)
|
||||
encoded_gathered_context = jsonable_encoder(gathered_context)
|
||||
encoded_tool_state = jsonable_encoder(base_checkpoint.get("tool_state") or {})
|
||||
|
||||
updated_checkpoint = {
|
||||
"version": TEXT_CHAT_CHECKPOINT_VERSION,
|
||||
"anchor_turn_id": pending_turn.get("id"),
|
||||
"current_node_id": current_node.id if current_node else None,
|
||||
"messages": jsonable_encoder(context.get_messages()),
|
||||
"gathered_context": jsonable_encoder(gathered_context),
|
||||
"tool_state": jsonable_encoder(base_checkpoint.get("tool_state") or {}),
|
||||
"messages": encoded_messages,
|
||||
"gathered_context": encoded_gathered_context,
|
||||
"tool_state": encoded_tool_state,
|
||||
}
|
||||
|
||||
encoded_gathered_context = jsonable_encoder(gathered_context)
|
||||
trace_url = get_trace_url(trace_id, org_id=workflow.organization_id)
|
||||
if trace_url:
|
||||
encoded_gathered_context = {**encoded_gathered_context, "trace_url": trace_url}
|
||||
|
||||
encoded_events = jsonable_encoder(capture_processor.events)
|
||||
encoded_usage = jsonable_encoder(usage)
|
||||
encoded_initial_context = jsonable_encoder(initial_context)
|
||||
|
||||
return TextChatTurnExecutionResult(
|
||||
assistant_text=assistant_text,
|
||||
assistant_created_at=assistant_created_at,
|
||||
events=jsonable_encoder(capture_processor.events),
|
||||
usage=jsonable_encoder(usage),
|
||||
events=encoded_events,
|
||||
usage=encoded_usage,
|
||||
checkpoint=updated_checkpoint,
|
||||
gathered_context=encoded_gathered_context,
|
||||
initial_context=jsonable_encoder(initial_context),
|
||||
initial_context=encoded_initial_context,
|
||||
state=(
|
||||
WorkflowRunState.COMPLETED.value
|
||||
if engine.is_call_disposed()
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@ from loguru import logger
|
|||
from opentelemetry import trace
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.gen_ai import AzureOpenAIEmbeddingService, OpenAIEmbeddingService
|
||||
from api.services.gen_ai import build_embedding_service
|
||||
from api.services.pipecat.tracing_config import ensure_tracing
|
||||
|
||||
|
||||
|
|
@ -266,33 +265,19 @@ async def _perform_retrieval(
|
|||
"Model Configurations > Embedding."
|
||||
)
|
||||
|
||||
if (
|
||||
embeddings_provider == ServiceProviders.AZURE.value
|
||||
and embeddings_endpoint
|
||||
):
|
||||
embedding_service = AzureOpenAIEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=embeddings_api_key,
|
||||
endpoint=embeddings_endpoint,
|
||||
model_id=embeddings_model or "text-embedding-3-small",
|
||||
api_version=embeddings_api_version or "2024-02-15-preview",
|
||||
)
|
||||
else:
|
||||
default_headers = None
|
||||
if (
|
||||
embeddings_provider == ServiceProviders.DOGRAH.value
|
||||
and correlation_id
|
||||
):
|
||||
default_headers = {
|
||||
"X-Dograh-Correlation-Id": correlation_id,
|
||||
}
|
||||
embedding_service = OpenAIEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=embeddings_api_key,
|
||||
model_id=embeddings_model or "text-embedding-3-small",
|
||||
base_url=embeddings_base_url,
|
||||
default_headers=default_headers,
|
||||
)
|
||||
# Search runs inside a workflow run: reuse the run's MPS correlation
|
||||
# id (present only for v2 orgs; None otherwise → sent without the
|
||||
# protocol). The Dograh-managed path forwards it via request metadata.
|
||||
embedding_service = await build_embedding_service(
|
||||
db_client=db_client,
|
||||
provider=embeddings_provider,
|
||||
api_key=embeddings_api_key,
|
||||
model=embeddings_model,
|
||||
base_url=embeddings_base_url,
|
||||
endpoint=embeddings_endpoint,
|
||||
api_version=embeddings_api_version,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
results = await embedding_service.search_similar_chunks(
|
||||
query=query,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,28 @@ from loguru import logger
|
|||
|
||||
from api.db import db_client
|
||||
from api.db.models import KnowledgeBaseChunkModel
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.gen_ai import AzureOpenAIEmbeddingService, OpenAIEmbeddingService
|
||||
from api.services.gen_ai import build_embedding_service
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
from api.services.storage import storage_fs
|
||||
|
||||
MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
|
||||
EMBEDDING_BATCH_SIZE = 64
|
||||
|
||||
|
||||
async def _embed_texts_in_batches(
|
||||
embedding_service,
|
||||
texts: list[str],
|
||||
batch_size: int = EMBEDDING_BATCH_SIZE,
|
||||
) -> list[list[float]]:
|
||||
"""Generate embeddings in bounded batches for provider/MPS stability."""
|
||||
embeddings: list[list[float]] = []
|
||||
for start in range(0, len(texts), batch_size):
|
||||
batch = texts[start : start + batch_size]
|
||||
logger.info(
|
||||
f"Generating embedding batch {start // batch_size + 1} ({len(batch)} texts)"
|
||||
)
|
||||
embeddings.extend(await embedding_service.embed_texts(batch))
|
||||
return embeddings
|
||||
|
||||
|
||||
async def process_knowledge_base_document(
|
||||
|
|
@ -121,42 +137,13 @@ async def process_knowledge_base_document(
|
|||
mime_type=mime_type,
|
||||
)
|
||||
|
||||
logger.info(f"Delegating document processing to MPS (mode={retrieval_mode})")
|
||||
mps_response = await mps_service_key_client.process_document(
|
||||
file_path=temp_file_path,
|
||||
filename=filename,
|
||||
content_type=mime_type or "application/octet-stream",
|
||||
retrieval_mode=retrieval_mode,
|
||||
max_tokens=max_tokens,
|
||||
organization_id=organization_id,
|
||||
created_by=created_by_provider_id,
|
||||
)
|
||||
|
||||
docling_metadata = mps_response.get("docling_metadata", {})
|
||||
|
||||
if retrieval_mode == "full_document":
|
||||
full_text = mps_response.get("full_text") or ""
|
||||
await db_client.update_document_full_text(document_id, full_text)
|
||||
await db_client.update_document_status(
|
||||
document_id,
|
||||
"completed",
|
||||
total_chunks=0,
|
||||
docling_metadata=docling_metadata,
|
||||
)
|
||||
logger.info(
|
||||
f"Successfully processed full_document {document_id}. "
|
||||
f"Text length: {len(full_text)} chars"
|
||||
)
|
||||
return
|
||||
|
||||
# Chunked mode: fetch user embedding config, embed, and persist chunks.
|
||||
embeddings_provider = None
|
||||
embeddings_api_key = None
|
||||
embeddings_model = None
|
||||
embeddings_base_url = None
|
||||
embeddings_endpoint = None
|
||||
embeddings_api_version = None
|
||||
if document.created_by:
|
||||
if retrieval_mode == "chunked" and document.created_by:
|
||||
from api.services.configuration.ai_model_configuration import (
|
||||
apply_managed_embeddings_base_url,
|
||||
get_resolved_ai_model_configuration,
|
||||
|
|
@ -188,6 +175,34 @@ async def process_knowledge_base_document(
|
|||
f"model={embeddings_model}"
|
||||
)
|
||||
|
||||
logger.info(f"Delegating document processing to MPS (mode={retrieval_mode})")
|
||||
mps_response = await mps_service_key_client.process_document(
|
||||
file_path=temp_file_path,
|
||||
filename=filename,
|
||||
content_type=mime_type or "application/octet-stream",
|
||||
retrieval_mode=retrieval_mode,
|
||||
max_tokens=max_tokens,
|
||||
organization_id=organization_id,
|
||||
created_by=created_by_provider_id,
|
||||
)
|
||||
|
||||
docling_metadata = mps_response.get("docling_metadata", {})
|
||||
|
||||
if retrieval_mode == "full_document":
|
||||
full_text = mps_response.get("full_text") or ""
|
||||
await db_client.update_document_full_text(document_id, full_text)
|
||||
await db_client.update_document_status(
|
||||
document_id,
|
||||
"completed",
|
||||
total_chunks=0,
|
||||
docling_metadata=docling_metadata,
|
||||
)
|
||||
logger.info(
|
||||
f"Successfully processed full_document {document_id}. "
|
||||
f"Text length: {len(full_text)} chars"
|
||||
)
|
||||
return
|
||||
|
||||
if not embeddings_api_key:
|
||||
error_message = (
|
||||
"API key not configured. Please set your API key in "
|
||||
|
|
@ -199,21 +214,20 @@ async def process_knowledge_base_document(
|
|||
)
|
||||
return
|
||||
|
||||
if embeddings_provider == ServiceProviders.AZURE.value and embeddings_endpoint:
|
||||
embedding_service = AzureOpenAIEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=embeddings_api_key,
|
||||
endpoint=embeddings_endpoint,
|
||||
model_id=embeddings_model or "text-embedding-3-small",
|
||||
api_version=embeddings_api_version or "2024-02-15-preview",
|
||||
)
|
||||
else:
|
||||
embedding_service = OpenAIEmbeddingService(
|
||||
db_client=db_client,
|
||||
api_key=embeddings_api_key,
|
||||
model_id=embeddings_model or "text-embedding-3-small",
|
||||
base_url=embeddings_base_url,
|
||||
)
|
||||
# Ingestion runs outside any workflow run, so resolve the MPS correlation
|
||||
# id here (mint only for orgs already on v2; never create an account).
|
||||
embedding_service = await build_embedding_service(
|
||||
db_client=db_client,
|
||||
provider=embeddings_provider,
|
||||
api_key=embeddings_api_key,
|
||||
model=embeddings_model,
|
||||
base_url=embeddings_base_url,
|
||||
endpoint=embeddings_endpoint,
|
||||
api_version=embeddings_api_version,
|
||||
organization_id=organization_id,
|
||||
created_by=created_by_provider_id,
|
||||
resolve_correlation=True,
|
||||
)
|
||||
|
||||
mps_chunks = mps_response.get("chunks", [])
|
||||
if not mps_chunks:
|
||||
|
|
@ -242,12 +256,21 @@ async def process_knowledge_base_document(
|
|||
f"Generating embeddings for {len(chunk_texts)} chunks "
|
||||
f"using {embedding_service.get_model_id()}"
|
||||
)
|
||||
embeddings = await embedding_service.embed_texts(chunk_texts)
|
||||
embeddings = await _embed_texts_in_batches(embedding_service, chunk_texts)
|
||||
if len(embeddings) != len(chunk_records):
|
||||
raise ValueError(
|
||||
"Embedding count mismatch: "
|
||||
f"expected {len(chunk_records)}, got {len(embeddings)}"
|
||||
)
|
||||
for chunk_record, embedding in zip(chunk_records, embeddings):
|
||||
chunk_record.embedding = embedding
|
||||
|
||||
logger.info("Storing chunks in database")
|
||||
await db_client.create_chunks_batch(chunk_records)
|
||||
await db_client.replace_chunks_for_document(
|
||||
document_id=document_id,
|
||||
organization_id=organization_id,
|
||||
chunks=chunk_records,
|
||||
)
|
||||
|
||||
await db_client.update_document_status(
|
||||
document_id,
|
||||
|
|
@ -262,9 +285,8 @@ async def process_knowledge_base_document(
|
|||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing knowledge base document {document_id}: {e}",
|
||||
exc_info=True,
|
||||
logger.exception(
|
||||
"Error processing knowledge base document {}: {}", document_id, e
|
||||
)
|
||||
await db_client.update_document_status(
|
||||
document_id, "failed", error_message=str(e)
|
||||
|
|
|
|||
|
|
@ -436,6 +436,15 @@ async def _execute_webhook_node(
|
|||
|
||||
payload = render_template(webhook_data.payload_template or {}, render_context)
|
||||
|
||||
# Always surface the call disposition on the outgoing payload, even when the
|
||||
# template author didn't reference it. Fill only if absent so a template that
|
||||
# sets it explicitly keeps its own value.
|
||||
if isinstance(payload, dict):
|
||||
gathered_context = render_context.get("gathered_context") or {}
|
||||
payload.setdefault(
|
||||
"call_disposition", gathered_context.get("call_disposition", "")
|
||||
)
|
||||
|
||||
method = (webhook_data.http_method or "POST").upper()
|
||||
|
||||
logger.info(f"Executing webhook '{webhook_name}': {method}")
|
||||
|
|
|
|||
|
|
@ -160,14 +160,6 @@ def patch_run_pipeline_externals(
|
|||
NoopFeedbackObserver,
|
||||
)
|
||||
)
|
||||
# Disposition mapper would otherwise call out to the LLM.
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
)
|
||||
)
|
||||
# Capture the PipelineWorker so the test can drive it from outside.
|
||||
stack.enter_context(
|
||||
patch(
|
||||
|
|
|
|||
|
|
@ -11,8 +11,9 @@ from unittest.mock import AsyncMock, patch
|
|||
import pytest
|
||||
from starlette.requests import Request
|
||||
|
||||
from api.enums import TelephonyCallStatus
|
||||
from api.services.telephony.providers.cloudonix.provider import CloudonixProvider
|
||||
from api.services.telephony.providers.cloudonix.routes import handle_cloudonix_cdr
|
||||
from api.services.telephony.status_processor import StatusCallbackRequest
|
||||
|
||||
|
||||
def _json_request(body: bytes) -> Request:
|
||||
|
|
@ -79,33 +80,33 @@ async def test_cdr_route_handles_string_session():
|
|||
assert result == {"status": "error", "message": "Missing call_id field"}
|
||||
|
||||
|
||||
def test_from_cloudonix_cdr_tolerates_missing_session_and_disposition():
|
||||
"""``from_cloudonix_cdr`` must not crash on a partial CDR payload."""
|
||||
def test_parse_cloudonix_cdr_tolerates_missing_session_and_disposition():
|
||||
"""Cloudonix CDR parsing must not crash on a partial payload."""
|
||||
# Missing both session and disposition.
|
||||
req = StatusCallbackRequest.from_cloudonix_cdr({"domain": "acme.cloudonix.io"})
|
||||
assert req.call_id == ""
|
||||
assert req.status == ""
|
||||
req = CloudonixProvider.parse_cdr_status_callback({"domain": "acme.cloudonix.io"})
|
||||
assert req["call_id"] == ""
|
||||
assert req["status"] == ""
|
||||
|
||||
# Explicit null values.
|
||||
req = StatusCallbackRequest.from_cloudonix_cdr(
|
||||
req = CloudonixProvider.parse_cdr_status_callback(
|
||||
{"session": None, "disposition": None}
|
||||
)
|
||||
assert req.call_id == ""
|
||||
assert req.status == ""
|
||||
assert req["call_id"] == ""
|
||||
assert req["status"] == ""
|
||||
|
||||
|
||||
def test_from_cloudonix_cdr_tolerates_string_session():
|
||||
"""``from_cloudonix_cdr`` treats a non-object session as missing call_id."""
|
||||
req = StatusCallbackRequest.from_cloudonix_cdr(
|
||||
def test_parse_cloudonix_cdr_tolerates_string_session():
|
||||
"""Cloudonix CDR parsing treats a non-object session as missing call_id."""
|
||||
req = CloudonixProvider.parse_cdr_status_callback(
|
||||
{"session": "abc", "disposition": "ANSWER"}
|
||||
)
|
||||
assert req.call_id == ""
|
||||
assert req.status == "completed"
|
||||
assert req["call_id"] == ""
|
||||
assert req["status"] == TelephonyCallStatus.COMPLETED
|
||||
|
||||
|
||||
def test_from_cloudonix_cdr_maps_disposition_and_session_token():
|
||||
def test_parse_cloudonix_cdr_maps_disposition_and_session_token():
|
||||
"""Normal, well-formed CDR payloads still map correctly."""
|
||||
req = StatusCallbackRequest.from_cloudonix_cdr(
|
||||
req = CloudonixProvider.parse_cdr_status_callback(
|
||||
{
|
||||
"session": {"token": "abc123"},
|
||||
"disposition": "BUSY",
|
||||
|
|
@ -114,6 +115,20 @@ def test_from_cloudonix_cdr_maps_disposition_and_session_token():
|
|||
"billsec": 12,
|
||||
}
|
||||
)
|
||||
assert req.call_id == "abc123"
|
||||
assert req.status == "busy"
|
||||
assert req.duration == "12"
|
||||
assert req["call_id"] == "abc123"
|
||||
assert req["status"] == TelephonyCallStatus.BUSY
|
||||
assert req["duration"] == "12"
|
||||
|
||||
|
||||
def test_parse_cloudonix_cdr_preserves_zero_billsec():
|
||||
"""A zero billed duration must not fall back to total call duration."""
|
||||
req = CloudonixProvider.parse_cdr_status_callback(
|
||||
{
|
||||
"session": {"token": "abc123"},
|
||||
"disposition": "ANSWER",
|
||||
"billsec": 0,
|
||||
"duration": 42,
|
||||
}
|
||||
)
|
||||
|
||||
assert req["duration"] == "0"
|
||||
|
|
|
|||
98
api/tests/telephony/test_status_processor.py
Normal file
98
api/tests/telephony/test_status_processor.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.enums import TelephonyCallStatus, WorkflowRunState
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from api.tasks.function_names import FunctionNames
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialized_no_answer_enqueues_workflow_completion():
|
||||
workflow_run = SimpleNamespace(
|
||||
id=123,
|
||||
campaign_id=None,
|
||||
queued_run_id=None,
|
||||
state=WorkflowRunState.INITIALIZED.value,
|
||||
is_completed=False,
|
||||
logs={"telephony_status_callbacks": []},
|
||||
gathered_context={"call_tags": ["existing"]},
|
||||
)
|
||||
status = StatusCallbackRequest(
|
||||
call_id="call-123",
|
||||
status="No-Answer",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.status_processor.db_client") as mock_db,
|
||||
patch(
|
||||
"api.services.telephony.status_processor.enqueue_job",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_enqueue,
|
||||
):
|
||||
mock_db.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
|
||||
mock_db.update_workflow_run = AsyncMock()
|
||||
|
||||
await _process_status_update(123, status)
|
||||
|
||||
log_update = mock_db.update_workflow_run.await_args_list[0].kwargs
|
||||
callback_log = log_update["logs"]["telephony_status_callbacks"][0]
|
||||
assert callback_log["status"] == "no-answer"
|
||||
assert callback_log["call_id"] == "call-123"
|
||||
|
||||
completion_update = mock_db.update_workflow_run.await_args_list[1].kwargs
|
||||
assert completion_update["run_id"] == 123
|
||||
assert completion_update["is_completed"] is True
|
||||
assert completion_update["state"] == WorkflowRunState.COMPLETED.value
|
||||
assert completion_update["usage_info"] == {"call_duration_seconds": 0}
|
||||
assert completion_update["gathered_context"] == {
|
||||
"call_tags": ["existing", "not_connected", "telephony_no-answer"],
|
||||
"call_disposition": "no-answer",
|
||||
"mapped_call_disposition": "no-answer",
|
||||
"call_id": "call-123",
|
||||
}
|
||||
mock_enqueue.assert_awaited_once_with(
|
||||
FunctionNames.RUN_INTEGRATIONS_POST_WORKFLOW_RUN, 123
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_terminal_status_does_not_enqueue_workflow_completion():
|
||||
workflow_run = SimpleNamespace(
|
||||
id=456,
|
||||
campaign_id=None,
|
||||
queued_run_id=None,
|
||||
state=WorkflowRunState.RUNNING.value,
|
||||
is_completed=False,
|
||||
logs={"telephony_status_callbacks": []},
|
||||
gathered_context={"call_tags": ["not_connected"]},
|
||||
)
|
||||
status = StatusCallbackRequest(
|
||||
call_id="call-456",
|
||||
status=TelephonyCallStatus.FAILED,
|
||||
duration="7",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.status_processor.db_client") as mock_db,
|
||||
patch(
|
||||
"api.services.telephony.status_processor.enqueue_job",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_enqueue,
|
||||
):
|
||||
mock_db.get_workflow_run_by_id = AsyncMock(return_value=workflow_run)
|
||||
mock_db.update_workflow_run = AsyncMock()
|
||||
|
||||
await _process_status_update(456, status)
|
||||
|
||||
completion_update = mock_db.update_workflow_run.await_args_list[1].kwargs
|
||||
assert "usage_info" not in completion_update
|
||||
assert completion_update["gathered_context"]["call_tags"] == [
|
||||
"not_connected",
|
||||
"telephony_failed",
|
||||
]
|
||||
mock_enqueue.assert_not_awaited()
|
||||
|
|
@ -76,6 +76,34 @@ def _signature(
|
|||
return validator.compute_signature(url, form_data)
|
||||
|
||||
|
||||
def test_twilio_provider_applies_answering_machine_detection_params():
|
||||
provider = TwilioProvider(
|
||||
{
|
||||
"account_sid": "AC123",
|
||||
"auth_token": "twilio-auth-token",
|
||||
"from_numbers": ["+15551230002"],
|
||||
"amd_enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
data = provider.apply_answering_machine_detection_call_params({"To": "+1555"})
|
||||
|
||||
assert provider.supports_answering_machine_detection() is True
|
||||
assert data["MachineDetection"] == "Enable"
|
||||
|
||||
|
||||
def test_twilio_provider_parses_answering_machine_detection_result():
|
||||
provider = _provider()
|
||||
|
||||
result = provider.parse_answering_machine_detection_result(
|
||||
{"CallSid": "CA123", "AnsweredBy": "machine_start"}
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.call_id == "CA123"
|
||||
assert result.answered_by == "machine_start"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twiml_route_accepts_valid_signature_with_extra_query_param():
|
||||
provider = _provider()
|
||||
|
|
@ -251,3 +279,106 @@ async def test_twilio_status_callback_accepts_valid_signature():
|
|||
|
||||
assert result == {"status": "success"}
|
||||
process_status.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twilio_status_callback_persists_answering_machine_detection_result():
|
||||
provider = _provider()
|
||||
form_data = {
|
||||
"CallSid": "CA123",
|
||||
"CallStatus": "completed",
|
||||
"AnsweredBy": "machine_start",
|
||||
}
|
||||
request = _request(
|
||||
path="/api/v1/telephony/twilio/status-callback/123",
|
||||
query={},
|
||||
form_data=form_data,
|
||||
headers={
|
||||
"x-twilio-signature": _signature(
|
||||
provider,
|
||||
path="/api/v1/telephony/twilio/status-callback/123",
|
||||
query={},
|
||||
form_data=form_data,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
|
||||
patch(
|
||||
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"api.services.telephony.providers.twilio.routes._process_status_update",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
):
|
||||
db_client.get_workflow_run_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(workflow_id=7)
|
||||
)
|
||||
db_client.get_workflow_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(organization_id=11)
|
||||
)
|
||||
db_client.update_workflow_run = AsyncMock()
|
||||
|
||||
result = await handle_twilio_status_callback(
|
||||
workflow_run_id=123, request=request
|
||||
)
|
||||
|
||||
assert result == {"status": "success"}
|
||||
db_client.update_workflow_run.assert_awaited_once_with(
|
||||
run_id=123,
|
||||
gathered_context={"answered_by": "machine_start"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_twilio_status_callback_continues_when_amd_persistence_fails():
|
||||
provider = _provider()
|
||||
form_data = {
|
||||
"CallSid": "CA123",
|
||||
"CallStatus": "completed",
|
||||
"AnsweredBy": "machine_start",
|
||||
}
|
||||
request = _request(
|
||||
path="/api/v1/telephony/twilio/status-callback/123",
|
||||
query={},
|
||||
form_data=form_data,
|
||||
headers={
|
||||
"x-twilio-signature": _signature(
|
||||
provider,
|
||||
path="/api/v1/telephony/twilio/status-callback/123",
|
||||
query={},
|
||||
form_data=form_data,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("api.services.telephony.providers.twilio.routes.db_client") as db_client,
|
||||
patch(
|
||||
"api.services.telephony.providers.twilio.routes.get_telephony_provider_for_run",
|
||||
new_callable=AsyncMock,
|
||||
return_value=provider,
|
||||
),
|
||||
patch(
|
||||
"api.services.telephony.providers.twilio.routes._process_status_update",
|
||||
new_callable=AsyncMock,
|
||||
) as process_status,
|
||||
):
|
||||
db_client.get_workflow_run_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(workflow_id=7)
|
||||
)
|
||||
db_client.get_workflow_by_id = AsyncMock(
|
||||
return_value=SimpleNamespace(organization_id=11)
|
||||
)
|
||||
db_client.update_workflow_run = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
|
||||
result = await handle_twilio_status_callback(
|
||||
workflow_run_id=123, request=request
|
||||
)
|
||||
|
||||
assert result == {"status": "success"}
|
||||
process_status.assert_awaited_once()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ def test_dograh_v2_compiles_to_effective_managed_pipeline_with_embeddings():
|
|||
assert effective.stt.provider == "dograh"
|
||||
assert effective.stt.language == "multi"
|
||||
assert effective.embeddings.provider == "dograh"
|
||||
assert effective.embeddings.model == "default"
|
||||
assert effective.embeddings.model == "dograh_embedding_v1"
|
||||
assert effective.managed_service_version == 2
|
||||
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ async def test_byok_realtime_validator_does_not_require_stt_or_tts():
|
|||
"llm": {
|
||||
"provider": "google",
|
||||
"api_key": "google-llm-key",
|
||||
"model": "gemini-2.0-flash",
|
||||
"model": "gemini-2.5-flash",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -154,7 +154,7 @@ async def test_pipeline_validator_requires_stt_and_tts_when_not_realtime():
|
|||
llm=GoogleLLMService(
|
||||
provider="google",
|
||||
api_key="google-llm-key",
|
||||
model="gemini-2.0-flash",
|
||||
model="gemini-2.5-flash",
|
||||
),
|
||||
realtime=GoogleRealtimeLLMConfiguration(
|
||||
provider="google_realtime",
|
||||
|
|
|
|||
162
api/tests/test_dograh_embedding_service.py
Normal file
162
api/tests/test_dograh_embedding_service.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"""Tests for the Dograh-managed embedding service and its correlation resolver."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.gen_ai.embedding.dograh_service import DograhEmbeddingService
|
||||
from api.services.gen_ai.embedding.factory import resolve_embedding_correlation_id
|
||||
|
||||
|
||||
def _service_with_fake_client(correlation_id):
|
||||
service = DograhEmbeddingService(
|
||||
db_client=None,
|
||||
api_key="sk-test",
|
||||
model_id="text-embedding-3-small",
|
||||
base_url=None,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
create = AsyncMock(
|
||||
return_value=SimpleNamespace(data=[SimpleNamespace(embedding=[0.1, 0.2])])
|
||||
)
|
||||
service.client = SimpleNamespace(embeddings=SimpleNamespace(create=create))
|
||||
return service, create
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dograh_embedding_forwards_v2_protocol_when_correlation_present():
|
||||
service, create = _service_with_fake_client("corr-123")
|
||||
|
||||
await service.embed_texts(["hello"])
|
||||
|
||||
create.assert_awaited_once()
|
||||
kwargs = create.await_args.kwargs
|
||||
assert kwargs["input"] == ["hello"]
|
||||
assert kwargs["model"] == "text-embedding-3-small"
|
||||
assert kwargs["extra_body"] == {
|
||||
"metadata": {
|
||||
"correlation_id": "corr-123",
|
||||
"mps_billing_version": "2",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dograh_embedding_sends_plain_without_correlation():
|
||||
service, create = _service_with_fake_client(None)
|
||||
|
||||
await service.embed_texts(["hello"])
|
||||
|
||||
create.assert_awaited_once()
|
||||
# No correlation id (e.g. a v1 org) → no MPS metadata; MPS accepts plain calls.
|
||||
assert "extra_body" not in create.await_args.kwargs
|
||||
|
||||
|
||||
def _fake_mps_client(*, status_return=None, minted="minted"):
|
||||
return SimpleNamespace(
|
||||
get_billing_account_status=AsyncMock(return_value=status_return),
|
||||
create_correlation_id=AsyncMock(return_value={"correlation_id": minted}),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_oss_mints_directly(monkeypatch):
|
||||
fake = _fake_mps_client()
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "oss")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=None, service_key="sk-mps"
|
||||
)
|
||||
|
||||
assert result == "minted"
|
||||
fake.create_correlation_id.assert_awaited_once_with(service_key="sk-mps")
|
||||
fake.get_billing_account_status.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_hosted_v2_mints(monkeypatch):
|
||||
fake = _fake_mps_client(status_return={"billing_mode": "v2"})
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key="sk-mps", created_by="user-1"
|
||||
)
|
||||
|
||||
assert result == "minted"
|
||||
fake.get_billing_account_status.assert_awaited_once_with(42, created_by="user-1")
|
||||
fake.create_correlation_id.assert_awaited_once_with(service_key="sk-mps")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_hosted_v1_returns_none_without_minting(monkeypatch):
|
||||
fake = _fake_mps_client(status_return={"billing_mode": "v1"})
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key="sk-mps"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
fake.create_correlation_id.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_hosted_no_account_returns_none(monkeypatch):
|
||||
fake = _fake_mps_client(status_return=None)
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key="sk-mps"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
fake.create_correlation_id.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_no_service_key_returns_none(monkeypatch):
|
||||
fake = _fake_mps_client(status_return={"billing_mode": "v2"})
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key=None
|
||||
)
|
||||
|
||||
assert result is None
|
||||
fake.get_billing_account_status.assert_not_awaited()
|
||||
fake.create_correlation_id.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_correlation_swallows_errors(monkeypatch):
|
||||
fake = SimpleNamespace(
|
||||
get_billing_account_status=AsyncMock(side_effect=RuntimeError("mps down")),
|
||||
create_correlation_id=AsyncMock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"api.services.mps_service_key_client.mps_service_key_client", fake
|
||||
)
|
||||
monkeypatch.setattr("api.constants.DEPLOYMENT_MODE", "hosted")
|
||||
|
||||
# A transient MPS failure must not break embeddings — fall back to no protocol.
|
||||
result = await resolve_embedding_correlation_id(
|
||||
organization_id=42, service_key="sk-mps"
|
||||
)
|
||||
|
||||
assert result is None
|
||||
107
api/tests/test_dograh_stt_service_factory.py
Normal file
107
api/tests/test_dograh_stt_service_factory.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from pipecat.services.settings import NOT_GIVEN
|
||||
from pipecat.transcriptions.language import Language
|
||||
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.service_factory import (
|
||||
create_stt_service,
|
||||
dograh_stt_uses_flux_language,
|
||||
stt_uses_flux_turns,
|
||||
)
|
||||
|
||||
|
||||
def _audio_config() -> AudioConfig:
|
||||
return AudioConfig(
|
||||
transport_in_sample_rate=16000,
|
||||
transport_out_sample_rate=16000,
|
||||
)
|
||||
|
||||
|
||||
def _dograh_config(language: str | None) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
stt=SimpleNamespace(
|
||||
provider=ServiceProviders.DOGRAH.value,
|
||||
api_key="mps-key",
|
||||
model="default",
|
||||
language=language,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_dograh_flux_language_predicate_matches_multilingual_support():
|
||||
assert dograh_stt_uses_flux_language(None)
|
||||
assert dograh_stt_uses_flux_language("multi")
|
||||
assert dograh_stt_uses_flux_language("es")
|
||||
assert not dograh_stt_uses_flux_language("ar")
|
||||
|
||||
|
||||
def test_stt_uses_flux_turns_only_for_dograh_flux_supported_languages():
|
||||
assert stt_uses_flux_turns(_dograh_config("multi"))
|
||||
assert stt_uses_flux_turns(_dograh_config("es"))
|
||||
assert not stt_uses_flux_turns(_dograh_config("ar"))
|
||||
|
||||
|
||||
def test_create_dograh_multi_uses_flux_service_without_language_hint():
|
||||
user_config = _dograh_config("multi")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.pipecat.service_factory.DograhFluxSTTService"
|
||||
) as flux_service,
|
||||
patch("api.services.pipecat.service_factory.DograhSTTService") as stt_service,
|
||||
):
|
||||
create_stt_service(user_config, _audio_config(), correlation_id="corr-123")
|
||||
|
||||
flux_service.assert_called_once()
|
||||
stt_service.assert_not_called()
|
||||
kwargs = flux_service.call_args.kwargs
|
||||
assert kwargs["correlation_id"] == "corr-123"
|
||||
assert kwargs["settings"].model == "flux-general-multi"
|
||||
assert kwargs["settings"].language_hints is NOT_GIVEN
|
||||
|
||||
|
||||
def test_create_dograh_supported_language_uses_flux_service_with_hint():
|
||||
user_config = _dograh_config("es")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.pipecat.service_factory.DograhFluxSTTService"
|
||||
) as flux_service,
|
||||
patch("api.services.pipecat.service_factory.DograhSTTService") as stt_service,
|
||||
):
|
||||
create_stt_service(user_config, _audio_config(), keyterms=["Dograh"])
|
||||
|
||||
flux_service.assert_called_once()
|
||||
stt_service.assert_not_called()
|
||||
kwargs = flux_service.call_args.kwargs
|
||||
assert kwargs["settings"].model == "flux-general-multi"
|
||||
assert kwargs["settings"].language_hints == [Language.ES]
|
||||
assert kwargs["settings"].keyterm == ["Dograh"]
|
||||
|
||||
|
||||
def test_create_dograh_unsupported_language_falls_back_to_standard_stt_service():
|
||||
user_config = _dograh_config("ar")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"api.services.pipecat.service_factory.DograhFluxSTTService"
|
||||
) as flux_service,
|
||||
patch("api.services.pipecat.service_factory.DograhSTTService") as stt_service,
|
||||
):
|
||||
create_stt_service(
|
||||
user_config,
|
||||
_audio_config(),
|
||||
keyterms=["Dograh"],
|
||||
correlation_id="corr-123",
|
||||
)
|
||||
|
||||
flux_service.assert_not_called()
|
||||
stt_service.assert_called_once()
|
||||
kwargs = stt_service.call_args.kwargs
|
||||
assert kwargs["correlation_id"] == "corr-123"
|
||||
assert kwargs["settings"].model == "default"
|
||||
assert kwargs["settings"].language == "ar"
|
||||
assert kwargs["keyterms"] == ["Dograh"]
|
||||
160
api/tests/test_gemini_json_schema_adapter.py
Normal file
160
api/tests/test_gemini_json_schema_adapter.py
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from google.genai.types import GenerateContentConfig, LiveConnectConfig
|
||||
from pipecat.adapters.schemas.function_schema import FunctionSchema
|
||||
from pipecat.adapters.schemas.tools_schema import ToolsSchema
|
||||
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.pipecat.gemini_json_schema_adapter import (
|
||||
DograhGeminiJSONSchemaAdapter,
|
||||
)
|
||||
from api.services.pipecat.realtime.gemini_live import DograhGeminiLiveLLMService
|
||||
from api.services.pipecat.realtime.gemini_live_vertex import (
|
||||
DograhGeminiLiveVertexLLMService,
|
||||
)
|
||||
from api.services.pipecat.service_factory import (
|
||||
DograhGoogleLLMService,
|
||||
DograhGoogleVertexLLMService,
|
||||
create_llm_service_from_provider,
|
||||
)
|
||||
|
||||
|
||||
def test_gemini_tools_use_json_schema_parameters_for_external_schemas():
|
||||
function_schema = FunctionSchema(
|
||||
name="customer_lookup",
|
||||
description="Look up a customer by email.",
|
||||
properties={
|
||||
"customerEmail": {
|
||||
"description": "Customer email address",
|
||||
"anyOf": [
|
||||
{"anyOf": [{"not": {}}]},
|
||||
{"const": ""},
|
||||
],
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
},
|
||||
required=["customerEmail"],
|
||||
)
|
||||
|
||||
tools = DograhGeminiJSONSchemaAdapter().to_provider_tools_format(
|
||||
ToolsSchema(standard_tools=[function_schema])
|
||||
)
|
||||
|
||||
declaration = tools[0]["function_declarations"][0]
|
||||
assert "parameters" not in declaration
|
||||
assert (
|
||||
declaration["parameters_json_schema"]["properties"]["customerEmail"]["anyOf"][
|
||||
0
|
||||
]["anyOf"][0]["not"]
|
||||
== {}
|
||||
)
|
||||
assert (
|
||||
declaration["parameters_json_schema"]["properties"]["customerEmail"]["anyOf"][
|
||||
1
|
||||
]["const"]
|
||||
== ""
|
||||
)
|
||||
assert declaration["parameters_json_schema"]["properties"]["metadata"][
|
||||
"additionalProperties"
|
||||
] == {"type": "string"}
|
||||
|
||||
GenerateContentConfig(tools=tools)
|
||||
|
||||
|
||||
def test_gemini_tools_use_json_schema_parameters_for_no_argument_tools():
|
||||
function_schema = FunctionSchema(
|
||||
name="refresh_context",
|
||||
description="Refresh the current context.",
|
||||
properties={},
|
||||
required=[],
|
||||
)
|
||||
|
||||
tools = DograhGeminiJSONSchemaAdapter().to_provider_tools_format(
|
||||
ToolsSchema(standard_tools=[function_schema])
|
||||
)
|
||||
|
||||
declaration = tools[0]["function_declarations"][0]
|
||||
assert "parameters" not in declaration
|
||||
assert declaration["parameters_json_schema"] == {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
GenerateContentConfig(tools=tools)
|
||||
|
||||
|
||||
def test_google_service_classes_use_dograh_gemini_adapter_class():
|
||||
assert DograhGoogleLLMService.adapter_class is DograhGeminiJSONSchemaAdapter
|
||||
assert DograhGoogleVertexLLMService.adapter_class is DograhGeminiJSONSchemaAdapter
|
||||
|
||||
|
||||
def test_google_llm_service_factory_uses_dograh_service_class():
|
||||
with patch(
|
||||
"api.services.pipecat.service_factory.DograhGoogleLLMService",
|
||||
) as mock_service:
|
||||
result = create_llm_service_from_provider(
|
||||
provider=ServiceProviders.GOOGLE.value,
|
||||
model="gemini-2.5-flash",
|
||||
api_key="test-api-key",
|
||||
)
|
||||
|
||||
assert result is mock_service.return_value
|
||||
assert mock_service.call_args.kwargs["api_key"] == "test-api-key"
|
||||
assert mock_service.call_args.kwargs["settings"].model == "gemini-2.5-flash"
|
||||
|
||||
|
||||
def test_google_vertex_llm_service_factory_uses_dograh_service_class():
|
||||
with patch(
|
||||
"api.services.pipecat.service_factory.DograhGoogleVertexLLMService",
|
||||
) as mock_service:
|
||||
result = create_llm_service_from_provider(
|
||||
provider=ServiceProviders.GOOGLE_VERTEX.value,
|
||||
model="gemini-2.5-pro",
|
||||
api_key=None,
|
||||
project_id="demo-project",
|
||||
location="us-central1",
|
||||
credentials='{"type":"service_account"}',
|
||||
)
|
||||
|
||||
assert result is mock_service.return_value
|
||||
assert mock_service.call_args.kwargs["project_id"] == "demo-project"
|
||||
assert mock_service.call_args.kwargs["location"] == "us-central1"
|
||||
assert mock_service.call_args.kwargs["settings"].model == "gemini-2.5-pro"
|
||||
|
||||
|
||||
def test_gemini_live_service_classes_use_dograh_gemini_adapter_class():
|
||||
assert DograhGeminiLiveLLMService.adapter_class is DograhGeminiJSONSchemaAdapter
|
||||
# Vertex Live inherits adapter_class from DograhGeminiLiveLLMService via MRO.
|
||||
assert (
|
||||
DograhGeminiLiveVertexLLMService.adapter_class is DograhGeminiJSONSchemaAdapter
|
||||
)
|
||||
|
||||
|
||||
def test_gemini_live_config_accepts_json_schema_tools():
|
||||
function_schema = FunctionSchema(
|
||||
name="customer_lookup",
|
||||
description="Look up a customer by email.",
|
||||
properties={
|
||||
"customerEmail": {
|
||||
"description": "Customer email address",
|
||||
"anyOf": [{"not": {}}, {"const": ""}],
|
||||
},
|
||||
},
|
||||
required=["customerEmail"],
|
||||
)
|
||||
|
||||
tools = DograhGeminiJSONSchemaAdapter().to_provider_tools_format(
|
||||
ToolsSchema(standard_tools=[function_schema])
|
||||
)
|
||||
|
||||
declaration = tools[0]["function_declarations"][0]
|
||||
assert "parameters" not in declaration
|
||||
assert "parameters_json_schema" in declaration
|
||||
|
||||
# Gemini Live validates tools through LiveConnectConfig rather than
|
||||
# GenerateContentConfig; it must also accept the raw JSON Schema payload.
|
||||
LiveConnectConfig(tools=tools)
|
||||
|
|
@ -34,7 +34,7 @@ class TestGoogleVertexLLMConfiguration:
|
|||
class TestGoogleVertexLLMServiceFactory:
|
||||
def test_create_llm_service_from_provider_uses_vertex_service(self):
|
||||
with patch(
|
||||
"api.services.pipecat.service_factory.GoogleVertexLLMService"
|
||||
"api.services.pipecat.service_factory.DograhGoogleVertexLLMService"
|
||||
) as mock_service:
|
||||
create_llm_service_from_provider(
|
||||
provider=ServiceProviders.GOOGLE_VERTEX.value,
|
||||
|
|
@ -65,7 +65,7 @@ class TestGoogleVertexLLMServiceFactory:
|
|||
)
|
||||
|
||||
with patch(
|
||||
"api.services.pipecat.service_factory.GoogleVertexLLMService"
|
||||
"api.services.pipecat.service_factory.DograhGoogleVertexLLMService"
|
||||
) as mock_service:
|
||||
create_llm_service(user_config)
|
||||
|
||||
|
|
|
|||
26
api/tests/test_knowledge_base_processing_embeddings.py
Normal file
26
api/tests/test_knowledge_base_processing_embeddings.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import pytest
|
||||
|
||||
from api.tasks.knowledge_base_processing import _embed_texts_in_batches
|
||||
|
||||
|
||||
class FakeEmbeddingService:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
async def embed_texts(self, texts):
|
||||
self.calls.append(list(texts))
|
||||
return [[float(len(text))] for text in texts]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embed_texts_in_batches_preserves_order():
|
||||
service = FakeEmbeddingService()
|
||||
|
||||
embeddings = await _embed_texts_in_batches(
|
||||
service,
|
||||
["a", "bb", "ccc", "dddd", "eeeee"],
|
||||
batch_size=2,
|
||||
)
|
||||
|
||||
assert service.calls == [["a", "bb"], ["ccc", "dddd"], ["eeeee"]]
|
||||
assert embeddings == [[1.0], [2.0], [3.0], [4.0], [5.0]]
|
||||
|
|
@ -66,7 +66,7 @@ class TestMaskedKeyRejection:
|
|||
"llm": {
|
||||
"provider": "google",
|
||||
"api_key": MASKED_KEY,
|
||||
"model": "gemini-2.0-flash",
|
||||
"model": "gemini-2.5-flash",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -97,7 +97,7 @@ class TestMaskedKeyRejection:
|
|||
"llm": {
|
||||
"provider": "google",
|
||||
"api_key": ["AIzaSyRealKey123456", MASKED_KEY],
|
||||
"model": "gemini-2.0-flash",
|
||||
"model": "gemini-2.5-flash",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -115,7 +115,7 @@ class TestMaskedKeyRejection:
|
|||
llm=GoogleLLMService(
|
||||
provider="google",
|
||||
api_key=new_key,
|
||||
model="gemini-2.0-flash",
|
||||
model="gemini-2.5-flash",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -135,7 +135,7 @@ class TestMaskedKeyRejection:
|
|||
"llm": {
|
||||
"provider": "google",
|
||||
"api_key": new_key,
|
||||
"model": "gemini-2.0-flash",
|
||||
"model": "gemini-2.5-flash",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -126,22 +126,17 @@ async def run_pipeline_and_capture_context(
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
return llm, context
|
||||
|
||||
|
|
|
|||
|
|
@ -268,28 +268,23 @@ class TestEndCallViaNodeTransition:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={"user_intent": "end call"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_intent": "end call"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
# Verify end_call_with_reason was called
|
||||
assert len(test_helper.end_call_reasons) >= 1, (
|
||||
|
|
@ -371,28 +366,23 @@ class TestEndCallViaNodeTransition:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={"greeting_type": "formal", "user_name": "John"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"greeting_type": "formal", "user_name": "John"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
# Should have 3 LLM generations
|
||||
assert llm.get_current_step() == 3
|
||||
|
|
@ -469,28 +459,23 @@ class TestEndCallViaCustomTool:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="end_call_tool",
|
||||
return_value={"user_intent": "end"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_intent": "end"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
# Verify end_call_with_reason was called with END_CALL_TOOL_REASON
|
||||
assert len(test_helper.end_call_reasons) >= 1, (
|
||||
|
|
@ -560,28 +545,23 @@ class TestEndCallViaCustomTool:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="end_call_tool",
|
||||
return_value={"user_intent": "end"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_intent": "end"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
# Verify end_call_with_reason was called
|
||||
assert len(test_helper.end_call_reasons) >= 1, (
|
||||
|
|
@ -637,37 +617,32 @@ class TestEndCallViaClientDisconnect:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="user_hangup",
|
||||
return_value={"user_intent": "disconnected"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_intent": "disconnected"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_and_disconnect():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_and_disconnect():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Wait for initial generation to complete
|
||||
await asyncio.sleep(0.1)
|
||||
# Wait for initial generation to complete
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Simulate client disconnect by calling end_call_with_reason directly
|
||||
# This is what on_client_disconnected does
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
)
|
||||
# Simulate client disconnect by calling end_call_with_reason directly
|
||||
# This is what on_client_disconnected does
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
)
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_and_disconnect())
|
||||
await asyncio.gather(run_pipeline(), initialize_and_disconnect())
|
||||
|
||||
# Verify end_call_with_reason was called with USER_HANGUP
|
||||
assert EndTaskReason.USER_HANGUP.value in test_helper.end_call_reasons, (
|
||||
|
|
@ -727,46 +702,41 @@ class TestEndCallRaceConditions:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="first_reason",
|
||||
return_value={"user_intent": "end"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_intent": "end"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_and_race():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_and_race():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Wait for initial generation
|
||||
await asyncio.sleep(0.1)
|
||||
# Wait for initial generation
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Try to end call multiple times concurrently
|
||||
await asyncio.gather(
|
||||
engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
),
|
||||
engine.end_call_with_reason(
|
||||
EndTaskReason.END_CALL_TOOL_REASON.value,
|
||||
abort_immediately=True,
|
||||
),
|
||||
engine.end_call_with_reason(
|
||||
EndTaskReason.USER_QUALIFIED.value,
|
||||
abort_immediately=False,
|
||||
),
|
||||
)
|
||||
# Try to end call multiple times concurrently
|
||||
await asyncio.gather(
|
||||
engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
),
|
||||
engine.end_call_with_reason(
|
||||
EndTaskReason.END_CALL_TOOL_REASON.value,
|
||||
abort_immediately=True,
|
||||
),
|
||||
engine.end_call_with_reason(
|
||||
EndTaskReason.USER_QUALIFIED.value,
|
||||
abort_immediately=False,
|
||||
),
|
||||
)
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_and_race())
|
||||
await asyncio.gather(run_pipeline(), initialize_and_race())
|
||||
|
||||
# Due to the _call_disposed guard, only one end_call should fully execute
|
||||
# The tracked end_call_reasons will show all attempted calls
|
||||
|
|
@ -838,41 +808,34 @@ class TestEndCallRaceConditions:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="end_reason",
|
||||
return_value={"user_intent": "end"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_intent": "end"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_and_race_disconnect():
|
||||
nonlocal disconnect_called
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_and_race_disconnect():
|
||||
nonlocal disconnect_called
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Wait for the end_call tool to be called
|
||||
await asyncio.sleep(0.15)
|
||||
# Wait for the end_call tool to be called
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
# Simulate client disconnect racing with end_call tool
|
||||
disconnect_called = True
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
run_pipeline(), initialize_and_race_disconnect()
|
||||
# Simulate client disconnect racing with end_call tool
|
||||
disconnect_called = True
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
)
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_and_race_disconnect())
|
||||
|
||||
# Verify disconnect was attempted
|
||||
assert disconnect_called, "Disconnect should have been called"
|
||||
|
||||
|
|
@ -933,40 +896,35 @@ class TestEndCallExtractionBehavior:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
side_effect=mock_extraction,
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
side_effect=mock_extraction,
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_and_end():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_and_end():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Wait for initial generation
|
||||
await asyncio.sleep(0.1)
|
||||
# Wait for initial generation
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# End the call
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
)
|
||||
# End the call
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
)
|
||||
|
||||
# Verify extraction was awaited (synchronous)
|
||||
assert extraction_completed.is_set(), (
|
||||
"Extraction should have completed before end_call returned"
|
||||
)
|
||||
# Verify extraction was awaited (synchronous)
|
||||
assert extraction_completed.is_set(), (
|
||||
"Extraction should have completed before end_call returned"
|
||||
)
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_and_end())
|
||||
await asyncio.gather(run_pipeline(), initialize_and_end())
|
||||
|
||||
# Verify synchronous extraction was used
|
||||
sync_extractions = [
|
||||
|
|
@ -1058,35 +1016,30 @@ class TestEndCallExtractionBehavior:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
extraction_mock,
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
extraction_mock,
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_and_end():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_and_end():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Wait for initial generation
|
||||
await asyncio.sleep(0.1)
|
||||
# Wait for initial generation
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# End the call
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
)
|
||||
# End the call
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value, abort_immediately=True
|
||||
)
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_and_end())
|
||||
await asyncio.gather(run_pipeline(), initialize_and_end())
|
||||
|
||||
# Extraction should have been called but the inner _perform_extraction
|
||||
# should not have been called because extraction_enabled=False
|
||||
|
|
|
|||
|
|
@ -281,24 +281,19 @@ class TestNodeSwitchWithUserSpeech:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
# Start the LLM generation - user speech will be injected
|
||||
# automatically when FunctionCallResultFrame #1 is seen
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
# Start the LLM generation - user speech will be injected
|
||||
# automatically when FunctionCallResultFrame #1 is seen
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
# Total 4 generations out of which 1 was cancelled due to interruption
|
||||
assert llm.get_current_step() == 4
|
||||
|
|
|
|||
|
|
@ -117,24 +117,19 @@ async def run_pipeline_with_tool_calls(
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
# Small delay to let runner start
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
# Small delay to let runner start
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Run both concurrently
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
# Run both concurrently
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
return llm, context
|
||||
|
||||
|
|
|
|||
|
|
@ -171,31 +171,26 @@ class TestTransitionFunctionMutesUser:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={"user_intent": "end call"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_intent": "end call"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(run_pipeline(), initialize_engine()),
|
||||
timeout=10.0,
|
||||
)
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(run_pipeline(), initialize_engine()),
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
assert len(captured_states) == 1, (
|
||||
f"Expected the transition function to be invoked exactly once, "
|
||||
|
|
@ -245,31 +240,26 @@ class TestTransitionFunctionMutesUser:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={"user_intent": "end call"},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_intent": "end call"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(run_pipeline(), initialize_engine()),
|
||||
timeout=10.0,
|
||||
)
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(run_pipeline(), initialize_engine()),
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
assert function_call_mute_strategy._function_call_in_progress == set(), (
|
||||
"FunctionCallUserMuteStrategy should have cleared its in-progress "
|
||||
|
|
|
|||
|
|
@ -156,29 +156,24 @@ class TestVariableExtractionDuringTransitions:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
# Mock the actual extraction to avoid needing a real LLM
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={"user_name": "John Doe"},
|
||||
):
|
||||
# Mock the actual extraction to avoid needing a real LLM
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"user_name": "John Doe"},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
# Should have 3 LLM generations
|
||||
assert llm.get_current_step() == 3
|
||||
|
|
|
|||
88
api/tests/test_run_integrations_webhook.py
Normal file
88
api/tests/test_run_integrations_webhook.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from api.services.workflow.dto import WebhookNodeData
|
||||
from api.tasks.run_integrations import _execute_webhook_node
|
||||
|
||||
|
||||
def _mock_httpx_client(captured: dict):
|
||||
"""Build a patch target for httpx.AsyncClient that records the request kwargs."""
|
||||
response = MagicMock()
|
||||
response.status_code = 200
|
||||
response.raise_for_status = MagicMock()
|
||||
|
||||
async def _request(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return response
|
||||
|
||||
client = MagicMock()
|
||||
client.request = AsyncMock(side_effect=_request)
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.__aenter__ = AsyncMock(return_value=client)
|
||||
ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
return MagicMock(return_value=ctx)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_injects_disposition_when_absent():
|
||||
"""call_disposition is added to the payload when the template omits it."""
|
||||
webhook = WebhookNodeData(
|
||||
name="Test Webhook",
|
||||
enabled=True,
|
||||
endpoint_url="https://example.com/hook",
|
||||
payload_template={"event": "call_done"},
|
||||
)
|
||||
render_context = {"gathered_context": {"call_disposition": "no-answer"}}
|
||||
|
||||
captured: dict = {}
|
||||
with patch(
|
||||
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
|
||||
):
|
||||
ok = await _execute_webhook_node(webhook, render_context, organization_id=1)
|
||||
|
||||
assert ok is True
|
||||
assert captured["json"] == {
|
||||
"event": "call_done",
|
||||
"call_disposition": "no-answer",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_preserves_template_disposition():
|
||||
"""A disposition key set explicitly in the template is not overwritten."""
|
||||
webhook = WebhookNodeData(
|
||||
name="Test Webhook",
|
||||
enabled=True,
|
||||
endpoint_url="https://example.com/hook",
|
||||
payload_template={"call_disposition": "custom-from-template"},
|
||||
)
|
||||
render_context = {"gathered_context": {"call_disposition": "no-answer"}}
|
||||
|
||||
captured: dict = {}
|
||||
with patch(
|
||||
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
|
||||
):
|
||||
await _execute_webhook_node(webhook, render_context, organization_id=1)
|
||||
|
||||
assert captured["json"]["call_disposition"] == "custom-from-template"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_injects_empty_disposition_when_context_missing():
|
||||
"""Missing gathered_context values fall back to an empty string, not omission."""
|
||||
webhook = WebhookNodeData(
|
||||
name="Test Webhook",
|
||||
enabled=True,
|
||||
endpoint_url="https://example.com/hook",
|
||||
payload_template={},
|
||||
)
|
||||
|
||||
captured: dict = {}
|
||||
with patch(
|
||||
"api.tasks.run_integrations.httpx.AsyncClient", _mock_httpx_client(captured)
|
||||
):
|
||||
await _execute_webhook_node(webhook, {}, organization_id=1)
|
||||
|
||||
assert captured["json"] == {"call_disposition": ""}
|
||||
|
|
@ -241,11 +241,6 @@ async def run_pipeline_and_capture_frames(
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
),
|
||||
patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
),
|
||||
):
|
||||
|
||||
async def run():
|
||||
|
|
|
|||
|
|
@ -208,63 +208,58 @@ class TestTTSPauseWithAudioWriteFailure:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_and_end_call():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
async def initialize_and_end_call():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
|
||||
# Start LLM generation - this will trigger TTS
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
# Start LLM generation - this will trigger TTS
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Sleep so that processing is paused in TTS Service
|
||||
await asyncio.sleep(0.1)
|
||||
# Sleep so that processing is paused in TTS Service
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value,
|
||||
abort_immediately=False,
|
||||
)
|
||||
|
||||
# Create tasks explicitly for better control
|
||||
pipeline_task = asyncio.create_task(run_pipeline())
|
||||
end_call_task = asyncio.create_task(initialize_and_end_call())
|
||||
|
||||
# Wait with timeout
|
||||
done, pending = await asyncio.wait(
|
||||
[pipeline_task, end_call_task],
|
||||
timeout=3.0,
|
||||
return_when=asyncio.ALL_COMPLETED,
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value,
|
||||
abort_immediately=False,
|
||||
)
|
||||
|
||||
# If there are pending tasks, we timed out
|
||||
if pending:
|
||||
test_timed_out = True
|
||||
# Cancel all pending tasks
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
# Create tasks explicitly for better control
|
||||
pipeline_task = asyncio.create_task(run_pipeline())
|
||||
end_call_task = asyncio.create_task(initialize_and_end_call())
|
||||
|
||||
# Give limited time for cleanup
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*pending, return_exceptions=True),
|
||||
timeout=1.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass # Cleanup took too long, continue anyway
|
||||
# Wait with timeout
|
||||
done, pending = await asyncio.wait(
|
||||
[pipeline_task, end_call_task],
|
||||
timeout=3.0,
|
||||
return_when=asyncio.ALL_COMPLETED,
|
||||
)
|
||||
|
||||
# If there are pending tasks, we timed out
|
||||
if pending:
|
||||
test_timed_out = True
|
||||
# Cancel all pending tasks
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
# Give limited time for cleanup
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*pending, return_exceptions=True),
|
||||
timeout=1.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass # Cleanup took too long, continue anyway
|
||||
|
||||
# Verify audio write was attempted but failed
|
||||
output_transport = transport._output
|
||||
|
|
@ -327,62 +322,57 @@ class TestTTSPauseWithAudioWriteFailure:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_and_observe():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
async def initialize_and_observe():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Sleep so that processing is paused in TTS Service
|
||||
await asyncio.sleep(0.1)
|
||||
# Sleep so that processing is paused in TTS Service
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value,
|
||||
abort_immediately=False,
|
||||
)
|
||||
|
||||
# Create tasks explicitly for better control
|
||||
pipeline_task = asyncio.create_task(run_pipeline())
|
||||
end_call_task = asyncio.create_task(initialize_and_observe())
|
||||
|
||||
# Wait with timeout
|
||||
done, pending = await asyncio.wait(
|
||||
[pipeline_task, end_call_task],
|
||||
timeout=3.0,
|
||||
return_when=asyncio.ALL_COMPLETED,
|
||||
await engine.end_call_with_reason(
|
||||
EndTaskReason.USER_HANGUP.value,
|
||||
abort_immediately=False,
|
||||
)
|
||||
|
||||
# If there are pending tasks, we timed out
|
||||
if pending:
|
||||
test_timed_out = True
|
||||
# Cancel all pending tasks
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
# Create tasks explicitly for better control
|
||||
pipeline_task = asyncio.create_task(run_pipeline())
|
||||
end_call_task = asyncio.create_task(initialize_and_observe())
|
||||
|
||||
# Give limited time for cleanup
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*pending, return_exceptions=True),
|
||||
timeout=1.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass # Cleanup took too long, continue anyway
|
||||
# Wait with timeout
|
||||
done, pending = await asyncio.wait(
|
||||
[pipeline_task, end_call_task],
|
||||
timeout=3.0,
|
||||
return_when=asyncio.ALL_COMPLETED,
|
||||
)
|
||||
|
||||
# If there are pending tasks, we timed out
|
||||
if pending:
|
||||
test_timed_out = True
|
||||
# Cancel all pending tasks
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
# Give limited time for cleanup
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(*pending, return_exceptions=True),
|
||||
timeout=1.0,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass # Cleanup took too long, continue anyway
|
||||
|
||||
# Verify some frames were written successfully before failure
|
||||
output_transport = transport._output
|
||||
|
|
|
|||
|
|
@ -261,22 +261,17 @@ class TestUserIdleHandler:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
async def initialize_engine():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
await asyncio.gather(run_pipeline(), initialize_engine())
|
||||
|
||||
# All 5 LLM steps should have been consumed
|
||||
assert llm.get_current_step() == 5
|
||||
|
|
|
|||
|
|
@ -247,50 +247,45 @@ class TestUserMutingDuringBotSpeech:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def run_test():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
async def run_test():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
|
||||
# Trigger first LLM completion
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
# Trigger first LLM completion
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Wait for first bot started
|
||||
await asyncio.wait_for(
|
||||
observer.first_bot_started.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Queue user speaking frames so that second generation starts
|
||||
await queue_user_speaking_and_transcript_frames(task)
|
||||
|
||||
# Wait for first bot stopped
|
||||
await asyncio.wait_for(
|
||||
observer.first_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
await task.cancel()
|
||||
|
||||
await asyncio.gather(
|
||||
run_pipeline(),
|
||||
run_test(),
|
||||
return_exceptions=True,
|
||||
# Wait for first bot started
|
||||
await asyncio.wait_for(
|
||||
observer.first_bot_started.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Queue user speaking frames so that second generation starts
|
||||
await queue_user_speaking_and_transcript_frames(task)
|
||||
|
||||
# Wait for first bot stopped
|
||||
await asyncio.wait_for(
|
||||
observer.first_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
await task.cancel()
|
||||
|
||||
await asyncio.gather(
|
||||
run_pipeline(),
|
||||
run_test(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# VERIFY: Muted at first BotStartedSpeaking
|
||||
assert len(observer.mute_status_on_bot_started) >= 1
|
||||
assert observer.mute_status_on_bot_started[0] is True, (
|
||||
|
|
@ -337,55 +332,50 @@ class TestUserMutingDuringBotSpeech:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def run_test():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
async def run_test():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
|
||||
# Trigger first LLM completion
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
# Trigger first LLM completion
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Wait for first bot stopped (first response complete)
|
||||
await asyncio.wait_for(
|
||||
observer.first_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Queue user speaking frames for second generation
|
||||
await queue_user_speaking_and_transcript_frames(task)
|
||||
|
||||
# Wait for second bot started
|
||||
await asyncio.wait_for(
|
||||
observer.second_bot_started.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Wait for second bot stopped
|
||||
await asyncio.wait_for(
|
||||
observer.second_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
await task.cancel()
|
||||
|
||||
await asyncio.gather(
|
||||
run_pipeline(),
|
||||
run_test(),
|
||||
return_exceptions=True,
|
||||
# Wait for first bot stopped (first response complete)
|
||||
await asyncio.wait_for(
|
||||
observer.first_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Queue user speaking frames for second generation
|
||||
await queue_user_speaking_and_transcript_frames(task)
|
||||
|
||||
# Wait for second bot started
|
||||
await asyncio.wait_for(
|
||||
observer.second_bot_started.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Wait for second bot stopped
|
||||
await asyncio.wait_for(
|
||||
observer.second_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
await task.cancel()
|
||||
|
||||
await asyncio.gather(
|
||||
run_pipeline(),
|
||||
run_test(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# VERIFY: First bot started - should be muted (MuteUntilFirstBotComplete)
|
||||
assert len(observer.mute_status_on_bot_started) >= 2
|
||||
assert observer.mute_status_on_bot_started[0] is True, (
|
||||
|
|
@ -432,55 +422,50 @@ class TestUserMutingDuringBotSpeech:
|
|||
new_callable=AsyncMock,
|
||||
return_value=1,
|
||||
):
|
||||
with patch(
|
||||
"api.services.workflow.pipecat_engine.apply_disposition_mapping",
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value="completed",
|
||||
return_value={},
|
||||
):
|
||||
with patch.object(
|
||||
VariableExtractionManager,
|
||||
"_perform_extraction",
|
||||
new_callable=AsyncMock,
|
||||
return_value={},
|
||||
):
|
||||
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
async def run_pipeline():
|
||||
await run_pipeline_worker(task)
|
||||
|
||||
async def run_test():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
async def run_test():
|
||||
await asyncio.sleep(0.01)
|
||||
await engine.initialize()
|
||||
await engine.set_node(engine.workflow.start_node_id)
|
||||
|
||||
# Trigger first LLM completion
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
# Trigger first LLM completion
|
||||
await engine.llm.queue_frame(LLMContextFrame(engine.context))
|
||||
|
||||
# Wait for first bot stopped (first response complete)
|
||||
await asyncio.wait_for(
|
||||
observer.first_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Queue user speaking frames for second llm generation
|
||||
await queue_user_speaking_and_transcript_frames(task)
|
||||
|
||||
# Wait for second bot started
|
||||
await asyncio.wait_for(
|
||||
observer.second_bot_started.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Wait for second bot stopped
|
||||
await asyncio.wait_for(
|
||||
observer.second_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
await task.cancel()
|
||||
|
||||
await asyncio.gather(
|
||||
run_pipeline(),
|
||||
run_test(),
|
||||
return_exceptions=True,
|
||||
# Wait for first bot stopped (first response complete)
|
||||
await asyncio.wait_for(
|
||||
observer.first_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Queue user speaking frames for second llm generation
|
||||
await queue_user_speaking_and_transcript_frames(task)
|
||||
|
||||
# Wait for second bot started
|
||||
await asyncio.wait_for(
|
||||
observer.second_bot_started.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
# Wait for second bot stopped
|
||||
await asyncio.wait_for(
|
||||
observer.second_bot_stopped.wait(), timeout=5.0
|
||||
)
|
||||
|
||||
await task.cancel()
|
||||
|
||||
await asyncio.gather(
|
||||
run_pipeline(),
|
||||
run_test(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
# VERIFY: First bot started - should be muted (MuteUntilFirstBotComplete)
|
||||
assert len(observer.mute_status_on_bot_started) >= 2
|
||||
assert observer.mute_status_on_bot_started[0] is True, (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from pipecat.processors.aggregators.llm_context import LLMSpecificMessage
|
||||
|
||||
from api.db.models import OrganizationModel, UserModel
|
||||
from api.schemas.ai_model_configuration import EffectiveAIModelConfiguration
|
||||
from api.services.workflow.text_chat_runner import (
|
||||
_deserialize_text_chat_checkpoint_messages,
|
||||
_serialize_text_chat_checkpoint_messages,
|
||||
)
|
||||
from api.tests.integrations._run_pipeline_helpers import USER_CONFIGURATION
|
||||
from pipecat.tests import MockLLMService
|
||||
|
||||
|
|
@ -18,6 +24,49 @@ def _log_texts(logs: dict | None, event_type: str) -> list[str]:
|
|||
]
|
||||
|
||||
|
||||
def test_text_chat_checkpoint_messages_round_trip_google_thought_signature():
|
||||
signature = bytes.fromhex("12340a32010c39d6c7f38fd8b8eb6ab0")
|
||||
messages = [
|
||||
{"role": "assistant", "content": "Hello."},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Hi",
|
||||
},
|
||||
LLMSpecificMessage(
|
||||
llm="google",
|
||||
message={
|
||||
"type": "thought_signature",
|
||||
"signature": signature,
|
||||
"bookmark": {"text": "Hello."},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
encoded = _serialize_text_chat_checkpoint_messages(messages)
|
||||
|
||||
json.dumps(encoded)
|
||||
assert encoded[-1] == {
|
||||
"__specific__": True,
|
||||
"llm": "google",
|
||||
"message": {
|
||||
"type": "thought_signature",
|
||||
"signature": {
|
||||
"__type__": "bytes",
|
||||
"__data__": "EjQKMgEMOdbH84/YuOtqsA==",
|
||||
},
|
||||
"bookmark": {"text": "Hello."},
|
||||
},
|
||||
}
|
||||
|
||||
restored = _deserialize_text_chat_checkpoint_messages(encoded)
|
||||
|
||||
assert restored[:2] == messages[:2]
|
||||
assert isinstance(restored[-1], LLMSpecificMessage)
|
||||
assert restored[-1].llm == "google"
|
||||
assert restored[-1].message["signature"] == signature
|
||||
assert restored[-1].message["bookmark"] == {"text": "Hello."}
|
||||
|
||||
|
||||
async def _create_user_and_workflow(
|
||||
db_session,
|
||||
async_session,
|
||||
|
|
|
|||
|
|
@ -145,8 +145,27 @@ services:
|
|||
# Redis configuration (using containerized redis)
|
||||
REDIS_URL: "redis://:${REDIS_PASSWORD:-redissecret}@redis:6379"
|
||||
|
||||
# Storage configuration - using local MinIO
|
||||
ENABLE_AWS_S3: "false"
|
||||
# Storage configuration - bundled MinIO by default. Set ENABLE_AWS_S3=true
|
||||
# in .env to make the API use AWS S3 or another S3-compatible server.
|
||||
ENABLE_AWS_S3: "${ENABLE_AWS_S3:-false}"
|
||||
|
||||
# S3 backend configuration. Compose's .env file is used for interpolation,
|
||||
# but those values are not automatically injected into containers, so pass
|
||||
# the S3 settings through explicitly.
|
||||
AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID:-}"
|
||||
AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY:-}"
|
||||
AWS_SESSION_TOKEN: "${AWS_SESSION_TOKEN:-}"
|
||||
S3_BUCKET: "${S3_BUCKET:-}"
|
||||
S3_REGION: "${S3_REGION:-us-east-1}"
|
||||
|
||||
# For a non-AWS S3-compatible server, also set these in Compose's .env
|
||||
# S3_ENDPOINT_URL e.g. https://s3.example.com
|
||||
# S3_SIGNATURE_VERSION set "s3v4" if the server requires SigV4 (e.g. rustfs)
|
||||
# S3_ADDRESSING_STYLE set "path" if the server / TLS cert requires path-style
|
||||
# The S3 backend issues real presigned URLs, so the bucket can stay private.
|
||||
S3_ENDPOINT_URL: "${S3_ENDPOINT_URL:-}"
|
||||
S3_SIGNATURE_VERSION: "${S3_SIGNATURE_VERSION:-}"
|
||||
S3_ADDRESSING_STYLE: "${S3_ADDRESSING_STYLE:-}"
|
||||
|
||||
# MinIO
|
||||
MINIO_ENDPOINT: "minio:9000"
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -3,10 +3,12 @@ title: "LLM"
|
|||
description: "Voice Agents use LLM (Large Language Models), which are trained to understand the conversational context, and respond to users."
|
||||
---
|
||||
|
||||
You can currently use OpenAI, Google, Groq, Azure and Dograh LLMs in LLM configuration. There are some models provided by default for you to choose from the drop down.
|
||||
Dograh platform supports OpenAI, Google AI Studio, Google Vertex AI, Azure OpenAI, AWS Bedrock, Groq, OpenRouter, Hugging Face, MiniMax, Sarvam, and Dograh-managed LLMs. There are some models provided by default for you to choose from the drop down.
|
||||
|
||||
For locally deployed or self-hosted LLMs, Dograh also supports OpenAI-compatible endpoints such as Ollama and vLLM.
|
||||
|
||||

|
||||
|
||||
If you don't find a model in the drop down, you can always add a model manually.
|
||||
|
||||

|
||||

|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ title: "Transcriber"
|
|||
description: "Voice Agents use STT (Speech to Text), to transcribe what the user speaks. This transcribed speech as text goes into an LLM to generate the response that gets played out to the user."
|
||||
---
|
||||
|
||||
Dograh platform ships with Deepgram, Cartesia, OpenAI and Dograh transcribers by default. You can take a look at the providers documentation of which language to select for your language requirements.
|
||||
Dograh platform supports Deepgram, OpenAI, Google, Azure Speech, AssemblyAI, Speechmatics, Cartesia, Gladia, Sarvam, Smallest AI, Hugging Face, and Dograh transcribers. You can take a look at the providers documentation of which language to select for your language requirements.
|
||||
|
||||
Example: Deepgram has their language support documentation at https://developers.deepgram.com/docs/models-languages-overview#nova-3
|
||||
For locally deployed or self-hosted STT models, Dograh also supports Speaches, an OpenAI API-compatible server for streaming transcription.
|
||||
|
||||
Example: Deepgram has their language support documentation at https://developers.deepgram.com/docs/models-languages-overview#nova-3
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ title: "Voice"
|
|||
description: "Voice Agents use TTS (Text to Speech), which generates audio that LLMs generate during the course of a conversation. This is the audio that the end user having the conversation listens to."
|
||||
---
|
||||
|
||||
Dograh platform ships with Elevenlabs, Deepgram, OpenAI and Dograh TTS engines by default. There are some voices from the providers that we ship by default. You can refer to the providers API documentation to select a voice ID thats most relevant for your language requirement.
|
||||
Dograh platform supports ElevenLabs, OpenAI, Google, Azure Speech, Deepgram, Cartesia, Smallest AI, MiniMax, Sarvam, Rime, Inworld, Camb.ai, and Dograh TTS engines. There are some voices from the providers that we ship by default. You can refer to the providers API documentation to select a voice ID that's most relevant for your language requirement.
|
||||
|
||||
If you dont find your favourite voice, you can always add the voice ID manually.
|
||||
For locally deployed or self-hosted TTS models, Dograh also supports Speaches, an OpenAI API-compatible server for speech generation.
|
||||
|
||||

|
||||
If you don't find your favourite voice, you can always add the voice ID manually.
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -95,6 +95,32 @@ Dograh uses **MinIO by default**, which is bundled with the self-hosted deployme
|
|||
| `ENABLE_AWS_S3` | `false` | Set to `true` to use AWS S3 instead of MinIO |
|
||||
| `S3_BUCKET` | `null` | S3 bucket name |
|
||||
| `S3_REGION` | `us-east-1` | AWS region |
|
||||
| `S3_ENDPOINT_URL` | `null` | Custom S3 endpoint for S3-compatible servers (e.g. `https://s3.example.com`). Leave unset for AWS. |
|
||||
| `S3_SIGNATURE_VERSION` | `null` | Signing version. Unset uses botocore's default; set `s3v4` for servers that require SigV4. |
|
||||
| `S3_ADDRESSING_STYLE` | `null` | `auto` (default), `path`, or `virtual`. Many S3-compatible servers and TLS setups require `path`. |
|
||||
|
||||
Credentials come from the standard `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` environment variables.
|
||||
|
||||
#### S3-compatible servers (MinIO, rustfs, Ceph, ...)
|
||||
|
||||
The S3 backend can target any S3-compatible server, not just AWS. Prefer it over the MinIO backend when you need **presigned URLs against a private bucket**: the MinIO backend returns plain unsigned object URLs and relies on the bucket being anonymously public-readable, whereas the S3 backend issues real presigned URLs so the bucket can stay private.
|
||||
|
||||
To use it, set `ENABLE_AWS_S3=true` and point it at your server with the `S3_*` overrides above. For example, against [rustfs](https://github.com/rustfs/rustfs):
|
||||
|
||||
```bash
|
||||
ENABLE_AWS_S3=true
|
||||
S3_BUCKET=voice-audio
|
||||
S3_REGION=us-east-1
|
||||
S3_ENDPOINT_URL=https://s3.example.com
|
||||
S3_SIGNATURE_VERSION=s3v4 # rustfs rejects SigV2 with SignatureDoesNotMatch
|
||||
S3_ADDRESSING_STYLE=path # rustfs and most non-AWS TLS certs require path-style
|
||||
AWS_ACCESS_KEY_ID=...
|
||||
AWS_SECRET_ACCESS_KEY=...
|
||||
```
|
||||
|
||||
<Note>
|
||||
Presigned URLs point at `S3_ENDPOINT_URL`, so that host must be reachable from the browser. Because browsers fetch transcripts cross-origin, the bucket also needs a CORS rule allowing your app's origin for `GET`/`HEAD` — configure this on the storage server (e.g. via `PutBucketCors`), not in Dograh.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
2
pipecat
2
pipecat
|
|
@ -1 +1 @@
|
|||
Subproject commit 85a48a37bf51e5b8854a85a2ff6319c67937b6fa
|
||||
Subproject commit afe5b6b6c90ad04557ad93b69f7f6ba662c2072e
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# generated by datamodel-codegen:
|
||||
# filename: dograh-openapi-XXXXXX.json.6F33jkClt9
|
||||
# timestamp: 2026-06-19T12:41:10+00:00
|
||||
# filename: dograh-openapi-XXXXXX.json.FJTeRc3I3o
|
||||
# timestamp: 2026-06-25T16:50:38+00:00
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
|
|||
172
ui/package-lock.json
generated
172
ui/package-lock.json
generated
|
|
@ -716,7 +716,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
|
|
@ -918,6 +917,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
|
|
@ -1032,6 +1032,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||
"integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.16.7",
|
||||
"@babel/runtime": "^7.18.3",
|
||||
|
|
@ -1050,13 +1051,15 @@
|
|||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
|
||||
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/babel-plugin/node_modules/source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
"integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -1066,6 +1069,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
|
||||
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
"@emotion/sheet": "^1.4.0",
|
||||
|
|
@ -1078,19 +1082,22 @@
|
|||
"version": "0.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz",
|
||||
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/memoize": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz",
|
||||
"integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/react": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.18.3",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
|
|
@ -1115,6 +1122,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz",
|
||||
"integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emotion/hash": "^0.9.2",
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
|
|
@ -1127,19 +1135,22 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz",
|
||||
"integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/unitless": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz",
|
||||
"integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/use-insertion-effect-with-fallbacks": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz",
|
||||
"integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
|
|
@ -1148,13 +1159,15 @@
|
|||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@emotion/weak-memoize": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz",
|
||||
"integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.7",
|
||||
|
|
@ -2442,6 +2455,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.25"
|
||||
|
|
@ -2680,7 +2694,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
|
|
@ -2731,7 +2744,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz",
|
||||
"integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.57.2",
|
||||
"@types/shimmer": "^1.2.0",
|
||||
|
|
@ -3403,7 +3415,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz",
|
||||
"integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
|
|
@ -8126,7 +8137,6 @@
|
|||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -10010,7 +10020,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
|
|
@ -10422,6 +10431,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
|
|
@ -10432,6 +10442,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz",
|
||||
"integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint": "*",
|
||||
"@types/estree": "*"
|
||||
|
|
@ -10484,7 +10495,8 @@
|
|||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.6.1",
|
||||
|
|
@ -10511,7 +10523,6 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
|
@ -10522,7 +10533,6 @@
|
|||
"integrity": "sha512-jFf/woGTVTjUJsl2O7hcopJ1r0upqoq/vIOoCj0yLh3RIXxWcljlpuZ+vEBRXsymD1jhfeJrlyTy/S1UW+4y1w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
|
|
@ -10532,6 +10542,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
|
||||
"integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
|
|
@ -11061,6 +11072,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
|
||||
"integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/helper-numbers": "1.13.2",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2"
|
||||
|
|
@ -11070,25 +11082,29 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz",
|
||||
"integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-api-error": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz",
|
||||
"integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-buffer": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz",
|
||||
"integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-numbers": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz",
|
||||
"integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/floating-point-hex-parser": "1.13.2",
|
||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||
|
|
@ -11099,13 +11115,15 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz",
|
||||
"integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/helper-wasm-section": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz",
|
||||
"integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -11118,6 +11136,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz",
|
||||
"integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@xtuc/ieee754": "^1.2.0"
|
||||
}
|
||||
|
|
@ -11127,6 +11146,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz",
|
||||
"integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@xtuc/long": "4.2.2"
|
||||
}
|
||||
|
|
@ -11135,13 +11155,15 @@
|
|||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz",
|
||||
"integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@webassemblyjs/wasm-edit": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz",
|
||||
"integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -11158,6 +11180,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz",
|
||||
"integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-wasm-bytecode": "1.13.2",
|
||||
|
|
@ -11171,6 +11194,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz",
|
||||
"integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-buffer": "1.14.1",
|
||||
|
|
@ -11183,6 +11207,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz",
|
||||
"integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@webassemblyjs/helper-api-error": "1.13.2",
|
||||
|
|
@ -11197,6 +11222,7 @@
|
|||
"resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz",
|
||||
"integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@webassemblyjs/ast": "1.14.1",
|
||||
"@xtuc/long": "4.2.2"
|
||||
|
|
@ -11212,13 +11238,15 @@
|
|||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
|
||||
"integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@xtuc/long": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
|
||||
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.2",
|
||||
|
|
@ -11285,7 +11313,6 @@
|
|||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -11307,6 +11334,7 @@
|
|||
"resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz",
|
||||
"integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
},
|
||||
|
|
@ -11358,6 +11386,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
|
|
@ -11375,6 +11404,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
|
|
@ -11390,7 +11420,8 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ansi-colors": {
|
||||
"version": "4.1.3",
|
||||
|
|
@ -11702,6 +11733,7 @@
|
|||
"resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz",
|
||||
"integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"cosmiconfig": "^7.0.0",
|
||||
|
|
@ -11829,7 +11861,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
|
|
@ -12034,6 +12065,7 @@
|
|||
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
||||
"integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
}
|
||||
|
|
@ -12292,6 +12324,7 @@
|
|||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
|
||||
"integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/parse-json": "^4.0.0",
|
||||
"import-fresh": "^3.2.1",
|
||||
|
|
@ -12463,7 +12496,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -12806,6 +12838,7 @@
|
|||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
|
|
@ -12902,6 +12935,7 @@
|
|||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
}
|
||||
|
|
@ -12910,7 +12944,8 @@
|
|||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.23.9",
|
||||
|
|
@ -13030,7 +13065,8 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
|
|
@ -13182,7 +13218,6 @@
|
|||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -13356,7 +13391,6 @@
|
|||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.8",
|
||||
|
|
@ -13644,6 +13678,7 @@
|
|||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
|
|
@ -13749,7 +13784,8 @@
|
|||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.1.4",
|
||||
|
|
@ -13831,7 +13867,8 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||
"integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "5.0.0",
|
||||
|
|
@ -14083,7 +14120,8 @@
|
|||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
|
||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
||||
"license": "BSD-2-Clause"
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "14.0.0",
|
||||
|
|
@ -14297,7 +14335,6 @@
|
|||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
|
||||
"integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
|
|
@ -14907,6 +14944,7 @@
|
|||
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
|
||||
"integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"merge-stream": "^2.0.0",
|
||||
|
|
@ -14921,6 +14959,7 @@
|
|||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
|
|
@ -15010,7 +15049,8 @@
|
|||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
||||
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
|
|
@ -15342,13 +15382,15 @@
|
|||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/loader-runner": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
|
||||
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.11.5"
|
||||
},
|
||||
|
|
@ -15429,13 +15471,15 @@
|
|||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/merge-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
|
|
@ -15466,6 +15510,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
|
|
@ -15475,6 +15520,7 @@
|
|||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
|
|
@ -15572,14 +15618,14 @@
|
|||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.14",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.14.tgz",
|
||||
"integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.14",
|
||||
"@swc/helpers": "0.5.15",
|
||||
|
|
@ -16007,6 +16053,7 @@
|
|||
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
|
||||
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.0.0",
|
||||
"error-ex": "^1.3.1",
|
||||
|
|
@ -16081,6 +16128,7 @@
|
|||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
|
||||
"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -16523,7 +16571,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -16554,7 +16601,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
|
|
@ -16581,7 +16627,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz",
|
||||
"integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
|
@ -16606,15 +16651,13 @@
|
|||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
|
|
@ -16754,6 +16797,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
|
|
@ -16819,8 +16863,7 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -16858,7 +16901,8 @@
|
|||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
|
|
@ -16895,6 +16939,7 @@
|
|||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -16979,7 +17024,6 @@
|
|||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
|
|
@ -17151,6 +17195,7 @@
|
|||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"ajv": "^8.9.0",
|
||||
|
|
@ -17187,6 +17232,7 @@
|
|||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
|
|
@ -17198,7 +17244,8 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.0.0",
|
||||
|
|
@ -17754,7 +17801,8 @@
|
|||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz",
|
||||
"integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
|
|
@ -17794,8 +17842,7 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.1.tgz",
|
||||
"integrity": "sha512-QNbdmeS979Efzim2g/bEvfuh+fTcIdp1y7gA+sb6OYSW74rt7Cr7M78AKdf6HqWT3d5AiTb7SwTT3sLQxr4/qw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss-animate": {
|
||||
"version": "1.0.7",
|
||||
|
|
@ -17824,6 +17871,7 @@
|
|||
"resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz",
|
||||
"integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.15.0",
|
||||
|
|
@ -17842,6 +17890,7 @@
|
|||
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz",
|
||||
"integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.25",
|
||||
"jest-worker": "^27.4.5",
|
||||
|
|
@ -17874,7 +17923,8 @@
|
|||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "3.1.0",
|
||||
|
|
@ -17951,7 +18001,6 @@
|
|||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -18140,7 +18189,6 @@
|
|||
"integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -18327,6 +18375,7 @@
|
|||
"resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz",
|
||||
"integrity": "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
|
|
@ -18413,6 +18462,7 @@
|
|||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
|
||||
"integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"graceful-fs": "^4.1.2"
|
||||
|
|
@ -18438,6 +18488,7 @@
|
|||
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz",
|
||||
"integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
|
|
@ -18501,6 +18552,7 @@
|
|||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^4.1.1"
|
||||
|
|
@ -18514,6 +18566,7 @@
|
|||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
|
|
@ -18691,6 +18744,7 @@
|
|||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
|
||||
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
|
|
@ -18799,7 +18853,6 @@
|
|||
"resolved": "https://registry.npmjs.org/yup/-/yup-1.7.1.tgz",
|
||||
"integrity": "sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"property-expr": "^2.0.5",
|
||||
"tiny-case": "^1.0.3",
|
||||
|
|
@ -18842,7 +18895,6 @@
|
|||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ui",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_OPTIONS=--enable-source-maps next dev --turbopack",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -5892,6 +5892,12 @@ export type TwilioConfigurationRequest = {
|
|||
* List of Twilio phone numbers
|
||||
*/
|
||||
from_numbers?: Array<string>;
|
||||
/**
|
||||
* Amd Enabled
|
||||
*
|
||||
* Detect whether outbound calls are answered by a person or machine. Twilio may bill AMD as an additional per-call feature.
|
||||
*/
|
||||
amd_enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -5916,6 +5922,10 @@ export type TwilioConfigurationResponse = {
|
|||
* From Numbers
|
||||
*/
|
||||
from_numbers: Array<string>;
|
||||
/**
|
||||
* Amd Enabled
|
||||
*/
|
||||
amd_enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -6294,6 +6304,26 @@ export type VobizConfigurationResponse = {
|
|||
from_numbers: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* VoiceFacets
|
||||
*
|
||||
* Distinct selector values across a provider's full voice catalog.
|
||||
*/
|
||||
export type VoiceFacets = {
|
||||
/**
|
||||
* Genders
|
||||
*/
|
||||
genders?: Array<string>;
|
||||
/**
|
||||
* Accents
|
||||
*/
|
||||
accents?: Array<string>;
|
||||
/**
|
||||
* Languages
|
||||
*/
|
||||
languages?: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* VoiceInfo
|
||||
*/
|
||||
|
|
@ -6340,6 +6370,7 @@ export type VoicesResponse = {
|
|||
* Voices
|
||||
*/
|
||||
voices: Array<VoiceInfo>;
|
||||
facets?: VoiceFacets | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -9208,6 +9239,18 @@ export type GetVoicesApiV1UserConfigurationsVoicesProviderGetData = {
|
|||
* Language
|
||||
*/
|
||||
language?: string | null;
|
||||
/**
|
||||
* Q
|
||||
*/
|
||||
q?: string | null;
|
||||
/**
|
||||
* Gender
|
||||
*/
|
||||
gender?: string | null;
|
||||
/**
|
||||
* Accent
|
||||
*/
|
||||
accent?: string | null;
|
||||
};
|
||||
url: '/api/v1/user/configurations/voices/{provider}';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,15 +12,18 @@ import {
|
|||
} from "@/components/ServiceConfigurationForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VoiceSelectorModal } from "@/components/VoiceSelectorModal";
|
||||
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
|
||||
|
||||
type ModelMode = "realtime" | "dograh" | "byok";
|
||||
|
||||
// Sentinel language value for "Multilingual (Auto-detect)".
|
||||
const MULTILINGUAL_LANGUAGE_CODE = "multi";
|
||||
|
||||
interface DograhDefaults {
|
||||
voices: string[];
|
||||
allow_custom_input?: boolean;
|
||||
|
|
@ -31,6 +34,8 @@ interface DograhDefaults {
|
|||
step?: number;
|
||||
};
|
||||
languages: string[];
|
||||
// Languages covered by the "multi" (Multilingual / Auto-detect) option.
|
||||
multilingual_languages?: string[];
|
||||
defaults: {
|
||||
voice: string;
|
||||
speed: number;
|
||||
|
|
@ -278,11 +283,15 @@ export function AIModelConfigurationV2Editor({
|
|||
const [realtimeInitialConfig, setRealtimeInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [pipelineInitialConfig, setPipelineInitialConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [isSavingDograh, setIsSavingDograh] = useState(false);
|
||||
const [isCustomVoice, setIsCustomVoice] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const allowCustomVoice = defaults.dograh.allow_custom_input ?? false;
|
||||
const dograhSpeedRange = defaults.dograh.speed_range ?? { min: 0.5, max: 2.0, step: 0.1 };
|
||||
const multilingualLanguageNames = useMemo(() => {
|
||||
const codes = defaults.dograh.multilingual_languages ?? [];
|
||||
if (codes.length === 0) return null;
|
||||
return codes.map((code) => LANGUAGE_DISPLAY_NAMES[code] || code).join(", ");
|
||||
}, [defaults.dograh.multilingual_languages]);
|
||||
|
||||
useEffect(() => {
|
||||
const rawConfiguration = asRecord(configuration);
|
||||
|
|
@ -290,7 +299,6 @@ export function AIModelConfigurationV2Editor({
|
|||
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
|
||||
const nextDograh = buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration);
|
||||
setDograh(nextDograh);
|
||||
setIsCustomVoice(allowCustomVoice && !defaults.dograh.voices.includes(nextDograh.voice));
|
||||
setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true));
|
||||
setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false));
|
||||
}, [configuration, defaults, effectiveConfiguration, allowCustomVoice]);
|
||||
|
|
@ -390,45 +398,34 @@ export function AIModelConfigurationV2Editor({
|
|||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Voice</Label>
|
||||
{isCustomVoice ? (
|
||||
<Input
|
||||
placeholder="Enter voice"
|
||||
value={dograh.voice}
|
||||
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.voices.map((voice) => (
|
||||
<SelectItem key={voice} value={voice}>
|
||||
{voice}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{allowCustomVoice && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="dograh-custom-voice"
|
||||
checked={isCustomVoice}
|
||||
onCheckedChange={(checked) => {
|
||||
const custom = checked as boolean;
|
||||
setIsCustomVoice(custom);
|
||||
if (!custom) {
|
||||
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
|
||||
Enter Custom Value
|
||||
</Label>
|
||||
</div>
|
||||
<VoiceSelectorModal
|
||||
provider="dograh"
|
||||
value={dograh.voice}
|
||||
onChange={(voice) => setDograh({ ...dograh, voice })}
|
||||
allowManualInput={allowCustomVoice}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dograh.language === MULTILINGUAL_LANGUAGE_CODE && multilingualLanguageNames && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Auto-detects {multilingualLanguageNames}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -451,23 +448,7 @@ export function AIModelConfigurationV2Editor({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label>Language</Label>
|
||||
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{defaults.dograh.languages.map((language) => (
|
||||
<SelectItem key={language} value={language}>
|
||||
{LANGUAGE_DISPLAY_NAMES[language] || language}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dograh-api-key">API Key</Label>
|
||||
<div className="relative">
|
||||
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
|
|
|
|||
|
|
@ -10,11 +10,13 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Providers that have MPS voice endpoints
|
||||
type TTSProviderWithVoices = "elevenlabs" | "deepgram" | "sarvam" | "cartesia" | "dograh" | "rime";
|
||||
const MPS_VOICE_PROVIDERS: TTSProviderWithVoices[] = ["elevenlabs", "deepgram", "sarvam", "cartesia", "dograh", "rime"];
|
||||
const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
interface VoiceSelectorProps {
|
||||
provider: string;
|
||||
|
|
@ -22,6 +24,8 @@ interface VoiceSelectorProps {
|
|||
onChange: (voiceId: string) => void;
|
||||
model?: string;
|
||||
language?: string;
|
||||
showFilters?: boolean;
|
||||
allowManualInput?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -31,10 +35,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||
onChange,
|
||||
model,
|
||||
language,
|
||||
showFilters = false,
|
||||
allowManualInput = true,
|
||||
className,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [genderFilter, setGenderFilter] = useState(ALL_FILTER_VALUE);
|
||||
const [languageFilter, setLanguageFilter] = useState(ALL_FILTER_VALUE);
|
||||
const [accentFilter, setAccentFilter] = useState(ALL_FILTER_VALUE);
|
||||
const [isManualInput, setIsManualInput] = useState(false);
|
||||
const [manualVoiceId, setManualVoiceId] = useState(value || "");
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
||||
|
|
@ -102,13 +111,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||
useEffect(() => {
|
||||
if (value && voices.length > 0) {
|
||||
const voiceExists = voices.some((v) => v.voice_id === value);
|
||||
if (!voiceExists) {
|
||||
if (!voiceExists && allowManualInput) {
|
||||
// If the value doesn't exist in the list, switch to manual input mode
|
||||
setIsManualInput(true);
|
||||
setManualVoiceId(value);
|
||||
} else if (voiceExists) {
|
||||
setIsManualInput(false);
|
||||
}
|
||||
}
|
||||
}, [value, voices]);
|
||||
}, [value, voices, allowManualInput]);
|
||||
|
||||
// Cleanup audio on unmount or when popover closes
|
||||
useEffect(() => {
|
||||
|
|
@ -131,7 +142,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||
|
||||
const filteredVoices = voices.filter((voice) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
const matchesSearch = (
|
||||
voice.name.toLowerCase().includes(searchLower) ||
|
||||
voice.voice_id.toLowerCase().includes(searchLower) ||
|
||||
(voice.description?.toLowerCase() || "").includes(searchLower) ||
|
||||
|
|
@ -139,8 +150,23 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||
(voice.gender?.toLowerCase() || "").includes(searchLower) ||
|
||||
(voice.language?.toLowerCase() || "").includes(searchLower)
|
||||
);
|
||||
if (!matchesSearch) return false;
|
||||
if (genderFilter !== ALL_FILTER_VALUE && (voice.gender || "").toLowerCase() !== genderFilter) return false;
|
||||
if (languageFilter !== ALL_FILTER_VALUE && (voice.language || "").toLowerCase() !== languageFilter) return false;
|
||||
if (accentFilter !== ALL_FILTER_VALUE && (voice.accent || "").toLowerCase() !== accentFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const genderOptions = Array.from(
|
||||
new Set(voices.map((voice) => voice.gender?.toLowerCase()).filter(Boolean) as string[]),
|
||||
).sort();
|
||||
const languageOptions = Array.from(
|
||||
new Set(voices.map((voice) => voice.language?.toLowerCase()).filter(Boolean) as string[]),
|
||||
).sort();
|
||||
const accentOptions = Array.from(
|
||||
new Set(voices.map((voice) => voice.accent?.toLowerCase()).filter(Boolean) as string[]),
|
||||
).sort();
|
||||
|
||||
const handleSelectVoice = (voiceId: string) => {
|
||||
onChange(voiceId);
|
||||
setIsOpen(false);
|
||||
|
|
@ -148,6 +174,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||
};
|
||||
|
||||
const handleManualInputToggle = (checked: boolean) => {
|
||||
if (!allowManualInput) return;
|
||||
setIsManualInput(checked);
|
||||
if (checked) {
|
||||
setManualVoiceId(value || "");
|
||||
|
|
@ -219,7 +246,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
if (isManualInput) {
|
||||
if (isManualInput && allowManualInput) {
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<Input
|
||||
|
|
@ -281,6 +308,52 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{showFilters && (
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<Select value={genderFilter} onValueChange={setGenderFilter}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="Gender" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_FILTER_VALUE}>All genders</SelectItem>
|
||||
{genderOptions.map((gender) => (
|
||||
<SelectItem key={gender} value={gender} className="capitalize">
|
||||
{gender}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={languageFilter} onValueChange={setLanguageFilter}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_FILTER_VALUE}>All languages</SelectItem>
|
||||
{languageOptions.map((voiceLanguage) => (
|
||||
<SelectItem key={voiceLanguage} value={voiceLanguage} className="uppercase">
|
||||
{voiceLanguage}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={accentFilter} onValueChange={setAccentFilter}>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="Accent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_FILTER_VALUE}>All accents</SelectItem>
|
||||
{accentOptions.map((accent) => (
|
||||
<SelectItem key={accent} value={accent} className="uppercase">
|
||||
{accent}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[300px] overflow-auto space-y-1">
|
||||
{error ? (
|
||||
<p className="text-sm text-red-500 text-center py-4">
|
||||
|
|
@ -358,26 +431,30 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="pt-2 border-t flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="manual-voice-input-popup"
|
||||
checked={isManualInput}
|
||||
onCheckedChange={(checked) => {
|
||||
handleManualInputToggle(checked as boolean);
|
||||
if (checked) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="manual-voice-input-popup"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Add Voice ID Manually
|
||||
</Label>
|
||||
</div>
|
||||
{allowManualInput ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="manual-voice-input-popup"
|
||||
checked={isManualInput}
|
||||
onCheckedChange={(checked) => {
|
||||
handleManualInputToggle(checked as boolean);
|
||||
if (checked) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="manual-voice-input-popup"
|
||||
className="text-sm font-normal cursor-pointer"
|
||||
>
|
||||
Add Voice ID Manually
|
||||
</Label>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{voices.length} voices available
|
||||
{filteredVoices.length} of {voices.length} voices
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
451
ui/src/components/VoiceSelectorModal.tsx
Normal file
451
ui/src/components/VoiceSelectorModal.tsx
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
"use client";
|
||||
|
||||
import { Check, ChevronDown, Loader2, Pencil, Play, Square } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getVoicesApiV1UserConfigurationsVoicesProviderGet } from "@/client/sdk.gen";
|
||||
import { VoiceInfo } from "@/client/types.gen";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ACCENT_DISPLAY_NAMES } from "@/constants/accents";
|
||||
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ALL_FILTER_VALUE = "__all__";
|
||||
|
||||
// Defaults so the modal opens on a focused set instead of the full catalog.
|
||||
const DEFAULT_GENDER = "female";
|
||||
const DEFAULT_ACCENT = "us"; // American
|
||||
const DEFAULT_LANGUAGE = "en";
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
interface Facets {
|
||||
genders: string[];
|
||||
accents: string[];
|
||||
languages: string[];
|
||||
}
|
||||
|
||||
const EMPTY_FACETS: Facets = { genders: [], accents: [], languages: [] };
|
||||
|
||||
interface VoiceSelectorModalProps {
|
||||
provider: string;
|
||||
value: string;
|
||||
onChange: (voiceId: string) => void;
|
||||
/** Optional model passed through to the voice catalog query. */
|
||||
model?: string;
|
||||
/** Allow typing a raw voice ID for voices outside the catalog. */
|
||||
allowManualInput?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||
|
||||
const accentLabel = (code?: string | null) =>
|
||||
code ? ACCENT_DISPLAY_NAMES[code.toLowerCase()] || capitalize(code) : "";
|
||||
const languageLabel = (code?: string | null) =>
|
||||
code ? LANGUAGE_DISPLAY_NAMES[code] || code.toUpperCase() : "";
|
||||
const genderLabel = (gender?: string | null) => (gender ? capitalize(gender) : "");
|
||||
|
||||
/** Build the "Accent · Gender · Language" trait line shown under a voice name. */
|
||||
function voiceTraits(voice: VoiceInfo): string {
|
||||
return [accentLabel(voice.accent), genderLabel(voice.gender), languageLabel(voice.language)]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
/** Ensure the active filter value is always an option so the Select can render it. */
|
||||
function withSelected(options: string[], selected: string): string[] {
|
||||
if (selected === ALL_FILTER_VALUE || options.includes(selected)) return options;
|
||||
return [selected, ...options];
|
||||
}
|
||||
|
||||
export const VoiceSelectorModal: React.FC<VoiceSelectorModalProps> = ({
|
||||
provider,
|
||||
value,
|
||||
onChange,
|
||||
model,
|
||||
allowManualInput = false,
|
||||
className,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [voices, setVoices] = useState<VoiceInfo[]>([]);
|
||||
const [facets, setFacets] = useState<Facets>(EMPTY_FACETS);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Filters drive a server-side query (we never fetch the whole catalog).
|
||||
const [gender, setGender] = useState(DEFAULT_GENDER);
|
||||
const [accent, setAccent] = useState(DEFAULT_ACCENT);
|
||||
const [language, setLanguage] = useState(DEFAULT_LANGUAGE);
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
// Pending (in-modal) selection; only committed via "Use this voice".
|
||||
const [pendingVoiceId, setPendingVoiceId] = useState(value);
|
||||
const [selectedVoiceInfo, setSelectedVoiceInfo] = useState<VoiceInfo | null>(null);
|
||||
const [manualMode, setManualMode] = useState(false);
|
||||
const [manualVoiceId, setManualVoiceId] = useState("");
|
||||
|
||||
// Preview playback.
|
||||
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const requestId = useRef(0);
|
||||
|
||||
const stopPreview = useCallback(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current = null;
|
||||
}
|
||||
setPlayingVoiceId(null);
|
||||
}, []);
|
||||
|
||||
// Debounce the search box so typing doesn't fire a request per keystroke.
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(searchInput), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchInput]);
|
||||
|
||||
// Resolve the currently-selected voice (for the trigger label) without
|
||||
// pulling the catalog: a targeted lookup by voice ID.
|
||||
useEffect(() => {
|
||||
if (!value) {
|
||||
setSelectedVoiceInfo(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
(async () => {
|
||||
const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({
|
||||
path: { provider: provider as never },
|
||||
query: { q: value },
|
||||
});
|
||||
if (!active) return;
|
||||
const found = response.data?.voices?.find((voice) => voice.voice_id === value) ?? null;
|
||||
setSelectedVoiceInfo(found);
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [value, provider]);
|
||||
|
||||
// Fetch the filtered voice list (server-side) whenever the modal is open
|
||||
// and a filter changes. A request counter discards out-of-order responses.
|
||||
useEffect(() => {
|
||||
if (!isOpen || manualMode) return;
|
||||
const id = ++requestId.current;
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
(async () => {
|
||||
const query: Record<string, string> = {};
|
||||
if (model) query.model = model;
|
||||
if (gender !== ALL_FILTER_VALUE) query.gender = gender;
|
||||
if (accent !== ALL_FILTER_VALUE) query.accent = accent;
|
||||
if (language !== ALL_FILTER_VALUE) query.language = language;
|
||||
const search = debouncedSearch.trim();
|
||||
if (search) query.q = search;
|
||||
|
||||
const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({
|
||||
path: { provider: provider as never },
|
||||
query,
|
||||
});
|
||||
if (id !== requestId.current) return; // a newer request superseded this one
|
||||
|
||||
if (response.error) {
|
||||
setError("Failed to load voices");
|
||||
setVoices([]);
|
||||
} else {
|
||||
setVoices(response.data?.voices ?? []);
|
||||
if (response.data?.facets) {
|
||||
setFacets({
|
||||
genders: response.data.facets.genders ?? [],
|
||||
accents: response.data.facets.accents ?? [],
|
||||
languages: response.data.facets.languages ?? [],
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
})();
|
||||
}, [isOpen, manualMode, provider, model, gender, accent, language, debouncedSearch]);
|
||||
|
||||
// Stop any preview when the modal closes / unmounts.
|
||||
useEffect(() => {
|
||||
if (!isOpen) stopPreview();
|
||||
return () => stopPreview();
|
||||
}, [isOpen, stopPreview]);
|
||||
|
||||
// Facets arrive sorted by raw code; present them sorted by display label so
|
||||
// the dropdowns read alphabetically (e.g. "American" near the top, not "us").
|
||||
const toSortedOptions = (codes: string[], selected: string, label: (code: string) => string) =>
|
||||
withSelected(codes, selected)
|
||||
.map((code) => ({ value: code, label: label(code) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const genderOptions = useMemo(
|
||||
() => toSortedOptions(facets.genders, gender, genderLabel),
|
||||
[facets.genders, gender],
|
||||
);
|
||||
const accentOptions = useMemo(
|
||||
() => toSortedOptions(facets.accents, accent, accentLabel),
|
||||
[facets.accents, accent],
|
||||
);
|
||||
const languageOptions = useMemo(
|
||||
() => toSortedOptions(facets.languages, language, languageLabel),
|
||||
[facets.languages, language],
|
||||
);
|
||||
|
||||
const openModal = () => {
|
||||
setGender(DEFAULT_GENDER);
|
||||
setAccent(DEFAULT_ACCENT);
|
||||
setLanguage(DEFAULT_LANGUAGE);
|
||||
setSearchInput("");
|
||||
setDebouncedSearch("");
|
||||
setManualMode(false);
|
||||
setManualVoiceId(value);
|
||||
setPendingVoiceId(value);
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const playPreview = (voice: VoiceInfo) => {
|
||||
if (playingVoiceId === voice.voice_id) {
|
||||
stopPreview();
|
||||
return;
|
||||
}
|
||||
stopPreview();
|
||||
if (!voice.preview_url) return;
|
||||
const audio = new Audio(voice.preview_url);
|
||||
audioRef.current = audio;
|
||||
setPlayingVoiceId(voice.voice_id);
|
||||
const clear = () => {
|
||||
if (audioRef.current === audio) audioRef.current = null;
|
||||
setPlayingVoiceId((current) => (current === voice.voice_id ? null : current));
|
||||
};
|
||||
audio.onended = clear;
|
||||
audio.onerror = clear;
|
||||
audio.play().catch(clear);
|
||||
};
|
||||
|
||||
const commitSelection = () => {
|
||||
if (manualMode) {
|
||||
const next = manualVoiceId.trim();
|
||||
if (next) onChange(next);
|
||||
} else if (pendingVoiceId) {
|
||||
onChange(pendingVoiceId);
|
||||
const chosen = voices.find((voice) => voice.voice_id === pendingVoiceId);
|
||||
if (chosen) setSelectedVoiceInfo(chosen);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const triggerLabel = selectedVoiceInfo?.name || value || "Select a voice";
|
||||
const triggerTraits = selectedVoiceInfo ? voiceTraits(selectedVoiceInfo) : "";
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn("w-full justify-between", !value && "text-muted-foreground")}
|
||||
onClick={openModal}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-medium">{triggerLabel}</span>
|
||||
{triggerTraits && (
|
||||
<span className="truncate text-xs text-muted-foreground">{triggerTraits}</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl">
|
||||
<DialogHeader className="border-b px-6 py-4">
|
||||
<DialogTitle>Select Voice</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Filter row: Gender · Accent · Language · Search */}
|
||||
<div className="flex flex-wrap items-center gap-2 border-b px-6 py-3">
|
||||
<Select value={gender} onValueChange={setGender} disabled={manualMode}>
|
||||
<SelectTrigger className="h-9 w-[130px]">
|
||||
<SelectValue placeholder="Gender" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_FILTER_VALUE}>All genders</SelectItem>
|
||||
{genderOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={accent} onValueChange={setAccent} disabled={manualMode}>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue placeholder="Accent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_FILTER_VALUE}>All accents</SelectItem>
|
||||
{accentOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={language} onValueChange={setLanguage} disabled={manualMode}>
|
||||
<SelectTrigger className="h-9 w-[150px]">
|
||||
<SelectValue placeholder="Language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_FILTER_VALUE}>All languages</SelectItem>
|
||||
{languageOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="Search voices..."
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
className="h-9 min-w-[160px] flex-1"
|
||||
disabled={manualMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="min-h-[260px] flex-1 overflow-auto px-6 py-4">
|
||||
{manualMode ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="manual-voice-id">Custom voice ID</Label>
|
||||
<Input
|
||||
id="manual-voice-id"
|
||||
placeholder="Enter voice ID"
|
||||
value={manualVoiceId}
|
||||
onChange={(event) => setManualVoiceId(event.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use a voice ID that isn't in the catalog above.
|
||||
</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="py-10 text-center text-sm text-destructive">{error}</p>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : voices.length === 0 ? (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
No voices match these filters
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{voices.map((voice) => {
|
||||
const isSelected = pendingVoiceId === voice.voice_id;
|
||||
const isPlaying = playingVoiceId === voice.voice_id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={voice.voice_id}
|
||||
onClick={() => setPendingVoiceId(voice.voice_id)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent",
|
||||
isSelected ? "border-primary ring-1 ring-primary" : "border-border",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={voice.preview_url ? 0 : -1}
|
||||
aria-label={isPlaying ? "Stop preview" : "Play preview"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
playPreview(voice);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
playPreview(voice);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
|
||||
voice.preview_url
|
||||
? "bg-primary/10 text-primary hover:bg-primary/20"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
) : (
|
||||
<Play className="h-4 w-4 fill-current" />
|
||||
)}
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{voice.name}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0 text-primary" />}
|
||||
</span>
|
||||
{voiceTraits(voice) && (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{voiceTraits(voice)}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate text-[11px] text-muted-foreground/70">
|
||||
ID: {voice.voice_id}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-3 border-t px-6 py-3">
|
||||
{allowManualInput ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setManualMode((prev) => !prev)}
|
||||
>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{manualMode ? "Browse catalog" : "Custom voice ID"}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{!manualMode && !isLoading && !error ? `${voices.length} voices` : ""}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={commitSelection}
|
||||
disabled={manualMode ? !manualVoiceId.trim() : !pendingVoiceId}
|
||||
>
|
||||
Use this voice
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -47,7 +47,8 @@ interface ConfigFormDialogProps {
|
|||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type FieldValues = Record<string, string | number | undefined>;
|
||||
type FieldValue = string | number | boolean | undefined;
|
||||
type FieldValues = Record<string, FieldValue>;
|
||||
|
||||
export function ConfigFormDialog({
|
||||
open,
|
||||
|
|
@ -104,7 +105,7 @@ export function ConfigFormDialog({
|
|||
if (!isEdit) setValues({});
|
||||
}, [providerName, isEdit]);
|
||||
|
||||
const updateField = (fieldName: string, value: string | number) => {
|
||||
const updateField = (fieldName: string, value: FieldValue) => {
|
||||
setValues((prev) => ({ ...prev, [fieldName]: value }));
|
||||
};
|
||||
|
||||
|
|
@ -292,8 +293,8 @@ export function ConfigFormDialog({
|
|||
|
||||
interface FieldInputProps {
|
||||
field: TelephonyProviderMetadata["fields"][number];
|
||||
value: string | number | undefined;
|
||||
onChange: (v: string | number) => void;
|
||||
value: FieldValue;
|
||||
onChange: (v: FieldValue) => void;
|
||||
isEdit: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -335,6 +336,15 @@ function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
|
|||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<Switch
|
||||
id={`cfg-field-${field.name}`}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
id={`cfg-field-${field.name}`}
|
||||
|
|
|
|||
56
ui/src/constants/accents.ts
Normal file
56
ui/src/constants/accents.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Display names for accent codes returned by the voice catalog.
|
||||
//
|
||||
// The catalog derives accent from a voice's locale country (e.g. "en-US" -> "us"),
|
||||
// so the stored/filter value is an ISO 3166-1 alpha-2 country code. These are the
|
||||
// human-readable accent labels shown in the UI; the underlying code stays the
|
||||
// filter value. Unknown codes fall back to a capitalized form at the call site.
|
||||
export const ACCENT_DISPLAY_NAMES: Record<string, string> = {
|
||||
us: "American",
|
||||
gb: "British",
|
||||
au: "Australian",
|
||||
ca: "Canadian",
|
||||
ie: "Irish",
|
||||
nz: "New Zealand",
|
||||
za: "South African",
|
||||
in: "Indian",
|
||||
bd: "Bangladeshi",
|
||||
sg: "Singaporean",
|
||||
my: "Malaysian",
|
||||
ph: "Filipino",
|
||||
id: "Indonesian",
|
||||
vn: "Vietnamese",
|
||||
th: "Thai",
|
||||
cn: "Chinese",
|
||||
jp: "Japanese",
|
||||
kr: "Korean",
|
||||
fr: "French",
|
||||
de: "German",
|
||||
ch: "Swiss",
|
||||
nl: "Dutch",
|
||||
it: "Italian",
|
||||
es: "Spanish",
|
||||
mx: "Mexican",
|
||||
co: "Colombian",
|
||||
bo: "Bolivian",
|
||||
br: "Brazilian",
|
||||
pt: "Portuguese",
|
||||
ru: "Russian",
|
||||
ua: "Ukrainian",
|
||||
pl: "Polish",
|
||||
cz: "Czech",
|
||||
sk: "Slovak",
|
||||
hu: "Hungarian",
|
||||
ro: "Romanian",
|
||||
bg: "Bulgarian",
|
||||
hr: "Croatian",
|
||||
gr: "Greek",
|
||||
ge: "Georgian",
|
||||
md: "Moldovan",
|
||||
se: "Swedish",
|
||||
no: "Norwegian",
|
||||
dk: "Danish",
|
||||
fi: "Finnish",
|
||||
tr: "Turkish",
|
||||
il: "Israeli",
|
||||
sa: "Saudi",
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue