From c74c3f908e5f85b97a0d6b39e25a8434254400f1 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Fri, 3 Apr 2026 15:25:45 +0000 Subject: [PATCH 1/3] feat: updting personas --- app/api/v1/routes/evaluators.py | 36 +- app/api/v1/routes/personas.py | 441 ++++- app/api/v1/routes/voice_agent.py | 15 +- .../017_revamp_personas_tts_fields.py | 42 + app/models/database.py | 11 +- app/models/schemas.py | 91 +- .../testing/test_agent_bridge_service.py | 11 +- app/services/testing/test_agent_service.py | 11 +- app/workers/tasks/helpers/llm_evaluation.py | 11 +- frontend/src/lib/api.ts | 62 +- .../evaluators/EvaluateTestAgents.tsx | 77 +- .../evaluators/evaluators/EvaluatorDetail.tsx | 21 +- .../results/EvaluatorResultDetail.tsx | 17 +- frontend/src/pages/personas/Personas.tsx | 1592 +++++++++-------- 14 files changed, 1546 insertions(+), 892 deletions(-) create mode 100644 app/migrations/017_revamp_personas_tts_fields.py diff --git a/app/api/v1/routes/evaluators.py b/app/api/v1/routes/evaluators.py index b009eb11..c37dfdb3 100644 --- a/app/api/v1/routes/evaluators.py +++ b/app/api/v1/routes/evaluators.py @@ -12,7 +12,7 @@ from app.database import get_db from app.dependencies import get_organization_id, get_api_key -from app.models.database import Evaluator, Agent, Persona, Scenario, EvaluatorResult, EvaluatorResultStatus +from app.models.database import Evaluator, Agent, Persona, Scenario, EvaluatorResult, EvaluatorResultStatus, VoiceBundle from app.models.schemas import ( EvaluatorCreate, EvaluatorUpdate, @@ -142,6 +142,21 @@ def create_evaluator( if not scenario: raise HTTPException(status_code=404, detail="Scenario not found") + if agent.voice_bundle_id and persona.tts_provider: + voice_bundle = db.query(VoiceBundle).filter(VoiceBundle.id == agent.voice_bundle_id).first() + if voice_bundle and voice_bundle.tts_provider: + vb_provider = (voice_bundle.tts_provider.value if hasattr(voice_bundle.tts_provider, "value") else str(voice_bundle.tts_provider)).lower() + persona_provider = persona.tts_provider.lower() + if vb_provider != persona_provider: + raise HTTPException( + status_code=400, + detail=( + f"Persona '{persona.name}' uses TTS provider '{persona.tts_provider}' " + f"but agent '{agent.name}' voice bundle uses '{voice_bundle.tts_provider}'. " + f"The persona's TTS provider must match the agent's voice bundle TTS provider." + ) + ) + evaluator_id = generate_unique_evaluator_id(db) evaluator = Evaluator( @@ -191,6 +206,25 @@ def create_evaluators_bulk( if len(personas) != len(bulk_data.persona_ids): raise HTTPException(status_code=404, detail="One or more personas not found") + # Validate TTS provider compatibility between personas and voice bundle + if agent.voice_bundle_id: + voice_bundle = db.query(VoiceBundle).filter(VoiceBundle.id == agent.voice_bundle_id).first() + if voice_bundle and voice_bundle.tts_provider: + vb_provider = (voice_bundle.tts_provider.value if hasattr(voice_bundle.tts_provider, "value") else str(voice_bundle.tts_provider)).lower() + mismatched = [ + p.name for p in personas + if p.tts_provider and p.tts_provider.lower() != vb_provider + ] + if mismatched: + raise HTTPException( + status_code=400, + detail=( + f"The following personas use a different TTS provider than the agent's voice bundle " + f"('{voice_bundle.tts_provider}'): {', '.join(mismatched)}. " + f"All personas must use a TTS provider that matches the agent's voice bundle." + ) + ) + # Create evaluators for each persona evaluators = [] for persona_id in bulk_data.persona_ids: diff --git a/app/api/v1/routes/personas.py b/app/api/v1/routes/personas.py index 12cba044..7543f8c2 100644 --- a/app/api/v1/routes/personas.py +++ b/app/api/v1/routes/personas.py @@ -1,23 +1,151 @@ """ Personas API Routes -Complete CRUD operations for test personas +CRUD for TTS provider-tied voice personas, voice-options catalog, +and custom voice management (ungated). """ from fastapi import APIRouter, Depends, HTTPException, status, Body, Query from fastapi.responses import JSONResponse from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from typing import List, Optional +from typing import List, Optional, Dict, Any from uuid import UUID +from pydantic import BaseModel from app.dependencies import get_db, get_organization_id -from app.models.database import Persona, Evaluator, EvaluatorResult, TestAgentConversation +from app.models.database import ( + Persona, Evaluator, EvaluatorResult, TestAgentConversation, CustomTTSVoice, + PromptOptimizationRun, CallRecording, +) from app.models.schemas import ( PersonaCreate, PersonaUpdate, PersonaResponse, PersonaCloneRequest ) +from app.models.enums import ModelProvider +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) +# --------------------------------------------------------------------------- +TTS_VOICES: Dict[str, List[Dict[str, str]]] = { + "openai": [ + {"id": "alloy", "name": "Alloy", "gender": "Neutral"}, + {"id": "ash", "name": "Ash", "gender": "Male"}, + {"id": "coral", "name": "Coral", "gender": "Female"}, + {"id": "echo", "name": "Echo", "gender": "Male"}, + {"id": "fable", "name": "Fable", "gender": "Male"}, + {"id": "onyx", "name": "Onyx", "gender": "Male"}, + {"id": "nova", "name": "Nova", "gender": "Female"}, + {"id": "sage", "name": "Sage", "gender": "Female"}, + {"id": "shimmer", "name": "Shimmer", "gender": "Female"}, + ], + "elevenlabs": [ + {"id": "21m00Tcm4TlvDq8ikWAM", "name": "Rachel", "gender": "Female"}, + {"id": "AZnzlk1XvdvUeBnXmlld", "name": "Domi", "gender": "Female"}, + {"id": "EXAVITQu4vr4xnSDxMaL", "name": "Bella", "gender": "Female"}, + {"id": "ErXwobaYiN019PkySvjV", "name": "Antoni", "gender": "Male"}, + {"id": "MF3mGyEYCl7XYWbV9V6O", "name": "Elli", "gender": "Female"}, + {"id": "TxGEqnHWrfWFTfGW9XjX", "name": "Josh", "gender": "Male"}, + {"id": "VR6AewLTigWG4xSOukaG", "name": "Arnold", "gender": "Male"}, + {"id": "pNInz6obpgDQGcFmaJgB", "name": "Adam", "gender": "Male"}, + {"id": "yoZ06aMxZJJ28mfd3POQ", "name": "Sam", "gender": "Male"}, + {"id": "jBpfuIE2acCO8z3wKNLl", "name": "Gigi", "gender": "Female"}, + ], + "cartesia": [ + {"id": "a0e99841-438c-4a64-b679-ae501e7d6091", "name": "Barbershop Man", "gender": "Male"}, + {"id": "79a125e8-cd45-4c13-8a67-188112f4dd22", "name": "British Lady", "gender": "Female"}, + {"id": "b7d50908-b17c-442d-ad8d-7c56a2ec8e67", "name": "Confident Woman", "gender": "Female"}, + {"id": "c8605446-247c-4f39-993c-e0e2ee1c5112", "name": "Friendly Sidekick", "gender": "Male"}, + {"id": "87748186-23bb-4571-ad1f-24094e1acbc5", "name": "Wise Guide", "gender": "Male"}, + {"id": "41534e16-2966-4c6b-9670-111411def906", "name": "Nonfiction Man", "gender": "Male"}, + {"id": "00a77add-48d5-4ef6-8157-71e5437b282d", "name": "Sportsman", "gender": "Male"}, + {"id": "638efaaa-4d0c-442e-b701-3fae16aad012", "name": "Southern Woman", "gender": "Female"}, + ], + "deepgram": [ + {"id": "aura-asteria-en", "name": "Asteria", "gender": "Female"}, + {"id": "aura-luna-en", "name": "Luna", "gender": "Female"}, + {"id": "aura-stella-en", "name": "Stella", "gender": "Female"}, + {"id": "aura-athena-en", "name": "Athena", "gender": "Female"}, + {"id": "aura-hera-en", "name": "Hera", "gender": "Female"}, + {"id": "aura-orion-en", "name": "Orion", "gender": "Male"}, + {"id": "aura-arcas-en", "name": "Arcas", "gender": "Male"}, + {"id": "aura-perseus-en", "name": "Perseus", "gender": "Male"}, + {"id": "aura-angus-en", "name": "Angus", "gender": "Male"}, + {"id": "aura-orpheus-en", "name": "Orpheus", "gender": "Male"}, + {"id": "aura-helios-en", "name": "Helios", "gender": "Male"}, + {"id": "aura-zeus-en", "name": "Zeus", "gender": "Male"}, + ], + "google": [ + {"id": "en-US-Neural2-A", "name": "Neural2 A", "gender": "Male"}, + {"id": "en-US-Neural2-C", "name": "Neural2 C", "gender": "Female"}, + {"id": "en-US-Neural2-D", "name": "Neural2 D", "gender": "Male"}, + {"id": "en-US-Neural2-E", "name": "Neural2 E", "gender": "Female"}, + {"id": "en-US-Neural2-F", "name": "Neural2 F", "gender": "Female"}, + {"id": "en-US-Neural2-G", "name": "Neural2 G", "gender": "Female"}, + {"id": "en-US-Neural2-H", "name": "Neural2 H", "gender": "Female"}, + {"id": "en-US-Neural2-I", "name": "Neural2 I", "gender": "Male"}, + {"id": "en-US-Neural2-J", "name": "Neural2 J", "gender": "Male"}, + ], + "sarvam": [ + {"id": "aditya", "name": "Aditya", "gender": "Male"}, + {"id": "ritu", "name": "Ritu", "gender": "Female"}, + {"id": "ashutosh", "name": "Ashutosh", "gender": "Male"}, + {"id": "priya", "name": "Priya", "gender": "Female"}, + {"id": "neha", "name": "Neha", "gender": "Female"}, + {"id": "rahul", "name": "Rahul", "gender": "Male"}, + {"id": "pooja", "name": "Pooja", "gender": "Female"}, + {"id": "rohan", "name": "Rohan", "gender": "Male"}, + {"id": "simran", "name": "Simran", "gender": "Female"}, + {"id": "kavya", "name": "Kavya", "gender": "Female"}, + ], + "voicemaker": [ + {"id": "ai3-Jony", "name": "Jony", "gender": "Male"}, + {"id": "ai2-Katie", "name": "Katie", "gender": "Female"}, + {"id": "ai1-Joanna", "name": "Joanna", "gender": "Female"}, + {"id": "pro1-Catherine", "name": "Catherine", "gender": "Female"}, + {"id": "proplus-Richard", "name": "Richard", "gender": "Male"}, + {"id": "proplus-Emma", "name": "Emma", "gender": "Female"}, + {"id": "ai3-Ana", "name": "Ana", "gender": "Female"}, + {"id": "ai3-Lea", "name": "Lea", "gender": "Female"}, + {"id": "ai3-Keiko", "name": "Keiko", "gender": "Female"}, + {"id": "ai3-Liang", "name": "Liang", "gender": "Male"}, + ], + "murf": [], +} + +PROVIDER_DISPLAY_NAMES: Dict[str, str] = { + "openai": "OpenAI", + "elevenlabs": "ElevenLabs", + "cartesia": "Cartesia", + "deepgram": "Deepgram", + "google": "Google", + "sarvam": "Sarvam", + "voicemaker": "VoiceMaker", + "murf": "Murf", + "azure": "Azure", + "aws": "AWS Polly", +} + + +# --------------------------------------------------------------------------- +# Custom voice schemas (inline, kept simple) +# --------------------------------------------------------------------------- +class CustomVoiceCreateRequest(BaseModel): + provider: str + voice_id: str + name: str + gender: Optional[str] = None + description: Optional[str] = None + + +class CustomVoiceUpdateRequest(BaseModel): + voice_id: Optional[str] = None + name: Optional[str] = None + gender: Optional[str] = None + description: Optional[str] = None + + @router.post("", response_model=PersonaResponse, status_code=status.HTTP_201_CREATED) async def create_persona( persona: PersonaCreate, @@ -29,10 +157,11 @@ async def create_persona( db_persona = Persona( organization_id=organization_id, name=persona.name, - language=persona.language, - accent=persona.accent, gender=persona.gender, - background_noise=persona.background_noise + tts_provider=persona.tts_provider, + tts_voice_id=persona.tts_voice_id, + tts_voice_name=persona.tts_voice_name, + is_custom=persona.is_custom, ) db.add(db_persona) db.commit() @@ -93,6 +222,218 @@ async def list_personas( ) +# ============================================ +# VOICE OPTIONS (built-in + custom, ungated) +# Must be registered BEFORE /{persona_id} routes. +# ============================================ + +def _serialize_custom_voice(voice: CustomTTSVoice) -> Dict[str, Any]: + return { + "id": str(voice.id), + "provider": voice.provider, + "voice_id": voice.voice_id, + "name": voice.name, + "gender": voice.gender or "Unknown", + "description": voice.description, + "is_custom": True, + "created_at": voice.created_at.isoformat() if voice.created_at else None, + } + + +@router.get("/voice-options", operation_id="getPersonaVoiceOptions") +async def get_voice_options( + provider: Optional[str] = None, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + """Return available TTS voices grouped by provider. + + Merges built-in static voices, model-config voices (e.g. Murf voice files), + and the org's custom voices. Not enterprise-gated. + """ + model_voices_by_provider: Dict[str, List[Dict[str, Any]]] = {} + for provider_enum in ModelProvider: + try: + tts_models = model_config_service.get_models_by_type(provider_enum, "tts") + except Exception: + tts_models = [] + for model_name in tts_models: + try: + voices_list = model_config_service.get_voices_for_model(model_name) + except Exception: + voices_list = [] + if voices_list and isinstance(voices_list, list): + existing = model_voices_by_provider.setdefault(provider_enum.value, []) + for v in voices_list: + if isinstance(v, dict) and v.get("id"): + existing.append({ + "id": v["id"], + "name": v.get("name", v["id"]), + "gender": v.get("gender", "Unknown"), + }) + + custom_query = db.query(CustomTTSVoice).filter(CustomTTSVoice.organization_id == organization_id) + if provider: + custom_query = custom_query.filter(CustomTTSVoice.provider == provider.lower()) + custom_voices = custom_query.order_by(CustomTTSVoice.name.asc()).all() + + custom_by_provider: Dict[str, List[Dict[str, Any]]] = {} + for cv in custom_voices: + custom_by_provider.setdefault(cv.provider, []).append({ + "id": cv.voice_id, + "name": cv.name, + "gender": cv.gender or "Unknown", + "is_custom": True, + "custom_voice_id": str(cv.id), + "description": cv.description, + }) + + all_keys: set = set(TTS_VOICES.keys()) | set(model_voices_by_provider.keys()) | set(custom_by_provider.keys()) + if provider: + all_keys = {k for k in all_keys if k == provider.lower()} + + result = [] + for key in sorted(all_keys): + seen: set = set() + voices: List[Dict[str, Any]] = [] + for v in TTS_VOICES.get(key, []): + if v["id"] not in seen: + seen.add(v["id"]) + voices.append({**v, "is_custom": False}) + for v in model_voices_by_provider.get(key, []): + if v["id"] not in seen: + seen.add(v["id"]) + voices.append({**v, "is_custom": False}) + for v in custom_by_provider.get(key, []): + if v["id"] not in seen: + seen.add(v["id"]) + voices.append(v) + if voices: + result.append({ + "id": key, + "name": PROVIDER_DISPLAY_NAMES.get(key, key.title()), + "voices": voices, + }) + + return {"providers": result} + + +# ============================================ +# CUSTOM VOICES (ungated, org-scoped) +# Must be registered BEFORE /{persona_id} routes. +# ============================================ + +@router.get("/custom-voices", operation_id="listPersonaCustomVoices") +async def list_custom_voices( + provider: Optional[str] = None, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + """List custom TTS voices for the organization.""" + query = db.query(CustomTTSVoice).filter(CustomTTSVoice.organization_id == organization_id) + if provider: + query = query.filter(CustomTTSVoice.provider == provider.lower()) + voices = query.order_by(CustomTTSVoice.provider.asc(), CustomTTSVoice.name.asc()).all() + return [_serialize_custom_voice(v) for v in voices] + + +@router.post("/custom-voices", status_code=status.HTTP_201_CREATED, operation_id="createPersonaCustomVoice") +async def create_custom_voice( + data: CustomVoiceCreateRequest, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + """Create a custom TTS voice (org-scoped).""" + prov = data.provider.strip().lower() + vid = data.voice_id.strip() + vname = data.name.strip() + if not prov or not vid or not vname: + raise HTTPException(400, "provider, voice_id, and name are required") + + existing = db.query(CustomTTSVoice).filter( + CustomTTSVoice.organization_id == organization_id, + CustomTTSVoice.provider == prov, + CustomTTSVoice.voice_id == vid, + ).first() + if existing: + raise HTTPException(409, f"Custom voice with provider={prov} voice_id={vid} already exists") + + voice = CustomTTSVoice( + organization_id=organization_id, + provider=prov, + voice_id=vid, + name=vname, + gender=data.gender, + description=data.description, + ) + db.add(voice) + db.commit() + db.refresh(voice) + return _serialize_custom_voice(voice) + + +@router.put("/custom-voices/{custom_voice_id}", operation_id="updatePersonaCustomVoice") +async def update_custom_voice( + custom_voice_id: UUID, + data: CustomVoiceUpdateRequest, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + """Update a custom TTS voice.""" + voice = db.query(CustomTTSVoice).filter( + CustomTTSVoice.id == custom_voice_id, + CustomTTSVoice.organization_id == organization_id, + ).first() + if not voice: + raise HTTPException(404, "Custom voice not found") + + if data.voice_id is not None: + cleaned = data.voice_id.strip() + if not cleaned: + raise HTTPException(400, "voice_id cannot be empty") + dup = db.query(CustomTTSVoice).filter( + CustomTTSVoice.organization_id == organization_id, + CustomTTSVoice.provider == voice.provider, + CustomTTSVoice.voice_id == cleaned, + CustomTTSVoice.id != custom_voice_id, + ).first() + if dup: + raise HTTPException(409, f"Another custom voice already uses voice_id={cleaned}") + voice.voice_id = cleaned + if data.name is not None: + voice.name = data.name.strip() + if data.gender is not None: + voice.gender = data.gender + if data.description is not None: + voice.description = data.description + + db.commit() + db.refresh(voice) + return _serialize_custom_voice(voice) + + +@router.delete("/custom-voices/{custom_voice_id}", operation_id="deletePersonaCustomVoice") +async def delete_custom_voice( + custom_voice_id: UUID, + organization_id: UUID = Depends(get_organization_id), + db: Session = Depends(get_db), +): + """Delete a custom TTS voice.""" + voice = db.query(CustomTTSVoice).filter( + CustomTTSVoice.id == custom_voice_id, + CustomTTSVoice.organization_id == organization_id, + ).first() + if not voice: + raise HTTPException(404, "Custom voice not found") + db.delete(voice) + db.commit() + return {"message": "Custom voice deleted"} + + +# ============================================ +# PERSONA BY ID (parameterized routes last) +# ============================================ + @router.get("/{persona_id}", response_model=PersonaResponse) async def get_persona( persona_id: UUID, @@ -228,25 +569,56 @@ async def delete_persona( }, ) - if dependencies: - # Delete in FK-safe order - db.query(EvaluatorResult).filter( - EvaluatorResult.persona_id == persona_id, - EvaluatorResult.organization_id == organization_id, - ).delete(synchronize_session=False) - - db.query(Evaluator).filter( - Evaluator.persona_id == persona_id, - Evaluator.organization_id == organization_id, - ).delete(synchronize_session=False) - - db.query(TestAgentConversation).filter( - TestAgentConversation.persona_id == persona_id, - TestAgentConversation.organization_id == organization_id, - ).delete(synchronize_session=False) - - db.delete(db_persona) - db.commit() + try: + if dependencies: + evaluator_ids = [ + e.id for e in db.query(Evaluator.id).filter( + Evaluator.persona_id == persona_id, + Evaluator.organization_id == organization_id, + ).all() + ] + + result_ids = [ + r.id for r in db.query(EvaluatorResult.id).filter( + EvaluatorResult.persona_id == persona_id, + EvaluatorResult.organization_id == organization_id, + ).all() + ] + + # Delete deepest FK children first + if evaluator_ids: + db.query(PromptOptimizationRun).filter( + PromptOptimizationRun.evaluator_id.in_(evaluator_ids), + ).delete(synchronize_session=False) + + if result_ids: + db.query(CallRecording).filter( + CallRecording.evaluator_result_id.in_(result_ids), + ).delete(synchronize_session=False) + + db.query(EvaluatorResult).filter( + EvaluatorResult.persona_id == persona_id, + EvaluatorResult.organization_id == organization_id, + ).delete(synchronize_session=False) + + db.query(Evaluator).filter( + Evaluator.persona_id == persona_id, + Evaluator.organization_id == organization_id, + ).delete(synchronize_session=False) + + db.query(TestAgentConversation).filter( + TestAgentConversation.persona_id == persona_id, + TestAgentConversation.organization_id == organization_id, + ).delete(synchronize_session=False) + + db.delete(db_persona) + db.commit() + except IntegrityError as e: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to cascade-delete persona dependencies: {str(e.orig)}", + ) if dependencies: return JSONResponse( @@ -276,14 +648,14 @@ async def clone_persona( if not source_persona: raise HTTPException(status_code=404, detail=f"Persona {persona_id} not found") - # Create new persona with same attributes new_persona = Persona( organization_id=organization_id, name=clone_request.name if clone_request.name else f"{source_persona.name} (Copy)", - language=source_persona.language, - accent=source_persona.accent, gender=source_persona.gender, - background_noise=source_persona.background_noise + tts_provider=source_persona.tts_provider, + tts_voice_id=source_persona.tts_voice_id, + tts_voice_name=source_persona.tts_voice_name, + is_custom=source_persona.is_custom, ) db.add(new_persona) db.commit() @@ -334,13 +706,12 @@ async def seed_demo_data( from app.models.database import Scenario try: - # Example personas personas_data = [ - {"name": "Grumpy Old Man", "language": "en", "accent": "american", "gender": "male", "background_noise": "none"}, - {"name": "Confused Senior", "language": "en", "accent": "american", "gender": "female", "background_noise": "home"}, - {"name": "Busy Professional", "language": "en", "accent": "american", "gender": "neutral", "background_noise": "office"}, - {"name": "Friendly Customer", "language": "en", "accent": "american", "gender": "female", "background_noise": "none"}, - {"name": "Angry Caller", "language": "en", "accent": "american", "gender": "male", "background_noise": "street"}, + {"name": "Grumpy Old Man", "gender": "male", "tts_provider": "openai", "tts_voice_id": "onyx", "tts_voice_name": "Onyx"}, + {"name": "Confused Senior", "gender": "female", "tts_provider": "openai", "tts_voice_id": "nova", "tts_voice_name": "Nova"}, + {"name": "Busy Professional", "gender": "neutral", "tts_provider": "openai", "tts_voice_id": "alloy", "tts_voice_name": "Alloy"}, + {"name": "Friendly Customer", "gender": "female", "tts_provider": "elevenlabs", "tts_voice_id": "21m00Tcm4TlvDq8ikWAM", "tts_voice_name": "Rachel"}, + {"name": "Angry Caller", "gender": "male", "tts_provider": "elevenlabs", "tts_voice_id": "TxGEqnHWrfWFTfGW9XjX", "tts_voice_name": "Josh"}, ] # Check if personas already exist to avoid duplicates diff --git a/app/api/v1/routes/voice_agent.py b/app/api/v1/routes/voice_agent.py index 4bedd2ad..4b587674 100644 --- a/app/api/v1/routes/voice_agent.py +++ b/app/api/v1/routes/voice_agent.py @@ -244,15 +244,14 @@ def resolve_api_key_for_provider(provider: ModelProvider) -> str | None: if persona: persona_parts = [] persona_parts.append(f"\n\nPersona: {persona.name}") - if persona.language: - persona_parts.append(f"Language: {persona.language.value}") - if persona.accent: - persona_parts.append(f"Accent: {persona.accent.value}") if persona.gender: - persona_parts.append(f"Gender: {persona.gender.value}") - if persona.background_noise and persona.background_noise.value != "none": - persona_parts.append(f"Background noise: {persona.background_noise.value}") - + gender_val = persona.gender.value if hasattr(persona.gender, "value") else persona.gender + persona_parts.append(f"Gender: {gender_val}") + if getattr(persona, "tts_provider", None): + persona_parts.append(f"Voice provider: {persona.tts_provider}") + if getattr(persona, "tts_voice_name", None): + persona_parts.append(f"Voice: {persona.tts_voice_name}") + if persona_parts: instruction_parts.append("\n".join(persona_parts)) except ValueError: diff --git a/app/migrations/017_revamp_personas_tts_fields.py b/app/migrations/017_revamp_personas_tts_fields.py new file mode 100644 index 00000000..4d8cecd0 --- /dev/null +++ b/app/migrations/017_revamp_personas_tts_fields.py @@ -0,0 +1,42 @@ +""" +Migration: Revamp personas table for TTS provider-based voice selection. + +Replaces generic speech attributes (language, accent, background_noise) with +TTS provider-tied voice identity fields (tts_provider, tts_voice_id, +tts_voice_name, is_custom). +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = "Revamp personas: add TTS voice fields, drop language/accent/background_noise" + + +def upgrade(db: Session): + db.execute(text(""" + ALTER TABLE personas + ADD COLUMN IF NOT EXISTS tts_provider VARCHAR(100), + ADD COLUMN IF NOT EXISTS tts_voice_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS tts_voice_name VARCHAR(255), + ADD COLUMN IF NOT EXISTS is_custom BOOLEAN DEFAULT FALSE, + DROP COLUMN IF EXISTS language, + DROP COLUMN IF EXISTS accent, + DROP COLUMN IF EXISTS background_noise + """)) + db.commit() + print("Revamped personas table: added TTS voice fields, dropped language/accent/background_noise") + + +def downgrade(db: Session): + db.execute(text(""" + ALTER TABLE personas + ADD COLUMN IF NOT EXISTS language VARCHAR(50) DEFAULT 'en', + ADD COLUMN IF NOT EXISTS accent VARCHAR(50) DEFAULT 'american', + ADD COLUMN IF NOT EXISTS background_noise VARCHAR(50) DEFAULT 'none', + DROP COLUMN IF EXISTS tts_provider, + DROP COLUMN IF EXISTS tts_voice_id, + DROP COLUMN IF EXISTS tts_voice_name, + DROP COLUMN IF EXISTS is_custom + """)) + db.commit() + print("Reverted personas table: restored language/accent/background_noise, dropped TTS voice fields") diff --git a/app/models/database.py b/app/models/database.py index 3fa85e5d..14e90460 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -238,19 +238,18 @@ class Agent(Base): class Persona(Base): - """Persona - The simulated caller/user for testing""" + """Persona - TTS provider-tied voice identity for testing""" __tablename__ = "personas" id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) name = Column(String, nullable=False) - language = Column(String, nullable=False, default=LanguageEnum.ENGLISH.value) - accent = Column(String, nullable=False, default=AccentEnum.AMERICAN.value) gender = Column(String, nullable=False, default=GenderEnum.NEUTRAL.value) - background_noise = Column(String, nullable=False, default=BackgroundNoiseEnum.NONE.value) - + tts_provider = Column(String(100), nullable=True) + tts_voice_id = Column(String(255), nullable=True) + tts_voice_name = Column(String(255), nullable=True) + is_custom = Column(Boolean, default=False) - created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) created_by = Column(String) diff --git a/app/models/schemas.py b/app/models/schemas.py index d12582fe..4c5eec17 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -339,100 +339,45 @@ class Config: # Persona Schemas class PersonaCreate(BaseModel): - """Schema for creating a new persona""" + """Schema for creating a new persona (TTS provider-tied voice identity)""" name: str = Field(..., min_length=1, max_length=255) - language: LanguageEnum = LanguageEnum.ENGLISH - accent: AccentEnum = AccentEnum.AMERICAN gender: GenderEnum = GenderEnum.NEUTRAL - background_noise: BackgroundNoiseEnum = BackgroundNoiseEnum.NONE + tts_provider: Optional[str] = None + tts_voice_id: Optional[str] = None + tts_voice_name: Optional[str] = None + is_custom: bool = False class PersonaUpdate(BaseModel): """Schema for updating a persona""" name: Optional[str] = None - language: Optional[LanguageEnum] = None - accent: Optional[AccentEnum] = None gender: Optional[GenderEnum] = None - background_noise: Optional[BackgroundNoiseEnum] = None + tts_provider: Optional[str] = None + tts_voice_id: Optional[str] = None + tts_voice_name: Optional[str] = None + is_custom: Optional[bool] = None class PersonaResponse(BaseModel): """Schema for persona response""" id: UUID name: str - language: LanguageEnum - accent: AccentEnum - gender: GenderEnum - background_noise: BackgroundNoiseEnum + gender: str + tts_provider: Optional[str] = None + tts_voice_id: Optional[str] = None + tts_voice_name: Optional[str] = None + is_custom: bool = False created_at: datetime updated_at: datetime - @validator('language', pre=True) - def convert_language(cls, v): - """Convert string to LanguageEnum (handles uppercase DB values).""" - if v is None: - return None - if isinstance(v, str): - v_lower = v.lower() - language_map = {'english': 'en', 'spanish': 'es', 'french': 'fr', 'german': 'de', - 'chinese': 'zh', 'japanese': 'ja', 'hindi': 'hi', 'arabic': 'ar'} - if v_lower in language_map: - return LanguageEnum(language_map[v_lower]) - try: - return LanguageEnum(v_lower) - except ValueError: - for enum_member in LanguageEnum: - if enum_member.name == v or enum_member.value == v: - return enum_member - raise ValueError(f"Invalid LanguageEnum value: {v}") - return v - - @validator('accent', pre=True) - def convert_accent(cls, v): - """Convert string to AccentEnum (handles uppercase DB values).""" - if v is None: - return None - if isinstance(v, str): - v_lower = v.lower() - try: - return AccentEnum(v_lower) - except ValueError: - for enum_member in AccentEnum: - if enum_member.name == v or enum_member.value == v: - return enum_member - raise ValueError(f"Invalid AccentEnum value: {v}") - return v - @validator('gender', pre=True) def convert_gender(cls, v): - """Convert string to GenderEnum (handles uppercase DB values).""" - if v is None: - return None - if isinstance(v, str): - v_lower = v.lower() - try: - return GenderEnum(v_lower) - except ValueError: - for enum_member in GenderEnum: - if enum_member.name == v or enum_member.value == v: - return enum_member - raise ValueError(f"Invalid GenderEnum value: {v}") - return v - - @validator('background_noise', pre=True) - def convert_background_noise(cls, v): - """Convert string to BackgroundNoiseEnum (handles uppercase DB values).""" if v is None: - return None + return "neutral" if isinstance(v, str): - v_lower = v.lower() - try: - return BackgroundNoiseEnum(v_lower) - except ValueError: - for enum_member in BackgroundNoiseEnum: - if enum_member.name == v or enum_member.value == v: - return enum_member - raise ValueError(f"Invalid BackgroundNoiseEnum value: {v}") + return v.lower() + if hasattr(v, 'value'): + return v.value return v class Config: diff --git a/app/services/testing/test_agent_bridge_service.py b/app/services/testing/test_agent_bridge_service.py index d8f4cd6e..e2196351 100644 --- a/app/services/testing/test_agent_bridge_service.py +++ b/app/services/testing/test_agent_bridge_service.py @@ -577,17 +577,14 @@ def resolve_api_key_for_provider(provider: ModelProvider) -> str | None: scenario_goal = scenario.required_info.get("goal", scenario_goal) first_message = scenario.required_info.get("first_message", first_message) - # Build persona description from available fields persona_traits = [] if hasattr(persona, "gender") and persona.gender: gender_val = persona.gender.value if hasattr(persona.gender, "value") else persona.gender persona_traits.append(f"{gender_val} caller") - if hasattr(persona, "accent") and persona.accent: - accent_val = persona.accent.value if hasattr(persona.accent, "value") else persona.accent - persona_traits.append(f"with {accent_val} accent") - if hasattr(persona, "language") and persona.language: - language_val = persona.language.value if hasattr(persona.language, "value") else persona.language - persona_traits.append(f"speaking {language_val}") + if hasattr(persona, "tts_voice_name") and persona.tts_voice_name: + persona_traits.append(f"voice: {persona.tts_voice_name}") + if hasattr(persona, "tts_provider") and persona.tts_provider: + persona_traits.append(f"provider: {persona.tts_provider}") persona_description = f"A caller named {persona.name}" if persona_traits: diff --git a/app/services/testing/test_agent_service.py b/app/services/testing/test_agent_service.py index ae3a6a4b..ef27a06b 100644 --- a/app/services/testing/test_agent_service.py +++ b/app/services/testing/test_agent_service.py @@ -54,11 +54,12 @@ def _build_system_prompt( # Persona information prompt_parts.append(f"\nYou are role-playing as: {persona.name}") - prompt_parts.append(f"Persona language: {persona.language.value}") - prompt_parts.append(f"Persona accent: {persona.accent.value}") - prompt_parts.append(f"Persona gender: {persona.gender.value}") - if persona.background_noise: - prompt_parts.append(f"Background noise: {persona.background_noise.value}") + gender_val = persona.gender.value if hasattr(persona.gender, "value") else persona.gender + prompt_parts.append(f"Persona gender: {gender_val}") + if persona.tts_provider: + prompt_parts.append(f"Voice provider: {persona.tts_provider}") + if persona.tts_voice_name: + prompt_parts.append(f"Voice: {persona.tts_voice_name}") # Scenario information prompt_parts.append(f"\nScenario: {scenario.name}") diff --git a/app/workers/tasks/helpers/llm_evaluation.py b/app/workers/tasks/helpers/llm_evaluation.py index 5ac2c603..431918d0 100644 --- a/app/workers/tasks/helpers/llm_evaluation.py +++ b/app/workers/tasks/helpers/llm_evaluation.py @@ -62,11 +62,12 @@ def build_evaluation_prompt( if agent and agent.call_type else "conversations" ) - language_val = ( - (persona.language.value if hasattr(persona.language, "value") else persona.language) - if persona and persona.language - else "N/A" - ) + language_val = "N/A" + if persona: + if hasattr(persona, "tts_voice_name") and persona.tts_voice_name: + language_val = f"{persona.tts_voice_name} ({persona.tts_provider or 'unknown'})" + elif hasattr(persona, "language") and persona.language: + language_val = persona.language.value if hasattr(persona.language, "value") else persona.language agent_objective = ( agent.description if agent and agent.description diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fba69298..a6ebd70b 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -331,10 +331,11 @@ class ApiClient { async createPersona(data: { name: string - language: string - accent: string gender: string - background_noise: string + tts_provider?: string + tts_voice_id?: string + tts_voice_name?: string + is_custom?: boolean }): Promise { const response = await this.client.post('/api/v1/personas', data) return response.data @@ -362,6 +363,61 @@ class ApiClient { return response.data } + // Persona voice options (built-in + custom voices, ungated) + async getPersonaVoiceOptions(provider?: string): Promise<{ + providers: Array<{ + id: string + name: string + voices: Array<{ + id: string + name: string + gender: string + is_custom: boolean + custom_voice_id?: string + description?: string | null + }> + }> + }> { + const response = await this.client.get('/api/v1/personas/voice-options', { + params: provider ? { provider } : undefined, + }) + return response.data + } + + // Custom voice CRUD (persona-scoped, ungated) + async listPersonaCustomVoices(provider?: string): Promise { + const response = await this.client.get('/api/v1/personas/custom-voices', { + params: provider ? { provider } : undefined, + }) + return response.data + } + + async createPersonaCustomVoice(data: { + provider: string + voice_id: string + name: string + gender?: string + description?: string + }): Promise { + const response = await this.client.post('/api/v1/personas/custom-voices', data) + return response.data + } + + async updatePersonaCustomVoice(customVoiceId: string, data: { + voice_id?: string + name?: string + gender?: string + description?: string + }): Promise { + const response = await this.client.put(`/api/v1/personas/custom-voices/${customVoiceId}`, data) + return response.data + } + + async deletePersonaCustomVoice(customVoiceId: string): Promise { + const response = await this.client.delete(`/api/v1/personas/custom-voices/${customVoiceId}`) + return response.data + } + // Scenarios endpoints async listScenarios(skip = 0, limit = 100): Promise { const response = await this.client.get('/api/v1/scenarios', { diff --git a/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx b/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx index 247f0bdd..e4b5a4e5 100644 --- a/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx +++ b/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx @@ -5,7 +5,8 @@ import { apiClient } from '../../../lib/api' import { useAgentStore } from '../../../store/agentStore' import { ModelProvider, AIProvider, Integration, IntegrationPlatform } from '../../../types/api' import Button from '../../../components/Button' -import { Plus, Trash2, Play, X, CheckSquare, Square, Sparkles, Brain, ChevronDown, AlertTriangle } from 'lucide-react' +import ProviderLogo from '../../../components/shared/ProviderLogo' +import { Plus, Trash2, Play, X, CheckSquare, Square, Sparkles, Brain, ChevronDown, AlertTriangle, Info } from 'lucide-react' import { useToast } from '../../../hooks/useToast' import { getProviderLabel, getProviderLogo } from '../../../config/providers' @@ -154,9 +155,30 @@ export default function EvaluateTestAgents() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [showLlmDropdown]) - const filteredPersonas = personas.filter((p: any) => !DEFAULT_PERSONA_NAMES.includes(p.name)) + const selectedAgentObj = agents.find((a: any) => a.id === modalAgentId) as any + const selectedAgentVoiceBundleId = selectedAgentObj?.voice_bundle_id + + const { data: agentVoiceBundle } = useQuery({ + queryKey: ['voicebundle', selectedAgentVoiceBundleId], + queryFn: () => apiClient.getVoiceBundle(selectedAgentVoiceBundleId), + enabled: !!selectedAgentVoiceBundleId, + }) + + const voiceBundleTtsProvider = agentVoiceBundle?.tts_provider + ? (typeof agentVoiceBundle.tts_provider === 'string' ? agentVoiceBundle.tts_provider : String(agentVoiceBundle.tts_provider)).toLowerCase() + : null + + const allPersonas = personas.filter((p: any) => !DEFAULT_PERSONA_NAMES.includes(p.name)) const filteredScenarios = scenarios.filter((s: any) => !DEFAULT_SCENARIO_NAMES.includes(s.name)) + const filteredPersonas = voiceBundleTtsProvider + ? allPersonas.filter((p: any) => p.tts_provider && p.tts_provider.toLowerCase() === voiceBundleTtsProvider) + : allPersonas + + const incompatibleCount = voiceBundleTtsProvider + ? allPersonas.length - filteredPersonas.length + : 0 + const createBulkMutation = useMutation({ mutationFn: (data: { name?: string; agent_id: string; scenario_id: string; persona_ids: string[]; tags?: string[] }) => apiClient.createEvaluatorsBulk(data), @@ -555,7 +577,7 @@ export default function EvaluateTestAgents() { {persona && ( - {persona.language} • {persona.accent} • {persona.gender} + {persona.tts_provider || '--'} • {persona.tts_voice_name || '--'} • {persona.gender} )} @@ -796,7 +818,10 @@ export default function EvaluateTestAgents() { setFormData({ ...formData, gender: 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 appearance-none bg-white pr-8" + > + {genders.map((g) => ( + + ))} + + + +

Auto-set when you pick a voice, but can be overridden.

+ + + ) + + // -- Loading / Error states -- if (isLoading) { return (
@@ -326,11 +493,8 @@ export default function Personas() {

{(error as any)?.response?.data?.detail || (error as any)?.message || 'Failed to load personas'}

-
@@ -341,627 +505,609 @@ export default function Personas() { <>
-
-
-

Test Personas

-

Create and manage personas for testing voice AI agents

-
-
- + {/* Header */} +
+
+

Test Personas

+

+ Create and manage voice personas for testing voice AI agents +

+
+ {activeTab === 'personas' ? ( + + ) : ( + + )}
-
- {personas.length === 0 ? ( -
- -

No personas yet

-

Create your first custom persona to get started

- + {/* Tab Nav */} +
+
- ) : ( -
- {/* User-Created Personas Section */} -
-
-
-
- -

Your Personas

- - {userPersonas.length} - + + {/* ===================== PERSONAS TAB ===================== */} + {activeTab === 'personas' && ( + <> + {personas.length === 0 ? ( +
+ +

No personas yet

+

Create your first voice persona to get started

+ +
+ ) : ( +
+
+ + + + + + + + + + + + {userPersonas.map((persona) => { + const providerInfo = persona.tts_provider ? getProviderInfo(persona.tts_provider) : null + return ( + + + + + + + + ) + })} + +
+ Name + + Provider + + Voice + + Gender + + Actions +
+ {persona.name} + + {persona.tts_provider ? ( +
+ + {providerInfo?.label || persona.tts_provider} +
+ ) : ( + -- + )} +
+
+ {persona.tts_voice_name || '--'} + {persona.is_custom && ( + + Custom + + )} +
+
+ + {persona.gender} + + +
+ + +
+
-

Personas you've created or cloned

-
- {userPersonas.length === 0 ? ( -
- -

No custom personas yet

-

Create your first custom persona to get started

-
) : ( -
- - - - - - - - - - - - - {userPersonas.map((persona) => { - const langConfig = languageConfig[persona.language] || { label: persona.language.toUpperCase(), color: 'text-gray-700', bgColor: 'bg-gray-100' } - const accConfig = accentConfig[persona.accent] || { label: persona.accent, color: 'text-gray-700', bgColor: 'bg-gray-100' } - const noiseInfo = noiseConfig[persona.background_noise] || { label: persona.background_noise, icon: Volume2, color: 'text-gray-700', bgColor: 'bg-gray-100' } - const NoiseIcon = noiseInfo.icon - - return ( - - - - - - - - - ) - })} - -
- Name - - Gender - - Language - - Accent - - Background Noise - - Actions -
- {persona.name} - - - {persona.gender} - - - - - {langConfig.label} - - - - - {accConfig.label} - - - - - {noiseInfo.label} - - -
- - - + + + +
+ + + {cv.description && ( +

{cv.description}

+ )} + +
+
+ Voice ID + + {cv.voice_id} + +
+ +
+ Gender + + {cv.gender || 'Unknown'} + +
+ + {cv.created_at && ( +
+ Added + + {new Date(cv.created_at).toLocaleDateString()} +
-
+ )} +
+
+
+ ) + })}
)} -
-
- )} + + )} - {/* Main Create Persona Modal */} - {showMainModal && renderModal( -
-
-
-

Create Persona

- + {/* ===================== CREATE PERSONA MODAL ===================== */} + {showCreateModal && renderModal( +
+
+
+

Create Persona

+ +
+
+
+ + setFormData({ ...formData, name: 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. Friendly Customer" + /> +
+ {renderVoiceFields()} +
+ + +
+
+
, + )} -
-

Create Custom Persona

-
-
- - setFormData({ ...formData, name: 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="Name of the persona" - /> -
-
- -
- - - - - -
-
-
- -
- - - - - + {/* ===================== EDIT PERSONA MODAL ===================== */} + {showEditModal && selectedPersona && renderModal( +
+
+
+

Edit Persona

+ +
+ +
+ + setFormData({ ...formData, name: 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" + /> +
+ {renderVoiceFields()} +
+ + +
+ +
+
, + )} + + {/* ===================== DELETE MODAL ===================== */} + {showDeleteModal && selectedPersona && renderModal( +
{ setShowDeleteModal(false); setSelectedPersona(null); setDeleteDependencies(null) }}> +
e.stopPropagation()}> +
+

Delete Persona

+ +
+
+ {deleteDependencies && ( +
+
+ +
+

This persona has dependent records

+
    + {deleteDependencies.evaluators &&
  • {deleteDependencies.evaluators} evaluator{deleteDependencies.evaluators !== 1 ? 's' : ''}
  • } + {deleteDependencies.evaluator_results &&
  • {deleteDependencies.evaluator_results} evaluator result{deleteDependencies.evaluator_results !== 1 ? 's' : ''}
  • } + {deleteDependencies.test_conversations &&
  • {deleteDependencies.test_conversations} test conversation{deleteDependencies.test_conversations !== 1 ? 's' : ''}
  • } +
+

Force deleting will remove the persona and all its dependent records.

+
-
- -
- - {genderIcons[formData.gender] || '🧑'} - - - + )} +
+
+
+
-
- - +
+

+ Are you sure you want to delete "{selectedPersona.name}"? +

+

This action cannot be undone.

-
-
+
+ + {deleteDependencies ? ( + - -
- + )} +
-
-
- )} - - {/* Edit Modal */} - {showEditModal && selectedPersona && renderModal( -
-
-
-

Edit Persona

-
-
-
- - setFormData({ ...formData, name: 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" - /> +
, + )} + + {/* ===================== ADD CUSTOM VOICE MODAL ===================== */} + {showCustomVoiceModal && renderModal( +
+
+
+

Add Custom Voice

+
-
- -
- - - - - + +

+ Register a custom voice ID from your TTS provider. Once added, it will appear in the voice selector when creating personas. +

+
+ +
+ {providers.map((p) => { + const isSelected = customVoiceForm.provider === p.id + return ( + + ) + })} +
-
-
- -
- - - - - +
+ + setCustomVoiceForm({ ...customVoiceForm, voice_id: 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="Provider-specific voice identifier" + />
-
-
- -
- - {genderIcons[formData.gender] || '🧑'} - - - +
+ + setCustomVoiceForm({ ...customVoiceForm, name: 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. My Custom Voice" + />
-
-
- - -
-
- - -
- -
-
- )} - - {/* Clone Modal */} - {showCloneModal && selectedPersona && renderModal( -
-
-
-

Clone Persona

- +
+ +
+ + +
+
+
+ +