diff --git a/.github/workflows/backend-tests-postgres.yml b/.github/workflows/backend-tests-postgres.yml new file mode 100644 index 00000000..222c61f8 --- /dev/null +++ b/.github/workflows/backend-tests-postgres.yml @@ -0,0 +1,61 @@ +name: Backend Tests (Postgres) + +on: + pull_request: + branches: + - main + - master + push: + branches: + - main + - master + +jobs: + postgres-test: + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: efficientai_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d efficientai_test" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/efficientai_test + TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/efficientai_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: efficientai_test + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 + REDIS_URL: redis://localhost:6379/0 + CELERY_BROKER_URL: redis://localhost:6379/0 + CELERY_RESULT_BACKEND: redis://localhost:6379/0 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: "pip" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run backend test suite on Postgres + run: make test diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 8a87fafd..863f778c 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -1,11 +1,9 @@ name: Release and Publish Docker Images on: - pull_request_target: - types: [closed] - branches: - - main - - master + workflow_run: + workflows: ["Backend Tests (Postgres)"] + types: [completed] env: REGISTRY: ghcr.io @@ -18,13 +16,16 @@ permissions: pull-requests: read concurrency: - group: release-${{ github.event.pull_request.base.ref }} + group: release-${{ github.event.workflow_run.head_branch || github.ref_name }} cancel-in-progress: false jobs: # ── Step 1: Compute version and create git tag ────────────── release: - if: github.event.pull_request.merged == true + if: > + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} @@ -32,14 +33,32 @@ jobs: next_tag: ${{ steps.version.outputs.next_tag }} steps: - - name: Determine bump type from PR labels + - name: Determine bump type from associated PR labels id: bump uses: actions/github-script@v7 + env: + MERGE_SHA: ${{ github.event.workflow_run.head_sha }} with: script: | - const labels = (context.payload.pull_request.labels || []).map((label) => - label.name.toLowerCase() - ); + const { owner, repo } = context.repo; + const mergeSha = process.env.MERGE_SHA; + let labels = []; + try { + const response = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: mergeSha, + }); + const mergedPR = (response.data || []).find((pr) => pr.merged_at); + if (mergedPR) { + labels = (mergedPR.labels || []).map((label) => label.name.toLowerCase()); + core.info(`Using labels from PR #${mergedPR.number}`); + } else { + core.info("No merged PR associated with this commit. Defaulting to patch."); + } + } catch (err) { + core.warning(`Unable to resolve PR labels for commit ${mergeSha}: ${err.message}`); + } core.info(`PR labels: ${labels.join(", ") || "none"}`); let bump = "patch"; @@ -58,12 +77,13 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 with: + ref: ${{ github.event.workflow_run.head_sha }} fetch-depth: 0 - name: Compute next semantic version id: version env: - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGE_SHA: ${{ github.event.workflow_run.head_sha }} shell: bash run: | set -euo pipefail @@ -130,7 +150,7 @@ jobs: if: steps.version.outputs.create_tag == 'true' env: NEXT_TAG: ${{ steps.version.outputs.next_tag }} - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGE_SHA: ${{ github.event.workflow_run.head_sha }} shell: bash run: | set -euo pipefail @@ -287,7 +307,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NEXT_TAG: ${{ needs.release.outputs.next_tag }} - MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGE_SHA: ${{ github.event.workflow_run.head_sha }} shell: bash run: | set -euo pipefail diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..1bf7d429 --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +.PHONY: help install-dev check-pytest test test-docker-db test-unit test-integration test-phase1 test-file test-k + +PYTHON ?= python +PYTEST ?= $(PYTHON) -m pytest +PYTEST_FLAGS ?= -q +TEST_DB_HOST ?= localhost +TEST_DB_PORT ?= 5432 +TEST_DB_NAME ?= efficientai +TEST_DB_USER ?= efficientai +TEST_DB_PASSWORD ?= password +TEST_DATABASE_URL ?= postgresql://$(TEST_DB_USER):$(TEST_DB_PASSWORD)@$(TEST_DB_HOST):$(TEST_DB_PORT)/$(TEST_DB_NAME) + +help: ## Show available make targets + @echo "Available targets:" + @echo " make install-dev - install project + dev dependencies" + @echo " make test - run all tests under tests/" + @echo " make test-docker-db - run tests against running Docker Compose Postgres" + @echo " make test-unit - run unit tests (marker: unit)" + @echo " make test-integration - run integration tests (marker: integration)" + @echo " make test-phase1 - run current Phase 1 suites" + @echo " make test-file FILE=...- run a specific test file/path" + @echo " make test-k K=... - run tests matching expression" + +install-dev: ## Install project and dev dependencies + $(PYTHON) -m pip install -e ".[dev]" + +check-pytest: + @$(PYTHON) -c "import pytest" >/dev/null 2>&1 || ( \ + echo "pytest is not installed in the current environment."; \ + echo "Run: make install-dev"; \ + echo "or: $(PYTHON) -m pip install pytest pytest-asyncio pytest-cov pytest-mock"; \ + exit 1; \ + ) + +test: check-pytest ## Run the full test suite + $(PYTEST) tests $(PYTEST_FLAGS) $(PYTEST_ARGS) + +test-docker-db: check-pytest ## Run tests against running Docker Compose Postgres + TEST_DATABASE_URL="$(TEST_DATABASE_URL)" DATABASE_URL="$(TEST_DATABASE_URL)" \ + POSTGRES_HOST="$(TEST_DB_HOST)" POSTGRES_PORT="$(TEST_DB_PORT)" POSTGRES_DB="$(TEST_DB_NAME)" \ + POSTGRES_USER="$(TEST_DB_USER)" POSTGRES_PASSWORD="$(TEST_DB_PASSWORD)" \ + $(PYTEST) tests $(PYTEST_FLAGS) $(PYTEST_ARGS) + +test-unit: check-pytest ## Run tests marked as unit + $(PYTEST) -m "unit" tests $(PYTEST_FLAGS) $(PYTEST_ARGS) + +test-integration: check-pytest ## Run tests marked as integration + $(PYTEST) -m "integration" tests $(PYTEST_FLAGS) $(PYTEST_ARGS) + +test-phase1: check-pytest ## Run Phase 1 test suites + $(PYTEST) tests/test_core tests/test_models tests/test_utils tests/test_services/test_helpers $(PYTEST_FLAGS) $(PYTEST_ARGS) + +test-file: check-pytest ## Run one test module/file; usage: make test-file FILE=tests/test_core/test_password.py + @if [ -z "$(FILE)" ]; then echo "FILE is required. Example: make test-file FILE=tests/test_core/test_password.py"; exit 1; fi + $(PYTEST) $(FILE) $(PYTEST_FLAGS) $(PYTEST_ARGS) + +test-k: check-pytest ## Run tests by keyword expression; usage: make test-k K=password + @if [ -z "$(K)" ]; then echo "K is required. Example: make test-k K=password"; exit 1; fi + $(PYTEST) tests -k "$(K)" $(PYTEST_FLAGS) $(PYTEST_ARGS) diff --git a/README.md b/README.md index 827f134f..a3ec6ead 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,43 @@ docker compose up -d - PostgreSQL running (locally or remote) - Redis running (locally or remote) +### Test Commands (Make) + +If you prefer shorthand commands, use the root `Makefile`: + +```bash +# Run all backend tests +make test + +# Run tests against a running Docker Compose Postgres +make test-docker-db + +# Run current Phase 1 suites +make test-phase1 + +# Run only unit or integration tests +make test-unit +make test-integration + +# Run a specific file +make test-file FILE=tests/test_core/test_password.py + +# Run tests by keyword +make test-k K=password +``` + +You can also pass extra pytest args: + +```bash +make test PYTEST_ARGS="-x -vv" +``` + +To override DB connection values for `make test-docker-db`: + +```bash +make test-docker-db TEST_DB_HOST=localhost TEST_DB_PORT=5432 TEST_DB_NAME=efficientai TEST_DB_USER=efficientai TEST_DB_PASSWORD=password +``` + --- ## 💻 CLI Commands diff --git a/app/api/v1/routes/aiproviders.py b/app/api/v1/routes/aiproviders.py index 5d7b6ea4..7637ba5f 100644 --- a/app/api/v1/routes/aiproviders.py +++ b/app/api/v1/routes/aiproviders.py @@ -130,7 +130,7 @@ async def update_aiprovider( status_code=404, detail=f"AI Provider {aiprovider_id} not found" ) - update_data = aiprovider_update.dict(exclude_unset=True) + update_data = aiprovider_update.model_dump(exclude_unset=True) for field, value in update_data.items(): if field == 'api_key' and value: encrypted_api_key = encrypt_api_key(value) @@ -189,8 +189,8 @@ async def test_aiprovider( # TODO: Implement actual API key testing based on provider type # For now, just update the last_tested_at timestamp - from datetime import datetime - aiprovider.last_tested_at = datetime.utcnow() + from datetime import datetime, timezone + aiprovider.last_tested_at = datetime.now(timezone.utc) db.commit() return {"status": "success", "message": "API key test completed"} diff --git a/app/api/v1/routes/alerts.py b/app/api/v1/routes/alerts.py index 0a52e384..be9cf9ce 100644 --- a/app/api/v1/routes/alerts.py +++ b/app/api/v1/routes/alerts.py @@ -6,7 +6,7 @@ from sqlalchemy import and_ from uuid import UUID from typing import List, Optional -from datetime import datetime +from datetime import datetime, timezone from app.database import get_db from app.dependencies import get_organization_id @@ -354,7 +354,7 @@ def test_alert_notification( raise HTTPException(status_code=404, detail="Alert not found") results = [] - now = datetime.utcnow() + now = datetime.now(timezone.utc) common_params = dict( alert_name=f"[TEST] {alert.name}", @@ -531,12 +531,12 @@ def update_alert_history( # Set timestamps based on status transition if new_status == AlertHistoryStatus.ACKNOWLEDGED.value and history.acknowledged_at is None: - history.acknowledged_at = datetime.utcnow() + history.acknowledged_at = datetime.now(timezone.utc) if update_data.acknowledged_by: history.acknowledged_by = update_data.acknowledged_by if new_status == AlertHistoryStatus.RESOLVED.value and history.resolved_at is None: - history.resolved_at = datetime.utcnow() + history.resolved_at = datetime.now(timezone.utc) if update_data.resolved_by: history.resolved_by = update_data.resolved_by if update_data.resolution_notes: 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/iam.py b/app/api/v1/routes/iam.py index 0fe845e3..e500db00 100644 --- a/app/api/v1/routes/iam.py +++ b/app/api/v1/routes/iam.py @@ -23,6 +23,13 @@ router = APIRouter(prefix="/iam", tags=["IAM"]) +def _to_aware_utc(dt: datetime) -> datetime: + """Normalize datetimes to timezone-aware UTC for safe comparisons.""" + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + def get_user_from_api_key(api_key: str, db: Session) -> User: """ Get user associated with API key. @@ -201,7 +208,7 @@ async def invite_user( if existing_invitation: # Check if expired - if existing_invitation.expires_at < datetime.now(timezone.utc): + if _to_aware_utc(existing_invitation.expires_at) < datetime.now(timezone.utc): existing_invitation.status = InvitationStatus.EXPIRED db.commit() else: @@ -264,7 +271,7 @@ async def list_invitations( result = [] for invitation in invitations: # Check if expired - if invitation.status == InvitationStatus.PENDING and invitation.expires_at < datetime.now(timezone.utc): + if invitation.status == InvitationStatus.PENDING and _to_aware_utc(invitation.expires_at) < datetime.now(timezone.utc): invitation.status = InvitationStatus.EXPIRED db.commit() diff --git a/app/api/v1/routes/manual_evaluations.py b/app/api/v1/routes/manual_evaluations.py index 8d2ccdef..4811114e 100644 --- a/app/api/v1/routes/manual_evaluations.py +++ b/app/api/v1/routes/manual_evaluations.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from typing import List, Optional from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from app.database import get_db from app.dependencies import get_api_key, get_organization_id @@ -40,8 +40,7 @@ class TranscriptionResponse(BaseModel): created_at: str updated_at: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class PresignedUrlResponse(BaseModel): diff --git a/app/api/v1/routes/observability.py b/app/api/v1/routes/observability.py index c4cfeec1..522d6273 100644 --- a/app/api/v1/routes/observability.py +++ b/app/api/v1/routes/observability.py @@ -6,7 +6,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from sqlalchemy.orm import Session from app.dependencies import get_api_key, get_db, get_organization_id @@ -35,9 +35,9 @@ class CallIngestionPayload(BaseModel): recording_url: Optional[str] = None provider_platform: Optional[str] = None - class Config: - extra = "allow" - json_schema_extra = { + model_config = ConfigDict( + extra="allow", + json_schema_extra={ "example": { "id": "0199e72d-795e-7ffe-b9b9-d3b08a3a11ae", "agent_id": 2, @@ -57,7 +57,8 @@ class Config: "endedReason": "customer-hungup", "recording_url": "https://storage.example.com/recordings/call_123.wav", } - } + }, + ) def _serialize_call_recording(call_recording: CallRecording, include_data: bool = False) -> Dict[str, Any]: diff --git a/app/api/v1/routes/personas.py b/app/api/v1/routes/personas.py index 12cba044..89b38c1f 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, @@ -138,7 +479,7 @@ async def update_persona( if not db_persona: raise HTTPException(status_code=404, detail=f"Persona {persona_id} not found") - update_data = persona_update.dict(exclude_unset=True) + update_data = persona_update.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(db_persona, field, value) @@ -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/profile.py b/app/api/v1/routes/profile.py index 27e75e12..c1711ea4 100644 --- a/app/api/v1/routes/profile.py +++ b/app/api/v1/routes/profile.py @@ -7,7 +7,7 @@ from typing import List, Optional from uuid import UUID from datetime import datetime, timezone -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict from app.dependencies import get_db, get_api_key, get_organization_id from app.models.database import ( @@ -29,8 +29,7 @@ class UserPreferencesResponse(BaseModel): default_agent_id: Optional[UUID] = None default_agent: Optional[dict] = None # Include agent details if set - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UserPreferencesUpdate(BaseModel): diff --git a/app/api/v1/routes/prompt_optimization.py b/app/api/v1/routes/prompt_optimization.py index 24bda345..3fc9f5ad 100644 --- a/app/api/v1/routes/prompt_optimization.py +++ b/app/api/v1/routes/prompt_optimization.py @@ -10,7 +10,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session from loguru import logger @@ -67,8 +67,7 @@ class OptimizationRunResponse(BaseModel): created_at: Optional[datetime] = None updated_at: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CandidateResponse(BaseModel): @@ -83,8 +82,7 @@ class CandidateResponse(BaseModel): pushed_to_provider_at: Optional[datetime] = None created_at: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # --------------------------------------------------------------------------- diff --git a/app/api/v1/routes/results.py b/app/api/v1/routes/results.py index 8caecc31..50d8a6c9 100644 --- a/app/api/v1/routes/results.py +++ b/app/api/v1/routes/results.py @@ -176,10 +176,12 @@ def compare_evaluations( """ evaluation_results = [] - for eval_id_str in comparison_data.evaluation_ids: + for eval_id_input in comparison_data.evaluation_ids: try: - eval_id = UUID(eval_id_str) - except ValueError: + # ComparisonRequest already validates UUIDs, but keep this branch for + # defensive compatibility if non-UUID inputs reach this code path. + eval_id = eval_id_input if isinstance(eval_id_input, UUID) else UUID(eval_id_input) + except (ValueError, TypeError): continue # Verify evaluation belongs to organization diff --git a/app/api/v1/routes/scenarios.py b/app/api/v1/routes/scenarios.py index cce91b7f..d1232f45 100644 --- a/app/api/v1/routes/scenarios.py +++ b/app/api/v1/routes/scenarios.py @@ -98,7 +98,7 @@ async def update_scenario( if not linked_agent: raise HTTPException(status_code=404, detail=f"Agent {scenario_update.agent_id} not found") - update_data = scenario_update.dict(exclude_unset=True) + update_data = scenario_update.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(db_scenario, field, value) diff --git a/app/api/v1/routes/test_agents.py b/app/api/v1/routes/test_agents.py index 5c750623..b6cb0e92 100644 --- a/app/api/v1/routes/test_agents.py +++ b/app/api/v1/routes/test_agents.py @@ -234,7 +234,7 @@ async def update_conversation( if not conversation: raise HTTPException(status_code=404, detail="Conversation not found") - update_data = conversation_update.dict(exclude_unset=True) + update_data = conversation_update.model_dump(exclude_unset=True) for field, value in update_data.items(): setattr(conversation, field, value) 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/api/v1/routes/voicebundles.py b/app/api/v1/routes/voicebundles.py index ebc74164..14fa1fdd 100644 --- a/app/api/v1/routes/voicebundles.py +++ b/app/api/v1/routes/voicebundles.py @@ -108,7 +108,7 @@ async def update_voicebundle( status_code=404, detail=f"VoiceBundle {voicebundle_id} not found" ) - update_data = voicebundle_update.dict(exclude_unset=True) + update_data = voicebundle_update.model_dump(exclude_unset=True) for field, value in update_data.items(): # Convert bundle_type enum to string value if present if field == 'bundle_type' and value is not None: diff --git a/app/core/password.py b/app/core/password.py index 78b6b1ef..ececcea7 100644 --- a/app/core/password.py +++ b/app/core/password.py @@ -1,8 +1,6 @@ """Password hashing utilities.""" -from passlib.context import CryptContext - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +import bcrypt def hash_password(password: str) -> str: @@ -15,7 +13,8 @@ def hash_password(password: str) -> str: Returns: Hashed password """ - return pwd_context.hash(password) + hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()) + return hashed.decode("utf-8") def verify_password(plain_password: str, hashed_password: str) -> bool: @@ -29,5 +28,8 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: Returns: True if password matches, False otherwise """ - return pwd_context.verify(plain_password, hashed_password) + try: + return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) + except ValueError: + return False diff --git a/app/database.py b/app/database.py index e990d212..8fda0d0b 100644 --- a/app/database.py +++ b/app/database.py @@ -1,8 +1,7 @@ """Database connection and session management.""" from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import declarative_base, sessionmaker from app.config import settings # Create database engine 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..8285eb31 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, Field, field_validator, validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing import Optional, List, Dict, Any from datetime import datetime from uuid import UUID @@ -41,8 +41,7 @@ class AudioFileResponse(AudioFileBase): channels: Optional[int] = None uploaded_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Evaluation Schemas @@ -57,7 +56,8 @@ class EvaluationCreate(BaseModel): default=["wer", "latency"], description="Metrics to calculate" ) - @validator("metrics") + @field_validator("metrics") + @classmethod def validate_metrics(cls, v): """Validate metrics list.""" allowed_metrics = ["wer", "cer", "latency", "quality_score", "rtf"] @@ -83,8 +83,7 @@ class EvaluationResponse(BaseModel): completed_at: Optional[datetime] = None error_message: Optional[str] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class EvaluationStatusResponse(BaseModel): @@ -109,8 +108,7 @@ class EvaluationResultResponse(BaseModel): model_used: Optional[str] = None created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class MetricsResponse(BaseModel): @@ -125,7 +123,7 @@ class MetricsResponse(BaseModel): class ComparisonRequest(BaseModel): """Schema for comparing multiple evaluations.""" - evaluation_ids: List[UUID] = Field(..., min_items=2, description="At least 2 evaluation IDs to compare") + evaluation_ids: List[UUID] = Field(..., min_length=2, description="At least 2 evaluation IDs to compare") class ComparisonResponse(BaseModel): @@ -151,8 +149,7 @@ class APIKeyResponse(BaseModel): is_active: bool created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Generic Response Schemas @@ -211,8 +208,7 @@ def validate_phone_number(self): raise ValueError('phone_number is required when call_medium is phone_call') return self - class Config: - json_schema_extra = { + model_config = ConfigDict(json_schema_extra={ "example": { "name": "Customer Support Bot", "phone_number": "+1234567890", @@ -223,7 +219,7 @@ class Config: "voice_ai_integration_id": "123e4567-e89b-12d3-a456-426614174001", "voice_ai_agent_id": "agent_abc123" } - } + }) class AgentUpdate(BaseModel): @@ -280,7 +276,8 @@ class AgentResponse(BaseModel): created_at: datetime updated_at: datetime - @validator('language', pre=True) + @field_validator('language', mode='before') + @classmethod def convert_language(cls, v): """Convert string to LanguageEnum (handles uppercase DB values like ENGLISH -> en).""" if v is None: @@ -301,7 +298,8 @@ def convert_language(cls, v): raise ValueError(f"Invalid LanguageEnum value: {v}") return v - @validator('call_type', pre=True) + @field_validator('call_type', mode='before') + @classmethod def convert_call_type(cls, v): """Convert string to CallTypeEnum (handles uppercase DB values).""" if v is None: @@ -317,7 +315,8 @@ def convert_call_type(cls, v): raise ValueError(f"Invalid CallTypeEnum value: {v}") return v - @validator('call_medium', pre=True) + @field_validator('call_medium', mode='before') + @classmethod def convert_call_medium(cls, v): """Convert string to CallMediumEnum (handles uppercase DB values).""" if v is None: @@ -333,110 +332,54 @@ def convert_call_medium(cls, v): raise ValueError(f"Invalid CallMediumEnum value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # 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) + @field_validator('gender', mode='before') + @classmethod 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: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class PersonaCloneRequest(BaseModel): @@ -471,8 +414,7 @@ class ScenarioResponse(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================ @@ -505,8 +447,7 @@ class UserResponse(BaseModel): is_active: bool created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class OrganizationMemberResponse(BaseModel): @@ -518,8 +459,7 @@ class OrganizationMemberResponse(BaseModel): joined_at: datetime user: UserResponse # Include user details - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Invitation Schemas @@ -540,7 +480,8 @@ class InvitationResponse(BaseModel): created_at: datetime organization_name: Optional[str] = None # Include organization name - @validator('role', pre=True) + @field_validator('role', mode='before') + @classmethod def convert_role(cls, v): """Convert string to RoleEnum (handles uppercase DB values).""" if v is None: @@ -556,7 +497,8 @@ def convert_role(cls, v): raise ValueError(f"Invalid RoleEnum value: {v}") return v - @validator('status', pre=True) + @field_validator('status', mode='before') + @classmethod def convert_status(cls, v): """Convert string to InvitationStatus (handles uppercase DB values).""" if v is None: @@ -572,8 +514,7 @@ def convert_status(cls, v): raise ValueError(f"Invalid InvitationStatus value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class InvitationUpdate(BaseModel): @@ -597,8 +538,7 @@ class ProfileResponse(BaseModel): created_at: datetime organizations: List[dict] = Field(default_factory=list) # List of org memberships - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================ @@ -634,7 +574,8 @@ class IntegrationResponse(BaseModel): last_tested_at: Optional[datetime] = None # Note: api_key is NOT included in response for security - @validator('platform', pre=True) + @field_validator('platform', mode='before') + @classmethod def convert_platform(cls, v): """Convert string to IntegrationPlatform enum if needed (handles uppercase DB values).""" if v is None: @@ -652,8 +593,7 @@ def convert_platform(cls, v): raise ValueError(f"Invalid IntegrationPlatform value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================ @@ -739,7 +679,8 @@ class AIProviderResponse(BaseModel): updated_at: datetime last_tested_at: Optional[datetime] - @validator('provider', pre=True) + @field_validator('provider', mode='before') + @classmethod def convert_provider(cls, v): """Convert string to ModelProvider (handles uppercase DB values).""" if v is None: @@ -755,8 +696,7 @@ def convert_provider(cls, v): raise ValueError(f"Invalid ModelProvider value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # VoiceBundle Schemas @@ -853,7 +793,8 @@ class VoiceBundleResponse(BaseModel): # Bundle type - can be string from DB or enum, validator handles conversion bundle_type: VoiceBundleType - @validator('bundle_type', pre=True) + @field_validator('bundle_type', mode='before') + @classmethod def convert_bundle_type(cls, v): """Convert string to VoiceBundleType enum if needed.""" if isinstance(v, str): @@ -867,7 +808,8 @@ def convert_bundle_type(cls, v): raise ValueError(f"Invalid bundle_type value: {v}") return v - @validator('stt_provider', 'llm_provider', 'tts_provider', 's2s_provider', pre=True) + @field_validator('stt_provider', 'llm_provider', 'tts_provider', 's2s_provider', mode='before') + @classmethod def convert_model_provider(cls, v): """Convert string to ModelProvider enum if needed (handles uppercase DB values).""" if v is None: @@ -914,8 +856,7 @@ def convert_model_provider(cls, v): updated_at: datetime created_by: Optional[str] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Test Agent Conversation Schemas @@ -927,15 +868,14 @@ class TestAgentConversationCreate(BaseModel): voice_bundle_id: UUID conversation_metadata: Optional[Dict[str, Any]] = None - class Config: - json_schema_extra = { + model_config = ConfigDict(json_schema_extra={ "example": { "agent_id": "123e4567-e89b-12d3-a456-426614174000", "persona_id": "123e4567-e89b-12d3-a456-426614174001", "scenario_id": "123e4567-e89b-12d3-a456-426614174002", "voice_bundle_id": "123e4567-e89b-12d3-a456-426614174003" } - } + }) class TestAgentConversationUpdate(BaseModel): @@ -966,8 +906,7 @@ class TestAgentConversationResponse(BaseModel): updated_at: datetime created_by: Optional[str] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ConversationTurn(BaseModel): @@ -986,15 +925,14 @@ class ConversationEvaluationCreate(BaseModel): llm_provider: Optional[ModelProvider] = ModelProvider.OPENAI llm_model: Optional[str] = "gpt-4o" - class Config: - json_schema_extra = { + model_config = ConfigDict(json_schema_extra={ "example": { "transcription_id": "123e4567-e89b-12d3-a456-426614174000", "agent_id": "123e4567-e89b-12d3-a456-426614174001", "llm_provider": "openai", "llm_model": "gpt-4o" } - } + }) class ConversationEvaluationResponse(BaseModel): @@ -1012,8 +950,7 @@ class ConversationEvaluationResponse(BaseModel): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Evaluator Schemas @@ -1058,7 +995,8 @@ class EvaluatorResponse(BaseModel): updated_at: datetime created_by: Optional[str] - @validator('llm_provider', pre=True) + @field_validator('llm_provider', mode='before') + @classmethod def convert_llm_provider(cls, v): """Convert string to ModelProvider (handles uppercase DB values).""" if v is None: @@ -1074,8 +1012,7 @@ def convert_llm_provider(cls, v): raise ValueError(f"Invalid ModelProvider value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class EvaluatorBulkCreate(BaseModel): @@ -1097,11 +1034,9 @@ class RunEvaluatorsResponse(BaseModel): task_ids: List[str] = Field(..., description="List of Celery task IDs for tracking") evaluator_results: List["EvaluatorResultResponse"] = Field(default_factory=list, description="List of created evaluator results") - class Config: - from_attributes = True - - class Config: - json_schema_extra = { + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ "example": { "agent_id": "123e4567-e89b-12d3-a456-426614174000", "scenario_id": "123e4567-e89b-12d3-a456-426614174002", @@ -1111,7 +1046,8 @@ class Config: ], "tags": ["test", "production"] } - } + }, + ) # Metric Schemas @@ -1123,8 +1059,7 @@ class MetricCreate(BaseModel): trigger: MetricTrigger = MetricTrigger.ALWAYS enabled: bool = True - class Config: - json_schema_extra = { + model_config = ConfigDict(json_schema_extra={ "example": { "name": "Professionalism", "description": "Measures the professional tone and behavior", @@ -1132,7 +1067,7 @@ class Config: "trigger": "always", "enabled": True } - } + }) class MetricUpdate(BaseModel): @@ -1158,7 +1093,8 @@ class MetricResponse(BaseModel): updated_at: datetime created_by: Optional[str] - @validator('metric_type', pre=True) + @field_validator('metric_type', mode='before') + @classmethod def convert_metric_type(cls, v): """Convert string to MetricType (handles uppercase DB values).""" if v is None: @@ -1174,7 +1110,8 @@ def convert_metric_type(cls, v): raise ValueError(f"Invalid MetricType value: {v}") return v - @validator('trigger', pre=True) + @field_validator('trigger', mode='before') + @classmethod def convert_trigger(cls, v): """Convert string to MetricTrigger (handles uppercase DB values).""" if v is None: @@ -1190,8 +1127,7 @@ def convert_trigger(cls, v): raise ValueError(f"Invalid MetricTrigger value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # Evaluator Result Schemas @@ -1258,7 +1194,8 @@ class EvaluatorResultResponse(BaseModel): scenario: Optional[ScenarioResponse] = None evaluator: Optional[EvaluatorResponse] = None - @validator('status', pre=True) + @field_validator('status', mode='before') + @classmethod def convert_status(cls, v): """Convert string to EvaluatorResultStatus (handles uppercase DB values).""" if v is None: @@ -1274,8 +1211,7 @@ def convert_status(cls, v): raise ValueError(f"Invalid EvaluatorResultStatus value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================ @@ -1302,8 +1238,7 @@ class AlertCreate(BaseModel): notify_emails: Optional[List[str]] = Field(default=None, description="List of email addresses to notify") notify_webhooks: Optional[List[str]] = Field(default=None, description="List of webhook URLs (Slack, etc.)") - class Config: - json_schema_extra = { + model_config = ConfigDict(json_schema_extra={ "example": { "name": "High Call Volume Alert", "description": "Alert when call volume exceeds threshold", @@ -1317,7 +1252,7 @@ class Config: "notify_emails": ["admin@example.com"], "notify_webhooks": ["https://hooks.slack.com/services/xxx"] } - } + }) class AlertUpdate(BaseModel): @@ -1374,7 +1309,8 @@ class AlertResponse(BaseModel): updated_at: datetime created_by: Optional[str] - @validator('metric_type', pre=True) + @field_validator('metric_type', mode='before') + @classmethod def convert_metric_type(cls, v): """Convert string to AlertMetricType.""" if v is None: @@ -1390,7 +1326,8 @@ def convert_metric_type(cls, v): raise ValueError(f"Invalid AlertMetricType value: {v}") return v - @validator('aggregation', pre=True) + @field_validator('aggregation', mode='before') + @classmethod def convert_aggregation(cls, v): """Convert string to AlertAggregation.""" if v is None: @@ -1406,7 +1343,8 @@ def convert_aggregation(cls, v): raise ValueError(f"Invalid AlertAggregation value: {v}") return v - @validator('operator', pre=True) + @field_validator('operator', mode='before') + @classmethod def convert_operator(cls, v): """Convert string to AlertOperator.""" if v is None: @@ -1421,7 +1359,8 @@ def convert_operator(cls, v): raise ValueError(f"Invalid AlertOperator value: {v}") return v - @validator('notify_frequency', pre=True) + @field_validator('notify_frequency', mode='before') + @classmethod def convert_notify_frequency(cls, v): """Convert string to AlertNotifyFrequency.""" if v is None: @@ -1437,7 +1376,8 @@ def convert_notify_frequency(cls, v): raise ValueError(f"Invalid AlertNotifyFrequency value: {v}") return v - @validator('status', pre=True) + @field_validator('status', mode='before') + @classmethod def convert_status(cls, v): """Convert string to AlertStatus.""" if v is None: @@ -1453,8 +1393,7 @@ def convert_status(cls, v): raise ValueError(f"Invalid AlertStatus value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class AlertHistoryResponse(BaseModel): @@ -1492,7 +1431,8 @@ class AlertHistoryResponse(BaseModel): # Related alert info (optional) alert: Optional[AlertResponse] = None - @validator('status', pre=True) + @field_validator('status', mode='before') + @classmethod def convert_status(cls, v): """Convert string to AlertHistoryStatus.""" if v is None: @@ -1508,8 +1448,7 @@ def convert_status(cls, v): raise ValueError(f"Invalid AlertHistoryStatus value: {v}") return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class AlertHistoryUpdate(BaseModel): @@ -1532,8 +1471,7 @@ class CronJobCreate(BaseModel): max_runs: int = Field(default=10, ge=1, le=1000, description="Maximum number of times to run") evaluator_ids: List[UUID] = Field(..., min_length=1, description="List of evaluator IDs to trigger") - class Config: - json_schema_extra = { + model_config = ConfigDict(json_schema_extra={ "example": { "name": "Daily Evaluation Run", "cron_expression": "0 9 * * 1-5", @@ -1541,7 +1479,7 @@ class Config: "max_runs": 100, "evaluator_ids": ["123e4567-e89b-12d3-a456-426614174000"] } - } + }) class CronJobUpdate(BaseModel): @@ -1571,7 +1509,8 @@ class CronJobResponse(BaseModel): updated_at: datetime created_by: Optional[str] - @validator('status', pre=True) + @field_validator('status', mode='before') + @classmethod def convert_status(cls, v): """Convert string to CronJobStatus.""" if v is None: @@ -1587,7 +1526,8 @@ def convert_status(cls, v): raise ValueError(f"Invalid status: {v}") return v - @validator('evaluator_ids', pre=True) + @field_validator('evaluator_ids', mode='before') + @classmethod def convert_evaluator_ids(cls, v): """Convert evaluator_ids from JSON to list of UUIDs.""" if v is None: @@ -1596,8 +1536,7 @@ def convert_evaluator_ids(cls, v): return [UUID(str(id)) if not isinstance(id, UUID) else id for id in v] return v - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================ @@ -1631,8 +1570,7 @@ class PromptPartialVersionResponse(BaseModel): created_at: datetime created_by: Optional[str] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class PromptPartialResponse(BaseModel): @@ -1648,13 +1586,11 @@ class PromptPartialResponse(BaseModel): updated_at: datetime created_by: Optional[str] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class PromptPartialDetailResponse(PromptPartialResponse): """Schema for prompt partial detail with versions.""" versions: List[PromptPartialVersionResponse] = [] - class Config: - from_attributes = True \ No newline at end of file + model_config = ConfigDict(from_attributes=True) \ No newline at end of file diff --git a/app/services/evaluation/evaluation_service.py b/app/services/evaluation/evaluation_service.py index 8c5fe5eb..c5a07ffb 100644 --- a/app/services/evaluation/evaluation_service.py +++ b/app/services/evaluation/evaluation_service.py @@ -1,7 +1,7 @@ """Core evaluation service for processing audio evaluations.""" import time -from datetime import datetime +from datetime import datetime, UTC from typing import Optional, Dict, Any from uuid import UUID from sqlalchemy.orm import Session @@ -94,7 +94,7 @@ def process_evaluation( # Update status to processing evaluation.status = EvaluationStatus.PROCESSING - evaluation.started_at = datetime.utcnow() + evaluation.started_at = datetime.now(UTC) db.commit() try: @@ -132,7 +132,7 @@ def process_evaluation( # Update evaluation status evaluation.status = EvaluationStatus.COMPLETED - evaluation.completed_at = datetime.utcnow() + evaluation.completed_at = datetime.now(UTC) db.commit() return { @@ -148,7 +148,7 @@ def process_evaluation( # Update status to failed evaluation.status = EvaluationStatus.FAILED evaluation.error_message = str(e) - evaluation.completed_at = datetime.utcnow() + evaluation.completed_at = datetime.now(UTC) db.commit() raise 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

- +
+ +
+ + +
+
+
+ +