diff --git a/EfficientAI-Docs/docs/advanced/database.md b/EfficientAI-Docs/docs/advanced/database.md index a0c32be8..6ec7f28a 100644 --- a/EfficientAI-Docs/docs/advanced/database.md +++ b/EfficientAI-Docs/docs/advanced/database.md @@ -89,7 +89,7 @@ Install the required system and Python packages: sudo apt-get update sudo apt-get install -y graphviz libgraphviz-dev pkg-config -# Install Python packages +# Install Python packagesdl pip install eralchemy graphviz ``` diff --git a/app/api/v1/api.py b/app/api/v1/api.py index 5317f035..bad4b1f6 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -32,6 +32,7 @@ voice_playground, prompt_partials, prompt_optimization, + telephony, ) api_router = APIRouter() @@ -67,4 +68,4 @@ api_router.include_router(voice_playground.router) api_router.include_router(prompt_partials.router) api_router.include_router(prompt_optimization.router) - +api_router.include_router(telephony.router) diff --git a/app/api/v1/routes/agents.py b/app/api/v1/routes/agents.py index 144991cd..f89fc561 100644 --- a/app/api/v1/routes/agents.py +++ b/app/api/v1/routes/agents.py @@ -15,7 +15,7 @@ from app.models.database import ( Agent, ConversationEvaluation, TestAgentConversation, VoiceBundle, AIProvider, Integration, IntegrationPlatform, CallMediumEnum, - Evaluator, EvaluatorResult, CallRecording, + Evaluator, EvaluatorResult, CallRecording, TelephonyPhoneNumber, ) from sqlalchemy import and_ from app.models.schemas import ( @@ -147,6 +147,38 @@ def generate_unique_agent_id(db: Session) -> str: ) +def _get_telephony_number_for_agent( + db: Session, + organization_id: UUID, + telephony_phone_number_id: Optional[UUID], + current_agent_id: Optional[UUID] = None, +) -> Optional[TelephonyPhoneNumber]: + """Validate a telephony number can be linked to the agent.""" + if not telephony_phone_number_id: + return None + + telephony_number = db.query(TelephonyPhoneNumber).filter( + and_( + TelephonyPhoneNumber.id == telephony_phone_number_id, + TelephonyPhoneNumber.organization_id == organization_id, + TelephonyPhoneNumber.is_active == True, + ) + ).first() + if not telephony_number: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Telephony phone number not found or inactive", + ) + + if telephony_number.agent_id and telephony_number.agent_id != current_agent_id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Selected telephony phone number is already assigned to another agent", + ) + + return telephony_number + + def get_agent_dependencies(db: Session, organization_id: UUID, agent_uuid: UUID) -> dict: """Return dependency counts that block non-force delete.""" evaluators_count = db.query(Evaluator).filter( @@ -196,8 +228,19 @@ async def create_agent( db: Session = Depends(get_db) ): """Create a new test agent""" + selected_telephony_number = None + resolved_phone_number = agent.phone_number + if agent.call_medium == CallMediumEnumSchema.PHONE_CALL: + selected_telephony_number = _get_telephony_number_for_agent( + db=db, + organization_id=organization_id, + telephony_phone_number_id=agent.telephony_phone_number_id, + ) + if selected_telephony_number: + resolved_phone_number = selected_telephony_number.phone_number + # Validate phone_number is provided when call_medium is phone_call - if agent.call_medium == CallMediumEnumSchema.PHONE_CALL and not agent.phone_number: + if agent.call_medium == CallMediumEnumSchema.PHONE_CALL and not resolved_phone_number: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="phone_number is required when call_medium is phone_call" @@ -253,17 +296,21 @@ async def create_agent( agent_id=agent_id, organization_id=organization_id, name=agent.name, - phone_number=agent.phone_number, + phone_number=resolved_phone_number, language=agent.language, description=agent.description, call_type=agent.call_type, call_medium=agent.call_medium, + telephony_phone_number_id=selected_telephony_number.id if selected_telephony_number else None, voice_bundle_id=agent.voice_bundle_id, ai_provider_id=agent.ai_provider_id, voice_ai_integration_id=agent.voice_ai_integration_id, voice_ai_agent_id=agent.voice_ai_agent_id ) db.add(db_agent) + db.flush() + if selected_telephony_number: + selected_telephony_number.agent_id = db_agent.id db.commit() db.refresh(db_agent) @@ -353,12 +400,39 @@ async def update_agent( if not db_agent: raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + update_data = agent_update.model_dump(exclude_unset=True, exclude_none=False) + old_telephony_phone_number_id = db_agent.telephony_phone_number_id + next_telephony_phone_number_id = update_data.get( + "telephony_phone_number_id", + old_telephony_phone_number_id, + ) # Determine the call_medium to validate - call_medium = agent_update.call_medium if agent_update.call_medium is not None else db_agent.call_medium + call_medium = ( + agent_update.call_medium + if agent_update.call_medium is not None + else db_agent.call_medium + ) + call_medium_value = call_medium.value if hasattr(call_medium, "value") else str(call_medium) + selected_telephony_number = None + if call_medium_value == CallMediumEnum.PHONE_CALL.value: + selected_telephony_number = _get_telephony_number_for_agent( + db=db, + organization_id=organization_id, + telephony_phone_number_id=next_telephony_phone_number_id, + current_agent_id=db_agent.id, + ) # Validate phone_number is provided when call_medium is phone_call - if call_medium == CallMediumEnum.PHONE_CALL: - phone_number = agent_update.phone_number if agent_update.phone_number is not None else db_agent.phone_number + if call_medium_value == CallMediumEnum.PHONE_CALL.value: + phone_number = ( + selected_telephony_number.phone_number + if selected_telephony_number + else ( + agent_update.phone_number + if agent_update.phone_number is not None + else db_agent.phone_number + ) + ) if not phone_number: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -408,13 +482,34 @@ async def update_agent( detail="voice_ai_agent_id is required when voice_ai_integration_id is provided" ) - # Convert the update model to dict, handling None values properly - # Use model_dump with exclude_unset to only get fields that were explicitly provided - update_data = agent_update.model_dump(exclude_unset=True, exclude_none=False) + if call_medium_value != CallMediumEnum.PHONE_CALL.value: + update_data["telephony_phone_number_id"] = None + + if selected_telephony_number: + update_data["telephony_phone_number_id"] = selected_telephony_number.id + update_data["phone_number"] = selected_telephony_number.phone_number # Apply updates for field, value in update_data.items(): setattr(db_agent, field, value) + + final_telephony_phone_number_id = update_data.get( + "telephony_phone_number_id", + old_telephony_phone_number_id, + ) + if old_telephony_phone_number_id and old_telephony_phone_number_id != final_telephony_phone_number_id: + old_telephony_number = db.query(TelephonyPhoneNumber).filter( + and_( + TelephonyPhoneNumber.id == old_telephony_phone_number_id, + TelephonyPhoneNumber.organization_id == organization_id, + TelephonyPhoneNumber.agent_id == db_agent.id, + ) + ).first() + if old_telephony_number: + old_telephony_number.agent_id = None + + if selected_telephony_number: + selected_telephony_number.agent_id = db_agent.id db.commit() db.refresh(db_agent) diff --git a/app/api/v1/routes/integrations.py b/app/api/v1/routes/integrations.py index 53d92b66..d0a0561c 100644 --- a/app/api/v1/routes/integrations.py +++ b/app/api/v1/routes/integrations.py @@ -8,6 +8,7 @@ from datetime import datetime, timezone from typing import List from uuid import UUID +from loguru import logger from app.dependencies import get_db, get_organization_id, get_api_key from app.models.database import Integration, IntegrationPlatform, Agent @@ -114,8 +115,25 @@ async def list_integrations( integrations = db.query(Integration).filter( Integration.organization_id == organization_id ).order_by(Integration.created_at.desc()).all() - - return integrations + + valid_platforms = {p.value for p in IntegrationPlatform} + filtered_integrations: List[Integration] = [] + for integration in integrations: + raw_platform = ( + integration.platform.value + if hasattr(integration.platform, "value") + else str(integration.platform).lower() + ) + if raw_platform in valid_platforms: + filtered_integrations.append(integration) + else: + logger.warning( + "Skipping integration {} with invalid platform '{}'", + integration.id, + integration.platform, + ) + + return filtered_integrations @router.get("/{integration_id}", response_model=IntegrationResponse) @@ -136,6 +154,14 @@ async def get_integration( if not integration: raise HTTPException(status_code=404, detail="Integration not found") + + raw_platform = ( + integration.platform.value + if hasattr(integration.platform, "value") + else str(integration.platform).lower() + ) + if raw_platform not in {p.value for p in IntegrationPlatform}: + raise HTTPException(status_code=404, detail="Integration not found") return integration diff --git a/app/api/v1/routes/metrics.py b/app/api/v1/routes/metrics.py index 96fd46e9..74c5ae41 100644 --- a/app/api/v1/routes/metrics.py +++ b/app/api/v1/routes/metrics.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from sqlalchemy import and_ from uuid import UUID -from typing import List +from typing import List, Optional from app.database import get_db from app.dependencies import get_organization_id, get_api_key @@ -39,14 +39,25 @@ def create_metric( detail="A metric with this name already exists" ) + enabled_surfaces = ( + metric_data.enabled_surfaces + if metric_data.enabled_surfaces is not None + else ((metric_data.supported_surfaces or ["agent"]) if metric_data.enabled else []) + ) metric = Metric( organization_id=organization_id, name=metric_data.name, description=metric_data.description, metric_type=metric_data.metric_type, trigger=metric_data.trigger, - enabled=metric_data.enabled, + enabled=len(enabled_surfaces) > 0, is_default=False, + metric_origin=metric_data.metric_origin or "custom", + supported_surfaces=metric_data.supported_surfaces or ["agent"], + enabled_surfaces=enabled_surfaces, + custom_data_type=metric_data.custom_data_type, + custom_config=metric_data.custom_config, + tags=metric_data.tags, ) db.add(metric) db.commit() @@ -57,14 +68,22 @@ def create_metric( @router.get("", response_model=List[MetricResponse]) def list_metrics( + surface: Optional[str] = None, organization_id: UUID = Depends(get_organization_id), db: Session = Depends(get_db), ): """List all metrics for the organization.""" - metrics = db.query(Metric).filter( + query = db.query(Metric).filter( Metric.organization_id == organization_id, ~Metric.name.in_(REMOVED_DEFAULT_METRICS), - ).order_by(Metric.is_default.desc(), Metric.created_at.desc()).all() + ) + metrics = query.order_by(Metric.is_default.desc(), Metric.created_at.desc()).all() + if surface: + normalized_surface = surface.strip().lower() + metrics = [ + m for m in metrics + if normalized_surface in (m.supported_surfaces or []) + ] return metrics @@ -147,6 +166,31 @@ def update_metric( if metric_data.enabled is not None: metric.enabled = metric_data.enabled + if metric_data.enabled and not metric.enabled_surfaces: + metric.enabled_surfaces = metric.supported_surfaces or ["agent"] + elif not metric_data.enabled: + metric.enabled_surfaces = [] + + if metric_data.metric_origin is not None: + metric.metric_origin = metric_data.metric_origin + + if metric_data.supported_surfaces is not None: + metric.supported_surfaces = metric_data.supported_surfaces + if metric.enabled and not metric_data.enabled_surfaces: + metric.enabled_surfaces = metric_data.supported_surfaces + + if metric_data.enabled_surfaces is not None: + metric.enabled_surfaces = metric_data.enabled_surfaces + metric.enabled = len(metric_data.enabled_surfaces) > 0 + + if metric_data.custom_data_type is not None: + metric.custom_data_type = metric_data.custom_data_type + + if metric_data.custom_config is not None: + metric.custom_config = metric_data.custom_config + + if metric_data.tags is not None: + metric.tags = metric_data.tags db.commit() db.refresh(metric) @@ -206,6 +250,9 @@ def seed_default_metrics( "metric_type": MetricType.RATING, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["agent", "voice_playground"], + "enabled_surfaces": ["agent", "voice_playground"], }, { "name": "Professionalism", @@ -213,6 +260,9 @@ def seed_default_metrics( "metric_type": MetricType.RATING, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["agent"], + "enabled_surfaces": ["agent"], }, { "name": "Problem Resolution", @@ -220,6 +270,9 @@ def seed_default_metrics( "metric_type": MetricType.BOOLEAN, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["agent"], + "enabled_surfaces": ["agent"], }, # ========================================================================= # Acoustic Metrics (Parselmouth - traditional voice analysis) @@ -230,6 +283,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": ["voice_playground"], }, { "name": "Jitter", @@ -237,6 +293,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": False, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": [], }, { "name": "Shimmer", @@ -244,6 +303,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": False, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": [], }, { "name": "HNR", @@ -251,6 +313,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": False, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": [], }, # ========================================================================= # AI Voice Metrics (ML models - human-likeness, emotion, consistency) @@ -261,6 +326,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": ["voice_playground"], }, { "name": "Emotion Category", @@ -268,6 +336,9 @@ def seed_default_metrics( "metric_type": MetricType.RATING, # Stored as text category "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": ["voice_playground"], }, { "name": "Emotion Confidence", @@ -275,6 +346,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": ["voice_playground"], }, { "name": "Valence", @@ -282,6 +356,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": ["voice_playground"], }, { "name": "Arousal", @@ -289,6 +366,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": ["voice_playground"], }, { "name": "Speaker Consistency", @@ -296,6 +376,9 @@ def seed_default_metrics( "metric_type": MetricType.NUMBER, "trigger": MetricTrigger.ALWAYS, "enabled": True, + "metric_origin": "default", + "supported_surfaces": ["voice_playground"], + "enabled_surfaces": ["voice_playground"], }, { "name": "Prosody Score", @@ -325,6 +408,9 @@ def seed_default_metrics( trigger=metric_data["trigger"], enabled=metric_data["enabled"], is_default=True, + metric_origin=metric_data.get("metric_origin", "default"), + supported_surfaces=metric_data.get("supported_surfaces", ["agent"]), + enabled_surfaces=metric_data.get("enabled_surfaces", ["agent"]), ) db.add(metric) created_metrics.append(metric) diff --git a/app/api/v1/routes/personas.py b/app/api/v1/routes/personas.py index 580c6660..e87efd21 100644 --- a/app/api/v1/routes/personas.py +++ b/app/api/v1/routes/personas.py @@ -10,12 +10,14 @@ from typing import List, Optional, Dict, Any from uuid import UUID from pydantic import BaseModel +from loguru import logger from app.dependencies import get_db, get_organization_id from app.models.database import ( Persona, Evaluator, EvaluatorResult, TestAgentConversation, CustomTTSVoice, PromptOptimizationRun, CallRecording, ) +from app.models.enums import LanguageEnum, AccentEnum, GenderEnum, BackgroundNoiseEnum from app.models.schemas import ( PersonaCreate, PersonaUpdate, PersonaResponse, PersonaCloneRequest ) @@ -23,8 +25,6 @@ from app.services.ai.model_config_service import model_config_service router = APIRouter(prefix="/personas", tags=["personas"]) - - # --------------------------------------------------------------------------- # Built-in voice catalog (same data used in voice_playground) # --------------------------------------------------------------------------- @@ -150,6 +150,22 @@ class CustomVoiceUpdateRequest(BaseModel): description: Optional[str] = None +def _is_valid_persona_row(persona: Persona) -> bool: + try: + # Backward compatibility: legacy persona rows may not have these attrs. + language_value = str(getattr(persona, "language", "en") or "en").lower() + accent_value = str(getattr(persona, "accent", "neutral") or "neutral").lower() + gender_value = str(getattr(persona, "gender", "neutral") or "neutral").lower() + noise_value = str(getattr(persona, "background_noise", "none") or "none").lower() + LanguageEnum(language_value) + AccentEnum(accent_value) + GenderEnum(gender_value) + BackgroundNoiseEnum(noise_value) + return True + except Exception: + return False + + @router.post("", response_model=PersonaResponse, status_code=status.HTTP_201_CREATED) async def create_persona( persona: PersonaCreate, @@ -213,7 +229,20 @@ async def list_personas( personas = db.query(Persona).filter( Persona.organization_id == organization_id ).offset(skip).limit(limit).all() - return personas + valid_personas: List[Persona] = [] + for persona in personas: + if _is_valid_persona_row(persona): + valid_personas.append(persona) + else: + logger.warning( + "Skipping persona {} with invalid enum values: language={}, accent={}, gender={}, noise={}", + persona.id, + persona.language, + persona.accent, + persona.gender, + persona.background_noise, + ) + return valid_personas except SQLAlchemyError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -452,6 +481,8 @@ async def get_persona( ).first() if not persona: raise HTTPException(status_code=404, detail=f"Persona {persona_id} not found") + if not _is_valid_persona_row(persona): + raise HTTPException(status_code=404, detail=f"Persona {persona_id} not found") return persona except HTTPException: raise diff --git a/app/api/v1/routes/telephony.py b/app/api/v1/routes/telephony.py new file mode 100644 index 00000000..cdb4fe97 --- /dev/null +++ b/app/api/v1/routes/telephony.py @@ -0,0 +1,312 @@ +"""Telephony API routes (provider-agnostic).""" + +from typing import Any, Dict, List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app.dependencies import get_api_key, get_db, get_organization_id +from app.models.database import TelephonyIntegration, TelephonyMaskedSession, TelephonyPhoneNumber +from app.models.schemas import ( + TelephonyIntegrationCreate, + TelephonyIntegrationResponse, + TelephonyIntegrationUpdate, + TelephonyMaskingSessionCreate, + TelephonyMaskingSessionResponse, + TelephonyOutboundCallRequest, + TelephonyOutboundCallResponse, + TelephonyPhoneNumberResponse, + TelephonyVerifyCheckRequest, + TelephonyVerifyCheckResponse, + TelephonyVerifyStartRequest, + TelephonyVerifyStartResponse, +) +from app.services.telephony.telephony_service import telephony_service + +router = APIRouter(prefix="/telephony", tags=["Telephony"]) + + +class TelephonyNumberUpdateRequest(BaseModel): + """Patch schema for organization number configuration.""" + + is_masking_pool: Optional[bool] = None + agent_id: Optional[UUID] = None + is_active: Optional[bool] = None + + +@router.post("/config", response_model=TelephonyIntegrationResponse, status_code=status.HTTP_201_CREATED) +async def create_telephony_config( + data: TelephonyIntegrationCreate, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + return telephony_service.save_integration(organization_id, data.model_dump(), db) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/config", response_model=TelephonyIntegrationResponse) +async def get_telephony_config( + provider: str = "plivo", + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + return telephony_service.get_org_integration(organization_id, db, provider=provider) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@router.put("/config", response_model=TelephonyIntegrationResponse) +async def update_telephony_config( + data: TelephonyIntegrationUpdate, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + return telephony_service.save_integration( + organization_id, + data.model_dump(exclude_none=True), + db, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/config/test") +async def test_telephony_config( + provider: str = "plivo", + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + ok = telephony_service.test_connection(organization_id, db, provider=provider) + return {"success": ok} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/numbers/sync", response_model=List[TelephonyPhoneNumberResponse]) +async def sync_telephony_numbers( + provider: str = "plivo", + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + return telephony_service.sync_numbers(organization_id, db, provider=provider) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/numbers", response_model=List[TelephonyPhoneNumberResponse]) +async def list_telephony_numbers( + provider: Optional[str] = None, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + return telephony_service.list_numbers(organization_id, db, provider=provider) + + +@router.patch("/numbers/{number_id}", response_model=TelephonyPhoneNumberResponse) +async def update_telephony_number( + number_id: UUID, + data: TelephonyNumberUpdateRequest, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + number = ( + db.query(TelephonyPhoneNumber) + .filter(TelephonyPhoneNumber.id == number_id, TelephonyPhoneNumber.organization_id == organization_id) + .first() + ) + if not number: + raise HTTPException(status_code=404, detail="Phone number not found") + + update_data = data.model_dump(exclude_none=True) + for key, value in update_data.items(): + setattr(number, key, value) + db.commit() + db.refresh(number) + return number + + +@router.post("/calls/outbound", response_model=TelephonyOutboundCallResponse) +async def create_outbound_call( + payload: TelephonyOutboundCallRequest, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + response = telephony_service.initiate_outbound_call( + organization_id, + payload.from_number, + payload.to_number, + payload.agent_id, + db, + ) + return TelephonyOutboundCallResponse( + provider_request_uuid=str( + response.get("request_uuid") or response.get("message_uuid") or response.get("api_id") or "" + ), + call_status=str(response.get("message") or response.get("call_status") or "queued"), + from_number=payload.from_number, + to_number=payload.to_number, + message="Outbound call initiated", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/verify/start", response_model=TelephonyVerifyStartResponse) +async def start_verify_session( + payload: TelephonyVerifyStartRequest, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + try: + session = telephony_service.start_voice_otp( + organization_id, + payload.phone_number, + api_key, + db, + provider=payload.provider, + ) + return TelephonyVerifyStartResponse( + session_id=session.id, + provider_session_uuid=session.provider_session_uuid, + status=session.status, + message="Voice OTP initiated", + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/verify/check", response_model=TelephonyVerifyCheckResponse) +async def check_verify_session( + payload: TelephonyVerifyCheckRequest, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + verified, message = telephony_service.check_voice_otp( + organization_id, + payload.session_id, + payload.otp_code, + db, + provider=payload.provider, + ) + return TelephonyVerifyCheckResponse( + verified=verified, + status="verified" if verified else "failed", + message=message, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.post("/masking/sessions", response_model=TelephonyMaskingSessionResponse) +async def create_masking_session( + payload: TelephonyMaskingSessionCreate, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + return telephony_service.create_masking_session( + org_id=organization_id, + party_a=payload.party_a_number, + party_b=payload.party_b_number, + expires_in_minutes=payload.expires_in_minutes or 60, + metadata=payload.metadata, + db=db, + provider=payload.provider, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/masking/sessions", response_model=List[TelephonyMaskingSessionResponse]) +async def list_masking_sessions( + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + return ( + db.query(TelephonyMaskedSession) + .filter( + TelephonyMaskedSession.organization_id == organization_id, + TelephonyMaskedSession.status == "active", + ) + .order_by(TelephonyMaskedSession.created_at.desc()) + .all() + ) + + +@router.patch("/masking/sessions/{session_id}") +async def end_masking_session( + session_id: UUID, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + del api_key + try: + telephony_service.end_masking_session(organization_id, session_id, db) + return {"status": "ended"} + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +async def _read_webhook_params(request: Request) -> Dict[str, Any]: + params: Dict[str, Any] = dict(request.query_params) + try: + form_data = await request.form() + params.update(dict(form_data)) + except Exception: + pass + return params + + +@router.post("/webhooks/answer") +async def telephony_answer_webhook(request: Request, db: Session = Depends(get_db)): + params = await _read_webhook_params(request) + xml = telephony_service.handle_answer_webhook(params, db) + return Response(content=xml, media_type="application/xml") + + +@router.post("/webhooks/events") +async def telephony_events_webhook(request: Request, db: Session = Depends(get_db)): + params = await _read_webhook_params(request) + telephony_service.handle_event_webhook(params, db) + return {"status": "ok"} + + +@router.post("/webhooks/masking") +async def telephony_masking_webhook(request: Request, db: Session = Depends(get_db)): + params = await _read_webhook_params(request) + xml = telephony_service.handle_masking_webhook(params, db) + return Response(content=xml, media_type="application/xml") diff --git a/app/cli.py b/app/cli.py index 538a264c..9116b3eb 100644 --- a/app/cli.py +++ b/app/cli.py @@ -228,7 +228,7 @@ def start(config: str, host: Optional[str], port: Optional[int], build_frontend: if not (frontend_dir / "node_modules").exists(): click.echo("📦 Installing frontend dependencies...") subprocess.run( - ["npm", "install"], + ["npm", "install", "--legacy-peer-deps"], cwd=frontend_dir, check=True, capture_output=True, @@ -497,7 +497,7 @@ def signal_handler(sig, frame): try: if not (frontend_dir / "node_modules").exists(): click.echo(" Installing frontend dependencies...") - subprocess.run(["npm", "install"], cwd=frontend_dir, check=True, capture_output=True) + subprocess.run(["npm", "install", "--legacy-peer-deps"], cwd=frontend_dir, check=True, capture_output=True) subprocess.run(["npm", "run", "build"], cwd=frontend_dir, check=True, capture_output=True) click.echo("✅ Frontend built successfully") except subprocess.CalledProcessError as e: diff --git a/app/config.py b/app/config.py index 093729fc..a73884ba 100644 --- a/app/config.py +++ b/app/config.py @@ -1,465 +1,479 @@ -"""Configuration management using Pydantic settings.""" - -import json -import yaml -from pathlib import Path -from typing import List, Optional, Union -from pydantic import field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """Application settings.""" - - # Application - APP_NAME: str = "Voice AI Evaluation Platform" - APP_VERSION: str = "0.1.0" - API_V1_PREFIX: str = "/api/v1" - DEBUG: bool = True - SECRET_KEY: str = "your-secret-key-here-change-in-production" - - # Server - HOST: str = "0.0.0.0" - PORT: int = 8000 - - # Database - DATABASE_URL: Optional[str] = None - POSTGRES_USER: str = "efficientai" - POSTGRES_PASSWORD: str = "password" - POSTGRES_HOST: str = "localhost" - POSTGRES_PORT: int = 5432 - POSTGRES_DB: str = "efficientai" - - # Redis - REDIS_URL: Optional[str] = None - REDIS_HOST: str = "localhost" - REDIS_PORT: int = 6379 - REDIS_DB: int = 0 - - # File Storage - UPLOAD_DIR: str = "./uploads" - MAX_FILE_SIZE_MB: int = 500 - ALLOWED_AUDIO_FORMATS: List[str] = ["wav", "mp3", "flac", "m4a"] - - # S3 Configuration - S3_ENABLED: bool = False - S3_BUCKET_NAME: Optional[str] = None - S3_REGION: str = "us-east-1" - S3_ACCESS_KEY_ID: Optional[str] = None - S3_SECRET_ACCESS_KEY: Optional[str] = None - S3_ENDPOINT_URL: Optional[str] = None # For S3-compatible services - S3_PREFIX: str = "audio/" # Prefix for audio files in bucket - - # Celery - CELERY_BROKER_URL: Optional[str] = None - CELERY_RESULT_BACKEND: Optional[str] = None - - # CORS - CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"] - - # API Settings - API_KEY_HEADER: str = "X-API-Key" - RATE_LIMIT_PER_MINUTE: int = 60 - - # Frontend - FRONTEND_DIR: str = "./frontend/dist" - - # SMTP / Email Notifications (for Alerts) - SMTP_HOST: Optional[str] = None # e.g., "smtp.gmail.com" - SMTP_PORT: int = 587 - SMTP_USERNAME: Optional[str] = None - SMTP_PASSWORD: Optional[str] = None - SMTP_FROM_EMAIL: Optional[str] = None # e.g., "alerts@efficientai.dev" - SMTP_FROM_NAME: str = "EfficientAI Alerts" - SMTP_USE_TLS: bool = True - - # Speaker Diarization (Optional) - HUGGINGFACE_TOKEN: Optional[str] = None # For pyannote.audio speaker diarization - DIARIZATION_NUM_SPEAKERS: Optional[int] = 2 # Force pyannote to detect this many speakers (None = auto-detect) - - # Enterprise License (JWT signed with RS256) - EFFICIENTAI_LICENSE: Optional[str] = None - - # ------------------------------------------------------------------------- - # Authentication providers - # ------------------------------------------------------------------------- - # Ordered list of enabled providers. Always includes "api_key" implicitly - # if left empty. Known values: api_key, local_password, external_oidc. - AUTH_PROVIDERS: List[str] = ["api_key"] - - # Local password (OSS). App-signed HS256 JWT using SECRET_KEY. - AUTH_LOCAL_TOKEN_TTL_MINUTES: int = 60 * 12 # 12h - # Allow self-service signup via POST /api/v1/auth/signup? Off in Cloud SaaS. - AUTH_LOCAL_ALLOW_SIGNUP: bool = True - - # External OIDC (enterprise license feature: oidc_sso). - # Works with any OIDC-compliant IdP: Okta, Azure AD, Google Workspace, - # AWS Cognito, Auth0, Ping, OneLogin, JumpCloud, etc. - AUTH_OIDC_ISSUER: Optional[str] = None - AUTH_OIDC_AUDIENCE: Optional[str] = None - AUTH_OIDC_CLIENT_ID: Optional[str] = None - AUTH_OIDC_JWKS_URI: Optional[str] = None # optional, derived from issuer - AUTH_OIDC_DEFAULT_ORG_NAME: Optional[str] = None - AUTH_OIDC_ORG_CLAIM_PATH: Optional[List[str]] = None - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=True, - # Make .env file optional - if it doesn't exist or has errors, use defaults - env_ignore_empty=True, - extra="ignore", # Ignore extra fields from env file - # Don't validate on assignment to allow validators to handle parsing - validate_assignment=False, - ) - - @field_validator("ALLOWED_AUDIO_FORMATS", mode="before") - @classmethod - def parse_allowed_formats(cls, v: Union[str, List[str], None]) -> List[str]: - """Parse ALLOWED_AUDIO_FORMATS from various formats.""" - # Handle None or empty values - if v is None: - return ["wav", "mp3", "flac", "m4a"] - - if isinstance(v, list): - return v if v else ["wav", "mp3", "flac", "m4a"] - - if isinstance(v, str): - # Handle empty or whitespace-only strings - v = v.strip() - if not v: - return ["wav", "mp3", "flac", "m4a"] - - # Try JSON first - if v.startswith("["): - try: - parsed = json.loads(v) - return parsed if isinstance(parsed, list) and parsed else ["wav", "mp3", "flac", "m4a"] - except (json.JSONDecodeError, ValueError): - pass - - # Fall back to comma-separated - formats = [fmt.strip() for fmt in v.split(",") if fmt.strip()] - return formats if formats else ["wav", "mp3", "flac", "m4a"] - - return ["wav", "mp3", "flac", "m4a"] # Default - - @field_validator("AUTH_PROVIDERS", mode="before") - @classmethod - def parse_auth_providers(cls, v: Union[str, List[str], None]) -> List[str]: - """Parse AUTH_PROVIDERS from JSON, CSV, or a native list.""" - if v is None: - return ["api_key"] - if isinstance(v, list): - return [str(x).strip() for x in v if str(x).strip()] or ["api_key"] - if isinstance(v, str): - v = v.strip() - if not v: - return ["api_key"] - if v.startswith("["): - try: - parsed = json.loads(v) - if isinstance(parsed, list): - return [str(x).strip() for x in parsed if str(x).strip()] or ["api_key"] - except (json.JSONDecodeError, ValueError): - pass - return [x.strip() for x in v.split(",") if x.strip()] or ["api_key"] - return ["api_key"] - - @field_validator("AUTH_OIDC_ORG_CLAIM_PATH", mode="before") - @classmethod - def parse_claim_path(cls, v): - """Parse a dotted claim path from JSON, CSV, or a native list.""" - if v is None or v == "": - return None - if isinstance(v, list): - return [str(x) for x in v] - if isinstance(v, str): - s = v.strip() - if not s: - return None - if s.startswith("["): - try: - parsed = json.loads(s) - if isinstance(parsed, list): - return [str(x) for x in parsed] - except (json.JSONDecodeError, ValueError): - pass - return [x.strip() for x in s.split(",") if x.strip()] or None - return None - - @field_validator("CORS_ORIGINS", mode="before") - @classmethod - def parse_cors_origins(cls, v: Union[str, List[str], None]) -> List[str]: - """Parse CORS_ORIGINS from various formats.""" - # Handle None or empty values - if v is None: - return ["http://localhost:3000", "http://localhost:8000"] - - if isinstance(v, list): - return v if v else ["http://localhost:3000", "http://localhost:8000"] - - if isinstance(v, str): - # Handle empty or whitespace-only strings - v = v.strip() - if not v: - return ["http://localhost:3000", "http://localhost:8000"] - - # Try JSON first - if v.startswith("["): - try: - parsed = json.loads(v) - if isinstance(parsed, list) and parsed: - return parsed - except (json.JSONDecodeError, ValueError): - pass - - # Fall back to comma-separated - origins = [origin.strip() for origin in v.split(",") if origin.strip()] - return origins if origins else ["http://localhost:3000", "http://localhost:8000"] - - return ["http://localhost:3000", "http://localhost:8000"] # Default - - def __init__(self, **kwargs): - super().__init__(**kwargs) - # Build DATABASE_URL if not provided - if not self.DATABASE_URL: - self.DATABASE_URL = ( - f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" - f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" - ) - - # Build REDIS_URL if not provided - if not self.REDIS_URL: - self.REDIS_URL = f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" - - # Build Celery URLs if not provided - if not self.CELERY_BROKER_URL: - self.CELERY_BROKER_URL = self.REDIS_URL - if not self.CELERY_RESULT_BACKEND: - self.CELERY_RESULT_BACKEND = self.REDIS_URL - - -def load_config_from_file(config_path: str) -> None: - """Load configuration from a YAML file and update global settings.""" - import yaml - - config_file = Path(config_path) - if not config_file.exists(): - raise FileNotFoundError(f"Config file not found: {config_path}") - - with open(config_file, "r") as f: - config_data = yaml.safe_load(f) or {} - - # Update settings with YAML values - if "app" in config_data: - app_config = config_data["app"] - if "name" in app_config: - settings.APP_NAME = app_config["name"] - if "version" in app_config: - settings.APP_VERSION = app_config["version"] - if "debug" in app_config: - settings.DEBUG = app_config["debug"] - if "secret_key" in app_config: - settings.SECRET_KEY = app_config["secret_key"] - - if "server" in config_data: - server_config = config_data["server"] - if "host" in server_config: - settings.HOST = server_config["host"] - if "port" in server_config: - settings.PORT = server_config["port"] - - if "database" in config_data: - db_config = config_data["database"] - if "url" in db_config: - settings.DATABASE_URL = db_config["url"] - else: - if "user" in db_config: - settings.POSTGRES_USER = db_config["user"] - if "password" in db_config: - settings.POSTGRES_PASSWORD = db_config["password"] - if "host" in db_config: - settings.POSTGRES_HOST = db_config["host"] - if "port" in db_config: - settings.POSTGRES_PORT = db_config["port"] - if "db" in db_config: - settings.POSTGRES_DB = db_config["db"] - # Rebuild DATABASE_URL - settings.DATABASE_URL = ( - f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}" - f"@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" - ) - - if "redis" in config_data: - redis_config = config_data["redis"] - if "url" in redis_config: - settings.REDIS_URL = redis_config["url"] - else: - if "host" in redis_config: - settings.REDIS_HOST = redis_config["host"] - if "port" in redis_config: - settings.REDIS_PORT = redis_config["port"] - if "db" in redis_config: - settings.REDIS_DB = redis_config["db"] - # Rebuild REDIS_URL - settings.REDIS_URL = f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}" - - if "celery" in config_data: - celery_config = config_data["celery"] - if "broker_url" in celery_config: - settings.CELERY_BROKER_URL = celery_config["broker_url"] - if "result_backend" in celery_config: - settings.CELERY_RESULT_BACKEND = celery_config["result_backend"] - - if "storage" in config_data: - storage_config = config_data["storage"] - if "upload_dir" in storage_config: - settings.UPLOAD_DIR = storage_config["upload_dir"] - if "max_file_size_mb" in storage_config: - settings.MAX_FILE_SIZE_MB = storage_config["max_file_size_mb"] - if "allowed_audio_formats" in storage_config: - settings.ALLOWED_AUDIO_FORMATS = storage_config["allowed_audio_formats"] - - if "s3" in config_data: - s3_config = config_data["s3"] - if "enabled" in s3_config: - settings.S3_ENABLED = s3_config["enabled"] - if "bucket_name" in s3_config: - settings.S3_BUCKET_NAME = s3_config["bucket_name"] - if "region" in s3_config: - settings.S3_REGION = s3_config["region"] - if "access_key_id" in s3_config: - settings.S3_ACCESS_KEY_ID = s3_config["access_key_id"] - if "secret_access_key" in s3_config: - settings.S3_SECRET_ACCESS_KEY = s3_config["secret_access_key"] - if "endpoint_url" in s3_config: - settings.S3_ENDPOINT_URL = s3_config["endpoint_url"] - if "prefix" in s3_config: - settings.S3_PREFIX = s3_config["prefix"] - - if "smtp" in config_data: - smtp_config = config_data["smtp"] - if "host" in smtp_config: - settings.SMTP_HOST = smtp_config["host"] - if "port" in smtp_config: - settings.SMTP_PORT = smtp_config["port"] - if "username" in smtp_config: - settings.SMTP_USERNAME = smtp_config["username"] - if "password" in smtp_config: - settings.SMTP_PASSWORD = smtp_config["password"] - if "from_email" in smtp_config: - settings.SMTP_FROM_EMAIL = smtp_config["from_email"] - if "from_name" in smtp_config: - settings.SMTP_FROM_NAME = smtp_config["from_name"] - if "use_tls" in smtp_config: - settings.SMTP_USE_TLS = smtp_config["use_tls"] - - if "diarization" in config_data: - diarization_config = config_data["diarization"] - if "huggingface_token" in diarization_config and diarization_config["huggingface_token"]: - settings.HUGGINGFACE_TOKEN = diarization_config["huggingface_token"] - if "num_speakers" in diarization_config: - val = diarization_config["num_speakers"] - settings.DIARIZATION_NUM_SPEAKERS = int(val) if val is not None else None - - if "cors" in config_data: - cors_config = config_data["cors"] - if "origins" in cors_config: - settings.CORS_ORIGINS = cors_config["origins"] - - if "api" in config_data: - api_config = config_data["api"] - if "prefix" in api_config: - settings.API_V1_PREFIX = api_config["prefix"] - if "key_header" in api_config: - settings.API_KEY_HEADER = api_config["key_header"] - if "rate_limit_per_minute" in api_config: - settings.RATE_LIMIT_PER_MINUTE = api_config["rate_limit_per_minute"] - - if "license" in config_data: - license_config = config_data["license"] - if "key" in license_config: - settings.EFFICIENTAI_LICENSE = license_config["key"] - - if "auth" in config_data: - auth_config = config_data["auth"] or {} - - providers = auth_config.get("providers") - if isinstance(providers, list): - settings.AUTH_PROVIDERS = [str(p).strip() for p in providers if str(p).strip()] - elif isinstance(providers, str) and providers.strip(): - settings.AUTH_PROVIDERS = [p.strip() for p in providers.split(",") if p.strip()] - - local_cfg = auth_config.get("local_password") or {} - if "token_ttl_minutes" in local_cfg: - try: - settings.AUTH_LOCAL_TOKEN_TTL_MINUTES = int(local_cfg["token_ttl_minutes"]) - except (TypeError, ValueError): - pass - if "allow_signup" in local_cfg: - settings.AUTH_LOCAL_ALLOW_SIGNUP = bool(local_cfg["allow_signup"]) - - oidc_cfg = auth_config.get("oidc") or auth_config.get("external_oidc") or {} - if "issuer" in oidc_cfg: - settings.AUTH_OIDC_ISSUER = oidc_cfg["issuer"] - if "audience" in oidc_cfg: - settings.AUTH_OIDC_AUDIENCE = oidc_cfg["audience"] - if "client_id" in oidc_cfg: - settings.AUTH_OIDC_CLIENT_ID = oidc_cfg["client_id"] - if "jwks_uri" in oidc_cfg: - settings.AUTH_OIDC_JWKS_URI = oidc_cfg["jwks_uri"] - if "default_org_name" in oidc_cfg: - settings.AUTH_OIDC_DEFAULT_ORG_NAME = oidc_cfg["default_org_name"] - if "org_claim_path" in oidc_cfg and isinstance(oidc_cfg["org_claim_path"], list): - settings.AUTH_OIDC_ORG_CLAIM_PATH = [str(x) for x in oidc_cfg["org_claim_path"]] - - # Update Celery URLs if they weren't explicitly set - if not settings.CELERY_BROKER_URL: - settings.CELERY_BROKER_URL = settings.REDIS_URL - if not settings.CELERY_RESULT_BACKEND: - settings.CELERY_RESULT_BACKEND = settings.REDIS_URL - - -# Initialize settings with error handling for problematic env vars -# If .env file has invalid format, we'll use defaults (YAML config will override anyway) -try: - settings = Settings() -except Exception as e: - # If there's an error loading from .env (e.g., invalid JSON in list fields), - # create settings with defaults. The YAML config loaded later will override these. - import warnings - import os - - warnings.warn( - f"Error loading .env file, using defaults. YAML config will override. Error: {str(e)[:100]}", - UserWarning, - stacklevel=2 - ) - - # Try to create settings without .env file by temporarily removing it - env_file = ".env" - if os.path.exists(env_file): - # Temporarily rename .env to avoid loading it - backup_file = f"{env_file}.backup" - try: - os.rename(env_file, backup_file) - settings = Settings() - os.rename(backup_file, env_file) - except Exception: - # If rename fails or Settings still fails, restore and use defaults - if os.path.exists(backup_file): - try: - os.rename(backup_file, env_file) - except Exception: - pass - # Create with explicit defaults - manually construct with default values - settings = Settings( - ALLOWED_AUDIO_FORMATS=["wav", "mp3", "flac", "m4a"], - CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"], - _env_file=None, # Don't load .env - ) - else: - # No .env file, create normally - settings = Settings() - +"""Configuration management using Pydantic settings.""" + +import json +import yaml +from pathlib import Path +from typing import List, Optional, Union +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Application settings.""" + + # Application + APP_NAME: str = "Voice AI Evaluation Platform" + APP_VERSION: str = "0.1.0" + API_V1_PREFIX: str = "/api/v1" + DEBUG: bool = True + SECRET_KEY: str = "your-secret-key-here-change-in-production" + + # Server + HOST: str = "0.0.0.0" + PORT: int = 8000 + + # Database + DATABASE_URL: Optional[str] = None + POSTGRES_USER: str = "efficientai" + POSTGRES_PASSWORD: str = "password" + POSTGRES_HOST: str = "localhost" + POSTGRES_PORT: int = 5432 + POSTGRES_DB: str = "efficientai" + + # Redis + REDIS_URL: Optional[str] = None + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + REDIS_DB: int = 0 + + # File Storage + UPLOAD_DIR: str = "./uploads" + MAX_FILE_SIZE_MB: int = 500 + ALLOWED_AUDIO_FORMATS: List[str] = ["wav", "mp3", "flac", "m4a"] + + # S3 Configuration + S3_ENABLED: bool = False + S3_BUCKET_NAME: Optional[str] = None + S3_REGION: str = "us-east-1" + S3_ACCESS_KEY_ID: Optional[str] = None + S3_SECRET_ACCESS_KEY: Optional[str] = None + S3_ENDPOINT_URL: Optional[str] = None # For S3-compatible services + S3_PREFIX: str = "audio/" # Prefix for audio files in bucket + + # Celery + CELERY_BROKER_URL: Optional[str] = None + CELERY_RESULT_BACKEND: Optional[str] = None + + # CORS + CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8000"] + + # API Settings + API_KEY_HEADER: str = "X-API-Key" + RATE_LIMIT_PER_MINUTE: int = 60 + + # Authentication + AUTH_PROVIDERS: List[str] = ["api_key"] + AUTH_LOCAL_ALLOW_SIGNUP: bool = True + AUTH_LOCAL_TOKEN_TTL_MINUTES: int = 720 + AUTH_OIDC_ISSUER: Optional[str] = None + AUTH_OIDC_CLIENT_ID: Optional[str] = None + AUTH_OIDC_AUDIENCE: Optional[str] = None + AUTH_OIDC_JWKS_URI: Optional[str] = None + AUTH_OIDC_ORG_CLAIM_PATH: List[str] = [] + AUTH_OIDC_DEFAULT_ORG_NAME: Optional[str] = None + + # Frontend + FRONTEND_DIR: str = "./frontend/dist" + + # SMTP / Email Notifications (for Alerts) + SMTP_HOST: Optional[str] = None # e.g., "smtp.gmail.com" + SMTP_PORT: int = 587 + SMTP_USERNAME: Optional[str] = None + SMTP_PASSWORD: Optional[str] = None + SMTP_FROM_EMAIL: Optional[str] = None # e.g., "alerts@efficientai.dev" + SMTP_FROM_NAME: str = "EfficientAI Alerts" + SMTP_USE_TLS: bool = True + + # Speaker Diarization (Optional) + HUGGINGFACE_TOKEN: Optional[str] = None # For pyannote.audio speaker diarization + DIARIZATION_NUM_SPEAKERS: Optional[int] = 2 # Force pyannote to detect this many speakers (None = auto-detect) + + # Enterprise License (JWT signed with RS256) + EFFICIENTAI_LICENSE: Optional[str] = None + + # Plivo Telephony (optional) + PLIVO_AUTH_ID: str = "" + PLIVO_AUTH_TOKEN: str = "" + PLIVO_VERIFY_APP_UUID: str = "" + PLIVO_WEBHOOK_BASE_URL: str = "" + + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=True, + # Make .env file optional - if it doesn't exist or has errors, use defaults + env_ignore_empty=True, + extra="ignore", # Ignore extra fields from env file + # Don't validate on assignment to allow validators to handle parsing + validate_assignment=False, + ) + + @field_validator("ALLOWED_AUDIO_FORMATS", mode="before") + @classmethod + def parse_allowed_formats(cls, v: Union[str, List[str], None]) -> List[str]: + """Parse ALLOWED_AUDIO_FORMATS from various formats.""" + # Handle None or empty values + if v is None: + return ["wav", "mp3", "flac", "m4a"] + + if isinstance(v, list): + return v if v else ["wav", "mp3", "flac", "m4a"] + + if isinstance(v, str): + # Handle empty or whitespace-only strings + v = v.strip() + if not v: + return ["wav", "mp3", "flac", "m4a"] + + # Try JSON first + if v.startswith("["): + try: + parsed = json.loads(v) + return parsed if isinstance(parsed, list) and parsed else ["wav", "mp3", "flac", "m4a"] + except (json.JSONDecodeError, ValueError): + pass + + # Fall back to comma-separated + formats = [fmt.strip() for fmt in v.split(",") if fmt.strip()] + return formats if formats else ["wav", "mp3", "flac", "m4a"] + + return ["wav", "mp3", "flac", "m4a"] # Default + + @field_validator("CORS_ORIGINS", mode="before") + @classmethod + def parse_cors_origins(cls, v: Union[str, List[str], None]) -> List[str]: + """Parse CORS_ORIGINS from various formats.""" + # Handle None or empty values + if v is None: + return ["http://localhost:3000", "http://localhost:8000"] + + if isinstance(v, list): + return v if v else ["http://localhost:3000", "http://localhost:8000"] + + if isinstance(v, str): + # Handle empty or whitespace-only strings + v = v.strip() + if not v: + return ["http://localhost:3000", "http://localhost:8000"] + + # Try JSON first + if v.startswith("["): + try: + parsed = json.loads(v) + if isinstance(parsed, list) and parsed: + return parsed + except (json.JSONDecodeError, ValueError): + pass + + # Fall back to comma-separated + origins = [origin.strip() for origin in v.split(",") if origin.strip()] + return origins if origins else ["http://localhost:3000", "http://localhost:8000"] + + return ["http://localhost:3000", "http://localhost:8000"] # Default + + @field_validator("AUTH_PROVIDERS", mode="before") + @classmethod + def parse_auth_providers(cls, v: Union[str, List[str], None]) -> List[str]: + """Parse AUTH_PROVIDERS from JSON arrays or CSV strings.""" + if v is None: + return ["api_key"] + + if isinstance(v, list): + providers = [str(provider).strip().lower() for provider in v if str(provider).strip()] + return providers or ["api_key"] + + if isinstance(v, str): + raw = v.strip() + if not raw: + return ["api_key"] + if raw.startswith("["): + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + providers = [ + str(provider).strip().lower() + for provider in parsed + if str(provider).strip() + ] + return providers or ["api_key"] + except (json.JSONDecodeError, ValueError): + pass + providers = [provider.strip().lower() for provider in raw.split(",") if provider.strip()] + return providers or ["api_key"] + + return ["api_key"] + + @field_validator("AUTH_OIDC_ORG_CLAIM_PATH", mode="before") + @classmethod + def parse_auth_oidc_org_claim_path(cls, v: Union[str, List[str], None]) -> List[str]: + """Parse AUTH_OIDC_ORG_CLAIM_PATH from JSON arrays or dot notation.""" + if v is None: + return [] + + if isinstance(v, list): + return [str(item).strip() for item in v if str(item).strip()] + + if isinstance(v, str): + raw = v.strip() + if not raw: + return [] + if raw.startswith("["): + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(item).strip() for item in parsed if str(item).strip()] + except (json.JSONDecodeError, ValueError): + pass + return [part.strip() for part in raw.split(".") if part.strip()] + + return [] + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # Build DATABASE_URL if not provided + if not self.DATABASE_URL: + self.DATABASE_URL = ( + f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + + # Build REDIS_URL if not provided + if not self.REDIS_URL: + self.REDIS_URL = f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + # Build Celery URLs if not provided + if not self.CELERY_BROKER_URL: + self.CELERY_BROKER_URL = self.REDIS_URL + if not self.CELERY_RESULT_BACKEND: + self.CELERY_RESULT_BACKEND = self.REDIS_URL + + +def load_config_from_file(config_path: str) -> None: + """Load configuration from a YAML file and update global settings.""" + import yaml + + config_file = Path(config_path) + if not config_file.exists(): + raise FileNotFoundError(f"Config file not found: {config_path}") + + with open(config_file, "r") as f: + config_data = yaml.safe_load(f) or {} + + # Update settings with YAML values + if "app" in config_data: + app_config = config_data["app"] + if "name" in app_config: + settings.APP_NAME = app_config["name"] + if "version" in app_config: + settings.APP_VERSION = app_config["version"] + if "debug" in app_config: + settings.DEBUG = app_config["debug"] + if "secret_key" in app_config: + settings.SECRET_KEY = app_config["secret_key"] + + if "server" in config_data: + server_config = config_data["server"] + if "host" in server_config: + settings.HOST = server_config["host"] + if "port" in server_config: + settings.PORT = server_config["port"] + + if "database" in config_data: + db_config = config_data["database"] + if "url" in db_config: + settings.DATABASE_URL = db_config["url"] + else: + if "user" in db_config: + settings.POSTGRES_USER = db_config["user"] + if "password" in db_config: + settings.POSTGRES_PASSWORD = db_config["password"] + if "host" in db_config: + settings.POSTGRES_HOST = db_config["host"] + if "port" in db_config: + settings.POSTGRES_PORT = db_config["port"] + if "db" in db_config: + settings.POSTGRES_DB = db_config["db"] + # Rebuild DATABASE_URL + settings.DATABASE_URL = ( + f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}" + f"@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + ) + + if "redis" in config_data: + redis_config = config_data["redis"] + if "url" in redis_config: + settings.REDIS_URL = redis_config["url"] + else: + if "host" in redis_config: + settings.REDIS_HOST = redis_config["host"] + if "port" in redis_config: + settings.REDIS_PORT = redis_config["port"] + if "db" in redis_config: + settings.REDIS_DB = redis_config["db"] + # Rebuild REDIS_URL + settings.REDIS_URL = f"redis://{settings.REDIS_HOST}:{settings.REDIS_PORT}/{settings.REDIS_DB}" + + if "celery" in config_data: + celery_config = config_data["celery"] + if "broker_url" in celery_config: + settings.CELERY_BROKER_URL = celery_config["broker_url"] + if "result_backend" in celery_config: + settings.CELERY_RESULT_BACKEND = celery_config["result_backend"] + + if "storage" in config_data: + storage_config = config_data["storage"] + if "upload_dir" in storage_config: + settings.UPLOAD_DIR = storage_config["upload_dir"] + if "max_file_size_mb" in storage_config: + settings.MAX_FILE_SIZE_MB = storage_config["max_file_size_mb"] + if "allowed_audio_formats" in storage_config: + settings.ALLOWED_AUDIO_FORMATS = storage_config["allowed_audio_formats"] + + if "s3" in config_data: + s3_config = config_data["s3"] + if "enabled" in s3_config: + settings.S3_ENABLED = s3_config["enabled"] + if "bucket_name" in s3_config: + settings.S3_BUCKET_NAME = s3_config["bucket_name"] + if "region" in s3_config: + settings.S3_REGION = s3_config["region"] + if "access_key_id" in s3_config: + settings.S3_ACCESS_KEY_ID = s3_config["access_key_id"] + if "secret_access_key" in s3_config: + settings.S3_SECRET_ACCESS_KEY = s3_config["secret_access_key"] + if "endpoint_url" in s3_config: + settings.S3_ENDPOINT_URL = s3_config["endpoint_url"] + if "prefix" in s3_config: + settings.S3_PREFIX = s3_config["prefix"] + + if "smtp" in config_data: + smtp_config = config_data["smtp"] + if "host" in smtp_config: + settings.SMTP_HOST = smtp_config["host"] + if "port" in smtp_config: + settings.SMTP_PORT = smtp_config["port"] + if "username" in smtp_config: + settings.SMTP_USERNAME = smtp_config["username"] + if "password" in smtp_config: + settings.SMTP_PASSWORD = smtp_config["password"] + if "from_email" in smtp_config: + settings.SMTP_FROM_EMAIL = smtp_config["from_email"] + if "from_name" in smtp_config: + settings.SMTP_FROM_NAME = smtp_config["from_name"] + if "use_tls" in smtp_config: + settings.SMTP_USE_TLS = smtp_config["use_tls"] + + if "diarization" in config_data: + diarization_config = config_data["diarization"] + if "huggingface_token" in diarization_config and diarization_config["huggingface_token"]: + settings.HUGGINGFACE_TOKEN = diarization_config["huggingface_token"] + if "num_speakers" in diarization_config: + val = diarization_config["num_speakers"] + settings.DIARIZATION_NUM_SPEAKERS = int(val) if val is not None else None + + if "cors" in config_data: + cors_config = config_data["cors"] + if "origins" in cors_config: + settings.CORS_ORIGINS = cors_config["origins"] + + if "api" in config_data: + api_config = config_data["api"] + if "prefix" in api_config: + settings.API_V1_PREFIX = api_config["prefix"] + if "key_header" in api_config: + settings.API_KEY_HEADER = api_config["key_header"] + if "rate_limit_per_minute" in api_config: + settings.RATE_LIMIT_PER_MINUTE = api_config["rate_limit_per_minute"] + + if "auth" in config_data: + auth_config = config_data["auth"] + if "providers" in auth_config: + settings.AUTH_PROVIDERS = auth_config["providers"] + + local_config = auth_config.get("local_password", {}) + if isinstance(local_config, dict): + if "allow_signup" in local_config: + settings.AUTH_LOCAL_ALLOW_SIGNUP = bool(local_config["allow_signup"]) + if "token_ttl_minutes" in local_config: + settings.AUTH_LOCAL_TOKEN_TTL_MINUTES = int(local_config["token_ttl_minutes"]) + + oidc_config = auth_config.get("oidc", {}) + if isinstance(oidc_config, dict): + if "issuer" in oidc_config: + settings.AUTH_OIDC_ISSUER = oidc_config["issuer"] + if "client_id" in oidc_config: + settings.AUTH_OIDC_CLIENT_ID = oidc_config["client_id"] + if "audience" in oidc_config: + settings.AUTH_OIDC_AUDIENCE = oidc_config["audience"] + if "jwks_uri" in oidc_config: + settings.AUTH_OIDC_JWKS_URI = oidc_config["jwks_uri"] + if "org_claim_path" in oidc_config: + settings.AUTH_OIDC_ORG_CLAIM_PATH = oidc_config["org_claim_path"] + if "default_org_name" in oidc_config: + settings.AUTH_OIDC_DEFAULT_ORG_NAME = oidc_config["default_org_name"] + + if "license" in config_data: + license_config = config_data["license"] + if "key" in license_config: + settings.EFFICIENTAI_LICENSE = license_config["key"] + + if "plivo" in config_data: + plivo_cfg = config_data["plivo"] + if plivo_cfg.get("auth_id"): + settings.PLIVO_AUTH_ID = plivo_cfg["auth_id"] + if plivo_cfg.get("auth_token"): + settings.PLIVO_AUTH_TOKEN = plivo_cfg["auth_token"] + if plivo_cfg.get("verify_app_uuid"): + settings.PLIVO_VERIFY_APP_UUID = plivo_cfg["verify_app_uuid"] + if plivo_cfg.get("webhook_base_url"): + settings.PLIVO_WEBHOOK_BASE_URL = plivo_cfg["webhook_base_url"] + + # Update Celery URLs if they weren't explicitly set + if not settings.CELERY_BROKER_URL: + settings.CELERY_BROKER_URL = settings.REDIS_URL + if not settings.CELERY_RESULT_BACKEND: + settings.CELERY_RESULT_BACKEND = settings.REDIS_URL + + +# Initialize settings with error handling for problematic env vars +# If .env file has invalid format, we'll use defaults (YAML config will override anyway) +try: + settings = Settings() +except Exception as e: + # If there's an error loading from .env (e.g., invalid JSON in list fields), + # create settings with defaults. The YAML config loaded later will override these. + import warnings + import os + + warnings.warn( + f"Error loading .env file, using defaults. YAML config will override. Error: {str(e)[:100]}", + UserWarning, + stacklevel=2 + ) + + # Try to create settings without .env file by temporarily removing it + env_file = ".env" + if os.path.exists(env_file): + # Temporarily rename .env to avoid loading it + backup_file = f"{env_file}.backup" + try: + os.rename(env_file, backup_file) + settings = Settings() + os.rename(backup_file, env_file) + except Exception: + # If rename fails or Settings still fails, restore and use defaults + if os.path.exists(backup_file): + try: + os.rename(backup_file, env_file) + except Exception: + pass + # Create with explicit defaults - manually construct with default values + settings = Settings( + ALLOWED_AUDIO_FORMATS=["wav", "mp3", "flac", "m4a"], + CORS_ORIGINS=["http://localhost:3000", "http://localhost:8000"], + _env_file=None, # Don't load .env + ) + else: + # No .env file, create normally + settings = Settings() + diff --git a/app/core/auth/providers.py b/app/core/auth/providers.py index 0ffabb33..e2fa3b88 100644 --- a/app/core/auth/providers.py +++ b/app/core/auth/providers.py @@ -88,6 +88,7 @@ def names(self) -> List[str]: _registry_singleton: Optional[ProviderRegistry] = None +_registry_signature: Optional[tuple[str, ...]] = None def get_provider_registry() -> ProviderRegistry: @@ -98,10 +99,7 @@ def get_provider_registry() -> ProviderRegistry: import time) so that config.yml values loaded by `load_config_from_file` at startup are visible. """ - global _registry_singleton - if _registry_singleton is not None: - return _registry_singleton - + global _registry_singleton, _registry_signature from app.config import settings from app.core.auth.api_key import ApiKeyProvider from app.core.auth.local import LocalPasswordProvider @@ -110,6 +108,11 @@ def get_provider_registry() -> ProviderRegistry: enabled = {p.strip().lower() for p in (settings.AUTH_PROVIDERS or []) if p} if not enabled: enabled = {"api_key"} + enabled_signature = tuple(sorted(enabled)) + + # Rebuild when the configured providers change (e.g. monkeypatched tests). + if _registry_singleton is not None and _registry_signature == enabled_signature: + return _registry_singleton # Fixed priority order: API keys first (deterministic machine path), then # the local-password bearer (which self-identifies by its `iss` claim), @@ -126,10 +129,12 @@ def get_provider_registry() -> ProviderRegistry: providers.append(builder()) _registry_singleton = ProviderRegistry(providers) + _registry_signature = enabled_signature return _registry_singleton def reset_provider_registry() -> None: """Drop the cached registry. Useful for tests and hot reload.""" - global _registry_singleton + global _registry_singleton, _registry_signature _registry_singleton = None + _registry_signature = None diff --git a/app/migrations/019_add_telephony_tables.py b/app/migrations/019_add_telephony_tables.py new file mode 100644 index 00000000..3e4d053f --- /dev/null +++ b/app/migrations/019_add_telephony_tables.py @@ -0,0 +1,195 @@ +""" +Migration: Add provider-agnostic telephony tables. + +Creates the four telephony_* tables that back per-organization telephony +integrations (Plivo, Twilio, Vonage, etc.), phone-number inventory, voice OTP +verification sessions, and number-masking sessions. +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = "Add telephony tables (integrations, phone numbers, verify sessions, masked sessions)" + + +def _table_exists(db: Session, table_name: str) -> bool: + result = db.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = :table_name + ) + """ + ), + {"table_name": table_name}, + ) + return bool(result.scalar()) + + +def upgrade(db: Session): + """Create telephony_* tables and indexes.""" + + if not _table_exists(db, "telephony_integrations"): + db.execute( + text( + """ + CREATE TABLE telephony_integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + provider VARCHAR(50) NOT NULL DEFAULT 'plivo', + auth_id VARCHAR(255) NOT NULL, + auth_token VARCHAR(512) NOT NULL, + verify_app_uuid VARCHAR(255), + voice_app_id VARCHAR(255), + sip_domain VARCHAR(255), + masking_config JSONB, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_tested_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_telephony_integration_org_provider UNIQUE (organization_id, provider) + ) + """ + ) + ) + db.execute( + text("CREATE INDEX ix_telephony_integrations_organization_id ON telephony_integrations(organization_id)") + ) + + if not _table_exists(db, "telephony_phone_numbers"): + db.execute( + text( + """ + CREATE TABLE telephony_phone_numbers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + telephony_integration_id UUID NOT NULL REFERENCES telephony_integrations(id), + phone_number VARCHAR(20) NOT NULL, + country_iso2 VARCHAR(2), + region VARCHAR(100), + number_type VARCHAR(20), + capabilities JSONB, + provider_app_id VARCHAR(255), + is_masking_pool BOOLEAN NOT NULL DEFAULT FALSE, + agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + CONSTRAINT uq_telephony_number_org_phone UNIQUE (organization_id, phone_number) + ) + """ + ) + ) + db.execute( + text("CREATE INDEX ix_telephony_phone_numbers_organization_id ON telephony_phone_numbers(organization_id)") + ) + db.execute( + text( + "CREATE INDEX ix_telephony_phone_numbers_telephony_integration_id " + "ON telephony_phone_numbers(telephony_integration_id)" + ) + ) + db.execute( + text("CREATE INDEX ix_telephony_phone_numbers_phone_number ON telephony_phone_numbers(phone_number)") + ) + db.execute(text("CREATE INDEX ix_telephony_phone_numbers_agent_id ON telephony_phone_numbers(agent_id)")) + + if not _table_exists(db, "telephony_verify_sessions"): + db.execute( + text( + """ + CREATE TABLE telephony_verify_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + provider_session_uuid VARCHAR(255) NOT NULL UNIQUE, + recipient_number VARCHAR(20) NOT NULL, + channel VARCHAR(10) NOT NULL DEFAULT 'voice', + status VARCHAR(20) NOT NULL DEFAULT 'pending', + initiated_by VARCHAR(255), + verify_app_uuid VARCHAR(255), + verified_at TIMESTAMP WITH TIME ZONE, + expires_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ) + """ + ) + ) + db.execute( + text( + "CREATE INDEX ix_telephony_verify_sessions_organization_id " + "ON telephony_verify_sessions(organization_id)" + ) + ) + db.execute( + text( + "CREATE INDEX ix_telephony_verify_sessions_provider_session_uuid " + "ON telephony_verify_sessions(provider_session_uuid)" + ) + ) + + if not _table_exists(db, "telephony_masked_sessions"): + db.execute( + text( + """ + CREATE TABLE telephony_masked_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + telephony_integration_id UUID NOT NULL REFERENCES telephony_integrations(id), + masked_number_id UUID NOT NULL REFERENCES telephony_phone_numbers(id), + masked_number VARCHAR(20) NOT NULL, + party_a_number VARCHAR(20) NOT NULL, + party_b_number VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + expires_at TIMESTAMP WITH TIME ZONE, + ended_at TIMESTAMP WITH TIME ZONE, + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() + ) + """ + ) + ) + db.execute( + text( + "CREATE INDEX ix_telephony_masked_sessions_organization_id " + "ON telephony_masked_sessions(organization_id)" + ) + ) + db.execute( + text( + "CREATE INDEX ix_telephony_masked_sessions_masked_number_id " + "ON telephony_masked_sessions(masked_number_id)" + ) + ) + db.execute( + text( + "CREATE INDEX ix_telephony_masked_sessions_masked_number " + "ON telephony_masked_sessions(masked_number)" + ) + ) + db.execute( + text("CREATE INDEX ix_telephony_masked_sessions_status ON telephony_masked_sessions(status)") + ) + # Only one active masking session per masked number at a time. + db.execute( + text( + """ + CREATE UNIQUE INDEX uq_telephony_masked_sessions_masked_number_active + ON telephony_masked_sessions(masked_number_id) + WHERE status = 'active' + """ + ) + ) + + db.commit() + + +def downgrade(db: Session): + db.execute(text("DROP TABLE IF EXISTS telephony_masked_sessions")) + db.execute(text("DROP TABLE IF EXISTS telephony_verify_sessions")) + db.execute(text("DROP TABLE IF EXISTS telephony_phone_numbers")) + db.execute(text("DROP TABLE IF EXISTS telephony_integrations")) + db.commit() diff --git a/app/migrations/020_add_telephony_link_to_agents.py b/app/migrations/020_add_telephony_link_to_agents.py new file mode 100644 index 00000000..00aa6b57 --- /dev/null +++ b/app/migrations/020_add_telephony_link_to_agents.py @@ -0,0 +1,101 @@ +""" +Migration: Link agents to telephony phone numbers. +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = "Add telephony_phone_number_id FK on agents for provider-linked phone call routing" + + +def _table_exists(db: Session, table_name: str) -> bool: + result = db.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_name = :table_name + ) + """ + ), + {"table_name": table_name}, + ) + return bool(result.scalar()) + + +def _column_exists(db: Session, table_name: str, column_name: str) -> bool: + result = db.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = :table_name AND column_name = :column_name + ) + """ + ), + {"table_name": table_name, "column_name": column_name}, + ) + return bool(result.scalar()) + + +def _constraint_exists(db: Session, table_name: str, constraint_name: str) -> bool: + result = db.execute( + text( + """ + SELECT EXISTS ( + SELECT 1 + FROM information_schema.table_constraints + WHERE table_name = :table_name + AND constraint_name = :constraint_name + ) + """ + ), + {"table_name": table_name, "constraint_name": constraint_name}, + ) + return bool(result.scalar()) + + +def upgrade(db: Session): + if _table_exists(db, "agents") and not _column_exists(db, "agents", "telephony_phone_number_id"): + db.execute(text("ALTER TABLE agents ADD COLUMN telephony_phone_number_id UUID")) + + if ( + _table_exists(db, "agents") + and _table_exists(db, "telephony_phone_numbers") + and _column_exists(db, "agents", "telephony_phone_number_id") + and not _constraint_exists(db, "agents", "fk_agents_telephony_phone_number_id") + ): + db.execute( + text( + """ + ALTER TABLE agents + ADD CONSTRAINT fk_agents_telephony_phone_number_id + FOREIGN KEY (telephony_phone_number_id) + REFERENCES telephony_phone_numbers(id) + ON DELETE SET NULL + """ + ) + ) + + db.execute( + text( + """ + CREATE INDEX IF NOT EXISTS ix_agents_telephony_phone_number_id + ON agents(telephony_phone_number_id) + """ + ) + ) + + db.commit() + + +def downgrade(db: Session): + if _table_exists(db, "agents"): + db.execute(text("DROP INDEX IF EXISTS ix_agents_telephony_phone_number_id")) + db.execute(text("ALTER TABLE agents DROP CONSTRAINT IF EXISTS fk_agents_telephony_phone_number_id")) + if _column_exists(db, "agents", "telephony_phone_number_id"): + db.execute(text("ALTER TABLE agents DROP COLUMN telephony_phone_number_id")) + + db.commit() diff --git a/app/models/database.py b/app/models/database.py index 9d1cfc4f..c9e76d90 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -1,943 +1,1051 @@ -"""SQLAlchemy database models.""" - -from sqlalchemy import Column, String, Integer, Float, DateTime, ForeignKey, Boolean, JSON, Enum, UniqueConstraint, Text -from sqlalchemy.dialects.postgresql import UUID -from sqlalchemy.orm import relationship -from sqlalchemy.sql import func -import uuid -import enum -from app.models.enums import ( - EvaluationType, EvaluationStatus, EvaluatorResultStatus, RoleEnum, InvitationStatus, - LanguageEnum, CallTypeEnum, CallMediumEnum, GenderEnum, AccentEnum, BackgroundNoiseEnum, - IntegrationPlatform, ModelProvider, VoiceBundleType, TestAgentConversationStatus, - MetricType, MetricTrigger, CallRecordingStatus, AlertMetricType, AlertAggregation, - AlertOperator, AlertNotifyFrequency, AlertStatus, AlertHistoryStatus, CronJobStatus, - PromptOptimizationStatus, -) - -def get_enum_values(enum_class): - """Helper to get values from enum class for SQLAlchemy.""" - return [e.value for e in enum_class] - -from app.database import Base - - -# Enums moved to enums.py - - -class Organization(Base): - """Organization model for multi-tenancy.""" - - __tablename__ = "organizations" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name = Column(String(255), nullable=False) - voice_playground_threshold_overrides = Column(JSON, nullable=True) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - # Relationships - api_keys = relationship("APIKey", back_populates="organization") - members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan") - invitations = relationship("Invitation", back_populates="organization", cascade="all, delete-orphan") - - -class User(Base): - """User model for authentication and profile management.""" - - __tablename__ = "users" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - email = Column(String(255), unique=True, nullable=False, index=True) - name = Column(String(255), nullable=True) - first_name = Column(String(255), nullable=True) - last_name = Column(String(255), nullable=True) - password_hash = Column(String(255), nullable=True) # Nullable for users created via invitation - is_active = Column(Boolean, default=True, nullable=False) - - # Auth-provider bookkeeping (populated by the pluggable auth system). - # - external_id: stable subject from the upstream IdP, e.g. "okta:". - # - auth_provider: name of the provider that first created this user. - # - mfa_enabled: has the user completed an MFA enrolment (enforced by the IdP). - # - last_login_at: most recent successful interactive (non-API-key) sign-in. - external_id = Column(String(255), unique=True, nullable=True, index=True) - auth_provider = Column(String(50), nullable=True) - mfa_enabled = Column(Boolean, default=False, nullable=False) - last_login_at = Column(DateTime(timezone=True), nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - # Relationships - organization_memberships = relationship("OrganizationMember", back_populates="user", cascade="all, delete-orphan") - api_keys = relationship("APIKey", back_populates="user") - invitations = relationship("Invitation", back_populates="invited_user", foreign_keys="Invitation.invited_user_id") - - -class OrganizationMember(Base): - """Organization membership with role.""" - - __tablename__ = "organization_members" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True) - role = Column(String, nullable=False, default=RoleEnum.READER.value) - - # User preferences for this organization - default_agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True, index=True) - - joined_at = Column(DateTime(timezone=True), server_default=func.now()) - - # Unique constraint: one membership per user per organization - __table_args__ = ( - UniqueConstraint('organization_id', 'user_id', name='uq_org_user'), - ) - - # Relationships - organization = relationship("Organization", back_populates="members") - user = relationship("User", back_populates="organization_memberships") - default_agent = relationship("Agent", foreign_keys=[default_agent_id]) - - -class Invitation(Base): - """Invitation model for inviting users to organizations.""" - - __tablename__ = "invitations" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - invited_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True, index=True) # Null if user doesn't exist yet - invited_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) - email = Column(String(255), nullable=False) # Email of invited user - role = Column(String, nullable=False, default=RoleEnum.READER.value) - status = Column(String, nullable=False, default=InvitationStatus.PENDING.value) - - - - token = Column(String(255), unique=True, nullable=False, index=True) # Invitation token - expires_at = Column(DateTime(timezone=True), nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - accepted_at = Column(DateTime(timezone=True), nullable=True) - - # Relationships - organization = relationship("Organization", back_populates="invitations") - invited_user = relationship("User", foreign_keys=[invited_user_id], back_populates="invitations") - invited_by = relationship("User", foreign_keys=[invited_by_id]) - - -class APIKey(Base): - """API Key model for authentication.""" - - __tablename__ = "api_keys" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - key = Column(String(255), unique=True, nullable=False, index=True) - name = Column(String(255), nullable=True) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True, index=True) # Optional: link to user - is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - last_used = Column(DateTime(timezone=True), nullable=True) - - # Relationships - organization = relationship("Organization", back_populates="api_keys") - user = relationship("User", back_populates="api_keys") - - -class AudioFile(Base): - """Audio file model.""" - - __tablename__ = "audio_files" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - filename = Column(String(255), nullable=False) - file_path = Column(String(512), nullable=False) - file_size = Column(Integer, nullable=False) # Size in bytes - duration = Column(Float, nullable=True) # Duration in seconds - sample_rate = Column(Integer, nullable=True) - channels = Column(Integer, nullable=True) - format = Column(String(10), nullable=False) # wav, mp3, flac, etc. - uploaded_at = Column(DateTime(timezone=True), server_default=func.now()) - - # Relationships - evaluations = relationship("Evaluation", back_populates="audio_file") - - -class Evaluation(Base): - """Evaluation job model.""" - - __tablename__ = "evaluations" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - audio_id = Column(UUID(as_uuid=True), ForeignKey("audio_files.id"), nullable=False) - reference_text = Column(String, nullable=True) # For WER calculation - evaluation_type = Column(String, nullable=False) - model_name = Column(String(100), nullable=True) - status = Column(String, default=EvaluationStatus.PENDING.value, nullable=False) - - - - metrics_requested = Column(JSON, nullable=True) # List of requested metrics - created_at = Column(DateTime(timezone=True), server_default=func.now()) - started_at = Column(DateTime(timezone=True), nullable=True) - completed_at = Column(DateTime(timezone=True), nullable=True) - error_message = Column(String, nullable=True) - - # Relationships - audio_file = relationship("AudioFile", back_populates="evaluations") - result = relationship("EvaluationResult", back_populates="evaluation", uselist=False) - - -class EvaluationResult(Base): - """Evaluation result model.""" - - __tablename__ = "evaluation_results" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - evaluation_id = Column(UUID(as_uuid=True), ForeignKey("evaluations.id"), nullable=False, unique=True) - transcript = Column(String, nullable=True) - metrics = Column(JSON, nullable=False) # {"wer": 0.05, "latency_ms": 1250, ...} - raw_output = Column(JSON, nullable=True) # Full model output - processing_time = Column(Float, nullable=True) # Processing time in seconds - model_used = Column(String(100), nullable=True) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - - # Relationships - evaluation = relationship("Evaluation", back_populates="result") - - -# ============================================ -# VAIOPS MODELS - Voice AI Ops -# ============================================ - -# Enums moved to enums.py - - -class Agent(Base): - """Test Agent - The voice AI agent being evaluated""" - __tablename__ = "agents" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - agent_id = Column(String(6), unique=True, nullable=True, index=True) # 6-digit ID - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - name = Column(String, nullable=False) - phone_number = Column(String, nullable=True) # Optional, required only for phone_call - language = Column(String, nullable=False, default=LanguageEnum.ENGLISH.value) - description = Column(String) - provider_prompt = Column(Text, nullable=True) - provider_prompt_synced_at = Column(DateTime(timezone=True), nullable=True) - call_type = Column(String, nullable=False, default=CallTypeEnum.OUTBOUND.value) - call_medium = Column(String, nullable=False, default=CallMediumEnum.PHONE_CALL.value) - - - - - # Voice configuration - either voice_bundle_id OR ai_provider_id OR voice_ai_integration_id (mutually exclusive) - voice_bundle_id = Column(UUID(as_uuid=True), ForeignKey("voicebundles.id"), nullable=True, index=True) - ai_provider_id = Column(UUID(as_uuid=True), ForeignKey("aiproviders.id"), nullable=True, index=True) - - # Voice AI agent integration (Retell, Vapi, etc.) - voice_ai_integration_id = Column(UUID(as_uuid=True), ForeignKey("integrations.id"), nullable=True, index=True) - voice_ai_agent_id = Column(String, nullable=True) # Agent ID from the external provider (Retell/Vapi) - - created_at = Column(DateTime, server_default=func.now()) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) - created_by = Column(String) - - -class Persona(Base): - """Persona - TTS provider-tied voice identity for testing""" - __tablename__ = "personas" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - name = Column(String, nullable=False) - gender = Column(String, nullable=False, default=GenderEnum.NEUTRAL.value) - tts_provider = Column(String(100), nullable=True) - tts_voice_id = Column(String(255), nullable=True) - tts_voice_name = Column(String(255), nullable=True) - is_custom = Column(Boolean, default=False) - - created_at = Column(DateTime, server_default=func.now()) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) - created_by = Column(String) - - -class Scenario(Base): - """Scenario - The conversation scenario/test case""" - __tablename__ = "scenarios" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True, index=True) - name = Column(String, nullable=False) - description = Column(String) - required_info = Column(JSON) - - created_at = Column(DateTime, server_default=func.now()) - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) - created_by = Column(String) - - -# Enums moved to enums.py - - -class Integration(Base): - """Integration model for connecting with external voice AI platforms.""" - __tablename__ = "integrations" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - platform = Column(String, nullable=False) - - - - name = Column(String, nullable=True) # Optional friendly name - api_key = Column(String, nullable=False) # Encrypted Private API key for the platform - public_key = Column(String, nullable=True) # Optional Public API key (e.g. for Vapi) - is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - last_tested_at = Column(DateTime(timezone=True), nullable=True) # When API key was last validated - - -class ManualTranscription(Base): - """Manual transcription model for storing transcriptions from S3 audio files.""" - - __tablename__ = "manual_transcriptions" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - name = Column(String(255), nullable=True) # User-friendly name for the transcription - audio_file_key = Column(String(512), nullable=False) # S3 key or file path - transcript = Column(String, nullable=False) # Full transcript text - speaker_segments = Column(JSON, nullable=True) # List of segments with speaker labels: [{"speaker": "Speaker 1", "text": "...", "start": 0.0, "end": 5.2}] - stt_model = Column(String(100), nullable=True) # STT model used (e.g., "whisper-1", "google-speech-v2") - stt_provider = Column(String, nullable=True) # Provider used - - - - language = Column(String(10), nullable=True) # Detected or specified language - processing_time = Column(Float, nullable=True) # Processing time in seconds - raw_output = Column(JSON, nullable=True) # Full model output for reference - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - -class ConversationEvaluation(Base): - """Conversation evaluation model for evaluating manual transcriptions against agent objectives.""" - - __tablename__ = "conversation_evaluations" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - transcription_id = Column(UUID(as_uuid=True), ForeignKey("manual_transcriptions.id"), nullable=False, index=True) - agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False, index=True) - - # Evaluation results - objective_achieved = Column(Boolean, nullable=False) # Binary: was the conversation objective achieved? - objective_achieved_reason = Column(String, nullable=True) # Explanation for the binary result - additional_metrics = Column(JSON, nullable=True) # Additional evaluation metrics (e.g., professionalism, clarity, etc.) - overall_score = Column(Float, nullable=True) # Overall score (0.0 to 1.0) - - # LLM metadata - llm_provider = Column(Enum(ModelProvider, native_enum=False), nullable=True) - - llm_model = Column(String(100), nullable=True) - llm_response = Column(JSON, nullable=True) # Full LLM response for reference - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - -class AIProvider(Base): - """AI Provider - Stores API keys for different AI platforms.""" - __tablename__ = "aiproviders" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - provider = Column(String, nullable=False) - - - - api_key = Column(String, nullable=False) # Encrypted API key - name = Column(String, nullable=True) # Optional friendly name - is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - last_tested_at = Column(DateTime(timezone=True), nullable=True) # When API key was last validated - - # Unique constraint: one active provider per organization - __table_args__ = ( - UniqueConstraint('organization_id', 'provider', name='unique_org_provider'), - ) - - -# Enums moved to enums.py - - -class VoiceBundle(Base): - """VoiceBundle - Composable unit combining STT, LLM, and TTS for voice AI testing, or S2S models.""" - __tablename__ = "voicebundles" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - name = Column(String, nullable=False) - description = Column(String, nullable=True) - - # Bundle type: either STT+LLM+TTS or S2S - # Using String instead of Enum to avoid SQLAlchemy enum conversion issues - # The enum conversion is handled in the Pydantic schemas - bundle_type = Column(String(50), nullable=False, default=VoiceBundleType.STT_LLM_TTS.value) - - # STT Configuration (references AIProvider via provider name) - required for STT_LLM_TTS, optional for S2S - stt_provider = Column(String, nullable=True) - - stt_model = Column(String, nullable=True) # e.g., "whisper-1", "google-speech-v2" - - # LLM Configuration (references AIProvider via provider name) - required for STT_LLM_TTS, optional for S2S - llm_provider = Column(String, nullable=True) - - llm_model = Column(String, nullable=True) # e.g., "gpt-4", "claude-3-opus" - llm_temperature = Column(Float, nullable=True, default=0.7) - llm_max_tokens = Column(Integer, nullable=True) - llm_config = Column(JSON, nullable=True) # Additional LLM configuration (extensible) - - # TTS Configuration (references AIProvider via provider name) - required for STT_LLM_TTS, optional for S2S - tts_provider = Column(String, nullable=True) - - tts_model = Column(String, nullable=True) # e.g., "tts-1", "neural-voice" - tts_voice = Column(String, nullable=True) # Voice selection if applicable - tts_config = Column(JSON, nullable=True) # Additional TTS configuration (extensible) - - # S2S Configuration - required for S2S type, optional for STT_LLM_TTS - s2s_provider = Column(String, nullable=True) - - - - s2s_model = Column(String, nullable=True) # e.g., "gpt-4o-transcribe", speech-to-speech model - s2s_config = Column(JSON, nullable=True) # Additional S2S configuration (extensible) - - # Additional configuration for extensibility - extra_metadata = Column(JSON, nullable=True) # For future extensions (renamed from 'metadata' to avoid SQLAlchemy conflict) - - is_active = Column(Boolean, default=True, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - -# Enums moved to enums.py - - -class TestAgentConversation(Base): - """Test Agent Conversation - Records conversations between test AI agent and voice AI agent.""" - __tablename__ = "test_agent_conversations" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - - # Configuration - agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False) - persona_id = Column(UUID(as_uuid=True), ForeignKey("personas.id"), nullable=False) - scenario_id = Column(UUID(as_uuid=True), ForeignKey("scenarios.id"), nullable=False) - voice_bundle_id = Column(UUID(as_uuid=True), ForeignKey("voicebundles.id"), nullable=True) - - # Conversation data - status = Column(String, nullable=False, default=TestAgentConversationStatus.INITIALIZING.value) - - - - live_transcription = Column(JSON, nullable=True) # Array of conversation turns with timestamps - conversation_audio_key = Column(String, nullable=True) # S3 key for recorded conversation audio - full_transcript = Column(String, nullable=True) # Full conversation transcript - - # Metadata - started_at = Column(DateTime(timezone=True), server_default=func.now()) - ended_at = Column(DateTime(timezone=True), nullable=True) - duration_seconds = Column(Float, nullable=True) - - # Additional metadata - conversation_metadata = Column(JSON, nullable=True) # Additional conversation metadata - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - -class Evaluator(Base): - """Evaluator - Configuration for testing agents with specific persona and scenario combinations, or custom prompt evaluators.""" - __tablename__ = "evaluators" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - evaluator_id = Column(String(6), unique=True, nullable=False, index=True) # 6-digit ID - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - - # Display name (required for custom evaluators, optional for standard) - name = Column(String, nullable=True) - - # Standard evaluator configuration (nullable for custom evaluators) - agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=True) - persona_id = Column(UUID(as_uuid=True), ForeignKey("personas.id"), nullable=True) - scenario_id = Column(UUID(as_uuid=True), ForeignKey("scenarios.id"), nullable=True) - - # Custom evaluator prompt (used instead of agent/persona/scenario) - custom_prompt = Column(Text, nullable=True) - - # LLM configuration for evaluation (overrides hardcoded defaults) - llm_provider = Column(String, nullable=True) # e.g. "openai", "anthropic", "google" - llm_model = Column(String, nullable=True) # e.g. "gpt-4.1", "claude-sonnet-4-20250514" - - # Tags for categorization - tags = Column(JSON, nullable=True) # Array of tag strings - - # Metadata - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - -# Enums moved to enums.py - - -class Metric(Base): - """Metric - Configuration for evaluation metrics.""" - __tablename__ = "metrics" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - - # Basic information - name = Column(String, nullable=False) - description = Column(String, nullable=True) - - # Configuration - metric_type = Column(String, nullable=False, default=MetricType.RATING.value) - trigger = Column(String, nullable=False, default=MetricTrigger.ALWAYS.value) - - - - enabled = Column(Boolean, nullable=False, default=True) - - # Metadata - is_default = Column(Boolean, nullable=False, default=False) # Pre-defined metrics - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - -class EvaluatorResult(Base): - """EvaluatorResult - Results from running an evaluator with transcription and metric evaluations.""" - __tablename__ = "evaluator_results" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - result_id = Column(String(6), unique=True, nullable=False, index=True) # 6-digit ID - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - - # References - evaluator_id = Column(UUID(as_uuid=True), ForeignKey("evaluators.id"), nullable=True, index=True) # Optional - can be None for test calls without persona/scenario - agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=True) # Nullable for custom evaluators - persona_id = Column(UUID(as_uuid=True), ForeignKey("personas.id"), nullable=True) # Optional - can be None for test calls - scenario_id = Column(UUID(as_uuid=True), ForeignKey("scenarios.id"), nullable=True) # Optional - can be None for test calls - - # Result data - name = Column(String, nullable=True) # Scenario name or test call name (optional) - timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - duration_seconds = Column(Float, nullable=True) # Call duration - status = Column(String(20), nullable=False, default=EvaluatorResultStatus.QUEUED.value) - - # Audio and transcription - audio_s3_key = Column(String, nullable=True) # S3 key for audio file - transcription = Column(String, nullable=True) # Full transcription - speaker_segments = Column(JSON, nullable=True) # List of segments with speaker labels: [{"speaker": "Speaker 1", "text": "...", "start": 0.0, "end": 5.2}] - - # Metric scores - JSON object with metric_id as key and score as value - # Format: {"metric_id_1": {"value": 85, "type": "rating"}, "metric_id_2": {"value": true, "type": "boolean"}} - metric_scores = Column(JSON, nullable=True) - - # Celery task tracking - celery_task_id = Column(String, nullable=True, index=True) # Celery task ID for tracking - - # Error information - error_message = Column(String, nullable=True) - - # Call event tracking (similar to CallRecording) - call_event = Column(String, nullable=True, index=True) # Latest call event (e.g., call_started, call_ended) - provider_call_id = Column(String, nullable=True, index=True) # Provider's call_id (e.g., Retell call_id) - provider_platform = Column(String, nullable=True) # e.g., "retell", "vapi" - call_data = Column(JSON, nullable=True) # Full call details from provider (like CallRecording) - - # Metadata - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - -# Enums moved to enums.py - - -class CallRecordingSource(str, enum.Enum): - """Source of the call recording data.""" - - PLAYGROUND = "playground" - WEBHOOK = "webhook" - - -class CallRecording(Base): - """Call Recording model for tracking voice provider calls.""" - __tablename__ = "call_recordings" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - call_short_id = Column(String(6), unique=True, nullable=False, index=True) # 6-digit ID - status = Column(Enum(CallRecordingStatus), nullable=False, default=CallRecordingStatus.PENDING, index=True) - call_event = Column(String, nullable=True, index=True) # Latest webhook event (e.g., call_started, call_ended) - source = Column(Enum(CallRecordingSource), nullable=False, default=CallRecordingSource.PLAYGROUND, index=True) - call_data = Column(JSON, nullable=True) # JSON blob for provider response - provider_call_id = Column(String, nullable=True, index=True) # Provider's call_id (e.g., Retell call_id) - provider_platform = Column(String, nullable=True) # e.g., "retell", "vapi" - agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=True) # Reference to our agent - - # Link to EvaluatorResult for metric evaluations - evaluator_result_id = Column(UUID(as_uuid=True), ForeignKey("evaluator_results.id"), nullable=True, index=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - -class Alert(Base): - """Alert model for configuring monitoring alerts.""" - __tablename__ = "alerts" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - - # Basic information - name = Column(String(255), nullable=False) - description = Column(String, nullable=True) - - # Metric condition configuration - metric_type = Column(String, nullable=False, default=AlertMetricType.NUMBER_OF_CALLS.value) - aggregation = Column(String, nullable=False, default=AlertAggregation.SUM.value) - operator = Column(String, nullable=False, default=AlertOperator.GREATER_THAN.value) - threshold_value = Column(Float, nullable=False) - time_window_minutes = Column(Integer, nullable=False, default=60) # Time window for aggregation - - # Agent selection (JSON array of agent UUIDs, null means all agents) - agent_ids = Column(JSON, nullable=True) - - # Notification configuration - notify_frequency = Column(String, nullable=False, default=AlertNotifyFrequency.IMMEDIATE.value) - notify_emails = Column(JSON, nullable=True) # Array of email addresses - notify_webhooks = Column(JSON, nullable=True) # Array of webhook URLs (Slack, etc.) - - # Status - status = Column(String, nullable=False, default=AlertStatus.ACTIVE.value) - - # Metadata - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - # Relationships - alert_history = relationship("AlertHistory", back_populates="alert", cascade="all, delete-orphan") - - -class AlertHistory(Base): - """Alert history model for tracking triggered alerts.""" - __tablename__ = "alert_history" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - alert_id = Column(UUID(as_uuid=True), ForeignKey("alerts.id"), nullable=False, index=True) - - # Trigger information - triggered_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - triggered_value = Column(Float, nullable=False) # The actual value that triggered the alert - threshold_value = Column(Float, nullable=False) # The threshold at time of trigger - - # Status tracking - status = Column(String, nullable=False, default=AlertHistoryStatus.TRIGGERED.value) - - # Notification tracking - notified_at = Column(DateTime(timezone=True), nullable=True) - notification_details = Column(JSON, nullable=True) # Details of sent notifications - - # Resolution - acknowledged_at = Column(DateTime(timezone=True), nullable=True) - acknowledged_by = Column(String, nullable=True) - resolved_at = Column(DateTime(timezone=True), nullable=True) - resolved_by = Column(String, nullable=True) - resolution_notes = Column(String, nullable=True) - - # Additional context - context_data = Column(JSON, nullable=True) # Additional data about the trigger - - # Metadata - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - # Relationships - alert = relationship("Alert", back_populates="alert_history") - - -class CronJob(Base): - """Cron job model for scheduling automated evaluator runs.""" - __tablename__ = "cron_jobs" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - - # Basic information - name = Column(String(255), nullable=False) - cron_expression = Column(String(100), nullable=False) # e.g., "0 9 * * 1-5" - timezone = Column(String(100), nullable=False, default="UTC") - - # Run configuration - max_runs = Column(Integer, nullable=False, default=10) - current_runs = Column(Integer, nullable=False, default=0) - - # Evaluators to trigger (JSON array of evaluator UUIDs) - evaluator_ids = Column(JSON, nullable=False) - - # Status - status = Column(String, nullable=False, default=CronJobStatus.ACTIVE.value) - - # Run tracking - next_run_at = Column(DateTime(timezone=True), nullable=True) - last_run_at = Column(DateTime(timezone=True), nullable=True) - - # Metadata - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - -class TTSComparisonStatus(str, enum.Enum): - PENDING = "pending" - GENERATING = "generating" - EVALUATING = "evaluating" - COMPLETED = "completed" - FAILED = "failed" - - -class TTSSampleStatus(str, enum.Enum): - PENDING = "pending" - GENERATING = "generating" - COMPLETED = "completed" - FAILED = "failed" - - -class TTSReportJobStatus(str, enum.Enum): - PENDING = "pending" - PROCESSING = "processing" - COMPLETED = "completed" - FAILED = "failed" - - -class TTSComparison(Base): - """TTS Comparison session for A/B testing voice providers.""" - __tablename__ = "tts_comparisons" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - simulation_id = Column(String(6), unique=True, index=True, nullable=True) - - name = Column(String(255), nullable=True) - status = Column(String(50), nullable=False, default=TTSComparisonStatus.PENDING.value) - - provider_a = Column(String(100), nullable=False) - model_a = Column(String(100), nullable=False) - voices_a = Column(JSON, nullable=False) - - provider_b = Column(String(100), nullable=True) - model_b = Column(String(100), nullable=True) - voices_b = Column(JSON, nullable=True) - - sample_texts = Column(JSON, nullable=False) - num_runs = Column(Integer, nullable=False, default=1) - - blind_test_results = Column(JSON, nullable=True) - evaluation_summary = Column(JSON, nullable=True) - - eval_stt_provider = Column(String(100), nullable=True) - eval_stt_model = Column(String(100), nullable=True) - - celery_task_id = Column(String, nullable=True, index=True) - error_message = Column(String, nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - samples = relationship("TTSSample", back_populates="comparison", cascade="all, delete-orphan") - - -class TTSSample(Base): - """Individual TTS audio sample within a comparison.""" - __tablename__ = "tts_samples" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - comparison_id = Column(UUID(as_uuid=True), ForeignKey("tts_comparisons.id", ondelete="CASCADE"), nullable=False, index=True) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - - provider = Column(String(100), nullable=False) - model = Column(String(100), nullable=False) - voice_id = Column(String(255), nullable=False) - voice_name = Column(String(255), nullable=True) - side = Column(String(1), nullable=True) # "A" or "B" - sample_index = Column(Integer, nullable=False) - run_index = Column(Integer, nullable=False, default=0) - - text = Column(String, nullable=False) - audio_s3_key = Column(String(512), nullable=True) - duration_seconds = Column(Float, nullable=True) - latency_ms = Column(Float, nullable=True) - ttfb_ms = Column(Float, nullable=True) - - evaluation_metrics = Column(JSON, nullable=True) - status = Column(String(50), nullable=False, default=TTSSampleStatus.PENDING.value) - error_message = Column(String, nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - comparison = relationship("TTSComparison", back_populates="samples") - - -class TTSReportJob(Base): - """Asynchronous PDF report generation jobs for Voice Playground.""" - __tablename__ = "tts_report_jobs" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - comparison_id = Column(UUID(as_uuid=True), ForeignKey("tts_comparisons.id", ondelete="CASCADE"), nullable=False, index=True) - - status = Column(String(50), nullable=False, default=TTSReportJobStatus.PENDING.value) - format = Column(String(20), nullable=False, default="pdf") - filename = Column(String(255), nullable=True) - s3_key = Column(String(512), nullable=True) - error_message = Column(String, nullable=True) - celery_task_id = Column(String, nullable=True, index=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - comparison = relationship("TTSComparison") - - -class PromptPartial(Base): - """Prompt Partial - Reusable prompt templates with version history.""" - __tablename__ = "prompt_partials" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - name = Column(String(255), nullable=False) - description = Column(String, nullable=True) - content = Column(Text, nullable=False) - tags = Column(JSON, nullable=True) - current_version = Column(Integer, nullable=False, default=1) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - versions = relationship("PromptPartialVersion", back_populates="prompt_partial", cascade="all, delete-orphan", order_by="PromptPartialVersion.version.desc()") - - -class PromptPartialVersion(Base): - """Version history for a prompt partial.""" - __tablename__ = "prompt_partial_versions" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - prompt_partial_id = Column(UUID(as_uuid=True), ForeignKey("prompt_partials.id", ondelete="CASCADE"), nullable=False, index=True) - version = Column(Integer, nullable=False) - content = Column(Text, nullable=False) - change_summary = Column(String, nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - created_by = Column(String, nullable=True) - - prompt_partial = relationship("PromptPartial", back_populates="versions") - - __table_args__ = ( - UniqueConstraint('prompt_partial_id', 'version', name='uq_prompt_partial_version'), - ) - - -class CustomTTSVoice(Base): - """Organization-scoped custom TTS voice metadata.""" - __tablename__ = "custom_tts_voices" - __table_args__ = ( - UniqueConstraint("organization_id", "provider", "voice_id", name="uq_custom_tts_voice_org_provider_voice_id"), - ) - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - provider = Column(String(100), nullable=False, index=True) - voice_id = Column(String(255), nullable=False) - name = Column(String(255), nullable=False) - gender = Column(String(50), nullable=True) - accent = Column(String(100), nullable=True) - description = Column(Text, nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - - -class PromptOptimizationRun(Base): - """A single GEPA prompt optimization run for an agent.""" - __tablename__ = "prompt_optimization_runs" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) - agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False, index=True) - evaluator_id = Column(UUID(as_uuid=True), ForeignKey("evaluators.id"), nullable=True) - voice_bundle_id = Column(UUID(as_uuid=True), ForeignKey("voicebundles.id"), nullable=True) - - seed_prompt = Column(Text, nullable=False) - best_prompt = Column(Text, nullable=True) - best_score = Column(Float, nullable=True) - - status = Column(String(20), nullable=False, default=PromptOptimizationStatus.PENDING.value) - config = Column(JSON, nullable=True) - reflection_trace = Column(JSON, nullable=True) - metric_history = Column(JSON, nullable=True) - - num_iterations = Column(Integer, nullable=True) - num_metric_calls = Column(Integer, nullable=True) - - celery_task_id = Column(String, nullable=True, index=True) - error_message = Column(Text, nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) - created_by = Column(String, nullable=True) - - candidates = relationship("PromptOptimizationCandidate", back_populates="optimization_run", cascade="all, delete-orphan") - - -class PromptOptimizationCandidate(Base): - """A candidate prompt generated during an optimization run.""" - __tablename__ = "prompt_optimization_candidates" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - optimization_run_id = Column(UUID(as_uuid=True), ForeignKey("prompt_optimization_runs.id", ondelete="CASCADE"), nullable=False, index=True) - - prompt_text = Column(Text, nullable=False) - score = Column(Float, nullable=True) - metric_breakdown = Column(JSON, nullable=True) - reflection_summary = Column(Text, nullable=True) - - parent_candidate_id = Column(UUID(as_uuid=True), ForeignKey("prompt_optimization_candidates.id"), nullable=True) - - is_accepted = Column(Boolean, nullable=False, default=False) - pushed_to_provider_at = Column(DateTime(timezone=True), nullable=True) - - created_at = Column(DateTime(timezone=True), server_default=func.now()) - - optimization_run = relationship("PromptOptimizationRun", back_populates="candidates") \ No newline at end of file +"""SQLAlchemy database models.""" + +from sqlalchemy import Column, String, Integer, Float, DateTime, ForeignKey, Boolean, JSON, Enum, UniqueConstraint, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +import uuid +import enum +from app.models.enums import ( + EvaluationType, EvaluationStatus, EvaluatorResultStatus, RoleEnum, InvitationStatus, + LanguageEnum, CallTypeEnum, CallMediumEnum, GenderEnum, AccentEnum, BackgroundNoiseEnum, + IntegrationPlatform, ModelProvider, VoiceBundleType, TestAgentConversationStatus, + MetricType, MetricTrigger, CallRecordingStatus, AlertMetricType, AlertAggregation, + AlertOperator, AlertNotifyFrequency, AlertStatus, AlertHistoryStatus, CronJobStatus, + PromptOptimizationStatus, +) + +def get_enum_values(enum_class): + """Helper to get values from enum class for SQLAlchemy.""" + return [e.value for e in enum_class] + +from app.database import Base + + +# Enums moved to enums.py + + +class Organization(Base): + """Organization model for multi-tenancy.""" + + __tablename__ = "organizations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String(255), nullable=False) + voice_playground_threshold_overrides = Column(JSON, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + api_keys = relationship("APIKey", back_populates="organization") + members = relationship("OrganizationMember", back_populates="organization", cascade="all, delete-orphan") + invitations = relationship("Invitation", back_populates="organization", cascade="all, delete-orphan") + + +class User(Base): + """User model for authentication and profile management.""" + + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=True) + first_name = Column(String(255), nullable=True) + last_name = Column(String(255), nullable=True) + password_hash = Column(String(255), nullable=True) # Nullable for users created via invitation + external_id = Column(String(255), unique=True, nullable=True, index=True) + auth_provider = Column(String(50), nullable=True) + mfa_enabled = Column(Boolean, default=False, nullable=False) + last_login_at = Column(DateTime(timezone=True), nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + organization_memberships = relationship("OrganizationMember", back_populates="user", cascade="all, delete-orphan") + api_keys = relationship("APIKey", back_populates="user") + invitations = relationship("Invitation", back_populates="invited_user", foreign_keys="Invitation.invited_user_id") + + +class OrganizationMember(Base): + """Organization membership with role.""" + + __tablename__ = "organization_members" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True) + role = Column(String, nullable=False, default=RoleEnum.READER.value) + + # User preferences for this organization + default_agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True, index=True) + + joined_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Unique constraint: one membership per user per organization + __table_args__ = ( + UniqueConstraint('organization_id', 'user_id', name='uq_org_user'), + ) + + # Relationships + organization = relationship("Organization", back_populates="members") + user = relationship("User", back_populates="organization_memberships") + default_agent = relationship("Agent", foreign_keys=[default_agent_id]) + + +class Invitation(Base): + """Invitation model for inviting users to organizations.""" + + __tablename__ = "invitations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + invited_user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True, index=True) # Null if user doesn't exist yet + invited_by_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + email = Column(String(255), nullable=False) # Email of invited user + role = Column(String, nullable=False, default=RoleEnum.READER.value) + status = Column(String, nullable=False, default=InvitationStatus.PENDING.value) + + + + token = Column(String(255), unique=True, nullable=False, index=True) # Invitation token + expires_at = Column(DateTime(timezone=True), nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + accepted_at = Column(DateTime(timezone=True), nullable=True) + + # Relationships + organization = relationship("Organization", back_populates="invitations") + invited_user = relationship("User", foreign_keys=[invited_user_id], back_populates="invitations") + invited_by = relationship("User", foreign_keys=[invited_by_id]) + + +class APIKey(Base): + """API Key model for authentication.""" + + __tablename__ = "api_keys" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + key = Column(String(255), unique=True, nullable=False, index=True) + name = Column(String(255), nullable=True) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True, index=True) # Optional: link to user + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + last_used = Column(DateTime(timezone=True), nullable=True) + + # Relationships + organization = relationship("Organization", back_populates="api_keys") + user = relationship("User", back_populates="api_keys") + + +class AudioFile(Base): + """Audio file model.""" + + __tablename__ = "audio_files" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + filename = Column(String(255), nullable=False) + file_path = Column(String(512), nullable=False) + file_size = Column(Integer, nullable=False) # Size in bytes + duration = Column(Float, nullable=True) # Duration in seconds + sample_rate = Column(Integer, nullable=True) + channels = Column(Integer, nullable=True) + format = Column(String(10), nullable=False) # wav, mp3, flac, etc. + uploaded_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + evaluations = relationship("Evaluation", back_populates="audio_file") + + +class Evaluation(Base): + """Evaluation job model.""" + + __tablename__ = "evaluations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + audio_id = Column(UUID(as_uuid=True), ForeignKey("audio_files.id"), nullable=False) + reference_text = Column(String, nullable=True) # For WER calculation + evaluation_type = Column(String, nullable=False) + model_name = Column(String(100), nullable=True) + status = Column(String, default=EvaluationStatus.PENDING.value, nullable=False) + + + + metrics_requested = Column(JSON, nullable=True) # List of requested metrics + created_at = Column(DateTime(timezone=True), server_default=func.now()) + started_at = Column(DateTime(timezone=True), nullable=True) + completed_at = Column(DateTime(timezone=True), nullable=True) + error_message = Column(String, nullable=True) + + # Relationships + audio_file = relationship("AudioFile", back_populates="evaluations") + result = relationship("EvaluationResult", back_populates="evaluation", uselist=False) + + +class EvaluationResult(Base): + """Evaluation result model.""" + + __tablename__ = "evaluation_results" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + evaluation_id = Column(UUID(as_uuid=True), ForeignKey("evaluations.id"), nullable=False, unique=True) + transcript = Column(String, nullable=True) + metrics = Column(JSON, nullable=False) # {"wer": 0.05, "latency_ms": 1250, ...} + raw_output = Column(JSON, nullable=True) # Full model output + processing_time = Column(Float, nullable=True) # Processing time in seconds + model_used = Column(String(100), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + # Relationships + evaluation = relationship("Evaluation", back_populates="result") + + +# ============================================ +# VAIOPS MODELS - Voice AI Ops +# ============================================ + +# Enums moved to enums.py + + +class Agent(Base): + """Test Agent - The voice AI agent being evaluated""" + __tablename__ = "agents" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + agent_id = Column(String(6), unique=True, nullable=True, index=True) # 6-digit ID + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + name = Column(String, nullable=False) + phone_number = Column(String, nullable=True) # Optional, required only for phone_call + language = Column(String, nullable=False, default=LanguageEnum.ENGLISH.value) + description = Column(String) + provider_prompt = Column(Text, nullable=True) + provider_prompt_synced_at = Column(DateTime(timezone=True), nullable=True) + call_type = Column(String, nullable=False, default=CallTypeEnum.OUTBOUND.value) + call_medium = Column(String, nullable=False, default=CallMediumEnum.PHONE_CALL.value) + telephony_phone_number_id = Column( + UUID(as_uuid=True), + ForeignKey("telephony_phone_numbers.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + + + + # Voice configuration - either voice_bundle_id OR ai_provider_id OR voice_ai_integration_id (mutually exclusive) + voice_bundle_id = Column(UUID(as_uuid=True), ForeignKey("voicebundles.id"), nullable=True, index=True) + ai_provider_id = Column(UUID(as_uuid=True), ForeignKey("aiproviders.id"), nullable=True, index=True) + + # Voice AI agent integration (Retell, Vapi, etc.) + voice_ai_integration_id = Column(UUID(as_uuid=True), ForeignKey("integrations.id"), nullable=True, index=True) + voice_ai_agent_id = Column(String, nullable=True) # Agent ID from the external provider (Retell/Vapi) + + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + created_by = Column(String) + + +class Persona(Base): + """Persona - TTS provider-tied voice identity for testing""" + __tablename__ = "personas" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + name = Column(String, nullable=False) + gender = Column(String, nullable=False, default=GenderEnum.NEUTRAL.value) + tts_provider = Column(String(100), nullable=True) + tts_voice_id = Column(String(255), nullable=True) + tts_voice_name = Column(String(255), nullable=True) + is_custom = Column(Boolean, default=False) + + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + created_by = Column(String) + + +class Scenario(Base): + """Scenario - The conversation scenario/test case""" + __tablename__ = "scenarios" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True, index=True) + name = Column(String, nullable=False) + description = Column(String) + required_info = Column(JSON) + + created_at = Column(DateTime, server_default=func.now()) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + created_by = Column(String) + + +# Enums moved to enums.py + + +class Integration(Base): + """Integration model for connecting with external voice AI platforms.""" + __tablename__ = "integrations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + platform = Column(String, nullable=False) + + + + name = Column(String, nullable=True) # Optional friendly name + api_key = Column(String, nullable=False) # Encrypted Private API key for the platform + public_key = Column(String, nullable=True) # Optional Public API key (e.g. for Vapi) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + last_tested_at = Column(DateTime(timezone=True), nullable=True) # When API key was last validated + + +class ManualTranscription(Base): + """Manual transcription model for storing transcriptions from S3 audio files.""" + + __tablename__ = "manual_transcriptions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + name = Column(String(255), nullable=True) # User-friendly name for the transcription + audio_file_key = Column(String(512), nullable=False) # S3 key or file path + transcript = Column(String, nullable=False) # Full transcript text + speaker_segments = Column(JSON, nullable=True) # List of segments with speaker labels: [{"speaker": "Speaker 1", "text": "...", "start": 0.0, "end": 5.2}] + stt_model = Column(String(100), nullable=True) # STT model used (e.g., "whisper-1", "google-speech-v2") + stt_provider = Column(String, nullable=True) # Provider used + + + + language = Column(String(10), nullable=True) # Detected or specified language + processing_time = Column(Float, nullable=True) # Processing time in seconds + raw_output = Column(JSON, nullable=True) # Full model output for reference + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class ConversationEvaluation(Base): + """Conversation evaluation model for evaluating manual transcriptions against agent objectives.""" + + __tablename__ = "conversation_evaluations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + transcription_id = Column(UUID(as_uuid=True), ForeignKey("manual_transcriptions.id"), nullable=False, index=True) + agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False, index=True) + + # Evaluation results + objective_achieved = Column(Boolean, nullable=False) # Binary: was the conversation objective achieved? + objective_achieved_reason = Column(String, nullable=True) # Explanation for the binary result + additional_metrics = Column(JSON, nullable=True) # Additional evaluation metrics (e.g., professionalism, clarity, etc.) + overall_score = Column(Float, nullable=True) # Overall score (0.0 to 1.0) + + # LLM metadata + llm_provider = Column(Enum(ModelProvider, native_enum=False), nullable=True) + + llm_model = Column(String(100), nullable=True) + llm_response = Column(JSON, nullable=True) # Full LLM response for reference + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class AIProvider(Base): + """AI Provider - Stores API keys for different AI platforms.""" + __tablename__ = "aiproviders" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + provider = Column(String, nullable=False) + + + + api_key = Column(String, nullable=False) # Encrypted API key + name = Column(String, nullable=True) # Optional friendly name + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + last_tested_at = Column(DateTime(timezone=True), nullable=True) # When API key was last validated + + # Unique constraint: one active provider per organization + __table_args__ = ( + UniqueConstraint('organization_id', 'provider', name='unique_org_provider'), + ) + + +# Enums moved to enums.py + + +class VoiceBundle(Base): + """VoiceBundle - Composable unit combining STT, LLM, and TTS for voice AI testing, or S2S models.""" + __tablename__ = "voicebundles" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + name = Column(String, nullable=False) + description = Column(String, nullable=True) + + # Bundle type: either STT+LLM+TTS or S2S + # Using String instead of Enum to avoid SQLAlchemy enum conversion issues + # The enum conversion is handled in the Pydantic schemas + bundle_type = Column(String(50), nullable=False, default=VoiceBundleType.STT_LLM_TTS.value) + + # STT Configuration (references AIProvider via provider name) - required for STT_LLM_TTS, optional for S2S + stt_provider = Column(String, nullable=True) + + stt_model = Column(String, nullable=True) # e.g., "whisper-1", "google-speech-v2" + + # LLM Configuration (references AIProvider via provider name) - required for STT_LLM_TTS, optional for S2S + llm_provider = Column(String, nullable=True) + + llm_model = Column(String, nullable=True) # e.g., "gpt-4", "claude-3-opus" + llm_temperature = Column(Float, nullable=True, default=0.7) + llm_max_tokens = Column(Integer, nullable=True) + llm_config = Column(JSON, nullable=True) # Additional LLM configuration (extensible) + + # TTS Configuration (references AIProvider via provider name) - required for STT_LLM_TTS, optional for S2S + tts_provider = Column(String, nullable=True) + + tts_model = Column(String, nullable=True) # e.g., "tts-1", "neural-voice" + tts_voice = Column(String, nullable=True) # Voice selection if applicable + tts_config = Column(JSON, nullable=True) # Additional TTS configuration (extensible) + + # S2S Configuration - required for S2S type, optional for STT_LLM_TTS + s2s_provider = Column(String, nullable=True) + + + + s2s_model = Column(String, nullable=True) # e.g., "gpt-4o-transcribe", speech-to-speech model + s2s_config = Column(JSON, nullable=True) # Additional S2S configuration (extensible) + + # Additional configuration for extensibility + extra_metadata = Column(JSON, nullable=True) # For future extensions (renamed from 'metadata' to avoid SQLAlchemy conflict) + + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + +# Enums moved to enums.py + + +class TestAgentConversation(Base): + """Test Agent Conversation - Records conversations between test AI agent and voice AI agent.""" + __tablename__ = "test_agent_conversations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + + # Configuration + agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False) + persona_id = Column(UUID(as_uuid=True), ForeignKey("personas.id"), nullable=False) + scenario_id = Column(UUID(as_uuid=True), ForeignKey("scenarios.id"), nullable=False) + voice_bundle_id = Column(UUID(as_uuid=True), ForeignKey("voicebundles.id"), nullable=True) + + # Conversation data + status = Column(String, nullable=False, default=TestAgentConversationStatus.INITIALIZING.value) + + + + live_transcription = Column(JSON, nullable=True) # Array of conversation turns with timestamps + conversation_audio_key = Column(String, nullable=True) # S3 key for recorded conversation audio + full_transcript = Column(String, nullable=True) # Full conversation transcript + + # Metadata + started_at = Column(DateTime(timezone=True), server_default=func.now()) + ended_at = Column(DateTime(timezone=True), nullable=True) + duration_seconds = Column(Float, nullable=True) + + # Additional metadata + conversation_metadata = Column(JSON, nullable=True) # Additional conversation metadata + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + +class Evaluator(Base): + """Evaluator - Configuration for testing agents with specific persona and scenario combinations, or custom prompt evaluators.""" + __tablename__ = "evaluators" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + evaluator_id = Column(String(6), unique=True, nullable=False, index=True) # 6-digit ID + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + + # Display name (required for custom evaluators, optional for standard) + name = Column(String, nullable=True) + + # Standard evaluator configuration (nullable for custom evaluators) + agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=True) + persona_id = Column(UUID(as_uuid=True), ForeignKey("personas.id"), nullable=True) + scenario_id = Column(UUID(as_uuid=True), ForeignKey("scenarios.id"), nullable=True) + + # Custom evaluator prompt (used instead of agent/persona/scenario) + custom_prompt = Column(Text, nullable=True) + + # LLM configuration for evaluation (overrides hardcoded defaults) + llm_provider = Column(String, nullable=True) # e.g. "openai", "anthropic", "google" + llm_model = Column(String, nullable=True) # e.g. "gpt-4.1", "claude-sonnet-4-20250514" + + # Tags for categorization + tags = Column(JSON, nullable=True) # Array of tag strings + + # Metadata + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + +# Enums moved to enums.py + + +class Metric(Base): + """Metric - Configuration for evaluation metrics.""" + __tablename__ = "metrics" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + + # Basic information + name = Column(String, nullable=False) + description = Column(String, nullable=True) + + # Configuration + metric_type = Column(String, nullable=False, default=MetricType.RATING.value) + trigger = Column(String, nullable=False, default=MetricTrigger.ALWAYS.value) + metric_origin = Column(String(30), nullable=False, default="default") + supported_surfaces = Column(JSON, nullable=False, default=list) # ["agent", "voice_playground", "blind_test"] + enabled_surfaces = Column(JSON, nullable=False, default=list) # subset of supported_surfaces + custom_data_type = Column(String(30), nullable=True) # "boolean" | "enum" | "number_range" + custom_config = Column(JSON, nullable=True) # enum options / number range config + tags = Column(JSON, nullable=True) # ["tone", "latency", ...] + + + enabled = Column(Boolean, nullable=False, default=True) + + # Metadata + is_default = Column(Boolean, nullable=False, default=False) # Pre-defined metrics + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + +class EvaluatorResult(Base): + """EvaluatorResult - Results from running an evaluator with transcription and metric evaluations.""" + __tablename__ = "evaluator_results" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + result_id = Column(String(6), unique=True, nullable=False, index=True) # 6-digit ID + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + + # References + evaluator_id = Column(UUID(as_uuid=True), ForeignKey("evaluators.id"), nullable=True, index=True) # Optional - can be None for test calls without persona/scenario + agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=True) # Nullable for custom evaluators + persona_id = Column(UUID(as_uuid=True), ForeignKey("personas.id"), nullable=True) # Optional - can be None for test calls + scenario_id = Column(UUID(as_uuid=True), ForeignKey("scenarios.id"), nullable=True) # Optional - can be None for test calls + + # Result data + name = Column(String, nullable=True) # Scenario name or test call name (optional) + timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + duration_seconds = Column(Float, nullable=True) # Call duration + status = Column(String(20), nullable=False, default=EvaluatorResultStatus.QUEUED.value) + + # Audio and transcription + audio_s3_key = Column(String, nullable=True) # S3 key for audio file + transcription = Column(String, nullable=True) # Full transcription + speaker_segments = Column(JSON, nullable=True) # List of segments with speaker labels: [{"speaker": "Speaker 1", "text": "...", "start": 0.0, "end": 5.2}] + + # Metric scores - JSON object with metric_id as key and score as value + # Format: {"metric_id_1": {"value": 85, "type": "rating"}, "metric_id_2": {"value": true, "type": "boolean"}} + metric_scores = Column(JSON, nullable=True) + + # Celery task tracking + celery_task_id = Column(String, nullable=True, index=True) # Celery task ID for tracking + + # Error information + error_message = Column(String, nullable=True) + + # Call event tracking (similar to CallRecording) + call_event = Column(String, nullable=True, index=True) # Latest call event (e.g., call_started, call_ended) + provider_call_id = Column(String, nullable=True, index=True) # Provider's call_id (e.g., Retell call_id) + provider_platform = Column(String, nullable=True) # e.g., "retell", "vapi" + call_data = Column(JSON, nullable=True) # Full call details from provider (like CallRecording) + + # Metadata + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + +# Enums moved to enums.py + + +class CallRecordingSource(str, enum.Enum): + """Source of the call recording data.""" + + PLAYGROUND = "playground" + WEBHOOK = "webhook" + + +class CallRecording(Base): + """Call Recording model for tracking voice provider calls.""" + __tablename__ = "call_recordings" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + call_short_id = Column(String(6), unique=True, nullable=False, index=True) # 6-digit ID + status = Column(Enum(CallRecordingStatus), nullable=False, default=CallRecordingStatus.PENDING, index=True) + call_event = Column(String, nullable=True, index=True) # Latest webhook event (e.g., call_started, call_ended) + source = Column(Enum(CallRecordingSource), nullable=False, default=CallRecordingSource.PLAYGROUND, index=True) + call_data = Column(JSON, nullable=True) # JSON blob for provider response + provider_call_id = Column(String, nullable=True, index=True) # Provider's call_id (e.g., Retell call_id) + provider_platform = Column(String, nullable=True) # e.g., "retell", "vapi" + agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=True) # Reference to our agent + + # Link to EvaluatorResult for metric evaluations + evaluator_result_id = Column(UUID(as_uuid=True), ForeignKey("evaluator_results.id"), nullable=True, index=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class Alert(Base): + """Alert model for configuring monitoring alerts.""" + __tablename__ = "alerts" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + + # Basic information + name = Column(String(255), nullable=False) + description = Column(String, nullable=True) + + # Metric condition configuration + metric_type = Column(String, nullable=False, default=AlertMetricType.NUMBER_OF_CALLS.value) + aggregation = Column(String, nullable=False, default=AlertAggregation.SUM.value) + operator = Column(String, nullable=False, default=AlertOperator.GREATER_THAN.value) + threshold_value = Column(Float, nullable=False) + time_window_minutes = Column(Integer, nullable=False, default=60) # Time window for aggregation + + # Agent selection (JSON array of agent UUIDs, null means all agents) + agent_ids = Column(JSON, nullable=True) + + # Notification configuration + notify_frequency = Column(String, nullable=False, default=AlertNotifyFrequency.IMMEDIATE.value) + notify_emails = Column(JSON, nullable=True) # Array of email addresses + notify_webhooks = Column(JSON, nullable=True) # Array of webhook URLs (Slack, etc.) + + # Status + status = Column(String, nullable=False, default=AlertStatus.ACTIVE.value) + + # Metadata + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + # Relationships + alert_history = relationship("AlertHistory", back_populates="alert", cascade="all, delete-orphan") + + +class AlertHistory(Base): + """Alert history model for tracking triggered alerts.""" + __tablename__ = "alert_history" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + alert_id = Column(UUID(as_uuid=True), ForeignKey("alerts.id"), nullable=False, index=True) + + # Trigger information + triggered_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + triggered_value = Column(Float, nullable=False) # The actual value that triggered the alert + threshold_value = Column(Float, nullable=False) # The threshold at time of trigger + + # Status tracking + status = Column(String, nullable=False, default=AlertHistoryStatus.TRIGGERED.value) + + # Notification tracking + notified_at = Column(DateTime(timezone=True), nullable=True) + notification_details = Column(JSON, nullable=True) # Details of sent notifications + + # Resolution + acknowledged_at = Column(DateTime(timezone=True), nullable=True) + acknowledged_by = Column(String, nullable=True) + resolved_at = Column(DateTime(timezone=True), nullable=True) + resolved_by = Column(String, nullable=True) + resolution_notes = Column(String, nullable=True) + + # Additional context + context_data = Column(JSON, nullable=True) # Additional data about the trigger + + # Metadata + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + # Relationships + alert = relationship("Alert", back_populates="alert_history") + + +class CronJob(Base): + """Cron job model for scheduling automated evaluator runs.""" + __tablename__ = "cron_jobs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + + # Basic information + name = Column(String(255), nullable=False) + cron_expression = Column(String(100), nullable=False) # e.g., "0 9 * * 1-5" + timezone = Column(String(100), nullable=False, default="UTC") + + # Run configuration + max_runs = Column(Integer, nullable=False, default=10) + current_runs = Column(Integer, nullable=False, default=0) + + # Evaluators to trigger (JSON array of evaluator UUIDs) + evaluator_ids = Column(JSON, nullable=False) + + # Status + status = Column(String, nullable=False, default=CronJobStatus.ACTIVE.value) + + # Run tracking + next_run_at = Column(DateTime(timezone=True), nullable=True) + last_run_at = Column(DateTime(timezone=True), nullable=True) + + # Metadata + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + +class TTSComparisonStatus(str, enum.Enum): + PENDING = "pending" + GENERATING = "generating" + EVALUATING = "evaluating" + COMPLETED = "completed" + FAILED = "failed" + + +class TTSSampleStatus(str, enum.Enum): + PENDING = "pending" + GENERATING = "generating" + COMPLETED = "completed" + FAILED = "failed" + + +class TTSReportJobStatus(str, enum.Enum): + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class TTSComparison(Base): + """TTS Comparison session for A/B testing voice providers.""" + __tablename__ = "tts_comparisons" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + simulation_id = Column(String(6), unique=True, index=True, nullable=True) + + name = Column(String(255), nullable=True) + status = Column(String(50), nullable=False, default=TTSComparisonStatus.PENDING.value) + + provider_a = Column(String(100), nullable=False) + model_a = Column(String(100), nullable=False) + voices_a = Column(JSON, nullable=False) + + provider_b = Column(String(100), nullable=True) + model_b = Column(String(100), nullable=True) + voices_b = Column(JSON, nullable=True) + + sample_texts = Column(JSON, nullable=False) + num_runs = Column(Integer, nullable=False, default=1) + + blind_test_results = Column(JSON, nullable=True) + evaluation_summary = Column(JSON, nullable=True) + + eval_stt_provider = Column(String(100), nullable=True) + eval_stt_model = Column(String(100), nullable=True) + + celery_task_id = Column(String, nullable=True, index=True) + error_message = Column(String, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + samples = relationship("TTSSample", back_populates="comparison", cascade="all, delete-orphan") + + +class TTSSample(Base): + """Individual TTS audio sample within a comparison.""" + __tablename__ = "tts_samples" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + comparison_id = Column(UUID(as_uuid=True), ForeignKey("tts_comparisons.id", ondelete="CASCADE"), nullable=False, index=True) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + + provider = Column(String(100), nullable=False) + model = Column(String(100), nullable=False) + voice_id = Column(String(255), nullable=False) + voice_name = Column(String(255), nullable=True) + side = Column(String(1), nullable=True) # "A" or "B" + sample_index = Column(Integer, nullable=False) + run_index = Column(Integer, nullable=False, default=0) + + text = Column(String, nullable=False) + audio_s3_key = Column(String(512), nullable=True) + duration_seconds = Column(Float, nullable=True) + latency_ms = Column(Float, nullable=True) + ttfb_ms = Column(Float, nullable=True) + + evaluation_metrics = Column(JSON, nullable=True) + status = Column(String(50), nullable=False, default=TTSSampleStatus.PENDING.value) + error_message = Column(String, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + comparison = relationship("TTSComparison", back_populates="samples") + + +class TTSReportJob(Base): + """Asynchronous PDF report generation jobs for Voice Playground.""" + __tablename__ = "tts_report_jobs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + comparison_id = Column(UUID(as_uuid=True), ForeignKey("tts_comparisons.id", ondelete="CASCADE"), nullable=False, index=True) + + status = Column(String(50), nullable=False, default=TTSReportJobStatus.PENDING.value) + format = Column(String(20), nullable=False, default="pdf") + filename = Column(String(255), nullable=True) + s3_key = Column(String(512), nullable=True) + error_message = Column(String, nullable=True) + celery_task_id = Column(String, nullable=True, index=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + comparison = relationship("TTSComparison") + + +class PromptPartial(Base): + """Prompt Partial - Reusable prompt templates with version history.""" + __tablename__ = "prompt_partials" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + name = Column(String(255), nullable=False) + description = Column(String, nullable=True) + content = Column(Text, nullable=False) + tags = Column(JSON, nullable=True) + current_version = Column(Integer, nullable=False, default=1) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + versions = relationship("PromptPartialVersion", back_populates="prompt_partial", cascade="all, delete-orphan", order_by="PromptPartialVersion.version.desc()") + + +class PromptPartialVersion(Base): + """Version history for a prompt partial.""" + __tablename__ = "prompt_partial_versions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + prompt_partial_id = Column(UUID(as_uuid=True), ForeignKey("prompt_partials.id", ondelete="CASCADE"), nullable=False, index=True) + version = Column(Integer, nullable=False) + content = Column(Text, nullable=False) + change_summary = Column(String, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + created_by = Column(String, nullable=True) + + prompt_partial = relationship("PromptPartial", back_populates="versions") + + __table_args__ = ( + UniqueConstraint('prompt_partial_id', 'version', name='uq_prompt_partial_version'), + ) + + +class CustomTTSVoice(Base): + """Organization-scoped custom TTS voice metadata.""" + __tablename__ = "custom_tts_voices" + __table_args__ = ( + UniqueConstraint("organization_id", "provider", "voice_id", name="uq_custom_tts_voice_org_provider_voice_id"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + provider = Column(String(100), nullable=False, index=True) + voice_id = Column(String(255), nullable=False) + name = Column(String(255), nullable=False) + gender = Column(String(50), nullable=True) + accent = Column(String(100), nullable=True) + description = Column(Text, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + +class PromptOptimizationRun(Base): + """A single GEPA prompt optimization run for an agent.""" + __tablename__ = "prompt_optimization_runs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False, index=True) + evaluator_id = Column(UUID(as_uuid=True), ForeignKey("evaluators.id"), nullable=True) + voice_bundle_id = Column(UUID(as_uuid=True), ForeignKey("voicebundles.id"), nullable=True) + + seed_prompt = Column(Text, nullable=False) + best_prompt = Column(Text, nullable=True) + best_score = Column(Float, nullable=True) + + status = Column(String(20), nullable=False, default=PromptOptimizationStatus.PENDING.value) + config = Column(JSON, nullable=True) + reflection_trace = Column(JSON, nullable=True) + metric_history = Column(JSON, nullable=True) + + num_iterations = Column(Integer, nullable=True) + num_metric_calls = Column(Integer, nullable=True) + + celery_task_id = Column(String, nullable=True, index=True) + error_message = Column(Text, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + candidates = relationship("PromptOptimizationCandidate", back_populates="optimization_run", cascade="all, delete-orphan") + + +class PromptOptimizationCandidate(Base): + """A candidate prompt generated during an optimization run.""" + __tablename__ = "prompt_optimization_candidates" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + optimization_run_id = Column(UUID(as_uuid=True), ForeignKey("prompt_optimization_runs.id", ondelete="CASCADE"), nullable=False, index=True) + + prompt_text = Column(Text, nullable=False) + score = Column(Float, nullable=True) + metric_breakdown = Column(JSON, nullable=True) + reflection_summary = Column(Text, nullable=True) + + parent_candidate_id = Column(UUID(as_uuid=True), ForeignKey("prompt_optimization_candidates.id"), nullable=True) + + is_accepted = Column(Boolean, nullable=False, default=False) + pushed_to_provider_at = Column(DateTime(timezone=True), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + optimization_run = relationship("PromptOptimizationRun", back_populates="candidates") + + +class TelephonyIntegration(Base): + """Per-organization telephony provider credentials and configuration.""" + + __tablename__ = "telephony_integrations" + __table_args__ = ( + UniqueConstraint("organization_id", "provider", name="uq_telephony_integration_org_provider"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + provider = Column(String(50), nullable=False, default="plivo") + + auth_id = Column(String(255), nullable=False) + auth_token = Column(String(512), nullable=False) + + verify_app_uuid = Column(String(255), nullable=True) + voice_app_id = Column(String(255), nullable=True) + sip_domain = Column(String(255), nullable=True) + masking_config = Column(JSON, nullable=True) + + is_active = Column(Boolean, default=True, nullable=False) + last_tested_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class TelephonyPhoneNumber(Base): + """Inventory of telephony phone numbers owned by an organization.""" + + __tablename__ = "telephony_phone_numbers" + __table_args__ = ( + UniqueConstraint("organization_id", "phone_number", name="uq_telephony_number_org_phone"), + ) + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + telephony_integration_id = Column( + UUID(as_uuid=True), ForeignKey("telephony_integrations.id"), nullable=False, index=True + ) + + phone_number = Column(String(20), nullable=False, index=True) + country_iso2 = Column(String(2), nullable=True) + region = Column(String(100), nullable=True) + number_type = Column(String(20), nullable=True) + capabilities = Column(JSON, nullable=True) + provider_app_id = Column(String(255), nullable=True) + + is_masking_pool = Column(Boolean, default=False, nullable=False) + agent_id = Column( + UUID(as_uuid=True), + ForeignKey( + "agents.id", + ondelete="SET NULL", + use_alter=True, + name="fk_telephony_phone_numbers_agent_id", + ), + nullable=True, + index=True, + ) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class TelephonyVerifySession(Base): + """Tracks voice OTP verification sessions via telephony provider.""" + + __tablename__ = "telephony_verify_sessions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + provider_session_uuid = Column(String(255), nullable=False, unique=True, index=True) + recipient_number = Column(String(20), nullable=False) + channel = Column(String(10), nullable=False, default="voice") + status = Column(String(20), nullable=False, default="pending") + initiated_by = Column(String(255), nullable=True) + verify_app_uuid = Column(String(255), nullable=True) + verified_at = Column(DateTime(timezone=True), nullable=True) + expires_at = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class TelephonyMaskedSession(Base): + """Number-masking session between two parties through a middle number.""" + + __tablename__ = "telephony_masked_sessions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + telephony_integration_id = Column(UUID(as_uuid=True), ForeignKey("telephony_integrations.id"), nullable=False) + masked_number_id = Column( + UUID(as_uuid=True), ForeignKey("telephony_phone_numbers.id"), nullable=False, index=True + ) + masked_number = Column(String(20), nullable=False) + party_a_number = Column(String(20), nullable=False) + party_b_number = Column(String(20), nullable=False) + status = Column(String(20), nullable=False, default="active") + expires_at = Column(DateTime(timezone=True), nullable=True) + ended_at = Column(DateTime(timezone=True), nullable=True) + session_metadata = Column("metadata", JSON, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/app/models/enums.py b/app/models/enums.py index 9bab4851..736000e3 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -61,6 +61,7 @@ class CallMediumEnum(str, enum.Enum): """Call medium""" PHONE_CALL = "phone_call" WEB_CALL = "web_call" + SIP_CALL = "sip_call" class GenderEnum(str, enum.Enum): """Gender options for personas""" @@ -101,6 +102,12 @@ class IntegrationPlatform(str, enum.Enum): VOICEMAKER = "voicemaker" SMALLEST = "smallest" + +class TelephonyProvider(str, enum.Enum): + """Telephony provider enumeration - extensible for future providers.""" + PLIVO = "plivo" + EXOTEL = "exotel" + class ModelProvider(str, enum.Enum): """Model provider enumeration for extensibility.""" OPENAI = "openai" diff --git a/app/models/schemas.py b/app/models/schemas.py index 8285eb31..27662fec 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -1,6 +1,6 @@ """Pydantic schemas for request/response validation.""" -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator, validator from typing import Optional, List, Dict, Any from datetime import datetime from uuid import UUID @@ -180,6 +180,7 @@ class AgentCreate(BaseModel): description: str = Field(..., min_length=1) call_type: CallTypeEnum = CallTypeEnum.OUTBOUND call_medium: CallMediumEnum = CallMediumEnum.PHONE_CALL + telephony_phone_number_id: Optional[UUID] = None voice_bundle_id: Optional[UUID] = None ai_provider_id: Optional[UUID] = None voice_ai_integration_id: UUID = Field(..., description="Voice AI integration is required") @@ -230,6 +231,7 @@ class AgentUpdate(BaseModel): description: Optional[str] = None call_type: Optional[CallTypeEnum] = None call_medium: Optional[CallMediumEnum] = None + telephony_phone_number_id: Optional[UUID] = None voice_bundle_id: Optional[UUID] = None voice_ai_integration_id: Optional[UUID] = None voice_ai_agent_id: Optional[str] = None @@ -267,6 +269,7 @@ class AgentResponse(BaseModel): description: Optional[str] call_type: CallTypeEnum call_medium: CallMediumEnum + telephony_phone_number_id: Optional[UUID] = None voice_bundle_id: Optional[UUID] ai_provider_id: Optional[UUID] voice_ai_integration_id: Optional[UUID] @@ -1058,6 +1061,12 @@ class MetricCreate(BaseModel): metric_type: MetricType = MetricType.RATING trigger: MetricTrigger = MetricTrigger.ALWAYS enabled: bool = True + metric_origin: str = "custom" + supported_surfaces: List[str] = ["agent"] + enabled_surfaces: Optional[List[str]] = None + custom_data_type: Optional[str] = None + custom_config: Optional[Dict[str, Any]] = None + tags: Optional[List[str]] = None model_config = ConfigDict(json_schema_extra={ "example": { @@ -1077,6 +1086,12 @@ class MetricUpdate(BaseModel): metric_type: Optional[MetricType] = None trigger: Optional[MetricTrigger] = None enabled: Optional[bool] = None + metric_origin: Optional[str] = None + supported_surfaces: Optional[List[str]] = None + enabled_surfaces: Optional[List[str]] = None + custom_data_type: Optional[str] = None + custom_config: Optional[Dict[str, Any]] = None + tags: Optional[List[str]] = None class MetricResponse(BaseModel): @@ -1089,6 +1104,12 @@ class MetricResponse(BaseModel): trigger: MetricTrigger enabled: bool is_default: bool + metric_origin: str + supported_surfaces: List[str] + enabled_surfaces: List[str] + custom_data_type: Optional[str] + custom_config: Optional[Dict[str, Any]] + tags: Optional[List[str]] created_at: datetime updated_at: datetime created_by: Optional[str] @@ -1126,6 +1147,22 @@ def convert_trigger(cls, v): return enum_member raise ValueError(f"Invalid MetricTrigger value: {v}") return v + + @validator('supported_surfaces', 'enabled_surfaces', pre=True) + def normalize_surfaces(cls, v): + if v is None: + return [] + if isinstance(v, str): + return [v] + if isinstance(v, (list, tuple)): + return [str(item).lower() for item in v if item] + return [] + + @validator('metric_origin', pre=True) + def normalize_metric_origin(cls, v): + if v is None: + return "custom" + return str(v).lower() model_config = ConfigDict(from_attributes=True) @@ -1593,4 +1630,148 @@ class PromptPartialDetailResponse(PromptPartialResponse): """Schema for prompt partial detail with versions.""" versions: List[PromptPartialVersionResponse] = [] - model_config = ConfigDict(from_attributes=True) \ No newline at end of file + model_config = ConfigDict(from_attributes=True) + + +# ============================================ +# TELEPHONY SCHEMAS (provider-agnostic) +# ============================================ + + +class TelephonyIntegrationCreate(BaseModel): + """Schema for creating a telephony provider integration.""" + + provider: str = "plivo" + auth_id: str + auth_token: str + verify_app_uuid: Optional[str] = None + voice_app_id: Optional[str] = None + sip_domain: Optional[str] = None + masking_config: Optional[Dict[str, Any]] = None + + +class TelephonyIntegrationUpdate(BaseModel): + """Schema for partial updates to a telephony provider integration.""" + + provider: Optional[str] = None + auth_id: Optional[str] = None + auth_token: Optional[str] = None + verify_app_uuid: Optional[str] = None + voice_app_id: Optional[str] = None + sip_domain: Optional[str] = None + masking_config: Optional[Dict[str, Any]] = None + is_active: Optional[bool] = None + + +class TelephonyIntegrationResponse(BaseModel): + """Safe response model for telephony integration without secrets.""" + + id: UUID + organization_id: UUID + provider: str + verify_app_uuid: Optional[str] + voice_app_id: Optional[str] + sip_domain: Optional[str] + masking_config: Optional[Dict[str, Any]] + is_active: bool + last_tested_at: Optional[datetime] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TelephonyPhoneNumberResponse(BaseModel): + """Telephony phone number inventory response schema.""" + + id: UUID + phone_number: str + country_iso2: Optional[str] + region: Optional[str] + number_type: Optional[str] + capabilities: Optional[Dict[str, Any]] + is_masking_pool: bool + agent_id: Optional[UUID] + is_active: bool + created_at: datetime + + class Config: + from_attributes = True + + +class TelephonyVerifyStartRequest(BaseModel): + """Request schema for starting voice OTP verification.""" + + phone_number: str + provider: str = "plivo" + + +class TelephonyVerifyStartResponse(BaseModel): + """Response schema for started voice OTP verification.""" + + session_id: UUID + provider_session_uuid: str + status: str + message: str + + +class TelephonyVerifyCheckRequest(BaseModel): + """Request schema for checking a submitted OTP code.""" + + session_id: UUID + otp_code: str + provider: str = "plivo" + + +class TelephonyVerifyCheckResponse(BaseModel): + """Response schema for OTP check status.""" + + verified: bool + status: str + message: str + + +class TelephonyMaskingSessionCreate(BaseModel): + """Request schema for creating a number masking session.""" + + party_a_number: str + party_b_number: str + provider: str = "plivo" + expires_in_minutes: Optional[int] = 60 + metadata: Optional[Dict[str, Any]] = None + provider: str = "plivo" + + +class TelephonyMaskingSessionResponse(BaseModel): + """Response schema for masking sessions.""" + + id: UUID + masked_number: str + party_a_number: str + party_b_number: str + status: str + expires_at: Optional[datetime] + created_at: datetime + + class Config: + from_attributes = True + + +class TelephonyOutboundCallRequest(BaseModel): + """Request schema for outbound call initiation.""" + + from_number: str + to_number: str + answer_url: Optional[str] = None + agent_id: Optional[UUID] = None + + +class TelephonyOutboundCallResponse(BaseModel): + """Response schema for outbound call initiation.""" + + provider_request_uuid: str + call_status: str + from_number: str + to_number: str + message: str diff --git a/app/services/telephony/__init__.py b/app/services/telephony/__init__.py new file mode 100644 index 00000000..65439553 --- /dev/null +++ b/app/services/telephony/__init__.py @@ -0,0 +1 @@ +"""Telephony services for Plivo integration.""" diff --git a/app/services/telephony/plivo_client.py b/app/services/telephony/plivo_client.py new file mode 100644 index 00000000..78c0094c --- /dev/null +++ b/app/services/telephony/plivo_client.py @@ -0,0 +1,139 @@ +"""Thin Plivo SDK wrapper for telephony operations.""" + +from typing import Any, Dict, List, Optional +from loguru import logger + +try: + import plivo +except ImportError: # pragma: no cover - environment-dependent optional dependency + plivo = None + + +def normalize_e164(phone_number: str) -> str: + """Normalize and validate an E.164 phone number.""" + if not phone_number: + raise ValueError("Phone number is required") + + normalized = phone_number.strip().replace(" ", "") + if not normalized.startswith("+"): + raise ValueError("Phone number must be in E.164 format and start with '+'") + if len(normalized) < 8 or len(normalized) > 20: + raise ValueError("Phone number must be between 8 and 20 chars in E.164 format") + if not normalized[1:].isdigit(): + raise ValueError("Phone number must contain digits only after '+'") + return normalized + + +class PlivoClient: + """Wrapper around plivo.RestClient that returns normalized dictionaries.""" + + def __init__(self, auth_id: str, auth_token: str): + if plivo is None: + raise ValueError( + "Plivo SDK is not installed. Install it with `pip install -e .` or `pip install plivo`." + ) + self.client = plivo.RestClient(auth_id=auth_id, auth_token=auth_token) + + @staticmethod + def _to_dict(data: Any) -> Dict[str, Any]: + if isinstance(data, dict): + return data + if hasattr(data, "to_dict"): + return data.to_dict() + if hasattr(data, "dict"): + return data.dict() + if hasattr(data, "__dict__"): + return dict(data.__dict__) + return {"raw": str(data)} + + def test_connection(self) -> bool: + """Check if the account credentials can perform API requests.""" + try: + self.client.calls.list(limit=1) + return True + except Exception as e: + logger.exception("Plivo connection test failed") + raise ValueError(f"Failed to connect to Plivo: {str(e)}") + + def list_numbers(self) -> List[Dict[str, Any]]: + """List account phone numbers.""" + try: + response = self.client.numbers.list() + response_dict = self._to_dict(response) + objects = response_dict.get("objects", []) + if isinstance(objects, list): + return [self._to_dict(item) for item in objects] + return [] + except Exception as e: + logger.exception("Failed to list Plivo numbers") + raise ValueError(f"Failed to list Plivo numbers: {str(e)}") + + def create_outbound_call( + self, + from_: str, + to_: str, + answer_url: str, + hangup_url: Optional[str] = None, + ) -> Dict[str, Any]: + """Create outbound voice call.""" + try: + kwargs: Dict[str, Any] = { + "from_": from_, + "to_": to_, + "answer_url": answer_url, + "answer_method": "POST", + } + if hangup_url: + kwargs["hangup_url"] = hangup_url + kwargs["hangup_method"] = "POST" + response = self.client.calls.create(**kwargs) + return self._to_dict(response) + except Exception as e: + logger.exception("Failed to create outbound call") + raise ValueError(f"Failed to create outbound call: {str(e)}") + + def get_call_details(self, call_uuid: str) -> Dict[str, Any]: + """Get call details by Plivo call UUID.""" + try: + response = self.client.calls.get(call_uuid) + return self._to_dict(response) + except Exception as e: + logger.exception("Failed to fetch call details") + raise ValueError(f"Failed to fetch call details: {str(e)}") + + def start_voice_verification( + self, recipient: str, app_uuid: str, callback_url: Optional[str] = None + ) -> Dict[str, Any]: + """Start voice OTP verification.""" + try: + kwargs: Dict[str, Any] = { + "recipient": recipient, + "app_uuid": app_uuid, + "channel": "voice", + } + if callback_url: + kwargs["url"] = callback_url + kwargs["method"] = "POST" + response = self.client.verify_session.create(**kwargs) + return self._to_dict(response) + except Exception as e: + logger.exception("Failed to start voice verification") + raise ValueError(f"Failed to start voice verification: {str(e)}") + + def check_verification(self, session_uuid: str, otp_code: str) -> Dict[str, Any]: + """Validate submitted OTP for a verification session.""" + try: + response = self.client.verify_session.validate(session_uuid=session_uuid, otp=otp_code) + return self._to_dict(response) + except Exception as e: + logger.exception("Failed to check voice verification") + raise ValueError(f"Failed to check voice verification: {str(e)}") + + def get_verify_session(self, session_uuid: str) -> Dict[str, Any]: + """Fetch verification session details.""" + try: + response = self.client.verify_session.get(session_uuid) + return self._to_dict(response) + except Exception as e: + logger.exception("Failed to fetch verify session") + raise ValueError(f"Failed to fetch verify session: {str(e)}") diff --git a/app/services/telephony/plivo_xml.py b/app/services/telephony/plivo_xml.py new file mode 100644 index 00000000..deb01b7c --- /dev/null +++ b/app/services/telephony/plivo_xml.py @@ -0,0 +1,38 @@ +"""Plivo XML builders for webhook responses.""" + +def _get_plivoxml(): + try: + from plivo import plivoxml + except ImportError as exc: # pragma: no cover - environment-dependent optional dependency + raise ValueError( + "Plivo SDK is not installed. Install it with `pip install -e .` or `pip install plivo`." + ) from exc + return plivoxml + + +def speak_and_hangup(message: str) -> str: + """Build XML to speak a message and hang up.""" + plivoxml = _get_plivoxml() + response = plivoxml.ResponseElement() + response.add(plivoxml.SpeakElement(message)) + response.add(plivoxml.HangupElement()) + return response.to_string() + + +def dial_number(to_number: str, caller_id: str) -> str: + """Build XML to dial a target number with caller ID.""" + plivoxml = _get_plivoxml() + response = plivoxml.ResponseElement() + dial = plivoxml.DialElement(callerId=caller_id) + dial.add(plivoxml.NumberElement(to_number)) + response.add(dial) + return response.to_string() + + +def reject_call(reason: str = "This number is not available.") -> str: + """Build XML to reject a call with a message.""" + plivoxml = _get_plivoxml() + response = plivoxml.ResponseElement() + response.add(plivoxml.SpeakElement(reason)) + response.add(plivoxml.HangupElement()) + return response.to_string() diff --git a/app/services/telephony/telephony_service.py b/app/services/telephony/telephony_service.py new file mode 100644 index 00000000..ad940474 --- /dev/null +++ b/app/services/telephony/telephony_service.py @@ -0,0 +1,444 @@ +"""Business logic for telephony provider flows (provider-agnostic).""" + +import random +import string +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple +from uuid import UUID + +from loguru import logger +from sqlalchemy.orm import Session + +from app.config import settings +from app.core.encryption import decrypt_api_key, encrypt_api_key +from app.models.database import ( + Agent, + CallRecording, + CallRecordingSource, + TelephonyIntegration, + TelephonyMaskedSession, + TelephonyPhoneNumber, + TelephonyVerifySession, +) +from app.models.enums import CallRecordingStatus +from app.services.telephony.plivo_client import PlivoClient, normalize_e164 +from app.services.telephony.plivo_xml import dial_number, reject_call, speak_and_hangup + + +class TelephonyService: + """Encapsulates telephony business operations for API routes.""" + + def get_org_integration( + self, org_id: UUID, db: Session, provider: str = "plivo" + ) -> TelephonyIntegration: + integration = ( + db.query(TelephonyIntegration) + .filter( + TelephonyIntegration.organization_id == org_id, + TelephonyIntegration.provider == provider, + TelephonyIntegration.is_active.is_(True), + ) + .first() + ) + if not integration: + raise ValueError(f"Active {provider} telephony integration not found for organization") + return integration + + def get_provider_client(self, org_id: UUID, db: Session, provider: str): + integration = self.get_org_integration(org_id, db, provider=provider) + auth_id = decrypt_api_key(integration.auth_id) + auth_token = decrypt_api_key(integration.auth_token) + provider_key = provider.lower() + if provider_key == "plivo": + return PlivoClient(auth_id=auth_id, auth_token=auth_token) + # Other providers (e.g. Exotel) are not yet implemented on the backend. + # The DB schema, enums, and UI accept them so credentials can be stored, + # but live operations will fail until a client wrapper lands. + raise ValueError(f"Unsupported telephony provider: {provider}") + + def save_integration( + self, org_id: UUID, data: Dict[str, Any], db: Session + ) -> TelephonyIntegration: + provider = data.get("provider", "plivo") + integration = ( + db.query(TelephonyIntegration) + .filter( + TelephonyIntegration.organization_id == org_id, + TelephonyIntegration.provider == provider, + ) + .first() + ) + encrypted_auth_id = encrypt_api_key(data["auth_id"]) if data.get("auth_id") else None + encrypted_auth_token = encrypt_api_key(data["auth_token"]) if data.get("auth_token") else None + effective_voice_app_id = ( + data.get("voice_app_id") + if "voice_app_id" in data + else (integration.voice_app_id if integration else None) + ) + if provider.lower() == "exotel" and not effective_voice_app_id: + raise ValueError("voice_app_id (Exotel Account SID) is required for Exotel") + + if integration: + if encrypted_auth_id: + integration.auth_id = encrypted_auth_id + if encrypted_auth_token: + integration.auth_token = encrypted_auth_token + if "verify_app_uuid" in data: + integration.verify_app_uuid = data.get("verify_app_uuid") + if "voice_app_id" in data: + integration.voice_app_id = data.get("voice_app_id") + if "sip_domain" in data: + integration.sip_domain = data.get("sip_domain") + if "masking_config" in data: + integration.masking_config = data.get("masking_config") + if "is_active" in data: + integration.is_active = bool(data.get("is_active")) + else: + if not encrypted_auth_id or not encrypted_auth_token: + raise ValueError("auth_id and auth_token are required for first-time setup") + integration = TelephonyIntegration( + organization_id=org_id, + provider=provider, + auth_id=encrypted_auth_id, + auth_token=encrypted_auth_token, + verify_app_uuid=data.get("verify_app_uuid"), + voice_app_id=data.get("voice_app_id"), + sip_domain=data.get("sip_domain"), + masking_config=data.get("masking_config"), + is_active=bool(data.get("is_active", True)), + ) + db.add(integration) + + db.commit() + db.refresh(integration) + return integration + + def test_connection(self, org_id: UUID, db: Session, provider: str = "plivo") -> bool: + client = self.get_provider_client(org_id, db, provider=provider) + ok = client.test_connection() + integration = self.get_org_integration(org_id, db, provider=provider) + integration.last_tested_at = datetime.now(timezone.utc) + db.commit() + return ok + + def sync_numbers(self, org_id: UUID, db: Session, provider: str = "plivo") -> List[TelephonyPhoneNumber]: + client = self.get_provider_client(org_id, db, provider=provider) + integration = self.get_org_integration(org_id, db, provider=provider) + numbers = client.list_numbers() + synced: List[TelephonyPhoneNumber] = [] + + for num in numbers: + raw = num.get("number") or num.get("phone_number") + if not raw: + continue + phone_number = normalize_e164(raw) + existing = ( + db.query(TelephonyPhoneNumber) + .filter( + TelephonyPhoneNumber.organization_id == org_id, + TelephonyPhoneNumber.phone_number == phone_number, + ) + .first() + ) + payload = { + "country_iso2": num.get("country_iso"), + "region": num.get("region"), + "number_type": num.get("number_type"), + "capabilities": num.get("capabilities") or {}, + "provider_app_id": num.get("app_id"), + "is_active": True, + } + if existing: + existing.telephony_integration_id = integration.id + for key, value in payload.items(): + setattr(existing, key, value) + synced.append(existing) + else: + row = TelephonyPhoneNumber( + organization_id=org_id, + telephony_integration_id=integration.id, + phone_number=phone_number, + **payload, + ) + db.add(row) + synced.append(row) + + db.commit() + return synced + + def list_numbers(self, org_id: UUID, db: Session, provider: Optional[str] = None) -> List[TelephonyPhoneNumber]: + query = db.query(TelephonyPhoneNumber).filter(TelephonyPhoneNumber.organization_id == org_id) + if provider: + query = query.join( + TelephonyIntegration, + TelephonyIntegration.id == TelephonyPhoneNumber.telephony_integration_id, + ).filter(TelephonyIntegration.provider == provider) + return query.order_by(TelephonyPhoneNumber.created_at.desc()).all() + + def initiate_outbound_call( + self, org_id: UUID, from_number: str, to_number: str, agent_id: Optional[UUID], db: Session + ) -> Dict[str, Any]: + from_number = normalize_e164(from_number) + to_number = normalize_e164(to_number) + + number_row = ( + db.query(TelephonyPhoneNumber) + .filter( + TelephonyPhoneNumber.organization_id == org_id, + TelephonyPhoneNumber.phone_number == from_number, + TelephonyPhoneNumber.is_active.is_(True), + ) + .first() + ) + if not number_row: + raise ValueError("from_number is not registered to this organization") + + integration = ( + db.query(TelephonyIntegration) + .filter( + TelephonyIntegration.id == number_row.telephony_integration_id, + TelephonyIntegration.organization_id == org_id, + ) + .first() + ) + if not integration: + raise ValueError("No active telephony integration found for from_number") + client = self.get_provider_client(org_id, db, provider=integration.provider) + + base = settings.PLIVO_WEBHOOK_BASE_URL.rstrip("/") + answer_url = f"{base}{settings.API_V1_PREFIX}/telephony/webhooks/answer" + hangup_url = f"{base}{settings.API_V1_PREFIX}/telephony/webhooks/events" + response = client.create_outbound_call( + from_=from_number, to_=to_number, answer_url=answer_url, hangup_url=hangup_url + ) + + call_short_id = "".join(random.choices(string.digits, k=6)) + call_uuid = response.get("request_uuid") or response.get("message_uuid") or response.get("api_id") + db.add( + CallRecording( + organization_id=org_id, + call_short_id=call_short_id, + status=CallRecordingStatus.PENDING, + source=CallRecordingSource.WEBHOOK, + call_event="outbound_initiated", + call_data=response, + provider_call_id=call_uuid, + provider_platform=integration.provider, + agent_id=agent_id, + ) + ) + db.commit() + return response + + def start_voice_otp( + self, org_id: UUID, phone_number: str, api_key: str, db: Session, provider: str = "plivo" + ) -> TelephonyVerifySession: + del api_key + integration = self.get_org_integration(org_id, db, provider=provider) + app_uuid = integration.verify_app_uuid or settings.PLIVO_VERIFY_APP_UUID + if not app_uuid: + raise ValueError("verify_app_uuid is not configured for voice OTP") + + recipient = normalize_e164(phone_number) + callback_url = None + if settings.PLIVO_WEBHOOK_BASE_URL: + base = settings.PLIVO_WEBHOOK_BASE_URL.rstrip("/") + callback_url = f"{base}{settings.API_V1_PREFIX}/telephony/webhooks/events" + + response = self.get_provider_client(org_id, db, provider=provider).start_voice_verification( + recipient=recipient, app_uuid=app_uuid, callback_url=callback_url + ) + + session_uuid = response.get("session_uuid") or response.get("session_uuid4") + if not session_uuid: + raise ValueError("Verify response missing session UUID") + + session = TelephonyVerifySession( + organization_id=org_id, + provider_session_uuid=session_uuid, + recipient_number=recipient, + channel="voice", + status=response.get("status", "pending"), + verify_app_uuid=app_uuid, + initiated_by="api", + ) + db.add(session) + db.commit() + db.refresh(session) + return session + + def check_voice_otp( + self, org_id: UUID, session_id: UUID, otp_code: str, db: Session, provider: str = "plivo" + ) -> Tuple[bool, str]: + session = ( + db.query(TelephonyVerifySession) + .filter( + TelephonyVerifySession.id == session_id, + TelephonyVerifySession.organization_id == org_id, + ) + .first() + ) + if not session: + raise ValueError("Verification session not found") + + result = self.get_provider_client(org_id, db, provider=provider).check_verification( + session_uuid=session.provider_session_uuid, + otp_code=otp_code.strip(), + ) + status_value = (result.get("status") or "").lower() + verified = status_value in {"success", "verified", "approved", "valid"} + + session.status = "verified" if verified else "failed" + if verified: + session.verified_at = datetime.now(timezone.utc) + db.commit() + return verified, result.get("message", "Verification processed") + + def create_masking_session( + self, + org_id: UUID, + party_a: str, + party_b: str, + expires_in_minutes: int, + metadata: Optional[Dict[str, Any]], + db: Session, + provider: str = "plivo", + ) -> TelephonyMaskedSession: + integration = self.get_org_integration(org_id, db, provider=provider) + party_a = normalize_e164(party_a) + party_b = normalize_e164(party_b) + + active_ids = ( + db.query(TelephonyMaskedSession.masked_number_id) + .filter( + TelephonyMaskedSession.organization_id == org_id, + TelephonyMaskedSession.status == "active", + ) + .subquery() + ) + number = ( + db.query(TelephonyPhoneNumber) + .filter( + TelephonyPhoneNumber.organization_id == org_id, + TelephonyPhoneNumber.is_masking_pool.is_(True), + TelephonyPhoneNumber.is_active.is_(True), + ~TelephonyPhoneNumber.id.in_(active_ids), + ) + .first() + ) + if not number: + raise ValueError("No available masking pool number") + + expiry = datetime.now(timezone.utc) + timedelta(minutes=max(expires_in_minutes or 60, 1)) + session = TelephonyMaskedSession( + organization_id=org_id, + telephony_integration_id=integration.id, + masked_number_id=number.id, + masked_number=number.phone_number, + party_a_number=party_a, + party_b_number=party_b, + status="active", + expires_at=expiry, + session_metadata=metadata or {}, + ) + db.add(session) + db.commit() + db.refresh(session) + return session + + def end_masking_session(self, org_id: UUID, session_id: UUID, db: Session) -> None: + session = ( + db.query(TelephonyMaskedSession) + .filter(TelephonyMaskedSession.id == session_id, TelephonyMaskedSession.organization_id == org_id) + .first() + ) + if not session: + raise ValueError("Masking session not found") + session.status = "ended" + session.ended_at = datetime.now(timezone.utc) + db.commit() + + def handle_answer_webhook(self, params: Dict[str, Any], db: Session) -> str: + to_number = params.get("To") or params.get("to") + from_number = params.get("From") or params.get("from") + call_uuid = ( + params.get("CallUUID") + or params.get("CallSid") + or params.get("call_sid") + or params.get("Sid") + ) + logger.info("Telephony answer webhook call_uuid={} to={} from={}", call_uuid, to_number, from_number) + + if not to_number: + return speak_and_hangup("Call could not be routed.") + + to_number = normalize_e164(to_number) + number = db.query(TelephonyPhoneNumber).filter(TelephonyPhoneNumber.phone_number == to_number).first() + if not number: + return reject_call("This number is not configured.") + + if number.agent_id: + agent = db.query(Agent).filter(Agent.id == number.agent_id).first() + if agent and agent.phone_number: + try: + return dial_number(normalize_e164(agent.phone_number), to_number) + except ValueError: + logger.warning("Agent {} has non-E.164 phone number", agent.id) + + return speak_and_hangup("No active routing found for this number.") + + def handle_event_webhook(self, params: Dict[str, Any], db: Session) -> None: + call_uuid = ( + params.get("CallUUID") + or params.get("RequestUUID") + or params.get("CallSid") + or params.get("call_sid") + or params.get("Sid") + ) + call_status = params.get("CallStatus") or params.get("Event") or params.get("Status") + if not call_uuid: + return + + row = db.query(CallRecording).filter(CallRecording.provider_call_id == call_uuid).first() + if not row: + return + + row.status = CallRecordingStatus.UPDATED + row.call_event = (call_status or "updated").lower() + current = row.call_data if isinstance(row.call_data, dict) else {} + current["last_event"] = params + row.call_data = current + db.commit() + + def handle_masking_webhook(self, params: Dict[str, Any], db: Session) -> str: + from_number = params.get("From") + to_number = params.get("To") + if not from_number or not to_number: + return reject_call() + + from_number = normalize_e164(from_number) + to_number = normalize_e164(to_number) + now = datetime.now(timezone.utc) + + session = ( + db.query(TelephonyMaskedSession) + .filter( + TelephonyMaskedSession.masked_number == to_number, + TelephonyMaskedSession.status == "active", + ((TelephonyMaskedSession.party_a_number == from_number) | (TelephonyMaskedSession.party_b_number == from_number)), + ) + .first() + ) + + if not session: + return reject_call() + if session.expires_at and session.expires_at < now: + session.status = "expired" + db.commit() + return reject_call() + + target = session.party_b_number if from_number == session.party_a_number else session.party_a_number + return dial_number(target, to_number) + + +telephony_service = TelephonyService() diff --git a/frontend/src/config/providers.ts b/frontend/src/config/providers.ts index 302f640d..595530c6 100644 --- a/frontend/src/config/providers.ts +++ b/frontend/src/config/providers.ts @@ -3,7 +3,7 @@ * Single source of truth for all provider metadata in the frontend */ -import { ModelProvider, IntegrationPlatform } from '../types/api' +import { ModelProvider, IntegrationPlatform, TelephonyProvider } from '../types/api' export interface ProviderMetadata { label: string @@ -162,3 +162,45 @@ export const getIntegrationPlatformLogo = (platform: IntegrationPlatform): strin export const mapIntegrationToModelProvider = (platform: IntegrationPlatform): ModelProvider | null => INTEGRATION_PLATFORM_CONFIG[platform]?.modelProvider ?? null + + +// --- Telephony provider configuration --- + +export interface TelephonyProviderMetadata { + label: string + logo: string | null + description: string + fields: { key: string; label: string; required: boolean; type: 'text' | 'password' }[] +} + +export const TELEPHONY_PROVIDER_CONFIG: Record = { + [TelephonyProvider.PLIVO]: { + label: 'Plivo', + logo: null, + description: 'Voice telephony, SIP routing, voice OTP, and number masking', + fields: [ + { key: 'auth_id', label: 'Auth ID', required: true, type: 'password' }, + { key: 'auth_token', label: 'Auth Token', required: true, type: 'password' }, + { key: 'verify_app_uuid', label: 'Verify App UUID', required: false, type: 'text' }, + { key: 'sip_domain', label: 'SIP Domain', required: false, type: 'text' }, + ], + }, + [TelephonyProvider.EXOTEL]: { + label: 'Exotel', + logo: null, + description: 'Voice telephony, applet routing, voice OTP, and masking workflows', + fields: [ + { key: 'auth_id', label: 'API Key', required: true, type: 'password' }, + { key: 'auth_token', label: 'API Token', required: true, type: 'password' }, + { key: 'voice_app_id', label: 'Account SID', required: true, type: 'text' }, + { key: 'verify_app_uuid', label: 'Verification App ID', required: false, type: 'text' }, + { key: 'sip_domain', label: 'API Host (optional)', required: false, type: 'text' }, + ], + }, +} + +export const getTelephonyProviderLabel = (provider: TelephonyProvider): string => + TELEPHONY_PROVIDER_CONFIG[provider]?.label ?? provider + +export const getTelephonyProviderDescription = (provider: TelephonyProvider): string => + TELEPHONY_PROVIDER_CONFIG[provider]?.description ?? '' diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bdb15394..4d5ddc1e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,1835 +1,1939 @@ -import axios, { AxiosInstance } from 'axios' -import type { - AudioFile, - Evaluation, - EvaluationCreate, - EvaluationResult, - APIKey, - MessageResponse, - EvaluationStatus, - OrganizationMember, - Invitation, - InvitationCreate, - Profile, - UserUpdate, - UserPreferences, - UserPreferencesUpdate, - Role, - Integration, - IntegrationCreate, - S3ConnectionTestResponse, - S3ListFilesResponse, - S3BrowseResponse, - S3Status, -} from '../types/api' - -export interface EnterpriseFeatureMeta { - title: string - description?: string - category?: string -} - -export type EnterpriseFeatureCatalog = Record - -export interface LicenseInfoResponse { - is_enterprise: boolean - enabled_features: string[] - all_enterprise_features: string[] - feature_catalog?: EnterpriseFeatureCatalog - organization?: string -} - -type TTSReportOptionsPayload = { - show_runs?: boolean - min_runs_to_show?: number - include_latency?: boolean - include_ttfb?: boolean - include_endpoint?: boolean - include_naturalness?: boolean - include_hallucination?: boolean - include_prosody?: boolean - include_arousal?: boolean - include_valence?: boolean - include_cer?: boolean - include_wer?: boolean - include_hallucination_examples?: boolean - hallucination_examples_limit?: number - include_disclaimer_sections?: boolean - include_methodology_sections?: boolean - zone_threshold_overrides?: Record -} - -// When running in production (served from same origin), use relative path -// Otherwise use environment variable or default -const API_BASE_URL = import.meta.env.VITE_API_URL || - (import.meta.env.PROD ? '' : 'http://localhost:8000') - -// ------------------------------------------------------------------------- -// New pluggable-auth types. Mirror app/api/v1/routes/auth.py. -// ------------------------------------------------------------------------- - -export interface AuthProviderConfig { - name: 'api_key' | 'local_password' | 'external_oidc' - enabled: boolean - display_name: string - description?: string | null - supports_password?: boolean - supports_signup?: boolean - oidc_issuer?: string | null - oidc_client_id?: string | null - oidc_authorize_url?: string | null -} - -export interface AuthConfigResponse { - providers: AuthProviderConfig[] - tier: 'oss' | 'enterprise' -} - -export interface AuthUserSummary { - id: string - email: string - name?: string | null - first_name?: string | null - last_name?: string | null - organization_id: string - role?: string | null - has_password?: boolean - email_is_placeholder?: boolean -} - -export interface TokenResponse { - access_token: string - token_type: string - expires_in: number - user: AuthUserSummary -} - -class ApiClient { - private client: AxiosInstance - - constructor() { - this.client = axios.create({ - baseURL: API_BASE_URL, - headers: { - 'Content-Type': 'application/json', - }, - }) - - // Prefer Bearer token when present; fall back to API key. This order - // matches the backend provider registry so a user with both (e.g. signed - // in interactively but also pasted a machine API key) stays on the - // interactive identity. - this.client.interceptors.request.use((config) => { - const accessToken = localStorage.getItem('accessToken') - const apiKey = localStorage.getItem('apiKey') - if (accessToken) { - config.headers['Authorization'] = `Bearer ${accessToken}` - } else if (apiKey) { - config.headers['X-API-Key'] = apiKey - } - return config - }) - - this.client.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - localStorage.removeItem('apiKey') - localStorage.removeItem('accessToken') - localStorage.removeItem('authUser') - window.location.href = '/login' - } - return Promise.reject(error) - } - ) - } - - setApiKey(apiKey: string) { - localStorage.setItem('apiKey', apiKey) - } - - clearApiKey() { - localStorage.removeItem('apiKey') - } - - setAccessToken(token: string) { - localStorage.setItem('accessToken', token) - } - - clearAccessToken() { - localStorage.removeItem('accessToken') - localStorage.removeItem('authUser') - } - - // ----------------------------------------------------------------------- - // Pluggable auth endpoints - // ----------------------------------------------------------------------- - async getAuthConfig(): Promise { - const response = await this.client.get('/api/v1/auth/config') - return response.data - } - - async signup(data: { - email: string - password: string - organization_name?: string - first_name?: string - last_name?: string - }): Promise { - const response = await this.client.post('/api/v1/auth/signup', data) - return response.data - } - - async loginWithPassword(email: string, password: string): Promise { - const response = await this.client.post('/api/v1/auth/login', { email, password }) - return response.data - } - - async getMe(): Promise { - const response = await this.client.get('/api/v1/auth/me') - return response.data - } - - async logout(): Promise<{ success: boolean; auth_method: string }> { - const response = await this.client.post('/api/v1/auth/logout') - return response.data - } - - async setPassword(data: { - new_password: string - current_password?: string - email?: string - }): Promise { - const response = await this.client.post('/api/v1/auth/password', data) - return response.data - } - - async switchOrganization(organization_id: string): Promise { - const response = await this.client.post('/api/v1/auth/switch-org', { - organization_id, - }) - return response.data - } - - // Legacy: kept for callers that still need to mint an API key. The backend - // endpoint now requires an already-authenticated caller. - async generateApiKey(name?: string): Promise { - const response = await this.client.post('/api/v1/auth/generate-key', { name }) - return response.data - } - - async validateApiKey(): Promise<{ valid: boolean; message: string }> { - const response = await this.client.post('/api/v1/auth/validate') - return response.data - } - - // Settings / API Key Management endpoints - async listApiKeys(): Promise { - const response = await this.client.get('/api/v1/settings/api-keys') - return response.data - } - - async createApiKey(name?: string): Promise { - const response = await this.client.post('/api/v1/settings/api-keys', { name }) - return response.data - } - - async deleteApiKey(keyId: string): Promise { - await this.client.delete(`/api/v1/settings/api-keys/${keyId}`) - } - - async regenerateApiKey(keyId: string): Promise { - const response = await this.client.post(`/api/v1/settings/api-keys/${keyId}/regenerate`) - return response.data - } - - // Audio endpoints - async uploadAudio(file: File): Promise { - const formData = new FormData() - formData.append('file', file) - const response = await this.client.post('/api/v1/audio/upload', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }) - return response.data - } - - async getAudio(audioId: string): Promise { - const response = await this.client.get(`/api/v1/audio/${audioId}`) - return response.data - } - - async listAudio(skip = 0, limit = 100): Promise { - const response = await this.client.get('/api/v1/audio', { - params: { skip, limit }, - }) - return response.data - } - - async deleteAudio(audioId: string): Promise { - const response = await this.client.delete(`/api/v1/audio/${audioId}`) - return response.data - } - - async downloadAudio(audioId: string): Promise { - const response = await this.client.get(`/api/v1/audio/${audioId}/download`, { - responseType: 'blob', - }) - return response.data - } - - // Evaluation endpoints - async createEvaluation(data: EvaluationCreate): Promise { - const response = await this.client.post('/api/v1/evaluations/create', data) - return response.data - } - - async getEvaluation(evaluationId: string): Promise { - const response = await this.client.get(`/api/v1/evaluations/${evaluationId}`) - return response.data - } - - async listEvaluations( - skip = 0, - limit = 100, - status?: EvaluationStatus - ): Promise { - const response = await this.client.get('/api/v1/evaluations', { - params: { skip, limit, status }, - }) - return response.data - } - - async cancelEvaluation(evaluationId: string): Promise { - const response = await this.client.post(`/api/v1/evaluations/${evaluationId}/cancel`) - return response.data - } - - async deleteEvaluation(evaluationId: string): Promise { - const response = await this.client.delete(`/api/v1/evaluations/${evaluationId}`) - return response.data - } - - // Results endpoints - async getEvaluationResult(evaluationId: string): Promise { - const response = await this.client.get(`/api/v1/results/${evaluationId}`) - return response.data - } - - async getMetrics(evaluationId: string): Promise<{ - evaluation_id: string - metrics: Record - processing_time?: number | null - }> { - const response = await this.client.get(`/api/v1/results/${evaluationId}/metrics`) - return response.data - } - - async getTranscript(evaluationId: string): Promise<{ - evaluation_id: string - transcript: string - }> { - const response = await this.client.get(`/api/v1/results/${evaluationId}/transcript`) - return response.data - } - - async compareEvaluations(evaluationIds: string[]): Promise<{ - evaluations: EvaluationResult[] - comparison_metrics: Record - }> { - const response = await this.client.post('/api/v1/results/compare', { - evaluation_ids: evaluationIds, - }) - return response.data - } - - // Agents endpoints - async createAgent(data: { - name: string - phone_number?: string - language: string - description?: string | null - call_type: string - call_medium: string - voice_bundle_id?: string - ai_provider_id?: string - voice_ai_integration_id?: string - voice_ai_agent_id?: string - }): Promise { - const response = await this.client.post('/api/v1/agents', data) - return response.data - } - - async listAgents(skip = 0, limit = 100): Promise { - const response = await this.client.get('/api/v1/agents', { - params: { skip, limit }, - }) - return response.data - } - - async getAgent(agentId: string): Promise { - const response = await this.client.get(`/api/v1/agents/${agentId}`) - return response.data - } - - async updateAgent(agentId: string, data: { - name?: string - phone_number?: string - language?: string - description?: string | null - call_type?: string - call_medium?: string - voice_bundle_id?: string - ai_provider_id?: string - }): Promise { - const response = await this.client.put(`/api/v1/agents/${agentId}`, data) - return response.data - } - - async deleteAgent(agentId: string, force?: boolean): Promise { - const response = await this.client.delete(`/api/v1/agents/${agentId}`, { - params: force ? { force: true } : undefined, - }) - return response.data - } - - async getAgentDeleteImpact(agentId: string): Promise<{ - agent_id: string - agent_name: string - dependencies: Record - can_delete_without_force: boolean - }> { - const response = await this.client.get(`/api/v1/agents/${agentId}/delete-impact`) - return response.data - } - - async generateAgentDescription(data: { - description: string - tone?: string - format_style?: string - provider?: string - model?: string - }): Promise<{ content: string; provider: string; model: string }> { - const response = await this.client.post('/api/v1/agents/generate-description', data) - return response.data - } - - // Personas endpoints - async listPersonas(skip = 0, limit = 100): Promise { - const response = await this.client.get('/api/v1/personas', { - params: { skip, limit }, - }) - return response.data - } - - async getPersona(personaId: string): Promise { - const response = await this.client.get(`/api/v1/personas/${personaId}`) - return response.data - } - - async createPersona(data: { - name: string - gender: string - tts_provider?: string - tts_voice_id?: string - tts_voice_name?: string - is_custom?: boolean - }): Promise { - const response = await this.client.post('/api/v1/personas', data) - return response.data - } - - async updatePersona(personaId: string, data: any): Promise { - const response = await this.client.put(`/api/v1/personas/${personaId}`, data) - return response.data - } - - async deletePersona(personaId: string, force?: boolean): Promise { - const response = await this.client.delete(`/api/v1/personas/${personaId}`, { - params: force ? { force: true } : undefined, - }) - return response.data - } - - async clonePersona(personaId: string, name?: string): Promise { - const response = await this.client.post(`/api/v1/personas/${personaId}/clone`, { name }) - return response.data - } - - async seedDemoData(): Promise { - const response = await this.client.post('/api/v1/personas/seed-data') - return response.data - } - - // Persona voice options (built-in + custom voices, ungated) - async getPersonaVoiceOptions(provider?: string): Promise<{ - providers: Array<{ - id: string - name: string - voices: Array<{ - id: string - name: string - gender: string - is_custom: boolean - custom_voice_id?: string - description?: string | null - }> - }> - }> { - const response = await this.client.get('/api/v1/personas/voice-options', { - params: provider ? { provider } : undefined, - }) - return response.data - } - - // Custom voice CRUD (persona-scoped, ungated) - async listPersonaCustomVoices(provider?: string): Promise { - const response = await this.client.get('/api/v1/personas/custom-voices', { - params: provider ? { provider } : undefined, - }) - return response.data - } - - async createPersonaCustomVoice(data: { - provider: string - voice_id: string - name: string - gender?: string - description?: string - }): Promise { - const response = await this.client.post('/api/v1/personas/custom-voices', data) - return response.data - } - - async updatePersonaCustomVoice(customVoiceId: string, data: { - voice_id?: string - name?: string - gender?: string - description?: string - }): Promise { - const response = await this.client.put(`/api/v1/personas/custom-voices/${customVoiceId}`, data) - return response.data - } - - async deletePersonaCustomVoice(customVoiceId: string): Promise { - const response = await this.client.delete(`/api/v1/personas/custom-voices/${customVoiceId}`) - return response.data - } - - // Scenarios endpoints - async listScenarios(skip = 0, limit = 100): Promise { - const response = await this.client.get('/api/v1/scenarios', { - params: { skip, limit }, - }) - return response.data - } - - async getScenario(scenarioId: string): Promise { - const response = await this.client.get(`/api/v1/scenarios/${scenarioId}`) - return response.data - } - - async createScenario(data: { - name: string - agent_id?: string | null - description?: string | null - required_info: Record - }): Promise { - const response = await this.client.post('/api/v1/scenarios', data) - return response.data - } - - async updateScenario(scenarioId: string, data: { - name?: string - agent_id?: string | null - description?: string | null - required_info?: Record - }): Promise { - const response = await this.client.put(`/api/v1/scenarios/${scenarioId}`, data) - return response.data - } - - async deleteScenario(scenarioId: string, force?: boolean): Promise { - const response = await this.client.delete(`/api/v1/scenarios/${scenarioId}`, { - params: force ? { force: true } : undefined, - }) - return response.data - } - - // Chat/Inference endpoints - async chatCompletion(data: { - messages: Array<{ role: string; content: string }> - provider: string - model: string - temperature?: number - max_tokens?: number - }): Promise<{ - text: string - model: string - usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } - processing_time?: number - }> { - const response = await this.client.post('/api/v1/chat/completion', data) - return response.data - } - - // VoiceBundle endpoints - async listVoiceBundles(skip = 0, limit = 100): Promise { - const response = await this.client.get('/api/v1/voicebundles', { - params: { skip, limit }, - }) - return response.data - } - - async getVoiceBundle(voicebundleId: string): Promise { - const response = await this.client.get(`/api/v1/voicebundles/${voicebundleId}`) - return response.data - } - - async createVoiceBundle(data: any): Promise { - const response = await this.client.post('/api/v1/voicebundles', data) - return response.data - } - - async updateVoiceBundle(voicebundleId: string, data: any): Promise { - const response = await this.client.put(`/api/v1/voicebundles/${voicebundleId}`, data) - return response.data - } - - async deleteVoiceBundle(voicebundleId: string, force?: boolean): Promise { - const response = await this.client.delete(`/api/v1/voicebundles/${voicebundleId}`, { - params: force ? { force: true } : undefined, - }) - return response.data - } - - // AI Provider endpoints - async listAIProviders(): Promise { - const response = await this.client.get('/api/v1/aiproviders') - return response.data - } - - async getAIProvider(aiproviderId: string): Promise { - const response = await this.client.get(`/api/v1/aiproviders/${aiproviderId}`) - return response.data - } - - async createAIProvider(data: any): Promise { - const response = await this.client.post('/api/v1/aiproviders', data) - return response.data - } - - async updateAIProvider(aiproviderId: string, data: any): Promise { - const response = await this.client.put(`/api/v1/aiproviders/${aiproviderId}`, data) - return response.data - } - - async deleteAIProvider(aiproviderId: string): Promise { - await this.client.delete(`/api/v1/aiproviders/${aiproviderId}`) - } - - async testAIProvider(aiproviderId: string): Promise { - const response = await this.client.post(`/api/v1/aiproviders/${aiproviderId}/test`) - return response.data - } - - // IAM endpoints - async listOrganizationUsers(): Promise { - const response = await this.client.get('/api/v1/iam/users') - return response.data - } - - async inviteUser(data: InvitationCreate): Promise { - const response = await this.client.post('/api/v1/iam/invitations', data) - return response.data - } - - async listInvitations(): Promise { - const response = await this.client.get('/api/v1/iam/invitations') - return response.data - } - - async updateUserRole(userId: string, role: Role): Promise { - const response = await this.client.put(`/api/v1/iam/users/${userId}/role`, { role }) - return response.data - } - - async removeUser(userId: string): Promise { - await this.client.delete(`/api/v1/iam/users/${userId}`) - } - - async cancelInvitation(invitationId: string): Promise { - await this.client.delete(`/api/v1/iam/invitations/${invitationId}`) - } - - // Profile endpoints - async getProfile(): Promise { - const response = await this.client.get('/api/v1/profile') - return response.data - } - - async updateProfile(data: UserUpdate): Promise { - const response = await this.client.put('/api/v1/profile', data) - return response.data - } - - async getMyInvitations(): Promise { - const response = await this.client.get('/api/v1/profile/invitations') - return response.data - } - - async acceptInvitation(invitationId: string): Promise { - const response = await this.client.post(`/api/v1/profile/invitations/${invitationId}/accept`) - return response.data - } - - async declineInvitation(invitationId: string): Promise { - const response = await this.client.post(`/api/v1/profile/invitations/${invitationId}/decline`) - return response.data - } - - // User Preferences endpoints - async getUserPreferences(): Promise { - const response = await this.client.get('/api/v1/profile/preferences') - return response.data - } - - async updateUserPreferences(data: UserPreferencesUpdate): Promise { - const response = await this.client.put('/api/v1/profile/preferences', data) - return response.data - } - - // Integration endpoints - async listIntegrations(): Promise { - const response = await this.client.get('/api/v1/integrations') - return response.data - } - - async getIntegration(integrationId: string): Promise { - const response = await this.client.get(`/api/v1/integrations/${integrationId}`) - return response.data - } - - async createIntegration(data: IntegrationCreate): Promise { - const response = await this.client.post('/api/v1/integrations', data) - return response.data - } - - async updateIntegration(integrationId: string, data: Partial): Promise { - const response = await this.client.put(`/api/v1/integrations/${integrationId}`, data) - return response.data - } - - async deleteIntegration(integrationId: string, force?: boolean): Promise { - const response = await this.client.delete(`/api/v1/integrations/${integrationId}`, { - params: force ? { force: true } : undefined, - }) - return response.data - } - - async getIntegrationApiKey(integrationId: string): Promise<{ api_key: string; public_key?: string | null }> { - const response = await this.client.get(`/api/v1/integrations/${integrationId}/api-key`) - return response.data - } - - // Data Sources endpoints - async testS3Connection(): Promise { - const response = await this.client.post('/api/v1/data-sources/s3/test') - return response.data - } - - async listS3Files(prefix?: string, maxKeys = 1000): Promise { - const response = await this.client.get('/api/v1/data-sources/s3/files', { - params: { prefix, max_keys: maxKeys }, - }) - return response.data - } - - async browseS3(path = '', maxKeys = 1000): Promise { - const response = await this.client.get('/api/v1/data-sources/s3/browse', { - params: { path, max_keys: maxKeys }, - }) - return response.data - } - - async uploadToS3(file: File, customFilename?: string): Promise { - const formData = new FormData() - formData.append('file', file) - if (customFilename) { - formData.append('filename', customFilename) - } - const response = await this.client.post('/api/v1/data-sources/s3/upload', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }) - return response.data - } - - async getS3Status(): Promise { - const response = await this.client.get('/api/v1/data-sources/s3/status') - return response.data - } - - async downloadFromS3(fileKey: string): Promise { - const response = await this.client.get(`/api/v1/data-sources/s3/files/${encodeURIComponent(fileKey)}/download`, { - responseType: 'blob', - }) - // Create a blob URL and trigger download - const url = window.URL.createObjectURL(new Blob([response.data])) - const link = document.createElement('a') - link.href = url - link.setAttribute('download', fileKey.split('/').pop() || 'file') - document.body.appendChild(link) - link.click() - link.remove() - window.URL.revokeObjectURL(url) - } - - async getS3PresignedUrl(fileKey: string, expiration: number = 3600): Promise<{ url: string; expires_in: number }> { - const response = await this.client.get(`/api/v1/data-sources/s3/files/${encodeURIComponent(fileKey)}/presigned-url`, { - params: { expiration }, - }) - return response.data - } - - async deleteFromS3(fileKey: string): Promise { - const response = await this.client.delete(`/api/v1/data-sources/s3/files/${encodeURIComponent(fileKey)}`) - return response.data - } - - // Model Config endpoints - async getAllModels(): Promise> { - const response = await this.client.get('/api/v1/model-config/models') - return response.data - } - - async getModelConfig(modelName: string): Promise { - const response = await this.client.get(`/api/v1/model-config/models/${modelName}`) - return response.data - } - - async getModelsByProvider(provider: string): Promise { - const response = await this.client.get(`/api/v1/model-config/providers/${provider}/models`) - return response.data - } - - async getModelOptions(provider: string): Promise<{ stt: string[]; llm: string[]; tts: string[]; s2s: string[]; tts_voices: Record }> { - const response = await this.client.get(`/api/v1/model-config/providers/${provider}/options`) - const data = response.data - // Ensure s2s and tts_voices are always present (for backward compatibility) - return { - ...data, - s2s: data.s2s || [], - tts_voices: data.tts_voices || {}, - } - } - - async getModelsByType(provider: string, modelType: 'stt' | 'llm' | 'tts'): Promise { - const response = await this.client.get(`/api/v1/model-config/providers/${provider}/types/${modelType}/models`) - return response.data - } - - // Manual Evaluations endpoints - async listManualEvaluationAudioFiles(prefix?: string, maxKeys = 1000): Promise { - const response = await this.client.get('/api/v1/manual-evaluations/audio-files', { - params: { prefix, max_keys: maxKeys }, - }) - return response.data - } - - async getAudioPresignedUrl(fileKey: string, expiration = 3600): Promise<{ url: string; expires_in: number }> { - const response = await this.client.get( - `/api/v1/manual-evaluations/audio-files/${encodeURIComponent(fileKey)}/presigned-url`, - { - params: { expiration }, - } - ) - return response.data - } - - async transcribeAudio(data: { - audio_file_key: string - stt_provider: string - stt_model: string - name?: string - language?: string - enable_speaker_diarization?: boolean - }): Promise { - const response = await this.client.post('/api/v1/manual-evaluations/transcribe', data) - return response.data - } - - async listManualTranscriptions(skip = 0, limit = 100): Promise { - const response = await this.client.get('/api/v1/manual-evaluations/transcriptions', { - params: { skip, limit }, - }) - return response.data - } - - async getManualTranscription(transcriptionId: string): Promise { - const response = await this.client.get(`/api/v1/manual-evaluations/transcriptions/${transcriptionId}`) - return response.data - } - - async updateManualTranscription(transcriptionId: string, name: string): Promise { - const response = await this.client.patch(`/api/v1/manual-evaluations/transcriptions/${transcriptionId}`, { name }) - return response.data - } - - async deleteManualTranscription(transcriptionId: string): Promise { - const response = await this.client.delete(`/api/v1/manual-evaluations/transcriptions/${transcriptionId}`) - return response.data - } - - // Test Agent endpoints - async createTestAgentConversation(data: { - agent_id: string - persona_id: string - scenario_id: string - voice_bundle_id: string - conversation_metadata?: Record - }): Promise { - const response = await this.client.post('/api/v1/test-agents/conversations', data) - return response.data - } - - async getTestAgentConversation(conversationId: string): Promise { - const response = await this.client.get(`/api/v1/test-agents/conversations/${conversationId}`) - return response.data - } - - async listTestAgentConversations(): Promise { - const response = await this.client.get('/api/v1/test-agents/conversations') - return response.data - } - - async startTestAgentConversation(conversationId: string): Promise { - const response = await this.client.post(`/api/v1/test-agents/conversations/${conversationId}/start`) - return response.data - } - - async processTestAgentAudio(conversationId: string, audioFile: File, chunkTimestamp?: number): Promise { - const formData = new FormData() - formData.append('audio_file', audioFile) - if (chunkTimestamp !== undefined) { - formData.append('chunk_timestamp', chunkTimestamp.toString()) - } - const response = await this.client.post( - `/api/v1/test-agents/conversations/${conversationId}/process-audio`, - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ) - return response.data - } - - async getTestAgentResponseAudio(conversationId: string): Promise { - const response = await this.client.get( - `/api/v1/test-agents/conversations/${conversationId}/response-audio`, - { - responseType: 'blob', - } - ) - return response.data - } - - async endTestAgentConversation(conversationId: string, finalAudioKey?: string): Promise { - const response = await this.client.post(`/api/v1/test-agents/conversations/${conversationId}/end`, { - final_audio_key: finalAudioKey, - }) - return response.data - } - - async deleteTestAgentConversation(conversationId: string): Promise { - await this.client.delete(`/api/v1/test-agents/conversations/${conversationId}`) - } - - // Voice Agent endpoints - async getVoiceAgentConnection(): Promise<{ ws_url: string; endpoint: string }> { - const response = await this.client.post('/api/v1/voice-agent/connect') - return response.data - } - - // Playground endpoints - async createWebCall(data: { - agent_id: string - metadata?: Record - retell_llm_dynamic_variables?: Record - custom_sip_headers?: Record - }): Promise<{ - call_type: string - access_token?: string - call_id: string - agent_id: string - agent_version?: number - call_status?: string - agent_name?: string - metadata?: Record - retell_llm_dynamic_variables?: Record - sample_rate?: number - call_short_id?: string - signed_url?: string - host?: string - room_name?: string - conversation_id?: string - }> { - const response = await this.client.post('/api/v1/playground/web-call', data) - return response.data - } - - async listCallRecordings(skip = 0, limit = 100): Promise { - const response = await this.client.get('/api/v1/playground/call-recordings', { - params: { skip, limit }, - }) - return response.data - } - - async getCallRecording(callShortId: string): Promise { - const response = await this.client.get(`/api/v1/playground/call-recordings/${callShortId}`) - return response.data - } - - async refreshCallRecording(callShortId: string): Promise<{ message: string }> { - const response = await this.client.post(`/api/v1/playground/call-recordings/${callShortId}/refresh`) - return response.data - } - - async updateCallRecording(callShortId: string, providerCallId: string): Promise<{ message: string; provider_call_id: string }> { - const response = await this.client.put(`/api/v1/playground/call-recordings/${callShortId}`, { - provider_call_id: providerCallId - }) - return response.data - } - - async deleteCallRecording(callShortId: string): Promise<{ message: string }> { - const response = await this.client.delete(`/api/v1/playground/call-recordings/${callShortId}`) - return response.data - } - - async reEvaluateCallRecording(callShortId: string): Promise<{ - message: string - evaluator_result_id: string - result_id: string - audio_s3_key: string - task_id: string - }> { - const response = await this.client.post(`/api/v1/playground/call-recordings/${callShortId}/re-evaluate`) - return response.data - } - - async getCallRecordingAudioUrl(callShortId: string): Promise { - const response = await this.client.get( - `/api/v1/playground/call-recordings/${callShortId}/audio`, - { responseType: 'blob' } - ) - return URL.createObjectURL(response.data) - } - - async createCustomWebsocketSession(data: { - agent_id: string - websocket_url: string - transcript_entries: Array<{ role: 'user' | 'agent'; content: string; timestamp: string }> - started_at?: string - ended_at?: string - audio_file?: File - }): Promise<{ - message: string - call_short_id: string - audio_s3_key?: string | null - evaluator_result_id?: string | null - }> { - const formData = new FormData() - formData.append('agent_id', data.agent_id) - formData.append('websocket_url', data.websocket_url) - formData.append('transcript_entries', JSON.stringify(data.transcript_entries)) - if (data.started_at) { - formData.append('started_at', data.started_at) - } - if (data.ended_at) { - formData.append('ended_at', data.ended_at) - } - if (data.audio_file) { - formData.append('audio_file', data.audio_file) - } - - const response = await this.client.post('/api/v1/playground/custom-websocket-sessions', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - }) - return response.data - } - - async evaluateCustomWebsocketSession(callShortId: string): Promise<{ - message: string - evaluator_result_id: string - result_id: string - task_id: string - }> { - const response = await this.client.post(`/api/v1/playground/custom-websocket-sessions/${callShortId}/evaluate`) - return response.data - } - - async getAgentSttConfig(agentId: string): Promise<{ - available: boolean - provider?: string - model?: string - reason?: string - }> { - const response = await this.client.get(`/api/v1/playground/agents/${agentId}/stt-config`) - return response.data - } - - async transcribeTurn( - agentId: string, - channel: 'user' | 'agent', - audioBlob: Blob, - ): Promise<{ transcript: string; channel: string }> { - const formData = new FormData() - formData.append('agent_id', agentId) - formData.append('channel', channel) - formData.append('audio_file', audioBlob, `turn_${channel}_${Date.now()}.wav`) - const response = await this.client.post('/api/v1/playground/transcribe-turn', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }) - return response.data - } - - async summarizeTranscript(params: { - transcript?: string - entries?: Array<{ role: string; content: string; timestamp?: string }> - callShortId?: string - agentId?: string - force?: boolean - }): Promise<{ - summary: string - provider: string - model: string - source?: 'voice_bundle' | 'org_fallback' - cached?: boolean - generated_at?: string - usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } - }> { - const body: Record = {} - if (params.transcript) body.transcript = params.transcript - if (params.entries && params.entries.length > 0) body.entries = params.entries - if (params.callShortId) body.call_short_id = params.callShortId - if (params.agentId) body.agent_id = params.agentId - if (params.force) body.force = true - const response = await this.client.post('/api/v1/playground/summarize-transcript', body) - return response.data - } - - // Observability endpoints - async listObservabilityCalls(skip = 0, limit = 100): Promise { - const response = await this.client.get('/api/v1/observability/calls', { - params: { skip, limit }, - }) - return response.data - } - - async getObservabilityCall(callShortId: string): Promise { - const response = await this.client.get(`/api/v1/observability/calls/${callShortId}`) - return response.data - } - - async deleteObservabilityCall(callShortId: string): Promise<{ message: string }> { - const response = await this.client.delete(`/api/v1/observability/calls/${callShortId}`) - return response.data - } - - async evaluateObservabilityCall(callShortId: string, evaluatorId: string): Promise { - const response = await this.client.post(`/api/v1/observability/calls/${callShortId}/evaluate`, { - evaluator_id: evaluatorId, - }) - return response.data - } - - // Evaluator endpoints - async createEvaluator(data: { - name?: string - agent_id?: string - persona_id?: string - scenario_id?: string - custom_prompt?: string - llm_provider?: string - llm_model?: string - tags?: string[] - }): Promise { - const response = await this.client.post('/api/v1/evaluators', data) - return response.data - } - - async formatCustomPrompt(prompt: string): Promise<{ formatted_prompt: string }> { - const response = await this.client.post('/api/v1/evaluators/format-prompt', { prompt }) - return response.data - } - - async createEvaluatorsBulk(data: { - name?: string - agent_id: string - scenario_id: string - persona_ids: string[] - tags?: string[] - }): Promise { - const response = await this.client.post('/api/v1/evaluators/bulk', data) - return response.data - } - - async listEvaluators(): Promise { - const response = await this.client.get('/api/v1/evaluators') - return response.data - } - - async getEvaluator(evaluatorId: string): Promise { - const response = await this.client.get(`/api/v1/evaluators/${evaluatorId}`) - return response.data - } - - async updateEvaluator(evaluatorId: string, data: { - agent_id?: string - persona_id?: string - scenario_id?: string - name?: string - custom_prompt?: string - llm_provider?: string - llm_model?: string - tags?: string[] - }): Promise { - const response = await this.client.put(`/api/v1/evaluators/${evaluatorId}`, data) - return response.data - } - - async deleteEvaluator(evaluatorId: string, force?: boolean): Promise { - const response = await this.client.delete(`/api/v1/evaluators/${evaluatorId}`, { - params: force ? { force: true } : undefined, - }) - return response.data - } - - async runEvaluators(evaluatorIds: string[]): Promise<{ task_ids: string[]; evaluator_results: any[] }> { - const response = await this.client.post('/api/v1/evaluators/run', { evaluator_ids: evaluatorIds }) - return response.data - } - - // Metric endpoints - async createMetric(data: { - name: string - description?: string - metric_type: 'number' | 'boolean' | 'rating' - trigger?: 'always' - enabled?: boolean - }): Promise { - const response = await this.client.post('/api/v1/metrics', data) - return response.data - } - - async listMetrics(): Promise { - const response = await this.client.get('/api/v1/metrics') - return response.data - } - - // Evaluator Results endpoints - async listEvaluatorResults(evaluatorId?: string, playground?: boolean, testAgentsOnly?: boolean): Promise { - const params: any = {} - if (evaluatorId) { - params.evaluator_id = evaluatorId - } - if (playground !== undefined) { - params.playground = playground - } - if (testAgentsOnly !== undefined) { - params.test_agents_only = testAgentsOnly - } - const response = await this.client.get('/api/v1/evaluator-results', { params }) - return response.data - } - - async getEvaluatorResult(id: string, includeRelations: boolean = true): Promise { - const params = includeRelations ? { include_relations: 'true' } : {} - const response = await this.client.get(`/api/v1/evaluator-results/${id}`, { params }) - return response.data - } - - async getEvaluatorResultMetrics(id: string): Promise { - const response = await this.client.get(`/api/v1/evaluator-results/${id}/metrics`) - return response.data - } - - async createEvaluatorResultManual(data: { - evaluator_id: string - audio_s3_key: string - duration_seconds?: number - }): Promise { - const response = await this.client.post('/api/v1/evaluator-results', data) - return response.data - } - - async reEvaluateResult(id: string): Promise { - const response = await this.client.post(`/api/v1/evaluator-results/${id}/re-evaluate`) - return response.data - } - - async deleteEvaluatorResult(id: string): Promise { - await this.client.delete(`/api/v1/evaluator-results/${id}`) - } - - async deleteEvaluatorResultsBulk(ids: string[]): Promise { - // FastAPI expects multiple query parameters with the same name - // Build query string manually: result_ids=id1&result_ids=id2 - const params = new URLSearchParams() - ids.forEach(id => params.append('result_ids', id)) - await this.client.delete(`/api/v1/evaluator-results?${params.toString()}`) - } - - async getMetric(metricId: string): Promise { - const response = await this.client.get(`/api/v1/metrics/${metricId}`) - return response.data - } - - async updateMetric(metricId: string, data: { - name?: string - description?: string - metric_type?: 'number' | 'boolean' | 'rating' - trigger?: 'always' - enabled?: boolean - }): Promise { - const response = await this.client.put(`/api/v1/metrics/${metricId}`, data) - return response.data - } - - async deleteMetric(metricId: string): Promise { - await this.client.delete(`/api/v1/metrics/${metricId}`) - } - - async seedDefaultMetrics(): Promise { - const response = await this.client.post('/api/v1/metrics/seed-defaults') - return response.data - } - - // Alert endpoints - async listAlerts(status?: string): Promise { - const params: any = {} - if (status) { - params.status_filter = status - } - const response = await this.client.get('/api/v1/alerts', { params }) - return response.data - } - - async getAlert(alertId: string): Promise { - const response = await this.client.get(`/api/v1/alerts/${alertId}`) - return response.data - } - - async createAlert(data: { - name: string - description?: string | null - metric_type: string - aggregation: string - operator: string - threshold_value: number - time_window_minutes: number - agent_ids?: string[] | null - notify_frequency: string - notify_emails?: string[] - notify_webhooks?: string[] - }): Promise { - const response = await this.client.post('/api/v1/alerts', data) - return response.data - } - - async updateAlert(alertId: string, data: { - name?: string - description?: string | null - metric_type?: string - aggregation?: string - operator?: string - threshold_value?: number - time_window_minutes?: number - agent_ids?: string[] | null - notify_frequency?: string - notify_emails?: string[] - notify_webhooks?: string[] - status?: string - }): Promise { - const response = await this.client.put(`/api/v1/alerts/${alertId}`, data) - return response.data - } - - async deleteAlert(alertId: string): Promise { - await this.client.delete(`/api/v1/alerts/${alertId}`) - } - - async toggleAlertStatus(alertId: string): Promise { - const response = await this.client.post(`/api/v1/alerts/${alertId}/toggle`) - return response.data - } - - // Alert History endpoints - async listAlertHistory(status?: string, alertId?: string, skip = 0, limit = 100): Promise { - const params: any = { skip, limit } - if (status) { - params.status_filter = status - } - if (alertId) { - params.alert_id = alertId - } - const response = await this.client.get('/api/v1/alerts/history/all', { params }) - return response.data - } - - async getAlertHistoryItem(historyId: string): Promise { - const response = await this.client.get(`/api/v1/alerts/history/${historyId}`) - return response.data - } - - async acknowledgeAlertHistory(historyId: string): Promise { - const response = await this.client.put(`/api/v1/alerts/history/${historyId}`, { - status: 'acknowledged' - }) - return response.data - } - - async resolveAlertHistory(historyId: string, resolutionNotes?: string): Promise { - const response = await this.client.put(`/api/v1/alerts/history/${historyId}`, { - status: 'resolved', - resolution_notes: resolutionNotes - }) - return response.data - } - - // Alert Evaluation & Notification endpoints - async triggerAlert(alertId: string): Promise { - const response = await this.client.post(`/api/v1/alerts/${alertId}/trigger`) - return response.data - } - - async evaluateAllAlerts(): Promise { - const response = await this.client.post('/api/v1/alerts/evaluate/all') - return response.data - } - - async testAlertNotification(alertId: string, data: { - webhook_url?: string - email?: string - }): Promise { - const response = await this.client.post(`/api/v1/alerts/${alertId}/test-notification`, data) - return response.data - } - - // Cron Job endpoints - async listCronJobs(): Promise { - const response = await this.client.get('/api/v1/cron-jobs') - return response.data - } - - async getCronJob(cronJobId: string): Promise { - const response = await this.client.get(`/api/v1/cron-jobs/${cronJobId}`) - return response.data - } - - async createCronJob(data: { - name: string - cron_expression: string - timezone: string - max_runs: number - evaluator_ids: string[] - }): Promise { - const response = await this.client.post('/api/v1/cron-jobs', data) - return response.data - } - - async updateCronJob(cronJobId: string, data: { - name?: string - cron_expression?: string - timezone?: string - max_runs?: number - evaluator_ids?: string[] - status?: string - }): Promise { - const response = await this.client.put(`/api/v1/cron-jobs/${cronJobId}`, data) - return response.data - } - - async deleteCronJob(cronJobId: string): Promise { - await this.client.delete(`/api/v1/cron-jobs/${cronJobId}`) - } - - async toggleCronJobStatus(cronJobId: string): Promise { - const response = await this.client.post(`/api/v1/cron-jobs/${cronJobId}/toggle`) - return response.data - } - - // Voice Playground (TTS Comparison) endpoints - async listTTSProviders(): Promise { - const response = await this.client.get('/api/v1/voice-playground/tts-providers') - return response.data - } - - async listCustomTTSVoices(provider?: string): Promise> { - const response = await this.client.get('/api/v1/voice-playground/custom-voices', { - params: provider ? { provider } : undefined, - }) - return response.data - } - - async createCustomTTSVoice(data: { - provider: string - voice_id: string - name: string - gender?: string - accent?: string - description?: string - }): Promise { - const response = await this.client.post('/api/v1/voice-playground/custom-voices', data) - return response.data - } - - async updateCustomTTSVoice(customVoiceId: string, data: { - voice_id?: string - name?: string - gender?: string - accent?: string - description?: string - }): Promise { - const response = await this.client.put(`/api/v1/voice-playground/custom-voices/${customVoiceId}`, data) - return response.data - } - - async deleteCustomTTSVoice(customVoiceId: string): Promise { - const response = await this.client.delete(`/api/v1/voice-playground/custom-voices/${customVoiceId}`) - return response.data - } - - async createTTSComparison(data: { - name?: string - provider_a: string - model_a: string - voices_a: Array<{ id: string; name: string; sample_rate_hz?: number }> - provider_b?: string - model_b?: string - voices_b?: Array<{ id: string; name: string; sample_rate_hz?: number }> - sample_texts: string[] - num_runs?: number - }): Promise { - const response = await this.client.post('/api/v1/voice-playground/comparisons', data) - return response.data - } - - async listTTSComparisons(skip = 0, limit = 50): Promise { - const response = await this.client.get('/api/v1/voice-playground/comparisons', { - params: { skip, limit }, - }) - return response.data - } - - async getTTSComparison(comparisonId: string): Promise { - const response = await this.client.get(`/api/v1/voice-playground/comparisons/${comparisonId}`) - return response.data - } - - async generateTTSComparison(comparisonId: string): Promise<{ message: string; task_id: string }> { - const response = await this.client.post(`/api/v1/voice-playground/comparisons/${comparisonId}/generate`) - return response.data - } - - async submitBlindTest(comparisonId: string, results: Array<{ - sample_index: number - preferred: 'A' | 'B' - voice_a_id: string - voice_b_id: string - }>): Promise { - const response = await this.client.post( - `/api/v1/voice-playground/comparisons/${comparisonId}/blind-test`, - { results } - ) - return response.data - } - - async deleteTTSComparison(comparisonId: string): Promise { - await this.client.delete(`/api/v1/voice-playground/comparisons/${comparisonId}`) - } - - async generateSampleTexts(params: { - voice_bundle_id?: string - provider?: string - model?: string - scenario?: string - count?: number - length?: string - temperature?: number - }): Promise<{ samples: string[]; provider: string; model: string }> { - const response = await this.client.post('/api/v1/voice-playground/generate-samples', params) - return response.data - } - - async getTTSAnalytics(): Promise> { - const response = await this.client.get('/api/v1/voice-playground/analytics') - return response.data - } - - async downloadTTSComparisonReport( - comparisonId: string, - includeUnfinishedSamples = false, - reportOptions?: TTSReportOptionsPayload - ): Promise { - const response = await this.client.get( - `/api/v1/voice-playground/comparisons/${comparisonId}/report.pdf`, - { - params: { - include_unfinished_samples: includeUnfinishedSamples, - ...(reportOptions ? { report_options: JSON.stringify(reportOptions) } : {}), - }, - responseType: 'blob', - } - ) - return response.data - } - - async createTTSComparisonReportJob( - comparisonId: string, - reportOptions?: TTSReportOptionsPayload - ): Promise<{ - id: string - comparison_id: string - status: string - format: string - task_id?: string - report_options?: TTSReportOptionsPayload - created_at?: string | null - }> { - const response = await this.client.post( - `/api/v1/voice-playground/comparisons/${comparisonId}/reports`, - reportOptions ? { report_options: reportOptions } : {} - ) - return response.data - } - - async getVoicePlaygroundReportThresholdDefaults(): Promise<{ - zone_threshold_overrides: NonNullable - is_custom: boolean - }> { - const response = await this.client.get('/api/v1/voice-playground/report-threshold-defaults') - return response.data - } - - async updateVoicePlaygroundReportThresholdDefaults(data: { - zone_threshold_overrides?: NonNullable - reset_to_system_defaults?: boolean - }): Promise<{ - zone_threshold_overrides: NonNullable - is_custom: boolean - message: string - }> { - const response = await this.client.put('/api/v1/voice-playground/report-threshold-defaults', data) - return response.data - } - - async getTTSComparisonReportJob(reportJobId: string): Promise<{ - id: string - comparison_id: string - status: string - format: string - filename?: string | null - error_message?: string | null - task_id?: string | null - download_url?: string | null - report_options?: TTSReportOptionsPayload - created_at?: string | null - updated_at?: string | null - }> { - const response = await this.client.get(`/api/v1/voice-playground/reports/${reportJobId}`) - return response.data - } - - // Prompt Partials - async listPromptPartials(skip = 0, limit = 100, search?: string): Promise { - const response = await this.client.get('/api/v1/prompt-partials', { - params: { skip, limit, ...(search ? { search } : {}) }, - }) - return response.data - } - - async getPromptPartial(partialId: string): Promise { - const response = await this.client.get(`/api/v1/prompt-partials/${partialId}`) - return response.data - } - - async createPromptPartial(data: { - name: string - description?: string - content: string - tags?: string[] - }): Promise { - const response = await this.client.post('/api/v1/prompt-partials', data) - return response.data - } - - async updatePromptPartial(partialId: string, data: { - name?: string - description?: string - content?: string - tags?: string[] - change_summary?: string - }): Promise { - const response = await this.client.put(`/api/v1/prompt-partials/${partialId}`, data) - return response.data - } - - async deletePromptPartial(partialId: string): Promise { - await this.client.delete(`/api/v1/prompt-partials/${partialId}`) - } - - async listPromptPartialVersions(partialId: string): Promise { - const response = await this.client.get(`/api/v1/prompt-partials/${partialId}/versions`) - return response.data - } - - async getPromptPartialVersion(partialId: string, versionNumber: number): Promise { - const response = await this.client.get(`/api/v1/prompt-partials/${partialId}/versions/${versionNumber}`) - return response.data - } - - async revertPromptPartial(partialId: string, versionNumber: number): Promise { - const response = await this.client.post(`/api/v1/prompt-partials/${partialId}/revert/${versionNumber}`) - return response.data - } - - async clonePromptPartial(partialId: string): Promise { - const response = await this.client.post(`/api/v1/prompt-partials/${partialId}/clone`) - return response.data - } - - async generatePromptWithAI(data: { - description: string - tone?: string - format_style?: string - provider?: string - model?: string - }): Promise<{ content: string; provider: string; model: string }> { - const response = await this.client.post('/api/v1/prompt-partials/generate', data) - return response.data - } - - async improvePromptWithAI(data: { - content: string - instructions?: string - provider?: string - model?: string - }): Promise<{ content: string; provider: string; model: string }> { - const response = await this.client.post('/api/v1/prompt-partials/improve', data) - return response.data - } - - // License / Enterprise - async getLicenseInfo(): Promise { - const response = await this.client.get('/api/v1/settings/license-info') - return response.data - } - - // GEPA Prompt Optimization (Enterprise) - async createOptimizationRun(data: { - agent_id: string - evaluator_id?: string - voice_bundle_id?: string - config?: Record - }): Promise { - const response = await this.client.post('/api/v1/prompt-optimization/runs', data) - return response.data - } - - async deleteOptimizationRun(runId: string): Promise { - await this.client.delete(`/api/v1/prompt-optimization/runs/${runId}`) - } - - async listOptimizationRuns(agentId?: string): Promise { - const params = agentId ? { agent_id: agentId } : {} - const response = await this.client.get('/api/v1/prompt-optimization/runs', { params }) - return response.data - } - - async getOptimizationRun(runId: string): Promise { - const response = await this.client.get(`/api/v1/prompt-optimization/runs/${runId}`) - return response.data - } - - async listOptimizationCandidates(runId: string): Promise { - const response = await this.client.get(`/api/v1/prompt-optimization/runs/${runId}/candidates`) - return response.data - } - - async acceptCandidate(runId: string, candidateId: string): Promise { - const response = await this.client.post( - `/api/v1/prompt-optimization/runs/${runId}/candidates/${candidateId}/accept` - ) - return response.data - } - - async pushCandidateToProvider(runId: string, candidateId: string): Promise { - const response = await this.client.post( - `/api/v1/prompt-optimization/runs/${runId}/candidates/${candidateId}/push` - ) - return response.data - } - - async syncProviderPrompt(agentId: string): Promise<{ - provider_prompt: string | null - provider_prompt_synced_at: string | null - }> { - const response = await this.client.post(`/api/v1/agents/${agentId}/sync-provider-prompt`) - return response.data - } -} - -// Factory function to create ApiClient instance -// This ensures TypeScript correctly infers the type as ApiClient, not AxiosInstance -function createApiClient(): ApiClient { - return new ApiClient() -} - -// Create instance -const apiClientInstance = createApiClient() - -// Export with explicit type annotation - CRITICAL for TypeScript to recognize as ApiClient -// Without this explicit type, TypeScript may incorrectly infer AxiosInstance -export const apiClient: ApiClient = apiClientInstance - -// Re-export the type for explicit typing if needed -export type { ApiClient } - +import axios, { AxiosInstance } from 'axios' +import type { + AudioFile, + Evaluation, + EvaluationCreate, + EvaluationResult, + APIKey, + MessageResponse, + EvaluationStatus, + OrganizationMember, + Invitation, + InvitationCreate, + Profile, + UserUpdate, + UserPreferences, + UserPreferencesUpdate, + Role, + Integration, + IntegrationCreate, + S3ConnectionTestResponse, + S3ListFilesResponse, + S3BrowseResponse, + S3Status, +} from '../types/api' + +export interface EnterpriseFeatureMeta { + title: string + description?: string + category?: string +} + +export type EnterpriseFeatureCatalog = Record + +export interface LicenseInfoResponse { + is_enterprise: boolean + enabled_features: string[] + all_enterprise_features: string[] + feature_catalog?: EnterpriseFeatureCatalog + organization?: string +} + +export interface AuthProviderConfig { + name: 'api_key' | 'local_password' | 'external_oidc' + enabled: boolean + display_name: string + description?: string + supports_password?: boolean + supports_signup?: boolean + oidc_issuer?: string | null + oidc_client_id?: string | null + oidc_authorize_url?: string | null +} + +export interface AuthConfigResponse { + providers: AuthProviderConfig[] + tier: 'oss' | 'enterprise' +} + +export interface AuthUserSummary { + id: string + email: string + name?: string | null + first_name?: string | null + last_name?: string | null + organization_id: string + role?: string | null + has_password?: boolean + email_is_placeholder?: boolean +} + +export interface TokenResponse { + access_token: string + token_type: string + expires_in: number + user: AuthUserSummary +} + +export interface TelephonyIntegrationResponse { + id: string + organization_id: string + provider: string + verify_app_uuid?: string | null + voice_app_id?: string | null + sip_domain?: string | null + masking_config?: Record | null + is_active: boolean + last_tested_at?: string | null + created_at: string + updated_at: string +} + +export interface TelephonyIntegrationCreatePayload { + provider?: string + auth_id: string + auth_token: string + verify_app_uuid?: string + voice_app_id?: string + sip_domain?: string + masking_config?: Record +} + +export interface TelephonyIntegrationUpdatePayload { + provider?: string + auth_id?: string + auth_token?: string + verify_app_uuid?: string + voice_app_id?: string + sip_domain?: string + masking_config?: Record + is_active?: boolean +} + +export interface TelephonyPhoneNumberResponse { + id: string + phone_number: string + country_iso2?: string | null + region?: string | null + number_type?: string | null + capabilities?: Record | null + is_masking_pool: boolean + agent_id?: string | null + is_active: boolean + created_at: string +} + +type TTSReportOptionsPayload = { + show_runs?: boolean + min_runs_to_show?: number + include_latency?: boolean + include_ttfb?: boolean + include_endpoint?: boolean + include_naturalness?: boolean + include_hallucination?: boolean + include_prosody?: boolean + include_arousal?: boolean + include_valence?: boolean + include_cer?: boolean + include_wer?: boolean + include_hallucination_examples?: boolean + hallucination_examples_limit?: number + include_disclaimer_sections?: boolean + include_methodology_sections?: boolean + zone_threshold_overrides?: Record +} + +// When running in production (served from same origin), use relative path +// Otherwise use environment variable or default +const API_BASE_URL = import.meta.env.VITE_API_URL || + (import.meta.env.PROD ? '' : 'http://localhost:8000') + +class ApiClient { + private client: AxiosInstance + + constructor() { + this.client = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, + }) + + // Add request interceptor to add API key to headers + this.client.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken') + const apiKey = localStorage.getItem('apiKey') + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}` + } else if (config.headers.Authorization) { + delete config.headers.Authorization + } + if (apiKey) { + config.headers['X-API-Key'] = apiKey + } else if (config.headers['X-API-Key']) { + delete config.headers['X-API-Key'] + } + return config + }) + + // Add response interceptor for error handling + this.client.interceptors.response.use( + (response) => response, + (error) => { + // Only log out on 401 (authentication failure) + // 403 errors are authorization failures that should be handled by the calling code + if (error.response?.status === 401) { + // API key invalid, clear it + localStorage.removeItem('apiKey') + window.location.href = '/login' + } + return Promise.reject(error) + } + ) + } + + setApiKey(apiKey: string) { + localStorage.setItem('apiKey', apiKey) + } + + clearApiKey() { + localStorage.removeItem('apiKey') + } + + setAccessToken(accessToken: string) { + localStorage.setItem('accessToken', accessToken) + } + + clearAccessToken() { + localStorage.removeItem('accessToken') + } + + // Auth endpoints + async getAuthConfig(): Promise { + const response = await this.client.get('/api/v1/auth/config') + return response.data + } + + async signup(data: { + email: string + password: string + organization_name?: string + first_name?: string + last_name?: string + }): Promise { + const response = await this.client.post('/api/v1/auth/signup', data) + return response.data + } + + async loginWithPassword(email: string, password: string): Promise { + const response = await this.client.post('/api/v1/auth/login', { email, password }) + return response.data + } + + async logout(): Promise<{ success: boolean; auth_method: string }> { + const response = await this.client.post('/api/v1/auth/logout') + return response.data + } + + async getMe(): Promise { + const response = await this.client.get('/api/v1/auth/me') + return response.data + } + + async switchOrganization(organizationId: string): Promise { + const response = await this.client.post('/api/v1/auth/switch-org', { + organization_id: organizationId, + }) + return response.data + } + + async setPassword(data: { + new_password: string + current_password?: string + email?: string + }): Promise { + const response = await this.client.post('/api/v1/auth/password', data) + return response.data + } + + async generateApiKey(name?: string): Promise { + const response = await this.client.post('/api/v1/auth/generate-key', { name }) + return response.data + } + + async validateApiKey(): Promise<{ valid: boolean; message: string }> { + const response = await this.client.post('/api/v1/auth/validate') + return response.data + } + + // Settings / API Key Management endpoints + async listApiKeys(): Promise { + const response = await this.client.get('/api/v1/settings/api-keys') + return response.data + } + + async createApiKey(name?: string): Promise { + const response = await this.client.post('/api/v1/settings/api-keys', { name }) + return response.data + } + + async deleteApiKey(keyId: string): Promise { + await this.client.delete(`/api/v1/settings/api-keys/${keyId}`) + } + + async regenerateApiKey(keyId: string): Promise { + const response = await this.client.post(`/api/v1/settings/api-keys/${keyId}/regenerate`) + return response.data + } + + // Audio endpoints + async uploadAudio(file: File): Promise { + const formData = new FormData() + formData.append('file', file) + const response = await this.client.post('/api/v1/audio/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return response.data + } + + async getAudio(audioId: string): Promise { + const response = await this.client.get(`/api/v1/audio/${audioId}`) + return response.data + } + + async listAudio(skip = 0, limit = 100): Promise { + const response = await this.client.get('/api/v1/audio', { + params: { skip, limit }, + }) + return response.data + } + + async deleteAudio(audioId: string): Promise { + const response = await this.client.delete(`/api/v1/audio/${audioId}`) + return response.data + } + + async downloadAudio(audioId: string): Promise { + const response = await this.client.get(`/api/v1/audio/${audioId}/download`, { + responseType: 'blob', + }) + return response.data + } + + // Evaluation endpoints + async createEvaluation(data: EvaluationCreate): Promise { + const response = await this.client.post('/api/v1/evaluations/create', data) + return response.data + } + + async getEvaluation(evaluationId: string): Promise { + const response = await this.client.get(`/api/v1/evaluations/${evaluationId}`) + return response.data + } + + async listEvaluations( + skip = 0, + limit = 100, + status?: EvaluationStatus + ): Promise { + const response = await this.client.get('/api/v1/evaluations', { + params: { skip, limit, status }, + }) + return response.data + } + + async cancelEvaluation(evaluationId: string): Promise { + const response = await this.client.post(`/api/v1/evaluations/${evaluationId}/cancel`) + return response.data + } + + async deleteEvaluation(evaluationId: string): Promise { + const response = await this.client.delete(`/api/v1/evaluations/${evaluationId}`) + return response.data + } + + // Results endpoints + async getEvaluationResult(evaluationId: string): Promise { + const response = await this.client.get(`/api/v1/results/${evaluationId}`) + return response.data + } + + async getMetrics(evaluationId: string): Promise<{ + evaluation_id: string + metrics: Record + processing_time?: number | null + }> { + const response = await this.client.get(`/api/v1/results/${evaluationId}/metrics`) + return response.data + } + + async getTranscript(evaluationId: string): Promise<{ + evaluation_id: string + transcript: string + }> { + const response = await this.client.get(`/api/v1/results/${evaluationId}/transcript`) + return response.data + } + + async compareEvaluations(evaluationIds: string[]): Promise<{ + evaluations: EvaluationResult[] + comparison_metrics: Record + }> { + const response = await this.client.post('/api/v1/results/compare', { + evaluation_ids: evaluationIds, + }) + return response.data + } + + // Agents endpoints + async createAgent(data: { + name: string + phone_number?: string + telephony_phone_number_id?: string + language: string + description?: string | null + call_type: string + call_medium: string + voice_bundle_id?: string + ai_provider_id?: string + voice_ai_integration_id?: string + voice_ai_agent_id?: string + }): Promise { + const response = await this.client.post('/api/v1/agents', data) + return response.data + } + + async listAgents(skip = 0, limit = 100): Promise { + const response = await this.client.get('/api/v1/agents', { + params: { skip, limit }, + }) + return response.data + } + + async getAgent(agentId: string): Promise { + const response = await this.client.get(`/api/v1/agents/${agentId}`) + return response.data + } + + async updateAgent(agentId: string, data: { + name?: string + phone_number?: string + telephony_phone_number_id?: string | null + language?: string + description?: string | null + call_type?: string + call_medium?: string + voice_bundle_id?: string + ai_provider_id?: string + voice_ai_integration_id?: string + voice_ai_agent_id?: string + }): Promise { + const response = await this.client.put(`/api/v1/agents/${agentId}`, data) + return response.data + } + + async deleteAgent(agentId: string, force?: boolean): Promise { + const response = await this.client.delete(`/api/v1/agents/${agentId}`, { + params: force ? { force: true } : undefined, + }) + return response.data + } + + async getAgentDeleteImpact(agentId: string): Promise<{ + agent_id: string + agent_name: string + dependencies: Record + can_delete_without_force: boolean + }> { + const response = await this.client.get(`/api/v1/agents/${agentId}/delete-impact`) + return response.data + } + + async generateAgentDescription(data: { + description: string + tone?: string + format_style?: string + provider?: string + model?: string + }): Promise<{ content: string; provider: string; model: string }> { + const response = await this.client.post('/api/v1/agents/generate-description', data) + return response.data + } + + // Personas endpoints + async listPersonas(skip = 0, limit = 100): Promise { + const response = await this.client.get('/api/v1/personas', { + params: { skip, limit }, + }) + return response.data + } + + async getPersona(personaId: string): Promise { + const response = await this.client.get(`/api/v1/personas/${personaId}`) + return response.data + } + + async createPersona(data: { + name: string + gender: string + tts_provider?: string + tts_voice_id?: string + tts_voice_name?: string + is_custom?: boolean + }): Promise { + const response = await this.client.post('/api/v1/personas', data) + return response.data + } + + async updatePersona(personaId: string, data: any): Promise { + const response = await this.client.put(`/api/v1/personas/${personaId}`, data) + return response.data + } + + async deletePersona(personaId: string, force?: boolean): Promise { + const response = await this.client.delete(`/api/v1/personas/${personaId}`, { + params: force ? { force: true } : undefined, + }) + return response.data + } + + async clonePersona(personaId: string, name?: string): Promise { + const response = await this.client.post(`/api/v1/personas/${personaId}/clone`, { name }) + return response.data + } + + async seedDemoData(): Promise { + const response = await this.client.post('/api/v1/personas/seed-data') + return response.data + } + + // Persona voice options (built-in + custom voices, ungated) + async getPersonaVoiceOptions(provider?: string): Promise<{ + providers: Array<{ + id: string + name: string + voices: Array<{ + id: string + name: string + gender: string + is_custom: boolean + custom_voice_id?: string + description?: string | null + }> + }> + }> { + const response = await this.client.get('/api/v1/personas/voice-options', { + params: provider ? { provider } : undefined, + }) + return response.data + } + + // Custom voice CRUD (persona-scoped, ungated) + async listPersonaCustomVoices(provider?: string): Promise { + const response = await this.client.get('/api/v1/personas/custom-voices', { + params: provider ? { provider } : undefined, + }) + return response.data + } + + async createPersonaCustomVoice(data: { + provider: string + voice_id: string + name: string + gender?: string + description?: string + }): Promise { + const response = await this.client.post('/api/v1/personas/custom-voices', data) + return response.data + } + + async updatePersonaCustomVoice(customVoiceId: string, data: { + voice_id?: string + name?: string + gender?: string + description?: string + }): Promise { + const response = await this.client.put(`/api/v1/personas/custom-voices/${customVoiceId}`, data) + return response.data + } + + async deletePersonaCustomVoice(customVoiceId: string): Promise { + const response = await this.client.delete(`/api/v1/personas/custom-voices/${customVoiceId}`) + return response.data + } + + // Scenarios endpoints + async listScenarios(skip = 0, limit = 100): Promise { + const response = await this.client.get('/api/v1/scenarios', { + params: { skip, limit }, + }) + return response.data + } + + async getScenario(scenarioId: string): Promise { + const response = await this.client.get(`/api/v1/scenarios/${scenarioId}`) + return response.data + } + + async createScenario(data: { + name: string + agent_id?: string | null + description?: string | null + required_info: Record + }): Promise { + const response = await this.client.post('/api/v1/scenarios', data) + return response.data + } + + async updateScenario(scenarioId: string, data: { + name?: string + agent_id?: string | null + description?: string | null + required_info?: Record + }): Promise { + const response = await this.client.put(`/api/v1/scenarios/${scenarioId}`, data) + return response.data + } + + async deleteScenario(scenarioId: string, force?: boolean): Promise { + const response = await this.client.delete(`/api/v1/scenarios/${scenarioId}`, { + params: force ? { force: true } : undefined, + }) + return response.data + } + + // Chat/Inference endpoints + async chatCompletion(data: { + messages: Array<{ role: string; content: string }> + provider: string + model: string + temperature?: number + max_tokens?: number + }): Promise<{ + text: string + model: string + usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } + processing_time?: number + }> { + const response = await this.client.post('/api/v1/chat/completion', data) + return response.data + } + + // VoiceBundle endpoints + async listVoiceBundles(skip = 0, limit = 100): Promise { + const response = await this.client.get('/api/v1/voicebundles', { + params: { skip, limit }, + }) + return response.data + } + + async getVoiceBundle(voicebundleId: string): Promise { + const response = await this.client.get(`/api/v1/voicebundles/${voicebundleId}`) + return response.data + } + + async createVoiceBundle(data: any): Promise { + const response = await this.client.post('/api/v1/voicebundles', data) + return response.data + } + + async updateVoiceBundle(voicebundleId: string, data: any): Promise { + const response = await this.client.put(`/api/v1/voicebundles/${voicebundleId}`, data) + return response.data + } + + async deleteVoiceBundle(voicebundleId: string, force?: boolean): Promise { + const response = await this.client.delete(`/api/v1/voicebundles/${voicebundleId}`, { + params: force ? { force: true } : undefined, + }) + return response.data + } + + // AI Provider endpoints + async listAIProviders(): Promise { + const response = await this.client.get('/api/v1/aiproviders') + return response.data + } + + async getAIProvider(aiproviderId: string): Promise { + const response = await this.client.get(`/api/v1/aiproviders/${aiproviderId}`) + return response.data + } + + async createAIProvider(data: any): Promise { + const response = await this.client.post('/api/v1/aiproviders', data) + return response.data + } + + async updateAIProvider(aiproviderId: string, data: any): Promise { + const response = await this.client.put(`/api/v1/aiproviders/${aiproviderId}`, data) + return response.data + } + + async deleteAIProvider(aiproviderId: string): Promise { + await this.client.delete(`/api/v1/aiproviders/${aiproviderId}`) + } + + async testAIProvider(aiproviderId: string): Promise { + const response = await this.client.post(`/api/v1/aiproviders/${aiproviderId}/test`) + return response.data + } + + // IAM endpoints + async listOrganizationUsers(): Promise { + const response = await this.client.get('/api/v1/iam/users') + return response.data + } + + async inviteUser(data: InvitationCreate): Promise { + const response = await this.client.post('/api/v1/iam/invitations', data) + return response.data + } + + async listInvitations(): Promise { + const response = await this.client.get('/api/v1/iam/invitations') + return response.data + } + + async updateUserRole(userId: string, role: Role): Promise { + const response = await this.client.put(`/api/v1/iam/users/${userId}/role`, { role }) + return response.data + } + + async removeUser(userId: string): Promise { + await this.client.delete(`/api/v1/iam/users/${userId}`) + } + + async cancelInvitation(invitationId: string): Promise { + await this.client.delete(`/api/v1/iam/invitations/${invitationId}`) + } + + // Profile endpoints + async getProfile(): Promise { + const response = await this.client.get('/api/v1/profile') + return response.data + } + + async updateProfile(data: UserUpdate): Promise { + const response = await this.client.put('/api/v1/profile', data) + return response.data + } + + async getMyInvitations(): Promise { + const response = await this.client.get('/api/v1/profile/invitations') + return response.data + } + + async acceptInvitation(invitationId: string): Promise { + const response = await this.client.post(`/api/v1/profile/invitations/${invitationId}/accept`) + return response.data + } + + async declineInvitation(invitationId: string): Promise { + const response = await this.client.post(`/api/v1/profile/invitations/${invitationId}/decline`) + return response.data + } + + // User Preferences endpoints + async getUserPreferences(): Promise { + const response = await this.client.get('/api/v1/profile/preferences') + return response.data + } + + async updateUserPreferences(data: UserPreferencesUpdate): Promise { + const response = await this.client.put('/api/v1/profile/preferences', data) + return response.data + } + + // Integration endpoints + async listIntegrations(): Promise { + const response = await this.client.get('/api/v1/integrations') + return response.data + } + + async getIntegration(integrationId: string): Promise { + const response = await this.client.get(`/api/v1/integrations/${integrationId}`) + return response.data + } + + async createIntegration(data: IntegrationCreate): Promise { + const response = await this.client.post('/api/v1/integrations', data) + return response.data + } + + async updateIntegration(integrationId: string, data: Partial): Promise { + const response = await this.client.put(`/api/v1/integrations/${integrationId}`, data) + return response.data + } + + async deleteIntegration(integrationId: string, force?: boolean): Promise { + const response = await this.client.delete(`/api/v1/integrations/${integrationId}`, { + params: force ? { force: true } : undefined, + }) + return response.data + } + + async getIntegrationApiKey(integrationId: string): Promise<{ api_key: string; public_key?: string | null }> { + const response = await this.client.get(`/api/v1/integrations/${integrationId}/api-key`) + return response.data + } + + // Telephony endpoints (provider-agnostic) + async createTelephonyConfig(data: TelephonyIntegrationCreatePayload): Promise { + const response = await this.client.post('/api/v1/telephony/config', data) + return response.data + } + + async getTelephonyConfig(provider: string = 'plivo'): Promise { + const response = await this.client.get('/api/v1/telephony/config', { params: { provider } }) + return response.data + } + + async updateTelephonyConfig(data: TelephonyIntegrationUpdatePayload): Promise { + const response = await this.client.put('/api/v1/telephony/config', data) + return response.data + } + + async testTelephonyConfig(provider: string = 'plivo'): Promise<{ success: boolean }> { + const response = await this.client.post('/api/v1/telephony/config/test', null, { params: { provider } }) + return response.data + } + + async syncTelephonyNumbers(provider: string = 'plivo'): Promise { + const response = await this.client.post('/api/v1/telephony/numbers/sync', null, { + params: { provider }, + }) + return response.data + } + + async listTelephonyNumbers(provider?: string): Promise { + const response = await this.client.get('/api/v1/telephony/numbers', { + params: provider ? { provider } : undefined, + }) + return response.data + } + + async updateTelephonyNumber( + numberId: string, + data: { is_masking_pool?: boolean; agent_id?: string | null; is_active?: boolean } + ): Promise { + const response = await this.client.patch(`/api/v1/telephony/numbers/${numberId}`, data) + return response.data + } + + // Data Sources endpoints + async testS3Connection(): Promise { + const response = await this.client.post('/api/v1/data-sources/s3/test') + return response.data + } + + async listS3Files(prefix?: string, maxKeys = 1000): Promise { + const response = await this.client.get('/api/v1/data-sources/s3/files', { + params: { prefix, max_keys: maxKeys }, + }) + return response.data + } + + async browseS3(path = '', maxKeys = 1000): Promise { + const response = await this.client.get('/api/v1/data-sources/s3/browse', { + params: { path, max_keys: maxKeys }, + }) + return response.data + } + + async uploadToS3(file: File, customFilename?: string): Promise { + const formData = new FormData() + formData.append('file', file) + if (customFilename) { + formData.append('filename', customFilename) + } + const response = await this.client.post('/api/v1/data-sources/s3/upload', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return response.data + } + + async getS3Status(): Promise { + const response = await this.client.get('/api/v1/data-sources/s3/status') + return response.data + } + + async downloadFromS3(fileKey: string): Promise { + const response = await this.client.get(`/api/v1/data-sources/s3/files/${encodeURIComponent(fileKey)}/download`, { + responseType: 'blob', + }) + // Create a blob URL and trigger download + const url = window.URL.createObjectURL(new Blob([response.data])) + const link = document.createElement('a') + link.href = url + link.setAttribute('download', fileKey.split('/').pop() || 'file') + document.body.appendChild(link) + link.click() + link.remove() + window.URL.revokeObjectURL(url) + } + + async getS3PresignedUrl(fileKey: string, expiration: number = 3600): Promise<{ url: string; expires_in: number }> { + const response = await this.client.get(`/api/v1/data-sources/s3/files/${encodeURIComponent(fileKey)}/presigned-url`, { + params: { expiration }, + }) + return response.data + } + + async deleteFromS3(fileKey: string): Promise { + const response = await this.client.delete(`/api/v1/data-sources/s3/files/${encodeURIComponent(fileKey)}`) + return response.data + } + + // Model Config endpoints + async getAllModels(): Promise> { + const response = await this.client.get('/api/v1/model-config/models') + return response.data + } + + async getModelConfig(modelName: string): Promise { + const response = await this.client.get(`/api/v1/model-config/models/${modelName}`) + return response.data + } + + async getModelsByProvider(provider: string): Promise { + const response = await this.client.get(`/api/v1/model-config/providers/${provider}/models`) + return response.data + } + + async getModelOptions(provider: string): Promise<{ stt: string[]; llm: string[]; tts: string[]; s2s: string[]; tts_voices: Record }> { + const response = await this.client.get(`/api/v1/model-config/providers/${provider}/options`) + const data = response.data + // Ensure s2s and tts_voices are always present (for backward compatibility) + return { + ...data, + s2s: data.s2s || [], + tts_voices: data.tts_voices || {}, + } + } + + async getModelsByType(provider: string, modelType: 'stt' | 'llm' | 'tts'): Promise { + const response = await this.client.get(`/api/v1/model-config/providers/${provider}/types/${modelType}/models`) + return response.data + } + + // Manual Evaluations endpoints + async listManualEvaluationAudioFiles(prefix?: string, maxKeys = 1000): Promise { + const response = await this.client.get('/api/v1/manual-evaluations/audio-files', { + params: { prefix, max_keys: maxKeys }, + }) + return response.data + } + + async getAudioPresignedUrl(fileKey: string, expiration = 3600): Promise<{ url: string; expires_in: number }> { + const response = await this.client.get( + `/api/v1/manual-evaluations/audio-files/${encodeURIComponent(fileKey)}/presigned-url`, + { + params: { expiration }, + } + ) + return response.data + } + + async transcribeAudio(data: { + audio_file_key: string + stt_provider: string + stt_model: string + name?: string + language?: string + enable_speaker_diarization?: boolean + }): Promise { + const response = await this.client.post('/api/v1/manual-evaluations/transcribe', data) + return response.data + } + + async listManualTranscriptions(skip = 0, limit = 100): Promise { + const response = await this.client.get('/api/v1/manual-evaluations/transcriptions', { + params: { skip, limit }, + }) + return response.data + } + + async getManualTranscription(transcriptionId: string): Promise { + const response = await this.client.get(`/api/v1/manual-evaluations/transcriptions/${transcriptionId}`) + return response.data + } + + async updateManualTranscription(transcriptionId: string, name: string): Promise { + const response = await this.client.patch(`/api/v1/manual-evaluations/transcriptions/${transcriptionId}`, { name }) + return response.data + } + + async deleteManualTranscription(transcriptionId: string): Promise { + const response = await this.client.delete(`/api/v1/manual-evaluations/transcriptions/${transcriptionId}`) + return response.data + } + + // Test Agent endpoints + async createTestAgentConversation(data: { + agent_id: string + persona_id: string + scenario_id: string + voice_bundle_id: string + conversation_metadata?: Record + }): Promise { + const response = await this.client.post('/api/v1/test-agents/conversations', data) + return response.data + } + + async getTestAgentConversation(conversationId: string): Promise { + const response = await this.client.get(`/api/v1/test-agents/conversations/${conversationId}`) + return response.data + } + + async listTestAgentConversations(): Promise { + const response = await this.client.get('/api/v1/test-agents/conversations') + return response.data + } + + async startTestAgentConversation(conversationId: string): Promise { + const response = await this.client.post(`/api/v1/test-agents/conversations/${conversationId}/start`) + return response.data + } + + async processTestAgentAudio(conversationId: string, audioFile: File, chunkTimestamp?: number): Promise { + const formData = new FormData() + formData.append('audio_file', audioFile) + if (chunkTimestamp !== undefined) { + formData.append('chunk_timestamp', chunkTimestamp.toString()) + } + const response = await this.client.post( + `/api/v1/test-agents/conversations/${conversationId}/process-audio`, + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ) + return response.data + } + + async getTestAgentResponseAudio(conversationId: string): Promise { + const response = await this.client.get( + `/api/v1/test-agents/conversations/${conversationId}/response-audio`, + { + responseType: 'blob', + } + ) + return response.data + } + + async endTestAgentConversation(conversationId: string, finalAudioKey?: string): Promise { + const response = await this.client.post(`/api/v1/test-agents/conversations/${conversationId}/end`, { + final_audio_key: finalAudioKey, + }) + return response.data + } + + async deleteTestAgentConversation(conversationId: string): Promise { + await this.client.delete(`/api/v1/test-agents/conversations/${conversationId}`) + } + + // Voice Agent endpoints + async getVoiceAgentConnection(): Promise<{ ws_url: string; endpoint: string }> { + const response = await this.client.post('/api/v1/voice-agent/connect') + return response.data + } + + // Playground endpoints + async createWebCall(data: { + agent_id: string + metadata?: Record + retell_llm_dynamic_variables?: Record + custom_sip_headers?: Record + }): Promise<{ + call_type: string + access_token?: string + call_id: string + agent_id: string + agent_version?: number + call_status?: string + agent_name?: string + metadata?: Record + retell_llm_dynamic_variables?: Record + sample_rate?: number + call_short_id?: string + signed_url?: string + host?: string + room_name?: string + conversation_id?: string + }> { + const response = await this.client.post('/api/v1/playground/web-call', data) + return response.data + } + + async listCallRecordings(skip = 0, limit = 100): Promise { + const response = await this.client.get('/api/v1/playground/call-recordings', { + params: { skip, limit }, + }) + return response.data + } + + async getCallRecording(callShortId: string): Promise { + const response = await this.client.get(`/api/v1/playground/call-recordings/${callShortId}`) + return response.data + } + + async refreshCallRecording(callShortId: string): Promise<{ message: string }> { + const response = await this.client.post(`/api/v1/playground/call-recordings/${callShortId}/refresh`) + return response.data + } + + async updateCallRecording(callShortId: string, providerCallId: string): Promise<{ message: string; provider_call_id: string }> { + const response = await this.client.put(`/api/v1/playground/call-recordings/${callShortId}`, { + provider_call_id: providerCallId + }) + return response.data + } + + async deleteCallRecording(callShortId: string): Promise<{ message: string }> { + const response = await this.client.delete(`/api/v1/playground/call-recordings/${callShortId}`) + return response.data + } + + async reEvaluateCallRecording(callShortId: string): Promise<{ + message: string + evaluator_result_id: string + result_id: string + audio_s3_key: string + task_id: string + }> { + const response = await this.client.post(`/api/v1/playground/call-recordings/${callShortId}/re-evaluate`) + return response.data + } + + async getCallRecordingAudioUrl(callShortId: string): Promise { + const response = await this.client.get( + `/api/v1/playground/call-recordings/${callShortId}/audio`, + { responseType: 'blob' } + ) + return URL.createObjectURL(response.data) + } + + async createCustomWebsocketSession(data: { + agent_id: string + websocket_url: string + transcript_entries: Array<{ role: 'user' | 'agent'; content: string; timestamp: string }> + started_at?: string + ended_at?: string + audio_file?: File + }): Promise<{ + message: string + call_short_id: string + audio_s3_key?: string | null + evaluator_result_id?: string | null + }> { + const formData = new FormData() + formData.append('agent_id', data.agent_id) + formData.append('websocket_url', data.websocket_url) + formData.append('transcript_entries', JSON.stringify(data.transcript_entries)) + if (data.started_at) { + formData.append('started_at', data.started_at) + } + if (data.ended_at) { + formData.append('ended_at', data.ended_at) + } + if (data.audio_file) { + formData.append('audio_file', data.audio_file) + } + + const response = await this.client.post('/api/v1/playground/custom-websocket-sessions', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + return response.data + } + + async evaluateCustomWebsocketSession(callShortId: string): Promise<{ + message: string + evaluator_result_id: string + result_id: string + task_id: string + }> { + const response = await this.client.post(`/api/v1/playground/custom-websocket-sessions/${callShortId}/evaluate`) + return response.data + } + + async getAgentSttConfig(agentId: string): Promise<{ + available: boolean + provider?: string + model?: string + reason?: string + }> { + const response = await this.client.get(`/api/v1/playground/agents/${agentId}/stt-config`) + return response.data + } + + async transcribeTurn( + agentId: string, + channel: 'user' | 'agent', + audioBlob: Blob, + ): Promise<{ transcript: string; channel: string }> { + const formData = new FormData() + formData.append('agent_id', agentId) + formData.append('channel', channel) + formData.append('audio_file', audioBlob, `turn_${channel}_${Date.now()}.wav`) + const response = await this.client.post('/api/v1/playground/transcribe-turn', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data + } + + async summarizeTranscript(params: { + transcript?: string + entries?: Array<{ role: string; content: string; timestamp?: string }> + callShortId?: string + agentId?: string + force?: boolean + }): Promise<{ + summary: string + provider: string + model: string + source?: 'voice_bundle' | 'org_fallback' + cached?: boolean + generated_at?: string + usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } + }> { + const body: Record = {} + if (params.transcript) body.transcript = params.transcript + if (params.entries && params.entries.length > 0) body.entries = params.entries + if (params.callShortId) body.call_short_id = params.callShortId + if (params.agentId) body.agent_id = params.agentId + if (params.force) body.force = true + const response = await this.client.post('/api/v1/playground/summarize-transcript', body) + return response.data + } + + // Observability endpoints + async listObservabilityCalls(skip = 0, limit = 100): Promise { + const response = await this.client.get('/api/v1/observability/calls', { + params: { skip, limit }, + }) + return response.data + } + + async getObservabilityCall(callShortId: string): Promise { + const response = await this.client.get(`/api/v1/observability/calls/${callShortId}`) + return response.data + } + + async deleteObservabilityCall(callShortId: string): Promise<{ message: string }> { + const response = await this.client.delete(`/api/v1/observability/calls/${callShortId}`) + return response.data + } + + async evaluateObservabilityCall(callShortId: string, evaluatorId: string): Promise { + const response = await this.client.post(`/api/v1/observability/calls/${callShortId}/evaluate`, { + evaluator_id: evaluatorId, + }) + return response.data + } + + // Evaluator endpoints + async createEvaluator(data: { + name?: string + agent_id?: string + persona_id?: string + scenario_id?: string + custom_prompt?: string + llm_provider?: string + llm_model?: string + tags?: string[] + }): Promise { + const response = await this.client.post('/api/v1/evaluators', data) + return response.data + } + + async formatCustomPrompt(prompt: string): Promise<{ formatted_prompt: string }> { + const response = await this.client.post('/api/v1/evaluators/format-prompt', { prompt }) + return response.data + } + + async createEvaluatorsBulk(data: { + name?: string + agent_id: string + scenario_id: string + persona_ids: string[] + tags?: string[] + }): Promise { + const response = await this.client.post('/api/v1/evaluators/bulk', data) + return response.data + } + + async listEvaluators(): Promise { + const response = await this.client.get('/api/v1/evaluators') + return response.data + } + + async getEvaluator(evaluatorId: string): Promise { + const response = await this.client.get(`/api/v1/evaluators/${evaluatorId}`) + return response.data + } + + async updateEvaluator(evaluatorId: string, data: { + agent_id?: string + persona_id?: string + scenario_id?: string + name?: string + custom_prompt?: string + llm_provider?: string + llm_model?: string + tags?: string[] + }): Promise { + const response = await this.client.put(`/api/v1/evaluators/${evaluatorId}`, data) + return response.data + } + + async deleteEvaluator(evaluatorId: string, force?: boolean): Promise { + const response = await this.client.delete(`/api/v1/evaluators/${evaluatorId}`, { + params: force ? { force: true } : undefined, + }) + return response.data + } + + async runEvaluators(evaluatorIds: string[]): Promise<{ task_ids: string[]; evaluator_results: any[] }> { + const response = await this.client.post('/api/v1/evaluators/run', { evaluator_ids: evaluatorIds }) + return response.data + } + + // Metric endpoints + async createMetric(data: { + name: string + description?: string + metric_type: 'number' | 'boolean' | 'rating' + trigger?: 'always' + enabled?: boolean + metric_origin?: 'default' | 'custom' + supported_surfaces?: string[] + enabled_surfaces?: string[] + custom_data_type?: 'boolean' | 'enum' | 'number_range' + custom_config?: Record + tags?: string[] + }): Promise { + const response = await this.client.post('/api/v1/metrics', data) + return response.data + } + + async listMetrics(surface?: string): Promise { + const response = await this.client.get('/api/v1/metrics', { + params: surface ? { surface } : undefined, + }) + return response.data + } + + // Evaluator Results endpoints + async listEvaluatorResults(evaluatorId?: string, playground?: boolean, testAgentsOnly?: boolean): Promise { + const params: any = {} + if (evaluatorId) { + params.evaluator_id = evaluatorId + } + if (playground !== undefined) { + params.playground = playground + } + if (testAgentsOnly !== undefined) { + params.test_agents_only = testAgentsOnly + } + const response = await this.client.get('/api/v1/evaluator-results', { params }) + return response.data + } + + async getEvaluatorResult(id: string, includeRelations: boolean = true): Promise { + const params = includeRelations ? { include_relations: 'true' } : {} + const response = await this.client.get(`/api/v1/evaluator-results/${id}`, { params }) + return response.data + } + + async getEvaluatorResultMetrics(id: string): Promise { + const response = await this.client.get(`/api/v1/evaluator-results/${id}/metrics`) + return response.data + } + + async createEvaluatorResultManual(data: { + evaluator_id: string + audio_s3_key: string + duration_seconds?: number + }): Promise { + const response = await this.client.post('/api/v1/evaluator-results', data) + return response.data + } + + async reEvaluateResult(id: string): Promise { + const response = await this.client.post(`/api/v1/evaluator-results/${id}/re-evaluate`) + return response.data + } + + async deleteEvaluatorResult(id: string): Promise { + await this.client.delete(`/api/v1/evaluator-results/${id}`) + } + + async deleteEvaluatorResultsBulk(ids: string[]): Promise { + // FastAPI expects multiple query parameters with the same name + // Build query string manually: result_ids=id1&result_ids=id2 + const params = new URLSearchParams() + ids.forEach(id => params.append('result_ids', id)) + await this.client.delete(`/api/v1/evaluator-results?${params.toString()}`) + } + + async getMetric(metricId: string): Promise { + const response = await this.client.get(`/api/v1/metrics/${metricId}`) + return response.data + } + + async updateMetric(metricId: string, data: { + name?: string + description?: string + metric_type?: 'number' | 'boolean' | 'rating' + trigger?: 'always' + enabled?: boolean + metric_origin?: 'default' | 'custom' + supported_surfaces?: string[] + enabled_surfaces?: string[] + custom_data_type?: 'boolean' | 'enum' | 'number_range' + custom_config?: Record + tags?: string[] + }): Promise { + const response = await this.client.put(`/api/v1/metrics/${metricId}`, data) + return response.data + } + + async deleteMetric(metricId: string): Promise { + await this.client.delete(`/api/v1/metrics/${metricId}`) + } + + async seedDefaultMetrics(): Promise { + const response = await this.client.post('/api/v1/metrics/seed-defaults') + return response.data + } + + // Alert endpoints + async listAlerts(status?: string): Promise { + const params: any = {} + if (status) { + params.status_filter = status + } + const response = await this.client.get('/api/v1/alerts', { params }) + return response.data + } + + async getAlert(alertId: string): Promise { + const response = await this.client.get(`/api/v1/alerts/${alertId}`) + return response.data + } + + async createAlert(data: { + name: string + description?: string | null + metric_type: string + aggregation: string + operator: string + threshold_value: number + time_window_minutes: number + agent_ids?: string[] | null + notify_frequency: string + notify_emails?: string[] + notify_webhooks?: string[] + }): Promise { + const response = await this.client.post('/api/v1/alerts', data) + return response.data + } + + async updateAlert(alertId: string, data: { + name?: string + description?: string | null + metric_type?: string + aggregation?: string + operator?: string + threshold_value?: number + time_window_minutes?: number + agent_ids?: string[] | null + notify_frequency?: string + notify_emails?: string[] + notify_webhooks?: string[] + status?: string + }): Promise { + const response = await this.client.put(`/api/v1/alerts/${alertId}`, data) + return response.data + } + + async deleteAlert(alertId: string): Promise { + await this.client.delete(`/api/v1/alerts/${alertId}`) + } + + async toggleAlertStatus(alertId: string): Promise { + const response = await this.client.post(`/api/v1/alerts/${alertId}/toggle`) + return response.data + } + + // Alert History endpoints + async listAlertHistory(status?: string, alertId?: string, skip = 0, limit = 100): Promise { + const params: any = { skip, limit } + if (status) { + params.status_filter = status + } + if (alertId) { + params.alert_id = alertId + } + const response = await this.client.get('/api/v1/alerts/history/all', { params }) + return response.data + } + + async getAlertHistoryItem(historyId: string): Promise { + const response = await this.client.get(`/api/v1/alerts/history/${historyId}`) + return response.data + } + + async acknowledgeAlertHistory(historyId: string): Promise { + const response = await this.client.put(`/api/v1/alerts/history/${historyId}`, { + status: 'acknowledged' + }) + return response.data + } + + async resolveAlertHistory(historyId: string, resolutionNotes?: string): Promise { + const response = await this.client.put(`/api/v1/alerts/history/${historyId}`, { + status: 'resolved', + resolution_notes: resolutionNotes + }) + return response.data + } + + // Alert Evaluation & Notification endpoints + async triggerAlert(alertId: string): Promise { + const response = await this.client.post(`/api/v1/alerts/${alertId}/trigger`) + return response.data + } + + async evaluateAllAlerts(): Promise { + const response = await this.client.post('/api/v1/alerts/evaluate/all') + return response.data + } + + async testAlertNotification(alertId: string, data: { + webhook_url?: string + email?: string + }): Promise { + const response = await this.client.post(`/api/v1/alerts/${alertId}/test-notification`, data) + return response.data + } + + // Cron Job endpoints + async listCronJobs(): Promise { + const response = await this.client.get('/api/v1/cron-jobs') + return response.data + } + + async getCronJob(cronJobId: string): Promise { + const response = await this.client.get(`/api/v1/cron-jobs/${cronJobId}`) + return response.data + } + + async createCronJob(data: { + name: string + cron_expression: string + timezone: string + max_runs: number + evaluator_ids: string[] + }): Promise { + const response = await this.client.post('/api/v1/cron-jobs', data) + return response.data + } + + async updateCronJob(cronJobId: string, data: { + name?: string + cron_expression?: string + timezone?: string + max_runs?: number + evaluator_ids?: string[] + status?: string + }): Promise { + const response = await this.client.put(`/api/v1/cron-jobs/${cronJobId}`, data) + return response.data + } + + async deleteCronJob(cronJobId: string): Promise { + await this.client.delete(`/api/v1/cron-jobs/${cronJobId}`) + } + + async toggleCronJobStatus(cronJobId: string): Promise { + const response = await this.client.post(`/api/v1/cron-jobs/${cronJobId}/toggle`) + return response.data + } + + // Voice Playground (TTS Comparison) endpoints + async listTTSProviders(): Promise { + const response = await this.client.get('/api/v1/voice-playground/tts-providers') + return response.data + } + + async listCustomTTSVoices(provider?: string): Promise> { + const response = await this.client.get('/api/v1/voice-playground/custom-voices', { + params: provider ? { provider } : undefined, + }) + return response.data + } + + async createCustomTTSVoice(data: { + provider: string + voice_id: string + name: string + gender?: string + accent?: string + description?: string + }): Promise { + const response = await this.client.post('/api/v1/voice-playground/custom-voices', data) + return response.data + } + + async updateCustomTTSVoice(customVoiceId: string, data: { + voice_id?: string + name?: string + gender?: string + accent?: string + description?: string + }): Promise { + const response = await this.client.put(`/api/v1/voice-playground/custom-voices/${customVoiceId}`, data) + return response.data + } + + async deleteCustomTTSVoice(customVoiceId: string): Promise { + const response = await this.client.delete(`/api/v1/voice-playground/custom-voices/${customVoiceId}`) + return response.data + } + + async createTTSComparison(data: { + name?: string + provider_a: string + model_a: string + voices_a: Array<{ id: string; name: string; sample_rate_hz?: number }> + provider_b?: string + model_b?: string + voices_b?: Array<{ id: string; name: string; sample_rate_hz?: number }> + sample_texts: string[] + num_runs?: number + }): Promise { + const response = await this.client.post('/api/v1/voice-playground/comparisons', data) + return response.data + } + + async listTTSComparisons(skip = 0, limit = 50): Promise { + const response = await this.client.get('/api/v1/voice-playground/comparisons', { + params: { skip, limit }, + }) + return response.data + } + + async getTTSComparison(comparisonId: string): Promise { + const response = await this.client.get(`/api/v1/voice-playground/comparisons/${comparisonId}`) + return response.data + } + + async generateTTSComparison(comparisonId: string): Promise<{ message: string; task_id: string }> { + const response = await this.client.post(`/api/v1/voice-playground/comparisons/${comparisonId}/generate`) + return response.data + } + + async submitBlindTest(comparisonId: string, results: Array<{ + sample_index: number + preferred: 'A' | 'B' + voice_a_id: string + voice_b_id: string + }>): Promise { + const response = await this.client.post( + `/api/v1/voice-playground/comparisons/${comparisonId}/blind-test`, + { results } + ) + return response.data + } + + async deleteTTSComparison(comparisonId: string): Promise { + await this.client.delete(`/api/v1/voice-playground/comparisons/${comparisonId}`) + } + + async generateSampleTexts(params: { + voice_bundle_id?: string + provider?: string + model?: string + scenario?: string + count?: number + length?: string + temperature?: number + }): Promise<{ samples: string[]; provider: string; model: string }> { + const response = await this.client.post('/api/v1/voice-playground/generate-samples', params) + return response.data + } + + async getTTSAnalytics(): Promise> { + const response = await this.client.get('/api/v1/voice-playground/analytics') + return response.data + } + + async downloadTTSComparisonReport( + comparisonId: string, + includeUnfinishedSamples = false, + reportOptions?: TTSReportOptionsPayload + ): Promise { + const response = await this.client.get( + `/api/v1/voice-playground/comparisons/${comparisonId}/report.pdf`, + { + params: { + include_unfinished_samples: includeUnfinishedSamples, + ...(reportOptions ? { report_options: JSON.stringify(reportOptions) } : {}), + }, + responseType: 'blob', + } + ) + return response.data + } + + async createTTSComparisonReportJob( + comparisonId: string, + reportOptions?: TTSReportOptionsPayload + ): Promise<{ + id: string + comparison_id: string + status: string + format: string + task_id?: string + report_options?: TTSReportOptionsPayload + created_at?: string | null + }> { + const response = await this.client.post( + `/api/v1/voice-playground/comparisons/${comparisonId}/reports`, + reportOptions ? { report_options: reportOptions } : {} + ) + return response.data + } + + async getVoicePlaygroundReportThresholdDefaults(): Promise<{ + zone_threshold_overrides: NonNullable + is_custom: boolean + }> { + const response = await this.client.get('/api/v1/voice-playground/report-threshold-defaults') + return response.data + } + + async updateVoicePlaygroundReportThresholdDefaults(data: { + zone_threshold_overrides?: NonNullable + reset_to_system_defaults?: boolean + }): Promise<{ + zone_threshold_overrides: NonNullable + is_custom: boolean + message: string + }> { + const response = await this.client.put('/api/v1/voice-playground/report-threshold-defaults', data) + return response.data + } + + async getTTSComparisonReportJob(reportJobId: string): Promise<{ + id: string + comparison_id: string + status: string + format: string + filename?: string | null + error_message?: string | null + task_id?: string | null + download_url?: string | null + report_options?: TTSReportOptionsPayload + created_at?: string | null + updated_at?: string | null + }> { + const response = await this.client.get(`/api/v1/voice-playground/reports/${reportJobId}`) + return response.data + } + + // Prompt Partials + async listPromptPartials(skip = 0, limit = 100, search?: string): Promise { + const response = await this.client.get('/api/v1/prompt-partials', { + params: { skip, limit, ...(search ? { search } : {}) }, + }) + return response.data + } + + async getPromptPartial(partialId: string): Promise { + const response = await this.client.get(`/api/v1/prompt-partials/${partialId}`) + return response.data + } + + async createPromptPartial(data: { + name: string + description?: string + content: string + tags?: string[] + }): Promise { + const response = await this.client.post('/api/v1/prompt-partials', data) + return response.data + } + + async updatePromptPartial(partialId: string, data: { + name?: string + description?: string + content?: string + tags?: string[] + change_summary?: string + }): Promise { + const response = await this.client.put(`/api/v1/prompt-partials/${partialId}`, data) + return response.data + } + + async deletePromptPartial(partialId: string): Promise { + await this.client.delete(`/api/v1/prompt-partials/${partialId}`) + } + + async listPromptPartialVersions(partialId: string): Promise { + const response = await this.client.get(`/api/v1/prompt-partials/${partialId}/versions`) + return response.data + } + + async getPromptPartialVersion(partialId: string, versionNumber: number): Promise { + const response = await this.client.get(`/api/v1/prompt-partials/${partialId}/versions/${versionNumber}`) + return response.data + } + + async revertPromptPartial(partialId: string, versionNumber: number): Promise { + const response = await this.client.post(`/api/v1/prompt-partials/${partialId}/revert/${versionNumber}`) + return response.data + } + + async clonePromptPartial(partialId: string): Promise { + const response = await this.client.post(`/api/v1/prompt-partials/${partialId}/clone`) + return response.data + } + + async generatePromptWithAI(data: { + description: string + tone?: string + format_style?: string + provider?: string + model?: string + }): Promise<{ content: string; provider: string; model: string }> { + const response = await this.client.post('/api/v1/prompt-partials/generate', data) + return response.data + } + + async improvePromptWithAI(data: { + content: string + instructions?: string + provider?: string + model?: string + }): Promise<{ content: string; provider: string; model: string }> { + const response = await this.client.post('/api/v1/prompt-partials/improve', data) + return response.data + } + + // License / Enterprise + async getLicenseInfo(): Promise { + const response = await this.client.get('/api/v1/settings/license-info') + return response.data + } + + // GEPA Prompt Optimization (Enterprise) + async createOptimizationRun(data: { + agent_id: string + evaluator_id?: string + voice_bundle_id?: string + config?: Record + }): Promise { + const response = await this.client.post('/api/v1/prompt-optimization/runs', data) + return response.data + } + + async deleteOptimizationRun(runId: string): Promise { + await this.client.delete(`/api/v1/prompt-optimization/runs/${runId}`) + } + + async listOptimizationRuns(agentId?: string): Promise { + const params = agentId ? { agent_id: agentId } : {} + const response = await this.client.get('/api/v1/prompt-optimization/runs', { params }) + return response.data + } + + async getOptimizationRun(runId: string): Promise { + const response = await this.client.get(`/api/v1/prompt-optimization/runs/${runId}`) + return response.data + } + + async listOptimizationCandidates(runId: string): Promise { + const response = await this.client.get(`/api/v1/prompt-optimization/runs/${runId}/candidates`) + return response.data + } + + async acceptCandidate(runId: string, candidateId: string): Promise { + const response = await this.client.post( + `/api/v1/prompt-optimization/runs/${runId}/candidates/${candidateId}/accept` + ) + return response.data + } + + async pushCandidateToProvider(runId: string, candidateId: string): Promise { + const response = await this.client.post( + `/api/v1/prompt-optimization/runs/${runId}/candidates/${candidateId}/push` + ) + return response.data + } + + async syncProviderPrompt(agentId: string): Promise<{ + provider_prompt: string | null + provider_prompt_synced_at: string | null + }> { + const response = await this.client.post(`/api/v1/agents/${agentId}/sync-provider-prompt`) + return response.data + } +} + +// Factory function to create ApiClient instance +// This ensures TypeScript correctly infers the type as ApiClient, not AxiosInstance +function createApiClient(): ApiClient { + return new ApiClient() +} + +// Create instance +const apiClientInstance = createApiClient() + +// Export with explicit type annotation - CRITICAL for TypeScript to recognize as ApiClient +// Without this explicit type, TypeScript may incorrectly infer AxiosInstance +export const apiClient: ApiClient = apiClientInstance + +// Re-export the type for explicit typing if needed +export type { ApiClient } + diff --git a/frontend/src/pages/agents/AgentDetail.tsx b/frontend/src/pages/agents/AgentDetail.tsx index d1a218ae..c928e766 100644 --- a/frontend/src/pages/agents/AgentDetail.tsx +++ b/frontend/src/pages/agents/AgentDetail.tsx @@ -17,6 +17,7 @@ interface FormData { description: string call_type: string call_medium: 'phone_call' | 'web_call' + telephony_phone_number_id: string voice_bundle_id: string voice_ai_integration_id: string voice_ai_agent_id: string @@ -43,6 +44,7 @@ export default function AgentDetail() { description: '', call_type: 'outbound', call_medium: 'phone_call', + telephony_phone_number_id: '', voice_bundle_id: '', voice_ai_integration_id: '', voice_ai_agent_id: '' @@ -78,6 +80,7 @@ export default function AgentDetail() { description: agent.description || '', call_type: agent.call_type, call_medium: agent.call_medium || 'phone_call', + telephony_phone_number_id: agent.telephony_phone_number_id || '', voice_bundle_id: agent.voice_bundle_id || '', voice_ai_integration_id: agent.voice_ai_integration_id || '', voice_ai_agent_id: agent.voice_ai_agent_id || '' @@ -98,8 +101,10 @@ export default function AgentDetail() { if (data.call_medium === 'phone_call') { payload.phone_number = data.phone_number?.trim() || null + payload.telephony_phone_number_id = data.telephony_phone_number_id?.trim() || null } else { payload.phone_number = null + payload.telephony_phone_number_id = null } const voiceBundleId = data.voice_bundle_id?.trim() @@ -213,6 +218,7 @@ export default function AgentDetail() { description: agent.description || '', call_type: agent.call_type, call_medium: agent.call_medium || 'phone_call', + telephony_phone_number_id: agent.telephony_phone_number_id || '', voice_bundle_id: agent.voice_bundle_id || '', voice_ai_integration_id: agent.voice_ai_integration_id || '', voice_ai_agent_id: agent.voice_ai_agent_id || '' diff --git a/frontend/src/pages/agents/components/AgentEditForm.tsx b/frontend/src/pages/agents/components/AgentEditForm.tsx index 212eadb4..d1caef69 100644 --- a/frontend/src/pages/agents/components/AgentEditForm.tsx +++ b/frontend/src/pages/agents/components/AgentEditForm.tsx @@ -4,6 +4,7 @@ import { Sparkles, Loader2, Bot, Eye, Code, Trash2, Save, PhoneOutgoing, PhoneIn import ReactMarkdown from 'react-markdown' import Button from '../../../components/Button' import { apiClient } from '../../../lib/api' +import type { TelephonyIntegrationResponse, TelephonyPhoneNumberResponse } from '../../../lib/api' import { VoiceBundle, Integration, AIProvider, IntegrationPlatform, ModelProvider } from '../../../types/api' import { getProviderLabel, getIntegrationPlatformLabel, getIntegrationPlatformLogo } from '../../../config/providers' @@ -14,6 +15,7 @@ interface FormData { description: string call_type: string call_medium: 'phone_call' | 'web_call' + telephony_phone_number_id: string voice_bundle_id: string voice_ai_integration_id: string voice_ai_agent_id: string @@ -56,6 +58,7 @@ export default function AgentEditForm({ const [aiFormat, setAiFormat] = useState('structured') const [aiProvider, setAiProvider] = useState('') const [aiModel, setAiModel] = useState('') + const [phoneNumberInputMode, setPhoneNumberInputMode] = useState<'provider' | 'custom'>('provider') const { data: aiProviders = [] } = useQuery({ queryKey: ['ai-providers'], @@ -67,6 +70,17 @@ export default function AgentEditForm({ queryFn: () => apiClient.getModelOptions(aiProvider), enabled: !!aiProvider, }) + const { data: telephonyConfig, isError: isTelephonyConfigError } = useQuery({ + queryKey: ['telephony-config', 'plivo'], + queryFn: () => apiClient.getTelephonyConfig('plivo'), + enabled: formData.call_medium === 'phone_call', + retry: false, + }) + const { data: telephonyNumbers = [] } = useQuery({ + queryKey: ['telephony-numbers'], + queryFn: () => apiClient.listTelephonyNumbers(), + enabled: formData.call_medium === 'phone_call', + }) const llmModels = modelOptions?.llm || [] @@ -76,6 +90,33 @@ export default function AgentEditForm({ } }, [aiProvider, llmModels, aiModel]) + useEffect(() => { + if (formData.call_medium !== 'phone_call') { + return + } + + if (formData.telephony_phone_number_id) { + setPhoneNumberInputMode('provider') + return + } + + if (!telephonyConfig || telephonyNumbers.length === 0) { + setPhoneNumberInputMode('custom') + return + } + + const selectedExists = telephonyNumbers.some((n) => n.id === formData.telephony_phone_number_id) + if (!selectedExists && phoneNumberInputMode === 'provider') { + onChange({ ...formData, telephony_phone_number_id: '', phone_number: '' }) + } + }, [ + formData, + onChange, + phoneNumberInputMode, + telephonyConfig, + telephonyNumbers, + ]) + const generateDescriptionMutation = useMutation({ mutationFn: (data: { description: string; tone?: string; format_style?: string; provider?: string; model?: string }) => apiClient.generateAgentDescription(data), @@ -148,16 +189,93 @@ export default function AgentEditForm({ {/* Phone Number */} {formData.call_medium === 'phone_call' && ( -
- - onChange({ ...formData, phone_number: e.target.value })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="+1234567890" - /> +
+
+ +
+ + +
+
+ + {(!telephonyConfig || isTelephonyConfigError) && ( +

+ No telephony provider is configured yet. You can still enter a custom number, or configure + telephony in Integrations. +

+ )} + + {phoneNumberInputMode === 'provider' ? ( + + ) : ( + + onChange({ + ...formData, + phone_number: e.target.value.replace(/[^\d+]/g, ''), + }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="+1234567890" + /> + )}
)} diff --git a/frontend/src/pages/agents/components/AgentInfoView.tsx b/frontend/src/pages/agents/components/AgentInfoView.tsx index c97826db..e9fdf5d0 100644 --- a/frontend/src/pages/agents/components/AgentInfoView.tsx +++ b/frontend/src/pages/agents/components/AgentInfoView.tsx @@ -19,6 +19,7 @@ interface Agent { id: string name: string phone_number?: string | null + telephony_phone_number_id?: string | null language: string description?: string | null provider_prompt?: string | null @@ -95,7 +96,20 @@ export default function AgentInfoView({ {agent.phone_number && (
Phone Number
-
{agent.phone_number}
+
+ {agent.phone_number} + {agent.call_medium === 'phone_call' && ( + + {agent.telephony_phone_number_id ? 'Provider-linked' : 'Custom'} + + )} +
)}
diff --git a/frontend/src/pages/agents/components/CreateAgentModal.tsx b/frontend/src/pages/agents/components/CreateAgentModal.tsx index 53050807..9197ca66 100644 --- a/frontend/src/pages/agents/components/CreateAgentModal.tsx +++ b/frontend/src/pages/agents/components/CreateAgentModal.tsx @@ -4,6 +4,7 @@ import { X, Sparkles, Loader2, Bot, Eye, Code, FileText, PhoneOutgoing, PhoneInc import ReactMarkdown from 'react-markdown' import Button from '../../../components/Button' import { apiClient } from '../../../lib/api' +import type { TelephonyIntegrationResponse, TelephonyPhoneNumberResponse } from '../../../lib/api' import { AIProvider, VoiceBundle, Integration, IntegrationPlatform, ModelProvider } from '../../../types/api' import { getProviderLabel, getIntegrationPlatformLabel, getIntegrationPlatformLogo } from '../../../config/providers' @@ -14,6 +15,7 @@ interface FormData { description: string call_type: string call_medium: 'phone_call' | 'web_call' + telephony_phone_number_id: string voice_bundle_id: string voice_ai_integration_id: string voice_ai_agent_id: string @@ -55,6 +57,7 @@ export default function CreateAgentModal({ const [showUseSavedModal, setShowUseSavedModal] = useState(false) const [savedPromptSearch, setSavedPromptSearch] = useState('') const [selectedSavedPromptId, setSelectedSavedPromptId] = useState('') + const [phoneNumberInputMode, setPhoneNumberInputMode] = useState<'provider' | 'custom'>('provider') const [formData, setFormData] = useState({ name: '', @@ -63,6 +66,7 @@ export default function CreateAgentModal({ description: '', call_type: 'outbound', call_medium: 'phone_call', + telephony_phone_number_id: '', voice_bundle_id: '', voice_ai_integration_id: '', voice_ai_agent_id: '' @@ -82,6 +86,17 @@ export default function CreateAgentModal({ queryKey: ['ai-providers'], queryFn: () => apiClient.listAIProviders(), }) + const { data: telephonyConfig, isError: isTelephonyConfigError } = useQuery({ + queryKey: ['telephony-config', 'plivo'], + queryFn: () => apiClient.getTelephonyConfig('plivo'), + enabled: isOpen && formData.call_medium === 'phone_call', + retry: false, + }) + const { data: telephonyNumbers = [] } = useQuery({ + queryKey: ['telephony-numbers'], + queryFn: () => apiClient.listTelephonyNumbers(), + enabled: isOpen && formData.call_medium === 'phone_call', + }) const { data: modelOptions } = useQuery({ queryKey: ['model-options', aiProvider], @@ -102,6 +117,30 @@ export default function CreateAgentModal({ } }, [aiProvider, llmModels, aiModel]) + useEffect(() => { + if (formData.call_medium !== 'phone_call') { + return + } + const hasProviderNumbers = !!telephonyConfig && telephonyNumbers.length > 0 + if (!hasProviderNumbers && phoneNumberInputMode !== 'custom') { + setPhoneNumberInputMode('custom') + setFormData((prev) => ({ ...prev, telephony_phone_number_id: '' })) + } + if ( + phoneNumberInputMode === 'provider' && + formData.telephony_phone_number_id && + !telephonyNumbers.some((n) => n.id === formData.telephony_phone_number_id && !n.agent_id) + ) { + setFormData((prev) => ({ ...prev, telephony_phone_number_id: '', phone_number: '' })) + } + }, [ + formData.call_medium, + formData.telephony_phone_number_id, + phoneNumberInputMode, + telephonyConfig, + telephonyNumbers, + ]) + const generateDescriptionMutation = useMutation({ mutationFn: (data: { description: string; tone?: string; format_style?: string; provider?: string; model?: string }) => apiClient.generateAgentDescription(data), @@ -150,6 +189,9 @@ export default function CreateAgentModal({ if (data.call_medium === 'phone_call' && data.phone_number) { payload.phone_number = data.phone_number } + if (data.call_medium === 'phone_call' && data.telephony_phone_number_id) { + payload.telephony_phone_number_id = data.telephony_phone_number_id + } if (data.voice_bundle_id && data.voice_bundle_id.trim() !== '') { payload.voice_bundle_id = data.voice_bundle_id.trim() @@ -182,6 +224,7 @@ export default function CreateAgentModal({ description: '', call_type: 'outbound', call_medium: 'phone_call', + telephony_phone_number_id: '', voice_bundle_id: '', voice_ai_integration_id: '', voice_ai_agent_id: '' @@ -193,6 +236,7 @@ export default function CreateAgentModal({ setAiFormat('structured') setAiProvider('') setAiModel('') + setPhoneNumberInputMode('provider') setShowUseSavedModal(false) setSavedPromptSearch('') setSelectedSavedPromptId('') @@ -208,13 +252,20 @@ export default function CreateAgentModal({ } if (formData.call_medium === 'phone_call') { - if (!formData.phone_number || formData.phone_number.trim() === '') { - showToast('Phone number is required for phone calls.', 'error') - return - } - if (!/^[\d+]+$/.test(formData.phone_number)) { - showToast('Phone number must contain only digits and the + character.', 'error') - return + if (phoneNumberInputMode === 'provider') { + if (!formData.telephony_phone_number_id) { + showToast('Please select a telephony number from your provider.', 'error') + return + } + } else { + if (!formData.phone_number || formData.phone_number.trim() === '') { + showToast('Phone number is required for phone calls.', 'error') + return + } + if (!/^[\d+]+$/.test(formData.phone_number)) { + showToast('Phone number must contain only digits and the + character.', 'error') + return + } } } @@ -277,7 +328,8 @@ export default function CreateAgentModal({ onClick={() => setFormData({ ...formData, call_medium: medium, - phone_number: medium === 'web_call' ? '' : formData.phone_number + phone_number: medium === 'web_call' ? '' : formData.phone_number, + telephony_phone_number_id: medium === 'web_call' ? '' : formData.telephony_phone_number_id, })} className={`px-4 py-2 text-sm font-medium transition-colors focus:outline-none ${ formData.call_medium === medium @@ -293,16 +345,89 @@ export default function CreateAgentModal({ {/* Phone Number */} {formData.call_medium === 'phone_call' && ( -
- - setFormData({ ...formData, phone_number: e.target.value.replace(/[^\d+]/g, '') })} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="+1234567890" - /> +
+
+ +
+ + +
+
+ + {(!telephonyConfig || isTelephonyConfigError) && ( +

+ No telephony provider is configured yet. You can still enter a custom number, or configure + telephony in Integrations. +

+ )} + + {phoneNumberInputMode === 'provider' ? ( + + ) : ( + + setFormData({ + ...formData, + phone_number: e.target.value.replace(/[^\d+]/g, ''), + }) + } + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + placeholder="+1234567890" + /> + )}
)} diff --git a/frontend/src/pages/configurations/Integrations.tsx b/frontend/src/pages/configurations/Integrations.tsx index 0c2131ac..4542777f 100644 --- a/frontend/src/pages/configurations/Integrations.tsx +++ b/frontend/src/pages/configurations/Integrations.tsx @@ -1,18 +1,22 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query' import { apiClient } from '../../lib/api' -import { useState, useEffect, useRef, type ReactNode } from 'react' +import type { TelephonyPhoneNumberResponse } from '../../lib/api' +import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' import { createPortal } from 'react-dom' -import { Plus, Trash2, X, AlertCircle, Plug, Edit, Brain, ChevronDown } from 'lucide-react' -import { IntegrationCreate, IntegrationPlatform, Integration, AIProvider, AIProviderCreate, ModelProvider } from '../../types/api' +import { Plus, Trash2, X, AlertCircle, Plug, Edit, Brain, ChevronDown, Phone, RefreshCw, ShieldCheck, CheckCircle2 } from 'lucide-react' +import { IntegrationCreate, IntegrationPlatform, Integration, AIProvider, AIProviderCreate, ModelProvider, TelephonyProvider } from '../../types/api' import Button from '../../components/Button' import { useToast } from '../../hooks/useToast' import { getProviderLabel, getProviderLogo, getProviderDescription, + TELEPHONY_PROVIDER_CONFIG, + getTelephonyProviderLabel, + getTelephonyProviderDescription, } from '../../config/providers' -type IntegrationType = 'voice_platform' | 'ai_provider' | null +type IntegrationType = 'voice_platform' | 'ai_provider' | 'telephony_provider' | null export default function Integrations() { const queryClient = useQueryClient() @@ -37,6 +41,16 @@ export default function Integrations() { const providerDropdownRef = useRef(null) const platformDropdownRef = useRef(null) + // Telephony-specific state + const [selectedTelephonyProvider, setSelectedTelephonyProvider] = useState(null) + const [telephonyAuthId, setTelephonyAuthId] = useState('') + const [telephonyAuthToken, setTelephonyAuthToken] = useState('') + const [telephonyVerifyAppUuid, setTelephonyVerifyAppUuid] = useState('') + const [telephonyVoiceAppId, setTelephonyVoiceAppId] = useState('') + const [telephonySipDomain, setTelephonySipDomain] = useState('') + const [expandedTelephony, setExpandedTelephony] = useState(null) + const [telephonyProviderFilter, setTelephonyProviderFilter] = useState(TelephonyProvider.PLIVO) + const renderModal = (content: ReactNode) => { if (typeof document === 'undefined') return null return createPortal(content, document.body) @@ -52,129 +66,134 @@ export default function Integrations() { queryFn: () => apiClient.listAIProviders(), }) - // Voice Platform Mutations + const telephonyConfigQueries = useQueries({ + queries: Object.values(TelephonyProvider).map((provider) => ({ + queryKey: ['telephony-config', provider], + queryFn: () => apiClient.getTelephonyConfig(provider), + retry: false, + })), + }) + + const telephonyConfigs = telephonyConfigQueries + .map((query, index) => ({ + provider: Object.values(TelephonyProvider)[index], + config: query.data, + })) + .filter((entry): entry is { provider: TelephonyProvider; config: NonNullable } => Boolean(entry.config)) + + const telephonyConfig = telephonyConfigs.find((entry) => entry.provider === telephonyProviderFilter)?.config + || telephonyConfigs[0]?.config + + const activeTelephonyProvider = (telephonyConfig?.provider as TelephonyProvider | undefined) || telephonyProviderFilter + + useEffect(() => { + if (!telephonyConfig && telephonyConfigs.length > 0) { + setTelephonyProviderFilter(telephonyConfigs[0].provider) + } + }, [telephonyConfig, telephonyConfigs]) + + const { data: telephonyNumbers = [], isLoading: telephonyNumbersLoading } = useQuery({ + queryKey: ['telephony-numbers', activeTelephonyProvider], + queryFn: () => apiClient.listTelephonyNumbers(activeTelephonyProvider), + retry: false, + enabled: !!telephonyConfig, + }) + const createIntegrationMutation = useMutation({ mutationFn: (data: IntegrationCreate) => apiClient.createIntegration(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['integrations'] }) - showToast('Integration created successfully!', 'success') - resetForm() - }, - onError: (error: any) => { - showToast(`Failed to create integration: ${error.response?.data?.detail || error.message}`, 'error') - }, + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['integrations'] }); showToast('Integration created successfully!', 'success'); resetForm() }, + onError: (error: any) => { showToast(`Failed to create integration: ${error.response?.data?.detail || error.message}`, 'error') }, }) const updateIntegrationMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Partial }) => - apiClient.updateIntegration(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['integrations'] }) - showToast('Integration updated successfully!', 'success') - resetForm() - }, - onError: (error: any) => { - showToast(`Failed to update integration: ${error.response?.data?.detail || error.message}`, 'error') - }, + mutationFn: ({ id, data }: { id: string; data: Partial }) => apiClient.updateIntegration(id, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['integrations'] }); showToast('Integration updated successfully!', 'success'); resetForm() }, + onError: (error: any) => { showToast(`Failed to update integration: ${error.response?.data?.detail || error.message}`, 'error') }, }) const deleteIntegrationMutation = useMutation({ mutationFn: ({ id, force }: { id: string; force?: boolean }) => apiClient.deleteIntegration(id, force), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['integrations'] }) - showToast('Integration deleted successfully!', 'success') - setShowDeleteModal(false) - setIntegrationToDelete(null) - setDeleteDependencies(null) - }, + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['integrations'] }); showToast('Integration deleted successfully!', 'success'); setShowDeleteModal(false); setIntegrationToDelete(null); setDeleteDependencies(null) }, onError: (error: any) => { - const status = error.response?.status - const detail = error.response?.data?.detail - - if (status === 409 && detail?.dependencies) { - setDeleteDependencies(detail.dependencies) - return - } - - const errorMessage = typeof detail === 'string' - ? detail - : detail?.message || error.message || 'Failed to delete integration.' - showToast(errorMessage, 'error') + const status = error.response?.status; const detail = error.response?.data?.detail + if (status === 409 && detail?.dependencies) { setDeleteDependencies(detail.dependencies); return } + showToast(typeof detail === 'string' ? detail : detail?.message || error.message || 'Failed to delete integration.', 'error') }, }) - // AI Provider Mutations const createAIProviderMutation = useMutation({ mutationFn: (data: AIProviderCreate) => apiClient.createAIProvider(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['aiproviders'] }) - showToast('AI Provider configured successfully!', 'success') - resetForm() - }, - onError: (error: any) => { - showToast(`Failed to configure provider: ${error.response?.data?.detail || error.message}`, 'error') - }, + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['aiproviders'] }); showToast('AI Provider configured successfully!', 'success'); resetForm() }, + onError: (error: any) => { showToast(`Failed to configure provider: ${error.response?.data?.detail || error.message}`, 'error') }, }) const updateAIProviderMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: Partial }) => - apiClient.updateAIProvider(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['aiproviders'] }) - showToast('AI Provider updated successfully!', 'success') - resetForm() - }, - onError: (error: any) => { - showToast(`Failed to update provider: ${error.response?.data?.detail || error.message}`, 'error') - }, + mutationFn: ({ id, data }: { id: string; data: Partial }) => apiClient.updateAIProvider(id, data), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['aiproviders'] }); showToast('AI Provider updated successfully!', 'success'); resetForm() }, + onError: (error: any) => { showToast(`Failed to update provider: ${error.response?.data?.detail || error.message}`, 'error') }, }) const deleteAIProviderMutation = useMutation({ mutationFn: (id: string) => apiClient.deleteAIProvider(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['aiproviders'] }) - showToast('AI Provider deleted successfully!', 'success') - setShowDeleteAIProviderModal(false) - setAIProviderToDelete(null) - }, - onError: (error: any) => { - showToast(`Failed to delete provider: ${error.response?.data?.detail || error.message}`, 'error') + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['aiproviders'] }); showToast('AI Provider deleted successfully!', 'success'); setShowDeleteAIProviderModal(false); setAIProviderToDelete(null) }, + onError: (error: any) => { showToast(`Failed to delete provider: ${error.response?.data?.detail || error.message}`, 'error') }, + }) + + const saveTelephonyConfigMutation = useMutation({ + mutationFn: async () => { + const payload: Record = { provider: selectedTelephonyProvider || 'plivo' } + if (telephonyAuthId.trim()) payload.auth_id = telephonyAuthId.trim() + if (telephonyAuthToken.trim()) payload.auth_token = telephonyAuthToken.trim() + if (telephonyVerifyAppUuid.trim()) payload.verify_app_uuid = telephonyVerifyAppUuid.trim() + if (telephonyVoiceAppId.trim()) payload.voice_app_id = telephonyVoiceAppId.trim() + if (telephonySipDomain.trim()) payload.sip_domain = telephonySipDomain.trim() + if (telephonyConfig) return apiClient.updateTelephonyConfig(payload) + if (!payload.auth_id || !payload.auth_token) throw new Error('Auth ID and Auth Token are required for first-time setup') + if (payload.provider === 'exotel' && !payload.voice_app_id) { + throw new Error('Account SID is required for first-time Exotel setup') + } + return apiClient.createTelephonyConfig(payload as any) }, + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['telephony-config'] }); showToast('Telephony configuration saved successfully!', 'success'); resetForm() }, + onError: (error: any) => { showToast(error?.response?.data?.detail || error?.message || 'Failed to save telephony config', 'error') }, }) + const testTelephonyMutation = useMutation({ + mutationFn: () => apiClient.testTelephonyConfig( + (selectedTelephonyProvider || activeTelephonyProvider || 'plivo') as string, + ), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['telephony-config'] }); showToast('Telephony connection test succeeded!', 'success') }, + onError: (error: any) => { showToast(error?.response?.data?.detail || error?.message || 'Connection test failed', 'error') }, + }) - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (providerDropdownRef.current && !providerDropdownRef.current.contains(event.target as Node)) { - setShowProviderDropdown(false) - } - if (platformDropdownRef.current && !platformDropdownRef.current.contains(event.target as Node)) { - setShowPlatformDropdown(false) - } - } + const syncNumbersMutation = useMutation({ + mutationFn: () => apiClient.syncTelephonyNumbers( + (selectedTelephonyProvider || activeTelephonyProvider || 'plivo') as string, + ), + onSuccess: (synced) => { queryClient.invalidateQueries({ queryKey: ['telephony-numbers'] }); showToast(`Synced ${synced.length} number(s) from provider.`, 'success') }, + onError: (error: any) => { showToast(error?.response?.data?.detail || error?.message || 'Failed to sync numbers', 'error') }, + }) - if (showProviderDropdown || showPlatformDropdown) { - document.addEventListener('mousedown', handleClickOutside) - } + const updateNumberMutation = useMutation({ + mutationFn: ({ id, is_masking_pool }: { id: string; is_masking_pool: boolean }) => apiClient.updateTelephonyNumber(id, { is_masking_pool }), + onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['telephony-numbers'] }); showToast('Number settings updated.', 'success') }, + onError: (error: any) => { showToast(error?.response?.data?.detail || error?.message || 'Failed to update number', 'error') }, + }) - return () => { - document.removeEventListener('mousedown', handleClickOutside) + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (providerDropdownRef.current && !providerDropdownRef.current.contains(event.target as Node)) setShowProviderDropdown(false) + if (platformDropdownRef.current && !platformDropdownRef.current.contains(event.target as Node)) setShowPlatformDropdown(false) } + if (showProviderDropdown || showPlatformDropdown) document.addEventListener('mousedown', handleClickOutside) + return () => { document.removeEventListener('mousedown', handleClickOutside) } }, [showProviderDropdown, showPlatformDropdown]) const resetForm = () => { - setShowModal(false) - setIsEditMode(false) - setIntegrationType(null) - setSelectedIntegration(null) - setSelectedAIProvider(null) - setSelectedPlatform(null) - setSelectedProvider(null) - setShowProviderDropdown(false) - setShowPlatformDropdown(false) - setApiKey('') - setPublicKey('') - setName('') + setShowModal(false); setIsEditMode(false); setIntegrationType(null); setSelectedIntegration(null); setSelectedAIProvider(null) + setSelectedPlatform(null); setSelectedProvider(null); setShowProviderDropdown(false); setShowPlatformDropdown(false) + setApiKey(''); setPublicKey(''); setName('') + setSelectedTelephonyProvider(null); setTelephonyAuthId(''); setTelephonyAuthToken(''); setTelephonyVerifyAppUuid(''); setTelephonyVoiceAppId(''); setTelephonySipDomain('') } const handleEdit = (integration: Integration) => { @@ -189,100 +208,50 @@ export default function Integrations() { } const handleEditAIProvider = (provider: AIProvider) => { - setIntegrationType('ai_provider') - setSelectedAIProvider(provider) - setSelectedProvider(provider.provider) - setName(provider.name || '') - setApiKey('') // Don't pre-fill API key for security - setShowProviderDropdown(false) - setIsEditMode(true) - setShowModal(true) + setIntegrationType('ai_provider'); setSelectedAIProvider(provider); setSelectedProvider(provider.provider) + setName(provider.name || ''); setApiKey(''); setShowProviderDropdown(false); setIsEditMode(true); setShowModal(true) + } + + const handleEditTelephony = () => { + setIntegrationType('telephony_provider') + setSelectedTelephonyProvider((telephonyConfig?.provider as TelephonyProvider) || telephonyProviderFilter) + setTelephonyVerifyAppUuid(telephonyConfig?.verify_app_uuid || '') + setTelephonyVoiceAppId(telephonyConfig?.voice_app_id || '') + setTelephonySipDomain(telephonyConfig?.sip_domain || '') + setTelephonyAuthId(''); setTelephonyAuthToken(''); setIsEditMode(true); setShowModal(true) } const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - if (integrationType === 'voice_platform') { if (isEditMode && selectedIntegration) { - // Update existing integration const updateData: Partial = {} - if (name !== (selectedIntegration.name || '')) { - updateData.name = name || undefined - } - if (apiKey) { - updateData.api_key = apiKey - } - if (publicKey !== (selectedIntegration.public_key || '')) { - updateData.public_key = publicKey || undefined - } - - if (Object.keys(updateData).length > 0) { - updateIntegrationMutation.mutate({ id: selectedIntegration.id, data: updateData }) - } else { - resetForm() - } + if (name !== (selectedIntegration.name || '')) updateData.name = name || undefined + if (apiKey) updateData.api_key = apiKey + if (publicKey !== (selectedIntegration.public_key || '')) updateData.public_key = publicKey || undefined + if (Object.keys(updateData).length > 0) updateIntegrationMutation.mutate({ id: selectedIntegration.id, data: updateData }) + else resetForm() } else { - // Create new integration if (!selectedPlatform || !apiKey) return - - createIntegrationMutation.mutate({ - platform: selectedPlatform as IntegrationPlatform, - api_key: apiKey, - public_key: publicKey || undefined, - name: name || undefined, - }) + createIntegrationMutation.mutate({ platform: selectedPlatform as IntegrationPlatform, api_key: apiKey, public_key: publicKey || undefined, name: name || undefined }) } } else if (integrationType === 'ai_provider') { if (isEditMode && selectedAIProvider) { - // Update existing AI provider - if (!apiKey.trim()) { - showToast('Please enter an API key', 'error') - return - } - updateAIProviderMutation.mutate({ - id: selectedAIProvider.id, - data: { - api_key: apiKey, - name: name || null, - }, - }) + if (!apiKey.trim()) { showToast('Please enter an API key', 'error'); return } + updateAIProviderMutation.mutate({ id: selectedAIProvider.id, data: { api_key: apiKey, name: name || null } }) } else { - // Create new AI provider - if (!selectedProvider || !apiKey.trim()) { - showToast('Please select a provider and enter an API key', 'error') - return - } - createAIProviderMutation.mutate({ - provider: selectedProvider, - api_key: apiKey, - name: name || null, - }) + if (!selectedProvider || !apiKey.trim()) { showToast('Please select a provider and enter an API key', 'error'); return } + createAIProviderMutation.mutate({ provider: selectedProvider, api_key: apiKey, name: name || null }) } + } else if (integrationType === 'telephony_provider') { + saveTelephonyConfigMutation.mutate() } } - const handleDelete = (integration: Integration) => { - setIntegrationToDelete(integration) - setDeleteDependencies(null) - setShowDeleteModal(true) - } - - const handleDeleteAIProvider = (provider: AIProvider) => { - setAIProviderToDelete(provider) - setShowDeleteAIProviderModal(true) - } - - const confirmDeleteIntegration = (force?: boolean) => { - if (integrationToDelete) { - deleteIntegrationMutation.mutate({ id: integrationToDelete.id, force }) - } - } - - const confirmDeleteAIProvider = () => { - if (aiProviderToDelete) { - deleteAIProviderMutation.mutate(aiProviderToDelete.id) - } - } + const handleDelete = (integration: Integration) => { setIntegrationToDelete(integration); setDeleteDependencies(null); setShowDeleteModal(true) } + const handleDeleteAIProvider = (provider: AIProvider) => { setAIProviderToDelete(provider); setShowDeleteAIProviderModal(true) } + const confirmDeleteIntegration = (force?: boolean) => { if (integrationToDelete) deleteIntegrationMutation.mutate({ id: integrationToDelete.id, force }) } + const confirmDeleteAIProvider = () => { if (aiProviderToDelete) deleteAIProviderMutation.mutate(aiProviderToDelete.id) } const platforms = [ { @@ -341,123 +310,69 @@ export default function Integrations() { }, ] - // Get configured platforms const configuredPlatforms = new Set(integrations.map((i: Integration) => i.platform)) const availablePlatforms = platforms.filter(p => !configuredPlatforms.has(p.id)) - - // Get configured AI providers const configuredProviders = new Set(aiproviders.map((p: AIProvider) => p.provider)) const availableProviders = Object.values(ModelProvider).filter(p => !configuredProviders.has(p)) - - const getPlatformInfo = (platformId: IntegrationPlatform) => { - return platforms.find(p => p.id === platformId) - } + const telephonyStatus = useMemo(() => { if (!telephonyConfig) return 'Not configured'; if (!telephonyConfig.is_active) return 'Configured (inactive)'; return 'Configured (active)' }, [telephonyConfig]) + const getPlatformInfo = (platformId: IntegrationPlatform) => platforms.find(p => p.id === platformId) + const hasTelephony = telephonyConfigs.length > 0 + const totalConfigured = integrations.length + aiproviders.length + telephonyConfigs.length return (
- {/* Header */}

Integrations

-

- Connect with voice AI platforms and configure AI providers to test and evaluate agents -

+

Connect with voice AI platforms, AI providers, and telephony providers

- +
- {/* Configured Integrations */} - {(integrations.length > 0 || aiproviders.length > 0) && ( + {totalConfigured > 0 && (

Configured Integrations

These integrations are ready to use

- {/* Voice AI Platform Integrations */} {integrations.length > 0 && (

Voice Platforms

- - {integrations.length} - + {integrations.length}
{integrations.map((integration: Integration) => { const platformInfo = getPlatformInfo(integration.platform) return ( -
+
{platformInfo?.image ? ( -
- {platformInfo.name} -
+
{platformInfo.name}
) : ( -
- -
+
)}
-

- {platformInfo?.name || integration.platform} -

- - Voice Platform - - {integration.name && ( - ({integration.name}) - )} - {!integration.is_active && ( - - Inactive - - )} +

{platformInfo?.name || integration.platform}

+ Voice Platform + {integration.name && ({integration.name})} + {!integration.is_active && Inactive}
-

- {platformInfo?.description || 'Voice AI platform integration'} -

+

{platformInfo?.description || 'Voice AI platform integration'}

- - + +
@@ -467,81 +382,40 @@ export default function Integrations() {
)} - {/* AI Provider Integrations */} {aiproviders.length > 0 && ( -
+

AI Providers

- - {aiproviders.length} - + {aiproviders.length}
{aiproviders.map((provider: AIProvider) => ( -
+
{getProviderLogo(provider.provider) ? ( -
- {getProviderLabel(provider.provider)} -
+
{getProviderLabel(provider.provider)}
) : ( -
- -
+
)}
-

- {getProviderLabel(provider.provider)} -

- - AI Provider - - {provider.name && ( - ({provider.name}) - )} - {!provider.is_active && ( - - Inactive - - )} +

{getProviderLabel(provider.provider)}

+ AI Provider + {provider.name && ({provider.name})} + {!provider.is_active && Inactive}
-

- {getProviderDescription(provider.provider)} -

+

{getProviderDescription(provider.provider)}

- - + +
@@ -549,119 +423,146 @@ export default function Integrations() {
)} + + {hasTelephony && ( +
+
+
+ +

Telephony Providers

+ {telephonyConfigs.length} +
+ +
+
+
+
+
+
+
+
+
+
+
+
+

{getTelephonyProviderLabel(telephonyConfig!.provider as TelephonyProvider)}

+ Telephony + {telephonyStatus} +
+

{getTelephonyProviderDescription(telephonyConfig!.provider as TelephonyProvider)}

+ {((telephonyConfig!.provider as TelephonyProvider) === TelephonyProvider.EXOTEL) && ( +
+ {telephonyConfig!.voice_app_id && ( + Account SID: {telephonyConfig!.voice_app_id} + )} + {telephonyConfig!.sip_domain && ( + API Host: {telephonyConfig!.sip_domain} + )} +
+ )} + {telephonyConfig!.last_tested_at && ( +
Last tested: {new Date(telephonyConfig!.last_tested_at).toLocaleString()}
+ )} +
+
+
+ + + +
+
+ {expandedTelephony === telephonyConfig!.id && ( +
+
+

Phone Numbers

+ +
+ {telephonyNumbersLoading ? ( +

Loading numbers...

+ ) : telephonyNumbers.length === 0 ? ( +

No numbers synced yet. Click Sync Numbers to pull from your provider.

+ ) : ( +
+ {telephonyNumbers.map((num: TelephonyPhoneNumberResponse) => ( +
+
+
{num.phone_number}
+
{num.country_iso2 || 'N/A'} • {num.region || 'Unknown'} • {num.number_type || 'Unknown'}
+
+ +
+ ))} +
+ )} +
+ )} +
+
+
+ )}
)} - {integrations.length === 0 && aiproviders.length === 0 && ( + {totalConfigured === 0 && (
-

All integrations configured

-

You have configured all available integration platforms and AI providers

+

No integrations configured

+

Get started by adding a voice platform, AI provider, or telephony provider

)} - {/* Add/Edit Integration Modal */} {showModal && renderModal(
-
+
-

- {isEditMode - ? (integrationType === 'ai_provider' ? 'Edit AI Provider' : 'Edit Integration') - : 'Add Integration'} -

- +

{isEditMode ? (integrationType === 'ai_provider' ? 'Edit AI Provider' : integrationType === 'telephony_provider' ? 'Edit Telephony Provider' : 'Edit Integration') : 'Add Integration'}

+
- {/* Integration Type Selector (only show when creating) */} {!isEditMode && (
- -
- - +
)} - {/* Voice Platform Form */} {integrationType === 'voice_platform' && ( <>
- +
- @@ -692,185 +593,141 @@ export default function Integrations() {
)}
- {selectedPlatform && (() => { - const platformInfo = getPlatformInfo(selectedPlatform as IntegrationPlatform) - return platformInfo?.description && ( -

{platformInfo.description}

- ) - })()} - {isEditMode && ( -

- Platform cannot be changed after creation -

- )} + {isEditMode &&

Platform cannot be changed after creation

}
- - setName(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="Integration name" - /> + + setName(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" placeholder="Integration name" />
- - setApiKey(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder={isEditMode ? "Enter new API key (optional)" : `Enter ${selectedPlatform === IntegrationPlatform.VAPI ? 'private ' : ''}API key`} - /> - + + setApiKey(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" + placeholder={isEditMode ? "Enter new API key (optional)" : `Enter ${selectedPlatform === IntegrationPlatform.VAPI ? 'private ' : ''}API key`} /> {selectedPlatform === IntegrationPlatform.VAPI && (
- - setPublicKey(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder={isEditMode ? "Enter new public API key (optional)" : "Enter public API key"} - /> + + setPublicKey(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" + placeholder={isEditMode ? "Enter new public API key (optional)" : "Enter public API key"} />
)} - -

- Your {selectedPlatform === IntegrationPlatform.VAPI ? 'API keys' : 'API key'} will be encrypted and stored securely -

+

Your {selectedPlatform === IntegrationPlatform.VAPI ? 'API keys' : 'API key'} will be encrypted and stored securely

)} - {/* AI Provider Form */} {integrationType === 'ai_provider' && ( <>
- +
- {showProviderDropdown && availableProviders.length > 0 && (
{availableProviders.map((provider) => ( - ))}
)}
- {selectedProvider && ( -

{getProviderDescription(selectedProvider)}

- )} - {isEditMode && selectedAIProvider && ( -

- Provider cannot be changed after creation -

- )} + {selectedProvider &&

{getProviderDescription(selectedProvider)}

} + {isEditMode && selectedAIProvider &&

Provider cannot be changed after creation

}
- - setName(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder="e.g., OpenAI Production Key" - /> + + setName(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" placeholder="e.g., OpenAI Production Key" />
- - setApiKey(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" - placeholder={isEditMode ? "Enter new API key" : "Enter API key"} - /> -

- Your API key will be encrypted and stored securely -

+ + setApiKey(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500" + placeholder={isEditMode ? "Enter new API key" : "Enter API key"} /> +

Your API key will be encrypted and stored securely

)} - {/* Error Messages */} + {integrationType === 'telephony_provider' && ( + <> +
+ +
+ {Object.values(TelephonyProvider).map((tp) => { + const meta = TELEPHONY_PROVIDER_CONFIG[tp] + return ( + + ) + })} +
+
+ {selectedTelephonyProvider && ( + <> +
+
+ + setTelephonyAuthId(e.target.value)} required={!isEditMode} + placeholder={isEditMode ? 'Leave blank to keep current' : selectedTelephonyProvider === TelephonyProvider.EXOTEL ? 'Enter API Key' : 'Enter Auth ID'} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" /> +
+
+ + setTelephonyAuthToken(e.target.value)} required={!isEditMode} + placeholder={isEditMode ? 'Leave blank to keep current' : selectedTelephonyProvider === TelephonyProvider.EXOTEL ? 'Enter API Token' : 'Enter Auth Token'} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" /> +
+ {selectedTelephonyProvider === TelephonyProvider.EXOTEL && ( +
+ + setTelephonyVoiceAppId(e.target.value)} + required placeholder="Enter Exotel Account SID" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" /> +
+ )} +
+ + setTelephonyVerifyAppUuid(e.target.value)} + placeholder="Optional: Verify App UUID" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" /> +
+
+ + setTelephonySipDomain(e.target.value)} + placeholder={selectedTelephonyProvider === TelephonyProvider.EXOTEL ? 'Optional: api.exotel.com' : 'Optional: SIP domain'} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" /> +
+
+

Credentials are encrypted and stored securely. Your browser never displays stored secrets.

+ + )} + + )} + {((integrationType === 'voice_platform' && (createIntegrationMutation.isError || updateIntegrationMutation.isError)) || - (integrationType === 'ai_provider' && (createAIProviderMutation.isError || updateAIProviderMutation.isError))) && ( + (integrationType === 'ai_provider' && (createAIProviderMutation.isError || updateAIProviderMutation.isError)) || + (integrationType === 'telephony_provider' && saveTelephonyConfigMutation.isError)) && (

{integrationType === 'voice_platform' - ? ((createIntegrationMutation.error || updateIntegrationMutation.error as any)?.response?.data?.detail || - (isEditMode ? 'Failed to update integration' : 'Failed to create integration')) - : ((createAIProviderMutation.error || updateAIProviderMutation.error as any)?.response?.data?.detail || - (isEditMode ? 'Failed to update provider' : 'Failed to configure provider'))} + ? ((createIntegrationMutation.error || updateIntegrationMutation.error as any)?.response?.data?.detail || (isEditMode ? 'Failed to update integration' : 'Failed to create integration')) + : integrationType === 'ai_provider' + ? ((createAIProviderMutation.error || updateAIProviderMutation.error as any)?.response?.data?.detail || (isEditMode ? 'Failed to update provider' : 'Failed to configure provider')) + : ((saveTelephonyConfigMutation.error as any)?.response?.data?.detail || (saveTelephonyConfigMutation.error as any)?.message || 'Failed to save telephony configuration')}

@@ -878,28 +735,11 @@ export default function Integrations() { )}
- - +
@@ -907,27 +747,12 @@ export default function Integrations() {
)} - {/* Delete Integration Confirmation Modal */} {showDeleteModal && integrationToDelete && renderModal( -
{ - setShowDeleteModal(false) - setIntegrationToDelete(null) - setDeleteDependencies(null) - }}> +
{ setShowDeleteModal(false); setIntegrationToDelete(null); setDeleteDependencies(null) }}>
e.stopPropagation()}>

Confirm Delete

- +
{deleteDependencies && ( @@ -935,79 +760,32 @@ export default function Integrations() {
-

- This integration has dependent records -

+

This integration has dependent records

    - {deleteDependencies.agents && ( -
  • {deleteDependencies.agents} agent{deleteDependencies.agents !== 1 ? 's' : ''} (will be unlinked, not deleted)
  • - )} + {deleteDependencies.agents &&
  • {deleteDependencies.agents} agent{deleteDependencies.agents !== 1 ? 's' : ''} (will be unlinked, not deleted)
  • }
-

- Force deleting will remove the integration and unlink all agents using it. -

+

Force deleting will remove the integration and unlink all agents using it.

)} -
-
-
- -
-
+
-

- Are you sure you want to delete this integration? -

+

Are you sure you want to delete this integration?

- {(() => { - const platformInfo = getPlatformInfo(integrationToDelete.platform) - return platformInfo?.name || integrationToDelete.platform - })()} - {integrationToDelete.name && ( - ({integrationToDelete.name}) - )} -

-

- This action cannot be undone. Any agents using this integration may stop working. + {(() => { const pi = getPlatformInfo(integrationToDelete.platform); return pi?.name || integrationToDelete.platform })()} + {integrationToDelete.name && ({integrationToDelete.name})}

+

This action cannot be undone. Any agents using this integration may stop working.

- + {deleteDependencies ? ( - + ) : ( - + )}
@@ -1015,68 +793,25 @@ export default function Integrations() {
)} - {/* Delete AI Provider Confirmation Modal */} {showDeleteAIProviderModal && aiProviderToDelete && renderModal( -
{ - setShowDeleteAIProviderModal(false) - setAIProviderToDelete(null) - }}> +
{ setShowDeleteAIProviderModal(false); setAIProviderToDelete(null) }}>
e.stopPropagation()}>

Confirm Delete

- +
-
-
- -
-
+
-

- Are you sure you want to delete the {getProviderLabel(aiProviderToDelete.provider)} configuration? -

- {aiProviderToDelete.name && ( -

- Name: {aiProviderToDelete.name} -

- )} -

- This action cannot be undone. Any VoiceBundles using this provider may stop working. -

+

Are you sure you want to delete the {getProviderLabel(aiProviderToDelete.provider)} configuration?

+ {aiProviderToDelete.name &&

Name: {aiProviderToDelete.name}

} +

This action cannot be undone. Any VoiceBundles using this provider may stop working.

- - + +
@@ -1085,4 +820,3 @@ export default function Integrations() {
) } - diff --git a/frontend/src/pages/evaluators/results/Results.tsx b/frontend/src/pages/evaluators/results/Results.tsx index 832e82b6..befbdeee 100644 --- a/frontend/src/pages/evaluators/results/Results.tsx +++ b/frontend/src/pages/evaluators/results/Results.tsx @@ -75,7 +75,7 @@ export default function Results() { const { data: metrics = [] } = useQuery({ queryKey: ['metrics'], - queryFn: () => apiClient.listMetrics(), + queryFn: () => apiClient.listMetrics('agent'), }) const { data: audioFiles } = useQuery({ diff --git a/frontend/src/pages/metrics/MetricsManagement.tsx b/frontend/src/pages/metrics/MetricsManagement.tsx index 43fb82c0..e5eb5f66 100644 --- a/frontend/src/pages/metrics/MetricsManagement.tsx +++ b/frontend/src/pages/metrics/MetricsManagement.tsx @@ -10,6 +10,12 @@ interface Metric { name: string description?: string metric_type: 'number' | 'boolean' | 'rating' + metric_origin: 'default' | 'custom' + supported_surfaces: Array<'agent' | 'voice_playground' | 'blind_test'> + enabled_surfaces: Array<'agent' | 'voice_playground' | 'blind_test'> + custom_data_type?: 'boolean' | 'enum' | 'number_range' | null + custom_config?: Record | null + tags?: string[] | null trigger: 'always' enabled: boolean is_default: boolean @@ -17,6 +23,9 @@ interface Metric { updated_at: string } +type MetricSurface = 'agent' | 'voice_playground' | 'blind_test' +type CustomDataType = 'boolean' | 'enum' | 'number_range' + // Quantitative: Raw acoustic measurements (Parselmouth - signal processing) // These are pure physical/mathematical measurements of the audio signal const ACOUSTIC_METRICS = new Set(['Pitch Variance', 'Jitter', 'Shimmer', 'HNR']) @@ -51,7 +60,9 @@ export default function MetricsManagement() { const queryClient = useQueryClient() const { showToast, ToastContainer } = useToast() const [showCreateModal, setShowCreateModal] = useState(false) + const [isCustomMetricMode, setIsCustomMetricMode] = useState(false) const [showEnableModal, setShowEnableModal] = useState(false) + const [surfaceFilter, setSurfaceFilter] = useState<'all' | MetricSurface>('all') const [editingMetric, setEditingMetric] = useState(null) const [sortField, setSortField] = useState<'type' | 'method'>('type') const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc') @@ -60,13 +71,22 @@ export default function MetricsManagement() { name: '', description: '', metric_type: 'rating' as 'number' | 'boolean' | 'rating', + metric_origin: 'custom' as 'default' | 'custom', + supported_surfaces: ['agent'] as MetricSurface[], + enabled_surfaces: ['agent'] as MetricSurface[], + custom_data_type: 'boolean' as CustomDataType, + enum_options_csv: '', + number_min: 0, + number_max: 10, + number_step: 1, + tags_csv: '', trigger: 'always' as 'always', enabled: true, }) const { data: metrics = [], isLoading } = useQuery({ - queryKey: ['metrics'], - queryFn: () => apiClient.listMetrics(), + queryKey: ['metrics', surfaceFilter], + queryFn: () => apiClient.listMetrics(surfaceFilter === 'all' ? undefined : surfaceFilter), }) // Seed default metrics on first load if none exist @@ -145,17 +165,64 @@ export default function MetricsManagement() { name: '', description: '', metric_type: 'rating', + metric_origin: 'custom', + supported_surfaces: ['agent'], + enabled_surfaces: ['agent'], + custom_data_type: 'boolean', + enum_options_csv: '', + number_min: 0, + number_max: 10, + number_step: 1, + tags_csv: '', trigger: 'always', enabled: true, }) } + const getCustomConfigFromForm = () => { + if (formData.custom_data_type === 'enum') { + const options = formData.enum_options_csv + .split(',') + .map((opt) => opt.trim()) + .filter(Boolean) + return { options } + } + if (formData.custom_data_type === 'number_range') { + return { + min: Number(formData.number_min), + max: Number(formData.number_max), + step: Number(formData.number_step), + } + } + return {} + } + + const buildPayload = () => { + const tags = formData.tags_csv + .split(',') + .map((tag) => tag.trim()) + .filter(Boolean) + return { + name: formData.name, + description: formData.description, + metric_type: formData.metric_type, + trigger: formData.trigger, + enabled: formData.enabled, + metric_origin: formData.metric_origin, + supported_surfaces: formData.supported_surfaces, + enabled_surfaces: formData.enabled ? formData.enabled_surfaces : [], + custom_data_type: formData.metric_origin === 'custom' ? formData.custom_data_type : undefined, + custom_config: formData.metric_origin === 'custom' ? getCustomConfigFromForm() : undefined, + tags: tags.length > 0 ? tags : undefined, + } + } + const handleCreate = () => { if (!formData.name.trim()) { alert('Please enter a metric name') return } - createMutation.mutate(formData) + createMutation.mutate(buildPayload() as any) } const handleEdit = (metric: Metric) => { @@ -164,9 +231,19 @@ export default function MetricsManagement() { name: metric.name, description: metric.description || '', metric_type: metric.metric_type, + metric_origin: metric.metric_origin || 'custom', + supported_surfaces: (metric.supported_surfaces?.length ? metric.supported_surfaces : ['agent']) as MetricSurface[], + enabled_surfaces: (metric.enabled_surfaces?.length ? metric.enabled_surfaces : ['agent']) as MetricSurface[], + custom_data_type: (metric.custom_data_type || 'boolean') as CustomDataType, + enum_options_csv: Array.isArray(metric.custom_config?.options) ? metric.custom_config.options.join(', ') : '', + number_min: Number(metric.custom_config?.min ?? 0), + number_max: Number(metric.custom_config?.max ?? 10), + number_step: Number(metric.custom_config?.step ?? 1), + tags_csv: metric.tags?.join(', ') || '', trigger: metric.trigger, enabled: metric.enabled, }) + setIsCustomMetricMode(metric.metric_origin === 'custom') setShowCreateModal(true) } @@ -176,7 +253,7 @@ export default function MetricsManagement() { alert('Please enter a metric name') return } - updateMutation.mutate({ id: editingMetric.id, data: formData }) + updateMutation.mutate({ id: editingMetric.id, data: buildPayload() as any }) } const handleToggleEnabled = (metric: Metric) => { @@ -198,6 +275,7 @@ export default function MetricsManagement() { const closeModal = () => { setShowCreateModal(false) + setIsCustomMetricMode(false) setEditingMetric(null) resetForm() } @@ -278,6 +356,33 @@ export default function MetricsManagement() { > Add Metric +
+ {isCustomMetricMode && ( + <> +
+ + +
+ + {formData.custom_data_type === 'enum' && ( +
+ + setFormData({ ...formData, enum_options_csv: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500" + placeholder="Excellent, Good, Neutral, Poor" + /> +
+ )} + + {formData.custom_data_type === 'number_range' && ( +
+
+ + setFormData({ ...formData, number_min: Number(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500" + /> +
+
+ + setFormData({ ...formData, number_max: Number(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500" + /> +
+
+ + setFormData({ ...formData, number_step: Number(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500" + /> +
+
+ )} + + )} + +
+ +
+ {(['agent', 'voice_playground', 'blind_test'] as MetricSurface[]).map((surface) => ( + + ))} +
+
+ +
+ + setFormData({ ...formData, tags_csv: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500" + placeholder="quality, compliance, friendliness" + /> +
+
+ {/* Playback Profile */} +
+
+

Playback Profile

+

+ Simulate how TTS sounds over telephony by forcing lower sample rates. +

+
+ +
+ {/* Evaluation STT Settings */} =0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, "node_modules/react-textarea-autosize": { "version": "8.5.9", "resolved": "https://registry.npmjs.org/react-textarea-autosize/-/react-textarea-autosize-8.5.9.tgz", @@ -3178,6 +3208,13 @@ "tslib": "^2.1.0" } }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "peer": true + }, "node_modules/scroll-into-view-if-needed": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.0.10.tgz", @@ -3234,6 +3271,13 @@ } } }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT", + "peer": true + }, "node_modules/ts-debounce": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", diff --git a/pyproject.toml b/pyproject.toml index 1ebc74bd..9e422f0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ dependencies = [ "soxr>=0.3.0", "typing-extensions>=4.9.0", "onnxruntime>=1.17.0", + "plivo>=4.47.0", ] [project.optional-dependencies] @@ -114,6 +115,9 @@ google = [ "google-cloud-texttospeech>=2.16.0", "google-cloud-speech>=2.26.0", ] +plivo = [ + "plivo>=4.47.0", +] # GEPA prompt optimization (enterprise feature) gepa = [ "gepa", @@ -166,4 +170,3 @@ filterwarnings = [ "ignore:Error loading \\.env file, using defaults\\..*:UserWarning:app\\.database", "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:efficientai\\.audio\\.utils", ] -