From e8d38526569d655af3cac56058549f50aa3bb954 Mon Sep 17 00:00:00 2001 From: Aadhar Singh Bhadauria Date: Wed, 1 Apr 2026 16:00:58 +0530 Subject: [PATCH 1/6] feat: add provider-agnostic telephony and agent telephony profiles Consolidate telephony as a provider-agnostic integration and wire phone-call agents to telephony numbers with provider selection and fallback custom input. Add telephony playback profiles in voice playground so TTS can be evaluated under narrowband/wideband call conditions. Made-with: Cursor --- EfficientAI-Docs/docs/advanced/database.md | 2 +- app/api/v1/api.py | 3 +- app/api/v1/routes/agents.py | 113 +- app/api/v1/routes/telephony.py | 299 ++++++ app/cli.py | 4 +- app/config.py | 17 + .../016_add_plivo_telephony_tables.py | 169 +++ .../017_rename_plivo_to_telephony.py | 161 +++ .../018_add_telephony_link_to_agents.py | 101 ++ app/models/database.py | 106 +- app/models/enums.py | 6 + app/models/schemas.py | 148 ++- app/services/telephony/__init__.py | 1 + app/services/telephony/plivo_client.py | 139 +++ app/services/telephony/plivo_xml.py | 38 + app/services/telephony/telephony_service.py | 410 +++++++ frontend/src/config/providers.ts | 32 +- frontend/src/lib/api.ts | 89 ++ frontend/src/pages/agents/AgentDetail.tsx | 6 + .../pages/agents/components/AgentEditForm.tsx | 138 ++- .../pages/agents/components/AgentInfoView.tsx | 16 +- .../agents/components/CreateAgentModal.tsx | 161 ++- .../src/pages/configurations/Integrations.tsx | 996 ++++++------------ .../voice/components/PlaygroundTab.tsx | 71 +- frontend/src/types/api.ts | 5 + package-lock.json | 46 +- pyproject.toml | 5 +- 27 files changed, 2568 insertions(+), 714 deletions(-) create mode 100644 app/api/v1/routes/telephony.py create mode 100644 app/migrations/016_add_plivo_telephony_tables.py create mode 100644 app/migrations/017_rename_plivo_to_telephony.py create mode 100644 app/migrations/018_add_telephony_link_to_agents.py create mode 100644 app/services/telephony/__init__.py create mode 100644 app/services/telephony/plivo_client.py create mode 100644 app/services/telephony/plivo_xml.py create mode 100644 app/services/telephony/telephony_service.py 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/telephony.py b/app/api/v1/routes/telephony.py new file mode 100644 index 00000000..71623aaa --- /dev/null +++ b/app/api/v1/routes/telephony.py @@ -0,0 +1,299 @@ +"""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( + 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) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/numbers", response_model=List[TelephonyPhoneNumberResponse]) +async def list_telephony_numbers( + 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) + + +@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) + 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 + ) + 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, + ) + 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 d58bfbf4..ce182883 100644 --- a/app/config.py +++ b/app/config.py @@ -80,6 +80,12 @@ class Settings(BaseSettings): # 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", @@ -316,6 +322,17 @@ def load_config_from_file(config_path: str) -> None: 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 diff --git a/app/migrations/016_add_plivo_telephony_tables.py b/app/migrations/016_add_plivo_telephony_tables.py new file mode 100644 index 00000000..4f9edec8 --- /dev/null +++ b/app/migrations/016_add_plivo_telephony_tables.py @@ -0,0 +1,169 @@ +""" +Migration: Add Plivo telephony tables. +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = "Add plivo telephony tables for integrations, numbers, verify sessions, and masking 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 Plivo telephony tables and indexes.""" + + if not _table_exists(db, "plivo_integrations"): + db.execute( + text( + """ + CREATE TABLE plivo_integrations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + 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_plivo_integration_org UNIQUE (organization_id) + ) + """ + ) + ) + db.execute( + text("CREATE INDEX ix_plivo_integrations_organization_id ON plivo_integrations(organization_id)") + ) + + if not _table_exists(db, "plivo_phone_numbers"): + db.execute( + text( + """ + CREATE TABLE plivo_phone_numbers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + plivo_integration_id UUID NOT NULL REFERENCES plivo_integrations(id), + phone_number VARCHAR(20) NOT NULL, + country_iso2 VARCHAR(2), + region VARCHAR(100), + number_type VARCHAR(20), + capabilities JSONB, + plivo_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_plivo_number_org_phone UNIQUE (organization_id, phone_number) + ) + """ + ) + ) + db.execute( + text("CREATE INDEX ix_plivo_phone_numbers_organization_id ON plivo_phone_numbers(organization_id)") + ) + db.execute( + text( + "CREATE INDEX ix_plivo_phone_numbers_plivo_integration_id ON plivo_phone_numbers(plivo_integration_id)" + ) + ) + db.execute(text("CREATE INDEX ix_plivo_phone_numbers_phone_number ON plivo_phone_numbers(phone_number)")) + db.execute(text("CREATE INDEX ix_plivo_phone_numbers_agent_id ON plivo_phone_numbers(agent_id)")) + + if not _table_exists(db, "plivo_verify_sessions"): + db.execute( + text( + """ + CREATE TABLE plivo_verify_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + plivo_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_plivo_verify_sessions_organization_id ON plivo_verify_sessions(organization_id)") + ) + db.execute( + text("CREATE INDEX ix_plivo_verify_sessions_plivo_session_uuid ON plivo_verify_sessions(plivo_session_uuid)") + ) + + if not _table_exists(db, "plivo_masked_sessions"): + db.execute( + text( + """ + CREATE TABLE plivo_masked_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + plivo_integration_id UUID NOT NULL REFERENCES plivo_integrations(id), + masked_number_id UUID NOT NULL REFERENCES plivo_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_plivo_masked_sessions_organization_id ON plivo_masked_sessions(organization_id)") + ) + db.execute( + text("CREATE INDEX ix_plivo_masked_sessions_masked_number_id ON plivo_masked_sessions(masked_number_id)") + ) + db.execute( + text("CREATE INDEX ix_plivo_masked_sessions_masked_number ON plivo_masked_sessions(masked_number)") + ) + db.execute(text("CREATE INDEX ix_plivo_masked_sessions_status ON plivo_masked_sessions(status)")) + db.execute( + text( + """ + CREATE UNIQUE INDEX uq_plivo_masked_sessions_masked_number_active + ON plivo_masked_sessions(masked_number_id) + WHERE status = 'active' + """ + ) + ) + + db.commit() + + +def downgrade(db: Session): + db.execute(text("DROP TABLE IF EXISTS plivo_masked_sessions")) + db.execute(text("DROP TABLE IF EXISTS plivo_verify_sessions")) + db.execute(text("DROP TABLE IF EXISTS plivo_phone_numbers")) + db.execute(text("DROP TABLE IF EXISTS plivo_integrations")) + db.commit() diff --git a/app/migrations/017_rename_plivo_to_telephony.py b/app/migrations/017_rename_plivo_to_telephony.py new file mode 100644 index 00000000..0bc8e1ba --- /dev/null +++ b/app/migrations/017_rename_plivo_to_telephony.py @@ -0,0 +1,161 @@ +""" +Migration: Rename plivo_* tables to telephony_* for provider-agnostic telephony support. + +Renames tables and key columns so the schema supports multiple telephony providers +(Plivo, Twilio, Vonage, etc.) rather than being Plivo-specific. +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = "Rename plivo_* tables to telephony_*, add provider column, rename FK columns" + + +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 upgrade(db: Session): + """Rename plivo_* tables to telephony_* and add provider column.""" + + # --- 1. Rename tables (order matters due to FK deps: children first) --- + renames = [ + ("plivo_masked_sessions", "telephony_masked_sessions"), + ("plivo_verify_sessions", "telephony_verify_sessions"), + ("plivo_phone_numbers", "telephony_phone_numbers"), + ("plivo_integrations", "telephony_integrations"), + ] + for old_name, new_name in renames: + if _table_exists(db, old_name) and not _table_exists(db, new_name): + db.execute(text(f"ALTER TABLE {old_name} RENAME TO {new_name}")) + + # --- 2. Add provider column to telephony_integrations --- + if _table_exists(db, "telephony_integrations") and not _column_exists(db, "telephony_integrations", "provider"): + db.execute( + text("ALTER TABLE telephony_integrations ADD COLUMN provider VARCHAR(50) NOT NULL DEFAULT 'plivo'") + ) + + # --- 3. Rename columns --- + # telephony_phone_numbers: plivo_integration_id -> telephony_integration_id + if _table_exists(db, "telephony_phone_numbers") and _column_exists(db, "telephony_phone_numbers", "plivo_integration_id"): + db.execute( + text("ALTER TABLE telephony_phone_numbers RENAME COLUMN plivo_integration_id TO telephony_integration_id") + ) + + # telephony_phone_numbers: plivo_app_id -> provider_app_id + if _table_exists(db, "telephony_phone_numbers") and _column_exists(db, "telephony_phone_numbers", "plivo_app_id"): + db.execute( + text("ALTER TABLE telephony_phone_numbers RENAME COLUMN plivo_app_id TO provider_app_id") + ) + + # telephony_verify_sessions: plivo_session_uuid -> provider_session_uuid + if _table_exists(db, "telephony_verify_sessions") and _column_exists(db, "telephony_verify_sessions", "plivo_session_uuid"): + db.execute( + text("ALTER TABLE telephony_verify_sessions RENAME COLUMN plivo_session_uuid TO provider_session_uuid") + ) + + # telephony_masked_sessions: plivo_integration_id -> telephony_integration_id + if _table_exists(db, "telephony_masked_sessions") and _column_exists(db, "telephony_masked_sessions", "plivo_integration_id"): + db.execute( + text("ALTER TABLE telephony_masked_sessions RENAME COLUMN plivo_integration_id TO telephony_integration_id") + ) + + # --- 4. Rename constraints --- + # Replace old unique constraint with new one that includes provider + try: + db.execute(text("ALTER TABLE telephony_integrations DROP CONSTRAINT IF EXISTS uq_plivo_integration_org")) + except Exception: + pass + try: + db.execute( + text( + """ + ALTER TABLE telephony_integrations + ADD CONSTRAINT uq_telephony_integration_org_provider + UNIQUE (organization_id, provider) + """ + ) + ) + except Exception: + pass + + try: + db.execute(text("ALTER TABLE telephony_phone_numbers DROP CONSTRAINT IF EXISTS uq_plivo_number_org_phone")) + except Exception: + pass + try: + db.execute( + text( + """ + ALTER TABLE telephony_phone_numbers + ADD CONSTRAINT uq_telephony_number_org_phone + UNIQUE (organization_id, phone_number) + """ + ) + ) + except Exception: + pass + + db.commit() + + +def downgrade(db: Session): + """Reverse the rename back to plivo_* tables.""" + + renames = [ + ("telephony_integrations", "plivo_integrations"), + ("telephony_phone_numbers", "plivo_phone_numbers"), + ("telephony_verify_sessions", "plivo_verify_sessions"), + ("telephony_masked_sessions", "plivo_masked_sessions"), + ] + + # Drop provider column + if _table_exists(db, "telephony_integrations") and _column_exists(db, "telephony_integrations", "provider"): + db.execute(text("ALTER TABLE telephony_integrations DROP COLUMN provider")) + + # Rename columns back + if _table_exists(db, "telephony_phone_numbers") and _column_exists(db, "telephony_phone_numbers", "telephony_integration_id"): + db.execute(text("ALTER TABLE telephony_phone_numbers RENAME COLUMN telephony_integration_id TO plivo_integration_id")) + + if _table_exists(db, "telephony_phone_numbers") and _column_exists(db, "telephony_phone_numbers", "provider_app_id"): + db.execute(text("ALTER TABLE telephony_phone_numbers RENAME COLUMN provider_app_id TO plivo_app_id")) + + if _table_exists(db, "telephony_verify_sessions") and _column_exists(db, "telephony_verify_sessions", "provider_session_uuid"): + db.execute(text("ALTER TABLE telephony_verify_sessions RENAME COLUMN provider_session_uuid TO plivo_session_uuid")) + + if _table_exists(db, "telephony_masked_sessions") and _column_exists(db, "telephony_masked_sessions", "telephony_integration_id"): + db.execute(text("ALTER TABLE telephony_masked_sessions RENAME COLUMN telephony_integration_id TO plivo_integration_id")) + + for old_name, new_name in renames: + if _table_exists(db, old_name) and not _table_exists(db, new_name): + db.execute(text(f"ALTER TABLE {old_name} RENAME TO {new_name}")) + + db.commit() diff --git a/app/migrations/018_add_telephony_link_to_agents.py b/app/migrations/018_add_telephony_link_to_agents.py new file mode 100644 index 00000000..ca65a1ca --- /dev/null +++ b/app/migrations/018_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 14e90460..0836a741 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -220,6 +220,12 @@ class Agent(Base): 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, + ) @@ -877,7 +883,6 @@ class CustomTTSVoice(Base): 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" @@ -929,4 +934,101 @@ class PromptOptimizationCandidate(Base): created_at = Column(DateTime(timezone=True), server_default=func.now()) - optimization_run = relationship("PromptOptimizationRun", back_populates="candidates") \ No newline at end of file + 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"), 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..1d8c0864 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,11 @@ class IntegrationPlatform(str, enum.Enum): VOICEMAKER = "voicemaker" SMALLEST = "smallest" + +class TelephonyProvider(str, enum.Enum): + """Telephony provider enumeration - extensible for future providers.""" + PLIVO = "plivo" + 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..b1057779 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -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] voice_bundle_id: Optional[UUID] ai_provider_id: Optional[UUID] voice_ai_integration_id: Optional[UUID] @@ -1593,4 +1596,147 @@ 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 + 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..765bb859 --- /dev/null +++ b/app/services/telephony/telephony_service.py @@ -0,0 +1,410 @@ +"""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_plivo_client(self, org_id: UUID, db: Session) -> PlivoClient: + integration = self.get_org_integration(org_id, db, provider="plivo") + auth_id = decrypt_api_key(integration.auth_id) + auth_token = decrypt_api_key(integration.auth_token) + return PlivoClient(auth_id=auth_id, auth_token=auth_token) + + 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 + + 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: + if provider == "plivo": + client = self.get_plivo_client(org_id, db) + ok = client.test_connection() + else: + raise ValueError(f"Connection test not implemented for provider: {provider}") + 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) -> List[TelephonyPhoneNumber]: + client = self.get_plivo_client(org_id, db) + integration = self.get_org_integration(org_id, db, provider="plivo") + 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) -> List[TelephonyPhoneNumber]: + return ( + db.query(TelephonyPhoneNumber) + .filter(TelephonyPhoneNumber.organization_id == org_id) + .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]: + client = self.get_plivo_client(org_id, db) + 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") + + 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="plivo", + agent_id=agent_id, + ) + ) + db.commit() + return response + + def start_voice_otp( + self, org_id: UUID, phone_number: str, api_key: str, db: Session + ) -> TelephonyVerifySession: + del api_key + integration = self.get_org_integration(org_id, db, provider="plivo") + 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_plivo_client(org_id, db).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 + ) -> 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_plivo_client(org_id, db).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, + ) -> TelephonyMaskedSession: + integration = self.get_org_integration(org_id, db, provider="plivo") + 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") + from_number = params.get("From") + call_uuid = params.get("CallUUID") + 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") + call_status = params.get("CallStatus") or params.get("Event") + 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..08db64a7 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,33 @@ 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' }, + ], + }, +} + +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 883e0b23..a6c3aa6a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -39,6 +39,52 @@ export interface LicenseInfoResponse { organization?: string } +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 + sip_domain?: string + masking_config?: Record +} + +export interface TelephonyIntegrationUpdatePayload { + 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 @@ -249,6 +295,7 @@ class ApiClient { async createAgent(data: { name: string phone_number?: string + telephony_phone_number_id?: string language: string description?: string | null call_type: string @@ -277,12 +324,15 @@ class ApiClient { 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 @@ -634,6 +684,45 @@ class ApiClient { 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(): Promise { + const response = await this.client.post('/api/v1/telephony/numbers/sync') + return response.data + } + + async listTelephonyNumbers(): Promise { + const response = await this.client.get('/api/v1/telephony/numbers') + 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') 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..8b451518 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 { 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,14 @@ 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 [telephonySipDomain, setTelephonySipDomain] = useState('') + const [expandedTelephony, setExpandedTelephony] = useState(null) + const renderModal = (content: ReactNode) => { if (typeof document === 'undefined') return null return createPortal(content, document.body) @@ -52,129 +64,105 @@ export default function Integrations() { queryFn: () => apiClient.listAIProviders(), }) - // Voice Platform Mutations + const { data: telephonyConfig } = useQuery({ + queryKey: ['telephony-config'], + queryFn: () => apiClient.getTelephonyConfig('plivo'), + retry: false, + }) + + const { data: telephonyNumbers = [], isLoading: telephonyNumbersLoading } = useQuery({ + queryKey: ['telephony-numbers'], + queryFn: () => apiClient.listTelephonyNumbers(), + retry: false, + }) + 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 (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') + 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('plivo'), + 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(), + 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(''); setTelephonySipDomain('') } const handleEdit = (integration: Integration) => { @@ -189,100 +177,47 @@ 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(TelephonyProvider.PLIVO) + setTelephonyVerifyAppUuid(telephonyConfig?.verify_app_uuid || ''); 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 +276,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 = !!telephonyConfig + const totalConfigured = integrations.length + aiproviders.length + (hasTelephony ? 1 : 0) 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 +348,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 +389,125 @@ export default function Integrations() {
)} + + {hasTelephony && ( +
+
+
+ +

Telephony Providers

+ 1 +
+
+
+
+
+
+
+
+
+
+
+

{getTelephonyProviderLabel(telephonyConfig!.provider as TelephonyProvider)}

+ Telephony + {telephonyStatus} +
+

{getTelephonyProviderDescription(telephonyConfig!.provider as TelephonyProvider)}

+ {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 +538,128 @@ 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' : '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' : 'Enter Auth Token'} 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="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 +667,11 @@ export default function Integrations() { )}
- - +
@@ -907,27 +679,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 +692,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 +725,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 +752,3 @@ export default function Integrations() {
) } - diff --git a/frontend/src/pages/playground/voice/components/PlaygroundTab.tsx b/frontend/src/pages/playground/voice/components/PlaygroundTab.tsx index 55339d64..845a3935 100644 --- a/frontend/src/pages/playground/voice/components/PlaygroundTab.tsx +++ b/frontend/src/pages/playground/voice/components/PlaygroundTab.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect } from 'react' import { useQuery } from '@tanstack/react-query' import { Loader2, Hash, Play, RotateCcw, ArrowRight, Volume2, Plus, CheckCircle2, Pause, ChevronDown, ChevronRight, Mic } from 'lucide-react' import { apiClient } from '../../../../lib/api' @@ -63,6 +63,52 @@ export default function PlaygroundTab() { createReportJob, openAsyncReport, } = useVoicePlayground() + const [playbackProfile, setPlaybackProfile] = useState<'default' | 'telephony_narrowband' | 'telephony_wideband'>('default') + + const getTelephonyRateForProvider = ( + provider: string, + preferredRate: number, + ): number | null => { + const providerData = providers.find((p) => p.provider === provider) + const supportedRates = providerData?.supported_sample_rates || [] + if (supportedRates.length === 0) { + return null + } + if (supportedRates.includes(preferredRate)) { + return preferredRate + } + const fallbackRate = preferredRate === 8000 ? 16000 : 8000 + if (supportedRates.includes(fallbackRate)) { + return fallbackRate + } + return supportedRates[0] + } + + useEffect(() => { + if (playbackProfile === 'default') { + setSampleRateA(null) + if (enableComparison) { + setSampleRateB(null) + } + return + } + + const preferredRate = playbackProfile === 'telephony_narrowband' ? 8000 : 16000 + if (providerA) { + setSampleRateA(getTelephonyRateForProvider(providerA, preferredRate)) + } + if (enableComparison && providerB) { + setSampleRateB(getTelephonyRateForProvider(providerB, preferredRate)) + } + }, [ + playbackProfile, + providerA, + providerB, + enableComparison, + providers, + setSampleRateA, + setSampleRateB, + ]) // Configuration step if (step === 'configure') { @@ -115,6 +161,29 @@ export default function PlaygroundTab() {
+ {/* 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 5abd33d9..bf83c7a2 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", ] - From 359c3425f16311139c7cb3ffd241058d82baa238 Mon Sep 17 00:00:00 2001 From: Aadhar Singh Bhadauria Date: Fri, 24 Apr 2026 13:22:57 +0530 Subject: [PATCH 2/6] fix: break agent-number FK cycle for test teardown Use an ALTER-based named FK on telephony phone number agent linkage so SQLAlchemy can drop metadata cleanly in tests without circular dependency errors. Made-with: Cursor --- app/models/database.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/app/models/database.py b/app/models/database.py index 0836a741..c44183e9 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -512,7 +512,12 @@ class Metric(Base): # 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) @@ -986,7 +991,15 @@ class TelephonyPhoneNumber(Base): is_masking_pool = Column(Boolean, default=False, nullable=False) agent_id = Column( - UUID(as_uuid=True), ForeignKey("agents.id", ondelete="SET NULL"), nullable=True, index=True + 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()) From d00bf6fed0fefc3f3b2593607f7056613fc4f56d Mon Sep 17 00:00:00 2001 From: Aadhar Singh Bhadauria Date: Fri, 24 Apr 2026 14:21:00 +0530 Subject: [PATCH 3/6] fix: restore test compatibility for persona and agent schemas Add backward-compatible persona enum validation for legacy rows and make AgentResponse telephony_phone_number_id optional by default so backend tests pass on current schema fixtures. Made-with: Cursor --- app/api/v1/routes/integrations.py | 30 +- app/api/v1/routes/metrics.py | 94 ++++++- app/api/v1/routes/personas.py | 37 ++- app/api/v1/routes/telephony.py | 21 +- app/models/enums.py | 1 + app/models/schemas.py | 39 ++- app/services/telephony/telephony_service.py | 104 ++++--- frontend/src/config/providers.ts | 12 + frontend/src/lib/api.ts | 32 ++- .../src/pages/configurations/Integrations.tsx | 110 ++++++-- .../src/pages/evaluators/results/Results.tsx | 2 +- .../src/pages/metrics/MetricsManagement.tsx | 260 +++++++++++++++++- .../src/pages/observability/Observability.tsx | 2 +- frontend/src/types/api.ts | 1 + 14 files changed, 662 insertions(+), 83 deletions(-) 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 index 71623aaa..cdb4fe97 100644 --- a/app/api/v1/routes/telephony.py +++ b/app/api/v1/routes/telephony.py @@ -99,25 +99,27 @@ async def test_telephony_config( @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) + 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) + return telephony_service.list_numbers(organization_id, db, provider=provider) @router.patch("/numbers/{number_id}", response_model=TelephonyPhoneNumberResponse) @@ -182,7 +184,13 @@ async def start_verify_session( db: Session = Depends(get_db), ): try: - session = telephony_service.start_voice_otp(organization_id, payload.phone_number, api_key, db) + 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, @@ -203,7 +211,11 @@ async def check_verify_session( del api_key try: verified, message = telephony_service.check_voice_otp( - organization_id, payload.session_id, payload.otp_code, db + organization_id, + payload.session_id, + payload.otp_code, + db, + provider=payload.provider, ) return TelephonyVerifyCheckResponse( verified=verified, @@ -230,6 +242,7 @@ async def create_masking_session( 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)) diff --git a/app/models/enums.py b/app/models/enums.py index 1d8c0864..736000e3 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -106,6 +106,7 @@ class IntegrationPlatform(str, enum.Enum): 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.""" diff --git a/app/models/schemas.py b/app/models/schemas.py index b1057779..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 @@ -269,7 +269,7 @@ class AgentResponse(BaseModel): description: Optional[str] call_type: CallTypeEnum call_medium: CallMediumEnum - telephony_phone_number_id: Optional[UUID] + telephony_phone_number_id: Optional[UUID] = None voice_bundle_id: Optional[UUID] ai_provider_id: Optional[UUID] voice_ai_integration_id: Optional[UUID] @@ -1061,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": { @@ -1080,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): @@ -1092,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] @@ -1129,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) @@ -1703,6 +1737,7 @@ class TelephonyMaskingSessionCreate(BaseModel): 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" diff --git a/app/services/telephony/telephony_service.py b/app/services/telephony/telephony_service.py index 765bb859..a0a3eab3 100644 --- a/app/services/telephony/telephony_service.py +++ b/app/services/telephony/telephony_service.py @@ -21,6 +21,7 @@ TelephonyVerifySession, ) from app.models.enums import CallRecordingStatus +from app.services.telephony.exotel_client import ExotelClient from app.services.telephony.plivo_client import PlivoClient, normalize_e164 from app.services.telephony.plivo_xml import dial_number, reject_call, speak_and_hangup @@ -44,11 +45,24 @@ def get_org_integration( raise ValueError(f"Active {provider} telephony integration not found for organization") return integration - def get_plivo_client(self, org_id: UUID, db: Session) -> PlivoClient: - integration = self.get_org_integration(org_id, db, provider="plivo") + 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) - return PlivoClient(auth_id=auth_id, auth_token=auth_token) + provider_key = provider.lower() + if provider_key == "plivo": + return PlivoClient(auth_id=auth_id, auth_token=auth_token) + if provider_key == "exotel": + account_sid = (integration.voice_app_id or "").strip() + if not account_sid: + raise ValueError("voice_app_id (Exotel Account SID) is required for Exotel") + return ExotelClient( + auth_id=auth_id, + auth_token=auth_token, + account_sid=account_sid, + subdomain=integration.sip_domain or None, + ) + raise ValueError(f"Unsupported telephony provider: {provider}") def save_integration( self, org_id: UUID, data: Dict[str, Any], db: Session @@ -64,6 +78,13 @@ def save_integration( ) 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: @@ -101,19 +122,16 @@ def save_integration( return integration def test_connection(self, org_id: UUID, db: Session, provider: str = "plivo") -> bool: - if provider == "plivo": - client = self.get_plivo_client(org_id, db) - ok = client.test_connection() - else: - raise ValueError(f"Connection test not implemented for provider: {provider}") + 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) -> List[TelephonyPhoneNumber]: - client = self.get_plivo_client(org_id, db) - integration = self.get_org_integration(org_id, db, provider="plivo") + 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] = [] @@ -156,18 +174,18 @@ def sync_numbers(self, org_id: UUID, db: Session) -> List[TelephonyPhoneNumber]: db.commit() return synced - def list_numbers(self, org_id: UUID, db: Session) -> List[TelephonyPhoneNumber]: - return ( - db.query(TelephonyPhoneNumber) - .filter(TelephonyPhoneNumber.organization_id == org_id) - .order_by(TelephonyPhoneNumber.created_at.desc()) - .all() - ) + 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]: - client = self.get_plivo_client(org_id, db) from_number = normalize_e164(from_number) to_number = normalize_e164(to_number) @@ -183,6 +201,18 @@ def initiate_outbound_call( 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" @@ -201,7 +231,7 @@ def initiate_outbound_call( call_event="outbound_initiated", call_data=response, provider_call_id=call_uuid, - provider_platform="plivo", + provider_platform=integration.provider, agent_id=agent_id, ) ) @@ -209,10 +239,10 @@ def initiate_outbound_call( return response def start_voice_otp( - self, org_id: UUID, phone_number: str, api_key: str, db: Session + 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="plivo") + 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") @@ -223,7 +253,7 @@ def start_voice_otp( base = settings.PLIVO_WEBHOOK_BASE_URL.rstrip("/") callback_url = f"{base}{settings.API_V1_PREFIX}/telephony/webhooks/events" - response = self.get_plivo_client(org_id, db).start_voice_verification( + response = self.get_provider_client(org_id, db, provider=provider).start_voice_verification( recipient=recipient, app_uuid=app_uuid, callback_url=callback_url ) @@ -246,7 +276,7 @@ def start_voice_otp( return session def check_voice_otp( - self, org_id: UUID, session_id: UUID, otp_code: str, db: Session + self, org_id: UUID, session_id: UUID, otp_code: str, db: Session, provider: str = "plivo" ) -> Tuple[bool, str]: session = ( db.query(TelephonyVerifySession) @@ -259,7 +289,7 @@ def check_voice_otp( if not session: raise ValueError("Verification session not found") - result = self.get_plivo_client(org_id, db).check_verification( + result = self.get_provider_client(org_id, db, provider=provider).check_verification( session_uuid=session.provider_session_uuid, otp_code=otp_code.strip(), ) @@ -280,8 +310,9 @@ def create_masking_session( expires_in_minutes: int, metadata: Optional[Dict[str, Any]], db: Session, + provider: str = "plivo", ) -> TelephonyMaskedSession: - integration = self.get_org_integration(org_id, db, provider="plivo") + integration = self.get_org_integration(org_id, db, provider=provider) party_a = normalize_e164(party_a) party_b = normalize_e164(party_b) @@ -336,9 +367,14 @@ def end_masking_session(self, org_id: UUID, session_id: UUID, db: Session) -> No db.commit() def handle_answer_webhook(self, params: Dict[str, Any], db: Session) -> str: - to_number = params.get("To") - from_number = params.get("From") - call_uuid = params.get("CallUUID") + 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: @@ -360,8 +396,14 @@ def handle_answer_webhook(self, params: Dict[str, Any], db: Session) -> str: 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") - call_status = params.get("CallStatus") or params.get("Event") + 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 diff --git a/frontend/src/config/providers.ts b/frontend/src/config/providers.ts index 08db64a7..595530c6 100644 --- a/frontend/src/config/providers.ts +++ b/frontend/src/config/providers.ts @@ -185,6 +185,18 @@ export const TELEPHONY_PROVIDER_CONFIG: Record diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a6c3aa6a..f0524546 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -58,11 +58,13 @@ export interface TelephonyIntegrationCreatePayload { 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 @@ -705,13 +707,17 @@ class ApiClient { return response.data } - async syncTelephonyNumbers(): Promise { - const response = await this.client.post('/api/v1/telephony/numbers/sync') + 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(): Promise { - const response = await this.client.get('/api/v1/telephony/numbers') + async listTelephonyNumbers(provider?: string): Promise { + const response = await this.client.get('/api/v1/telephony/numbers', { + params: provider ? { provider } : undefined, + }) return response.data } @@ -1214,13 +1220,21 @@ class ApiClient { 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(): Promise { - const response = await this.client.get('/api/v1/metrics') + async listMetrics(surface?: string): Promise { + const response = await this.client.get('/api/v1/metrics', { + params: surface ? { surface } : undefined, + }) return response.data } @@ -1288,6 +1302,12 @@ class ApiClient { 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 diff --git a/frontend/src/pages/configurations/Integrations.tsx b/frontend/src/pages/configurations/Integrations.tsx index 8b451518..4542777f 100644 --- a/frontend/src/pages/configurations/Integrations.tsx +++ b/frontend/src/pages/configurations/Integrations.tsx @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useQuery, useMutation, useQueryClient, useQueries } from '@tanstack/react-query' import { apiClient } from '../../lib/api' import type { TelephonyPhoneNumberResponse } from '../../lib/api' import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react' @@ -46,8 +46,10 @@ export default function Integrations() { 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 @@ -64,16 +66,37 @@ export default function Integrations() { queryFn: () => apiClient.listAIProviders(), }) - const { data: telephonyConfig } = useQuery({ - queryKey: ['telephony-config'], - queryFn: () => apiClient.getTelephonyConfig('plivo'), - retry: false, + 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'], - queryFn: () => apiClient.listTelephonyNumbers(), + queryKey: ['telephony-numbers', activeTelephonyProvider], + queryFn: () => apiClient.listTelephonyNumbers(activeTelephonyProvider), retry: false, + enabled: !!telephonyConfig, }) const createIntegrationMutation = useMutation({ @@ -122,9 +145,13 @@ export default function Integrations() { 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() }, @@ -132,13 +159,17 @@ export default function Integrations() { }) const testTelephonyMutation = useMutation({ - mutationFn: () => apiClient.testTelephonyConfig('plivo'), + 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') }, }) const syncNumbersMutation = useMutation({ - mutationFn: () => apiClient.syncTelephonyNumbers(), + 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') }, }) @@ -162,7 +193,7 @@ export default function Integrations() { 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(''); setTelephonySipDomain('') + setSelectedTelephonyProvider(null); setTelephonyAuthId(''); setTelephonyAuthToken(''); setTelephonyVerifyAppUuid(''); setTelephonyVoiceAppId(''); setTelephonySipDomain('') } const handleEdit = (integration: Integration) => { @@ -182,8 +213,11 @@ export default function Integrations() { } const handleEditTelephony = () => { - setIntegrationType('telephony_provider'); setSelectedTelephonyProvider(TelephonyProvider.PLIVO) - setTelephonyVerifyAppUuid(telephonyConfig?.verify_app_uuid || ''); setTelephonySipDomain(telephonyConfig?.sip_domain || '') + 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) } @@ -282,8 +316,8 @@ export default function Integrations() { const availableProviders = Object.values(ModelProvider).filter(p => !configuredProviders.has(p)) 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 = !!telephonyConfig - const totalConfigured = integrations.length + aiproviders.length + (hasTelephony ? 1 : 0) + const hasTelephony = telephonyConfigs.length > 0 + const totalConfigured = integrations.length + aiproviders.length + telephonyConfigs.length return (
@@ -396,7 +430,18 @@ export default function Integrations() {

Telephony Providers

- 1 + {telephonyConfigs.length} +
+ +
@@ -413,6 +458,16 @@ export default function Integrations() { {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()}
)} @@ -621,24 +676,37 @@ export default function Integrations() { <>
- + setTelephonyAuthId(e.target.value)} required={!isEditMode} - placeholder={isEditMode ? 'Leave blank to keep current' : 'Enter Auth ID'} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" /> + 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' : 'Enter Auth Token'} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" /> + 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="Optional: SIP domain" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-green-500" /> + 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.

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" + /> +
+