From a7949b528559fff581ffaf8213992e855d6a979c Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Tue, 17 Mar 2026 19:48:22 +0000 Subject: [PATCH 1/7] feat: updating lib lazy loading --- .../voice_playground_report_service.py | 43 +++++++++++++ app/workers/tasks/tts_comparison.py | 62 +++++++++++++------ docker/Dockerfile.worker | 16 ++++- 3 files changed, 99 insertions(+), 22 deletions(-) diff --git a/app/services/reporting/voice_playground_report_service.py b/app/services/reporting/voice_playground_report_service.py index cc4ac0ef..f815e6df 100644 --- a/app/services/reporting/voice_playground_report_service.py +++ b/app/services/reporting/voice_playground_report_service.py @@ -1155,14 +1155,57 @@ def _threshold_to_pct(raw_threshold: Any) -> float | None: "report_options": normalized_options, } + _weasyprint_deps_checked = False + + @staticmethod + def _ensure_weasyprint_system_deps(): + """One-shot check/install of system libraries required by WeasyPrint.""" + if VoicePlaygroundReportService._weasyprint_deps_checked: + return + VoicePlaygroundReportService._weasyprint_deps_checked = True + + import ctypes.util + if ctypes.util.find_library("gobject-2.0"): + return + + import shutil, subprocess, os + if shutil.which("apt-get") and os.geteuid() == 0: + from loguru import logger + logger.info("[PDF] WeasyPrint system libs missing – installing via apt-get...") + try: + subprocess.check_call( + ["apt-get", "update", "-qq"], + timeout=120, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + subprocess.check_call( + [ + "apt-get", "install", "-y", "-qq", + "libgobject-2.0-0", "libpango-1.0-0", "libpangocairo-1.0-0", + "libcairo2", "libgdk-pixbuf-2.0-0", "libffi-dev", "shared-mime-info", + ], + timeout=120, + ) + logger.info("[PDF] WeasyPrint system libs installed successfully") + except Exception as e: + logger.warning(f"[PDF] Auto-install of system libs failed: {e}") + def render_pdf(self, payload: dict[str, Any]) -> bytes: """Render report payload to PDF bytes.""" + self._ensure_weasyprint_system_deps() + try: from weasyprint import HTML except ImportError as exc: raise RuntimeError( "PDF generation requires weasyprint. Install dependencies and retry." ) from exc + except OSError as exc: + raise RuntimeError( + f"WeasyPrint system libraries missing: {exc}. " + "Install them with: apt-get install -y libgobject-2.0-0 libpango-1.0-0 " + "libpangocairo-1.0-0 libcairo2 libgdk-pixbuf-2.0-0 libffi-dev shared-mime-info" + ) from exc template = self._jinja_env.get_template("reports/voice_playground_report.html") html_content = template.render(**payload, service=self) diff --git a/app/workers/tasks/tts_comparison.py b/app/workers/tasks/tts_comparison.py index b028db26..0baa1690 100644 --- a/app/workers/tasks/tts_comparison.py +++ b/app/workers/tasks/tts_comparison.py @@ -120,11 +120,35 @@ def _normalize_entities(text: str) -> str: } +_nemo_install_attempted = False + + +def _lazy_install_nemo(): + """One-shot attempt to pip-install nemo_toolkit[asr] at runtime.""" + global _nemo_install_attempted + if _nemo_install_attempted: + return False + _nemo_install_attempted = True + + logger.info("[TTS Eval] NeMo not found – attempting auto-install (this may take a few minutes)...") + try: + import subprocess, sys + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--quiet", "nemo_toolkit[asr]>=1.20.0"], + timeout=600, + ) + logger.info("[TTS Eval] nemo_toolkit[asr] installed successfully") + return True + except Exception as install_err: + logger.warning(f"[TTS Eval] Auto-install of nemo_toolkit[asr] failed: {install_err}") + return False + + def _get_nemo_asr_model(): """Lazy-load NVIDIA NeMo Conformer CTC model for hallucination detection. - Requires: pip install efficientai[nemo-asr] - Returns the model instance, or None if NeMo is not installed. + On first call, if NeMo is not installed, attempts a one-time pip install. + Returns the model instance, or None if unavailable. """ global _nemo_asr_model @@ -133,30 +157,28 @@ def _get_nemo_asr_model(): try: import nemo.collections.asr as nemo_asr + except ImportError: + if _lazy_install_nemo(): + try: + import nemo.collections.asr as nemo_asr + except ImportError: + logger.warning("[TTS Eval] NeMo still not importable after install – WER/CER will be skipped") + return None + else: + logger.warning( + "[TTS Eval] NeMo is not installed – WER/CER hallucination metrics will be skipped. " + "To install manually: pip install 'nemo_toolkit[asr]'" + ) + return None + try: logger.info("[TTS Eval] Loading NeMo ASR model (stt_en_conformer_ctc_large)...") _nemo_asr_model = nemo_asr.models.ASRModel.from_pretrained("stt_en_conformer_ctc_large") logger.info("[TTS Eval] NeMo ASR model loaded successfully") return _nemo_asr_model - except ImportError as e: - logger.warning( - f"[TTS Eval] NeMo import failed: {e} – " - "WER/CER hallucination metrics will be skipped. " - "To enable, run:\n" - " pip install 'nemo_toolkit[asr]'\n" - " python -c \"import nemo.collections.asr as nemo_asr; " - "nemo_asr.models.ASRModel.from_pretrained('stt_en_conformer_ctc_large')\"" - ) except Exception as e: - logger.error( - f"[TTS Eval] NeMo ASR model failed to load: {e} – " - "The model may not be cached yet. To download it manually, run:\n" - " python -c \"import nemo.collections.asr as nemo_asr; " - "nemo_asr.models.ASRModel.from_pretrained('stt_en_conformer_ctc_large')\"", - exc_info=True, - ) - - return None + logger.error(f"[TTS Eval] NeMo ASR model failed to load: {e}", exc_info=True) + return None def _transcribe_audio_for_eval(audio_path: str) -> str | None: diff --git a/docker/Dockerfile.worker b/docker/Dockerfile.worker index 99292b81..1d582edf 100644 --- a/docker/Dockerfile.worker +++ b/docker/Dockerfile.worker @@ -4,12 +4,21 @@ FROM python:3.11-slim ARG INSTALL_EXTRAS="qualitative-voice,reports" ARG TORCH_CPU_ONLY="true" -# Install system dependencies (git needed for torch.hub clone, build-essential + -# cmake needed for packages like praat-parselmouth on arm64/Apple Silicon) +# Install system dependencies: +# - git: needed for torch.hub clone +# - build-essential + cmake: needed for packages like praat-parselmouth on arm64 +# - libgobject/libpango/libcairo/libgdk-pixbuf/libffi/shared-mime-info: WeasyPrint PDF rendering RUN apt-get update && apt-get install -y \ git \ build-essential \ cmake \ + libgobject-2.0-0 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libcairo2 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + shared-mime-info \ && rm -rf /var/lib/apt/lists/* # Install uv @@ -67,6 +76,9 @@ RUN mkdir -p /app/uploads # Notes: # - CPU-only PyTorch is installed by default (TORCH_CPU_ONLY=true), saving ~1.5GB # Set TORCH_CPU_ONLY=false if you need GPU support +# - NeMo ASR (WER/CER hallucination metrics) is NOT included by default (~2GB). +# It will be auto-installed on first use, or you can bake it in at build time: +# docker compose build --build-arg INSTALL_EXTRAS="qualitative-voice,reports,nemo-asr" worker # - ML models (emotion classifier, valence/arousal, UTMOS, NeMo ASR) are # downloaded lazily on first use, keeping the image smaller # - For persistent model caching across container restarts, mount volumes: From 485cc803770e1e185e98aaa92b46fe732e8f81d5 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Tue, 17 Mar 2026 20:09:41 +0000 Subject: [PATCH 2/7] feat: fixing voice maker and voice lib --- app/services/voice_agent/voice_bundle.py | 64 ++++++-- .../services/voicemaker/__init__.py | 3 + src/efficientai/services/voicemaker/tts.py | 148 ++++++++++++++++++ 3 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 src/efficientai/services/voicemaker/tts.py diff --git a/app/services/voice_agent/voice_bundle.py b/app/services/voice_agent/voice_bundle.py index 094ec72f..378a0295 100644 --- a/app/services/voice_agent/voice_bundle.py +++ b/app/services/voice_agent/voice_bundle.py @@ -132,6 +132,9 @@ def _get_service(service_name: str): elif service_name == "SarvamTTSService": from efficientai.services.sarvam.tts import SarvamTTSService service_class = SarvamTTSService + elif service_name == "VoiceMakerTTSService": + from efficientai.services.voicemaker.tts import VoiceMakerTTSService + service_class = VoiceMakerTTSService # LLM Services elif service_name == "OpenAILLMService": @@ -270,6 +273,16 @@ def _get_tts_providers(): model=model if model else "bulbul:v3", ), }, + "voicemaker": { + "env_key": "VOICEMAKER_API_KEY", + "default_voice": "ai3-Jony", + "default_model": "neural", + "factory": lambda api_key, voice_id, model: _get_service("VoiceMakerTTSService")( + api_key=api_key, + voice_id=voice_id, + model=model if model else "neural", + ), + }, } @@ -448,6 +461,8 @@ async def run_voice_bundle_fastapi( call_start_time = time.time() s3_key_result = None duration_result = None + transcript_text = None + conversation_turns = [] # Storage for audio data from the buffer processor recorded_audio_data = {"audio": None, "sample_rate": None, "num_channels": None} @@ -643,6 +658,34 @@ async def on_client_disconnected(transport, client): # Calculate duration duration_result = time.time() - call_start_time + # Extract conversation transcript from the LLM context + try: + raw_messages = context.messages if hasattr(context, 'messages') else [] + conversation_turns = [] + transcript_parts = [] + elapsed = 0.0 + for msg in raw_messages: + role = msg.get("role", "") + content = msg.get("content", "") + if not content or role == "system": + continue + speaker = "user" if role == "user" else "assistant" + turn_duration = max(1.0, len(content.split()) * 0.4) + conversation_turns.append({ + "speaker": speaker, + "text": content, + "start": round(elapsed, 2), + "end": round(elapsed + turn_duration, 2), + }) + transcript_parts.append(f"{speaker}: {content}") + elapsed += turn_duration + transcript_text = "\n".join(transcript_parts) if transcript_parts else None + logger.info(f"Captured {len(conversation_turns)} conversation turns from live pipeline") + except Exception as ctx_err: + logger.warning(f"Failed to extract conversation context: {ctx_err}") + conversation_turns = [] + transcript_text = None + # Merge captured audio chunks total_input_audio = b''.join(input_audio_chunks) if input_audio_chunks else b'' total_output_audio = b''.join(output_audio_chunks) if output_audio_chunks else b'' @@ -700,26 +743,23 @@ async def on_client_disconnected(transport, client): "agent_id": agent_id, "persona_id": persona_id, "scenario_id": scenario_id, + "transcription": transcript_text, + "speaker_segments": conversation_turns if conversation_turns else None, "error": str(e), } - if s3_key_result: - return { - "s3_key": s3_key_result, - "duration": duration_result, - "agent_id": agent_id, - "persona_id": persona_id, - "scenario_id": scenario_id, - } - - return { - "s3_key": None, + metadata = { + "s3_key": s3_key_result, "duration": duration_result, "agent_id": agent_id, "persona_id": persona_id, "scenario_id": scenario_id, - "error": "No audio file was uploaded", + "transcription": transcript_text, + "speaker_segments": conversation_turns if conversation_turns else None, } + if not s3_key_result and not transcript_text: + metadata["error"] = "No audio file was uploaded and no transcript captured" + return metadata if __name__ == "__main__": diff --git a/src/efficientai/services/voicemaker/__init__.py b/src/efficientai/services/voicemaker/__init__.py index a7427aa9..6d823726 100644 --- a/src/efficientai/services/voicemaker/__init__.py +++ b/src/efficientai/services/voicemaker/__init__.py @@ -6,4 +6,7 @@ def __getattr__(name): if name == "synthesize_voicemaker_bytes": from .http_tts import synthesize_voicemaker_bytes return synthesize_voicemaker_bytes + if name == "VoiceMakerTTSService": + from .tts import VoiceMakerTTSService + return VoiceMakerTTSService raise AttributeError(f"module 'efficientai.services.voicemaker' has no attribute '{name}'") diff --git a/src/efficientai/services/voicemaker/tts.py b/src/efficientai/services/voicemaker/tts.py new file mode 100644 index 00000000..349c9311 --- /dev/null +++ b/src/efficientai/services/voicemaker/tts.py @@ -0,0 +1,148 @@ +# +# VoiceMaker TTS service for Pipecat pipeline integration. +# + +from typing import AsyncGenerator, Optional + +import aiohttp +from loguru import logger +from pydantic import BaseModel + +from efficientai.frames.frames import ( + ErrorFrame, + Frame, + TTSAudioRawFrame, + TTSStartedFrame, + TTSStoppedFrame, +) +from efficientai.services.tts_service import TTSService +from efficientai.utils.tracing.service_decorators import traced_tts + +from .http_tts import _infer_language_code + + +class VoiceMakerTTSService(TTSService): + """VoiceMaker TTS service for real-time voice pipeline use. + + Downloads audio from VoiceMaker's REST API and yields PCM frames + compatible with the Pipecat pipeline. + """ + + class InputParams(BaseModel): + output_format: str = "wav" + sample_rate_hz: int = 24000 + + def __init__( + self, + *, + api_key: str, + voice_id: str = "ai3-Jony", + model: str = "neural", + sample_rate: int = 24000, + params: Optional[InputParams] = None, + **kwargs, + ): + super().__init__(sample_rate=sample_rate, **kwargs) + self._api_key = api_key + self._voice_id = voice_id + self._model = model + self._params = params or VoiceMakerTTSService.InputParams(sample_rate_hz=sample_rate) + self._session: Optional[aiohttp.ClientSession] = None + + def can_generate_metrics(self) -> bool: + return True + + async def _get_session(self) -> aiohttp.ClientSession: + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession() + return self._session + + async def stop(self, frame: Frame): + await super().stop(frame) + if self._session: + await self._session.close() + self._session = None + + async def cancel(self, frame: Frame): + await super().cancel(frame) + if self._session: + await self._session.close() + self._session = None + + @traced_tts + async def run_tts(self, text: str) -> AsyncGenerator[Frame, None]: + logger.debug(f"Generating VoiceMaker TTS: [{text}]") + + language_code = _infer_language_code(self._voice_id) + + payload = { + "VoiceId": self._voice_id, + "Text": text, + "LanguageCode": language_code, + "OutputFormat": "wav", + "SampleRate": str(self.sample_rate), + "ResponseType": "file", + } + if self._model: + engine = self._model + if engine.lower().startswith("voicemaker-"): + engine = engine[len("voicemaker-"):] + payload["Engine"] = engine + + headers = { + "Authorization": f"Bearer {self._api_key}", + "Content-Type": "application/json", + } + + try: + session = await self._get_session() + await self.start_ttfb_metrics() + + async with session.post( + "https://developer.voicemaker.in/api/v1/voice/convert", + json=payload, + headers=headers, + ) as response: + if response.status != 200: + error_text = await response.text() + logger.error(f"VoiceMaker TTS error: {response.status} - {error_text[:300]}") + yield ErrorFrame(error=f"VoiceMaker TTS error: {error_text[:300]}") + return + + data = await response.json() + audio_url = data.get("path") + if not audio_url: + yield ErrorFrame(error="VoiceMaker returned no audio path") + return + + await self.start_tts_usage_metrics(text) + yield TTSStartedFrame() + await self.stop_ttfb_metrics() + + async with session.get(audio_url) as audio_resp: + if audio_resp.status != 200: + yield ErrorFrame(error=f"VoiceMaker audio download failed: {audio_resp.status}") + return + + audio_bytes = await audio_resp.read() + + # Strip WAV header (44 bytes) to get raw PCM + pcm_data = audio_bytes[44:] if len(audio_bytes) > 44 else audio_bytes + + # Yield in chunks for smooth pipeline flow + chunk_size = 4096 + for i in range(0, len(pcm_data), chunk_size): + chunk = pcm_data[i : i + chunk_size] + if chunk: + yield TTSAudioRawFrame( + audio=chunk, + sample_rate=self.sample_rate, + num_channels=1, + ) + + yield TTSStoppedFrame() + + except Exception as e: + logger.error(f"VoiceMaker TTS exception: {e}") + yield ErrorFrame(error=f"VoiceMaker TTS exception: {str(e)}") + yield TTSStoppedFrame() From 61201b527e264020d97f185fbdc04b76eb3b9ebf Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Tue, 17 Mar 2026 21:21:00 +0000 Subject: [PATCH 3/7] feat: updating migrations start --- app/cli.py | 16 ++++++++++------ app/database.py | 5 +++++ .../001_add_default_agent_to_org_member.py | 9 +++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/app/cli.py b/app/cli.py index 1f71f3a8..538a264c 100644 --- a/app/cli.py +++ b/app/cli.py @@ -265,14 +265,16 @@ def start(config: str, host: Optional[str], port: Optional[int], build_frontend: click.echo("❌ npm not found. Please install Node.js and npm.", err=True) sys.exit(1) - # Run migrations before starting (unless explicitly skipped) + # Initialize DB tables and run migrations before starting (unless explicitly skipped) if not skip_migrations: - click.echo("🔄 Running database migrations before startup...") + click.echo("🔄 Initializing database and running migrations...") + from app.database import init_db from app.core.migrations import run_migrations, ensure_migrations_directory try: + init_db() ensure_migrations_directory() run_migrations() - click.echo("✅ Migrations completed successfully") + click.echo("✅ Database initialized and migrations completed") except Exception as e: click.echo(f"❌ Migration failed: {e}", err=True) click.echo("💡 You can skip migrations with --skip-migrations (not recommended)", err=True) @@ -472,14 +474,16 @@ def signal_handler(sig, frame): signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - # Run migrations before starting (unless explicitly skipped) + # Initialize DB tables and run migrations before starting (unless explicitly skipped) if not skip_migrations: - click.echo("🔄 Running database migrations before startup...") + click.echo("🔄 Initializing database and running migrations...") + from app.database import init_db from app.core.migrations import run_migrations, ensure_migrations_directory try: + init_db() ensure_migrations_directory() run_migrations() - click.echo("✅ Migrations completed successfully") + click.echo("✅ Database initialized and migrations completed") except Exception as e: click.echo(f"❌ Migration failed: {e}", err=True) click.echo("💡 You can skip migrations with --skip-migrations (not recommended)", err=True) diff --git a/app/database.py b/app/database.py index af8fdae0..e990d212 100644 --- a/app/database.py +++ b/app/database.py @@ -37,6 +37,11 @@ def get_db(): def init_db(): """Initialize database by creating all tables and run column migrations.""" + # Import all models so SQLAlchemy registers them with Base.metadata + # before create_all is called. Without this, tables won't be created + # on a fresh database. + import app.models.database # noqa: F401 + Base.metadata.create_all(bind=engine) _run_column_migrations() diff --git a/app/migrations/001_add_default_agent_to_org_member.py b/app/migrations/001_add_default_agent_to_org_member.py index 49a562dd..face5050 100644 --- a/app/migrations/001_add_default_agent_to_org_member.py +++ b/app/migrations/001_add_default_agent_to_org_member.py @@ -14,6 +14,15 @@ def upgrade(db: Session): """Add default_agent_id column with foreign key to agents table.""" + # Check if the table exists at all (on fresh DBs, create_all handles this) + table_check = db.execute(text(""" + SELECT 1 FROM information_schema.tables + WHERE table_name = 'organization_members' + """)) + if table_check.fetchone() is None: + print("Table organization_members does not exist yet (fresh DB, handled by create_all), skipping...") + return + # Check if column already exists result = db.execute(text(""" SELECT column_name From e3718afd5ae9bed1008b3b521f3c7d75c5dd8f2b Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Wed, 18 Mar 2026 08:54:20 +0000 Subject: [PATCH 4/7] fix: encryption key --- config.docker.worker.yml | 67 ++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 32 +++++++++++++++---- 2 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 config.docker.worker.yml diff --git a/config.docker.worker.yml b/config.docker.worker.yml new file mode 100644 index 00000000..c1e5da35 --- /dev/null +++ b/config.docker.worker.yml @@ -0,0 +1,67 @@ +# EfficientAI Docker Worker Configuration File +# The worker uses network_mode: host for WebRTC, so it must connect via +# "localhost" instead of Docker service names ("db", "redis"). + +# Application Settings +app: + name: "EfficientAI Voice AI Evaluation Platform" + version: "0.1.0" + debug: true + secret_key: "your-secret-key-here-change-in-production" + +# Server Settings +server: + host: "0.0.0.0" + port: 8000 + +# Database Configuration (localhost — worker uses host networking) +database: + url: "postgresql://efficientai:password@localhost:5432/efficientai" + +# Redis Configuration (localhost — worker uses host networking) +redis: + url: "redis://localhost:6379/0" + +# Celery Configuration (localhost — worker uses host networking) +celery: + broker_url: "redis://localhost:6379/0" + result_backend: "redis://localhost:6379/0" + +# File Storage +storage: + upload_dir: "/app/uploads" + max_file_size_mb: 500 + allowed_audio_formats: + - "wav" + - "mp3" + - "flac" + - "m4a" + +# S3 Configuration (for data sources integration) +# Copy your S3 settings from config.yml here +s3: + enabled: false + bucket_name: "your-s3-bucket-name" + region: "us-east-1" + access_key_id: "your-access-key-id" + secret_access_key: "your-secret-access-key" + endpoint_url: null # Optional: for S3-compatible services (e.g., MinIO, DigitalOcean Spaces) + prefix: "audio/" # Prefix for audio files in bucket + +# Speaker Diarization (pyannote.audio) +# Requires accepting model terms at https://huggingface.co/pyannote/speaker-diarization-3.1 +diarization: + huggingface_token: # Set your HuggingFace token here (e.g., "hf_xxxxx") + num_speakers: 2 # Force exact speaker count (2 = agent + customer). Set to null for auto-detect. + +# CORS Settings +cors: + origins: + - "http://localhost:3000" + - "http://localhost:8000" + +# API Settings +api: + prefix: "/api/v1" + key_header: "X-API-Key" + rate_limit_per_minute: 60 diff --git a/docker-compose.yml b/docker-compose.yml index b440d020..8dc802bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} volumes: - ./uploads:/app/uploads - - ./.encryption_key:/app/.encryption_key:ro + - encryption_key:/app/encryption_key_data - ./config.docker.yml:/app/config.yml:ro ports: - "8000:8000" @@ -65,7 +65,15 @@ services: condition: service_healthy redis: condition: service_healthy - command: eai start --config /app/config.yml --host 0.0.0.0 --port 8000 --no-build-frontend --no-reload + command: > + sh -c ' + if [ ! -f /app/encryption_key_data/.encryption_key ]; then + python3 -c "from cryptography.fernet import Fernet; open(\"/app/encryption_key_data/.encryption_key\", \"wb\").write(Fernet.generate_key())"; + echo "Generated new encryption key"; + fi && + ln -sf /app/encryption_key_data/.encryption_key /app/.encryption_key && + exec eai start --config /app/config.yml --host 0.0.0.0 --port 8000 --no-build-frontend --no-reload + ' worker: # Pre-built image from GitHub Container Registry @@ -78,15 +86,27 @@ services: # args: # INSTALL_EXTRAS: "qualitative-voice,reports" # container_name: efficientai_worker - # Use host network mode for WebRTC connectivity (Retell/Vapi calls) + # Use host network mode for WebRTC connectivity (Retell/Vapi calls). + # NOTE: host networking means Docker DNS is unavailable, so connection URLs + # must use "localhost" instead of Docker service names like "db" / "redis". network_mode: host environment: ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy volumes: - ./uploads:/app/uploads - - ./.encryption_key:/app/.encryption_key:ro - - ./config.yml:/app/config.yml:ro - command: eai worker --config /app/config.yml --loglevel info + - encryption_key:/app/encryption_key_data + - ./config.docker.worker.yml:/app/config.yml:ro + command: > + sh -c ' + ln -sf /app/encryption_key_data/.encryption_key /app/.encryption_key && + exec eai worker --config /app/config.yml --loglevel info + ' volumes: postgres_data: + encryption_key: From 4ebc25aa5d3c329cca5e0177fb1671e026f8d611 Mon Sep 17 00:00:00 2001 From: Tejas Narayan Date: Wed, 18 Mar 2026 18:16:32 +0000 Subject: [PATCH 5/7] feat: updating some more changes --- .gitignore | 4 +- app/core/encryption.py | 125 ++++++++++-------- app/services/voice_providers/retell.py | 29 ++-- app/services/voice_providers/vapi.py | 18 ++- config.docker.worker.yml | 67 ---------- docker-compose.yml | 47 +++---- .../playground/agent/AgentPlayground.tsx | 32 ++++- 7 files changed, 139 insertions(+), 183 deletions(-) delete mode 100644 config.docker.worker.yml diff --git a/.gitignore b/.gitignore index 7671fe7e..6fa1bb67 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,6 @@ env/ ENV/ .venv config.yml -config.docker.yml # uv .python-version uv.lock @@ -45,8 +44,9 @@ uv.lock .env .env.local -# Encryption key file (should never be committed) +# Encryption key file / data directory (should never be committed) .encryption_key +.data/ # Docker .dockerignore diff --git a/app/core/encryption.py b/app/core/encryption.py index b45815f2..87c0cf34 100644 --- a/app/core/encryption.py +++ b/app/core/encryption.py @@ -1,86 +1,96 @@ """Encryption utilities for sensitive data like API keys.""" +import logging import os from pathlib import Path from cryptography.fernet import Fernet +logger = logging.getLogger(__name__) + # Get encryption key from environment or use a persistent file-based key ENCRYPTION_KEY = os.getenv("ENCRYPTION_KEY") -ENCRYPTION_KEY_FILE = Path(".encryption_key") + +# Key file search paths (first match wins, new keys are written to the first +# writable path). .data/ is a directory mount that works reliably in both +# CLI and Docker, while .encryption_key is the legacy single-file location. +_KEY_FILE_PATHS = [ + Path(".data/.encryption_key"), + Path(".encryption_key"), +] # Store the Fernet instance (singleton pattern) _fernet_instance = None +def _read_key_from_file(path: Path) -> bytes | None: + """Try to read and validate a Fernet key from *path*. Returns None on failure.""" + if not path.is_file(): + return None + try: + key_bytes = path.read_text().strip().encode() + Fernet(key_bytes) + return key_bytes + except Exception as e: + logger.warning(f"Invalid encryption key in {path}: {e}") + return None + + +def _write_key_to_file(key: bytes) -> Path | None: + """Write *key* to the first writable candidate path. Returns the path used, or None.""" + for path in _KEY_FILE_PATHS: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(key) + os.chmod(path, 0o600) + logger.info(f"Encryption key saved to {path.absolute()}") + return path + except Exception: + continue + return None + + def get_or_create_encryption_key() -> bytes: """ Get or create a persistent encryption key. - + Priority: - 1. ENCRYPTION_KEY environment variable - 2. .encryption_key file (created if doesn't exist) - 3. Generate new key and save to file - - Returns: - Fernet key as bytes + 1. ENCRYPTION_KEY environment variable + 2. First valid key file found in _KEY_FILE_PATHS + 3. Generate new key and save to the first writable path """ - # First, try environment variable if ENCRYPTION_KEY: try: key_bytes = ENCRYPTION_KEY.encode() if isinstance(ENCRYPTION_KEY, str) else ENCRYPTION_KEY - # Validate it's a valid Fernet key Fernet(key_bytes) return key_bytes except Exception: - import logging - logger = logging.getLogger(__name__) - logger.warning(f"Invalid ENCRYPTION_KEY from environment, falling back to file-based key") - - # Second, try to read from file - if ENCRYPTION_KEY_FILE.exists(): - try: - key = ENCRYPTION_KEY_FILE.read_text().strip() - key_bytes = key.encode() if isinstance(key, str) else key - # Validate it's a valid Fernet key - Fernet(key_bytes) + logger.warning("Invalid ENCRYPTION_KEY from environment, falling back to file-based key") + + for path in _KEY_FILE_PATHS: + key_bytes = _read_key_from_file(path) + if key_bytes: return key_bytes - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.warning(f"Failed to read encryption key from file: {e}, generating new key") - - # Third, generate new key and save to file - import logging - logger = logging.getLogger(__name__) - logger.info("Generating new encryption key and saving to .encryption_key file") - + + logger.info("No existing encryption key found, generating a new one") key = Fernet.generate_key() - - # Save to file with restricted permissions (readable only by owner) - try: - ENCRYPTION_KEY_FILE.write_bytes(key) - # Set file permissions to 600 (read/write for owner only) - os.chmod(ENCRYPTION_KEY_FILE, 0o600) - logger.info(f"Encryption key saved to {ENCRYPTION_KEY_FILE.absolute()}") - - # Warn if this is the first time - if not ENCRYPTION_KEY: - import warnings - warnings.warn( - f"Using file-based encryption key stored in {ENCRYPTION_KEY_FILE.absolute()}. " - f"For production, set ENCRYPTION_KEY environment variable for better security.", - UserWarning - ) - except Exception as e: - logger.error(f"Failed to save encryption key to file: {e}") - # Still return the key so the app can function, but warn + + saved_path = _write_key_to_file(key) + if saved_path: import warnings warnings.warn( - f"Could not save encryption key to file. Key will be regenerated on next restart. " - f"Set ENCRYPTION_KEY environment variable for persistence.", - UserWarning + f"Using file-based encryption key stored in {saved_path.absolute()}. " + f"For production, set ENCRYPTION_KEY environment variable for better security.", + UserWarning, ) - + else: + logger.error("Failed to save encryption key to any file path") + import warnings + warnings.warn( + "Could not save encryption key to file. Key will be regenerated on next restart. " + "Set ENCRYPTION_KEY environment variable for persistence.", + UserWarning, + ) + return key @@ -113,7 +123,7 @@ def encrypt_api_key(api_key: str) -> str: Encrypted API key (base64 encoded) """ f = get_fernet() - encrypted = f.encrypt(api_key.encode('utf-8')) + encrypted = f.encrypt(api_key.strip().encode('utf-8')) return encrypted.decode('utf-8') @@ -146,9 +156,8 @@ def decrypt_api_key(encrypted_api_key: str) -> str: f = get_fernet() try: - # Try to decrypt (assumes it's encrypted) decrypted = f.decrypt(encrypted_api_key.encode('utf-8')) - return decrypted.decode('utf-8') + return decrypted.decode('utf-8').strip() except Exception as e: # If decryption fails import logging @@ -169,5 +178,5 @@ def decrypt_api_key(encrypted_api_key: str) -> str: else: # Key doesn't look encrypted, assume it's plain text (backward compatibility) logger.warning("API key decryption failed, assuming plain text (backward compatibility)") - return encrypted_api_key + return encrypted_api_key.strip() diff --git a/app/services/voice_providers/retell.py b/app/services/voice_providers/retell.py index f218f687..731d2910 100644 --- a/app/services/voice_providers/retell.py +++ b/app/services/voice_providers/retell.py @@ -94,25 +94,22 @@ def create_web_call( ), } except Exception as e: - # Extract more detailed error information error_message = str(e) - - # Check if it's a Retell API error with more details - if hasattr(e, 'status_code'): + + # Try to extract the human-readable message from Retell's API error body + upstream_msg = None + if hasattr(e, 'body') and isinstance(e.body, dict): + upstream_msg = e.body.get('message') + elif hasattr(e, 'response') and isinstance(e.response, dict): + upstream_msg = e.response.get('message') + + if upstream_msg: + error_message = upstream_msg + elif hasattr(e, 'status_code'): error_message = f"Retell API error (status {e.status_code}): {error_message}" - elif hasattr(e, 'response'): - try: - error_detail = e.response - if isinstance(error_detail, dict): - error_message = f"Retell API error: {error_detail.get('message', error_message)}" - except: - pass - - # Include agent_id in error for debugging + raise ValueError( - f"Failed to create Retell web call for agent_id '{agent_id}': {error_message}. " - f"Please verify: 1) The agent_id exists in Retell, 2) The API key has access to this agent, " - f"3) The agent is configured for web calls." + f"Retell: {error_message}" ) def create_agent( diff --git a/app/services/voice_providers/vapi.py b/app/services/voice_providers/vapi.py index 1965a3d4..53d8901a 100644 --- a/app/services/voice_providers/vapi.py +++ b/app/services/voice_providers/vapi.py @@ -223,11 +223,23 @@ def retrieve_call_metrics(self, call_id: str) -> Dict[str, Any]: try: headers = { "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json" } - response = requests.get(f"{self.api_url}/call/{call_id}", headers=headers, timeout=30) - response.raise_for_status() + url = f"{self.api_url}/call/{call_id}" + logger.debug(f"[VapiProvider] Fetching call metrics: GET {url}") + + response = requests.get(url, headers=headers, timeout=30) + + if not response.ok: + try: + error_body = response.json() + except Exception: + error_body = response.text[:500] + logger.error( + f"[VapiProvider] GET /call/{call_id} returned {response.status_code}: {error_body}" + ) + response.raise_for_status() + data = response.json() # Log raw response for debugging diff --git a/config.docker.worker.yml b/config.docker.worker.yml deleted file mode 100644 index c1e5da35..00000000 --- a/config.docker.worker.yml +++ /dev/null @@ -1,67 +0,0 @@ -# EfficientAI Docker Worker Configuration File -# The worker uses network_mode: host for WebRTC, so it must connect via -# "localhost" instead of Docker service names ("db", "redis"). - -# Application Settings -app: - name: "EfficientAI Voice AI Evaluation Platform" - version: "0.1.0" - debug: true - secret_key: "your-secret-key-here-change-in-production" - -# Server Settings -server: - host: "0.0.0.0" - port: 8000 - -# Database Configuration (localhost — worker uses host networking) -database: - url: "postgresql://efficientai:password@localhost:5432/efficientai" - -# Redis Configuration (localhost — worker uses host networking) -redis: - url: "redis://localhost:6379/0" - -# Celery Configuration (localhost — worker uses host networking) -celery: - broker_url: "redis://localhost:6379/0" - result_backend: "redis://localhost:6379/0" - -# File Storage -storage: - upload_dir: "/app/uploads" - max_file_size_mb: 500 - allowed_audio_formats: - - "wav" - - "mp3" - - "flac" - - "m4a" - -# S3 Configuration (for data sources integration) -# Copy your S3 settings from config.yml here -s3: - enabled: false - bucket_name: "your-s3-bucket-name" - region: "us-east-1" - access_key_id: "your-access-key-id" - secret_access_key: "your-secret-access-key" - endpoint_url: null # Optional: for S3-compatible services (e.g., MinIO, DigitalOcean Spaces) - prefix: "audio/" # Prefix for audio files in bucket - -# Speaker Diarization (pyannote.audio) -# Requires accepting model terms at https://huggingface.co/pyannote/speaker-diarization-3.1 -diarization: - huggingface_token: # Set your HuggingFace token here (e.g., "hf_xxxxx") - num_speakers: 2 # Force exact speaker count (2 = agent + customer). Set to null for auto-detect. - -# CORS Settings -cors: - origins: - - "http://localhost:3000" - - "http://localhost:8000" - -# API Settings -api: - prefix: "/api/v1" - key_header: "X-API-Key" - rate_limit_per_minute: 60 diff --git a/docker-compose.yml b/docker-compose.yml index 8dc802bd..35cf1773 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,10 +40,10 @@ services: # Use EFFICIENTAI_VERSION env var to pin to a specific version (e.g., 1.0.0) image: ghcr.io/efficientai-tech/efficientai-api:${EFFICIENTAI_VERSION:-latest} # For local development, uncomment below and comment out the image line: - # build: - # context: . - # dockerfile: docker/Dockerfile.api - # container_name: efficientai_api + build: + context: . + dockerfile: docker/Dockerfile.api + container_name: efficientai_api environment: DATABASE_URL: postgresql://${POSTGRES_USER:-efficientai}:${POSTGRES_PASSWORD:-password}@db:5432/${POSTGRES_DB:-efficientai} REDIS_URL: redis://redis:6379/0 @@ -56,7 +56,7 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} volumes: - ./uploads:/app/uploads - - encryption_key:/app/encryption_key_data + - ./.data:/app/.data - ./config.docker.yml:/app/config.yml:ro ports: - "8000:8000" @@ -65,31 +65,19 @@ services: condition: service_healthy redis: condition: service_healthy - command: > - sh -c ' - if [ ! -f /app/encryption_key_data/.encryption_key ]; then - python3 -c "from cryptography.fernet import Fernet; open(\"/app/encryption_key_data/.encryption_key\", \"wb\").write(Fernet.generate_key())"; - echo "Generated new encryption key"; - fi && - ln -sf /app/encryption_key_data/.encryption_key /app/.encryption_key && - exec eai start --config /app/config.yml --host 0.0.0.0 --port 8000 --no-build-frontend --no-reload - ' + command: eai start --config /app/config.yml --host 0.0.0.0 --port 8000 --no-build-frontend --no-reload worker: # Pre-built image from GitHub Container Registry # Use EFFICIENTAI_VERSION env var to pin to a specific version (e.g., 1.0.0) image: ghcr.io/efficientai-tech/efficientai-worker:${EFFICIENTAI_VERSION:-latest} # For local development, uncomment below and comment out the image line: - # build: - # context: . - # dockerfile: docker/Dockerfile.worker - # args: - # INSTALL_EXTRAS: "qualitative-voice,reports" - # container_name: efficientai_worker - # Use host network mode for WebRTC connectivity (Retell/Vapi calls). - # NOTE: host networking means Docker DNS is unavailable, so connection URLs - # must use "localhost" instead of Docker service names like "db" / "redis". - network_mode: host + build: + context: . + dockerfile: docker/Dockerfile.worker + args: + INSTALL_EXTRAS: "qualitative-voice,reports" + container_name: efficientai_worker environment: ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} depends_on: @@ -99,14 +87,9 @@ services: condition: service_healthy volumes: - ./uploads:/app/uploads - - encryption_key:/app/encryption_key_data - - ./config.docker.worker.yml:/app/config.yml:ro - command: > - sh -c ' - ln -sf /app/encryption_key_data/.encryption_key /app/.encryption_key && - exec eai worker --config /app/config.yml --loglevel info - ' + - ./.data:/app/.data + - ./config.docker.yml:/app/config.yml:ro + command: eai worker --config /app/config.yml --loglevel info volumes: postgres_data: - encryption_key: diff --git a/frontend/src/pages/playground/agent/AgentPlayground.tsx b/frontend/src/pages/playground/agent/AgentPlayground.tsx index 3bd0baf5..34cbcd27 100644 --- a/frontend/src/pages/playground/agent/AgentPlayground.tsx +++ b/frontend/src/pages/playground/agent/AgentPlayground.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom' import { useAgentStore } from '../../../store/agentStore' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient } from '../../../lib/api' -import { Play, X, Phone, PhoneOff, RefreshCw, Eye, Mic, Bot, PhoneCall, Trash2 } from 'lucide-react' +import { Play, X, Phone, PhoneOff, RefreshCw, Eye, Mic, Bot, PhoneCall, Trash2, AlertTriangle } from 'lucide-react' import Button from '../../../components/Button' import { useToast } from '../../../hooks/useToast' import { RetellWebClient } from 'retell-client-js-sdk' @@ -85,6 +85,13 @@ export default function AgentPlayground() { }, }) + // Check S3 storage status for warning + const { data: s3Status } = useQuery({ + queryKey: ['s3-status'], + queryFn: () => apiClient.getS3Status(), + staleTime: 60_000, + }) + const [activeTab, setActiveTab] = useState<'test_agents' | 'voice_ai_agents'>('voice_ai_agents') @@ -255,7 +262,8 @@ export default function AgentPlayground() { console.error('Failed to connect Retell:', error) setIsConnecting(false) setIsConnected(false) - showToast(`Failed to connect: ${error.message}`, 'error') + const detail = error?.response?.data?.detail || error?.message || 'Unknown error' + showToast(`Failed to connect: ${detail}`, 'error') } } else if (isVapiAgent) { const client = vapiClientRef.current @@ -354,7 +362,8 @@ export default function AgentPlayground() { console.error('Failed to connect Vapi:', error) setIsConnecting(false) setIsConnected(false) - showToast(`Failed to connect: ${error.message}`, 'error') + const detail = error?.response?.data?.detail || error?.message || 'Unknown error' + showToast(`Failed to connect: ${detail}`, 'error') } } else if (isElevenLabsAgent) { try { @@ -462,8 +471,9 @@ export default function AgentPlayground() { console.error('Failed to connect ElevenLabs:', error) setIsConnecting(false) setIsConnected(false) - const msg = typeof error === 'string' ? error : error?.message || JSON.stringify(error) - showToast(`Failed to connect: ${msg}`, 'error') + const detail = error?.response?.data?.detail + || (typeof error === 'string' ? error : error?.message || JSON.stringify(error)) + showToast(`Failed to connect: ${detail}`, 'error') } } } @@ -952,6 +962,18 @@ export default function AgentPlayground() {

+ {s3Status && !s3Status.enabled && ( +
+ +
+

Storage not configured

+

+ S3 storage is not configured. Audio recordings will not be saved. Configure storage in Settings > Data Sources to enable audio playback. +

+
+
+ )} +
{hasTestAgent && (
- {!selectedAgent && ( -
-

- Please select an agent from the top bar to create evaluators. -

-
- )} - {/* Evaluators List - Table Format */}
@@ -457,6 +459,9 @@ export default function EvaluateTestAgents() { Name + + Agent + Persona @@ -528,6 +533,17 @@ export default function EvaluateTestAgents() { )} + {/* Agent */} + + {evaluator.agent_id ? ( + + {agents.find((a: any) => a.id === evaluator.agent_id)?.name || 'Unknown'} + + ) : ( + + )} + + {/* Persona */} {isCustom ? ( @@ -774,6 +790,23 @@ export default function EvaluateTestAgents() { ) : ( <> +
+ + +
diff --git a/frontend/src/pages/playground/agent/AgentPlayground.tsx b/frontend/src/pages/playground/agent/AgentPlayground.tsx index 34cbcd27..8bc228cf 100644 --- a/frontend/src/pages/playground/agent/AgentPlayground.tsx +++ b/frontend/src/pages/playground/agent/AgentPlayground.tsx @@ -1,9 +1,9 @@ import { useState, useEffect, useRef } from 'react' import { useNavigate } from 'react-router-dom' import { useAgentStore } from '../../../store/agentStore' -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { apiClient } from '../../../lib/api' -import { Play, X, Phone, PhoneOff, RefreshCw, Eye, Mic, Bot, PhoneCall, Trash2, AlertTriangle } from 'lucide-react' +import { Play, X, Phone, PhoneOff, RefreshCw, Mic, Bot, PhoneCall, Trash2, AlertTriangle, CheckSquare, Square } from 'lucide-react' import Button from '../../../components/Button' import { useToast } from '../../../hooks/useToast' import { RetellWebClient } from 'retell-client-js-sdk' @@ -93,6 +93,10 @@ export default function AgentPlayground() { }) const [activeTab, setActiveTab] = useState<'test_agents' | 'voice_ai_agents'>('voice_ai_agents') + const [selectedCallIds, setSelectedCallIds] = useState>(new Set()) + const [isDeletingSelected, setIsDeletingSelected] = useState(false) + const [selectedTestResultIds, setSelectedTestResultIds] = useState>(new Set()) + const [isDeletingSelectedTests, setIsDeletingSelectedTests] = useState(false) // Find the integration for the agent @@ -532,55 +536,103 @@ export default function AgentPlayground() { navigate(`/playground/test-agent-results/${resultId}`) } - // Delete mutation for test agent results - const deleteTestResultMutation = useMutation({ - mutationFn: (resultId: string) => apiClient.deleteEvaluatorResult(resultId), - onSuccess: () => { - showToast('Test result deleted successfully', 'success') - queryClient.invalidateQueries({ queryKey: ['test-voice-agent-results'] }) - }, - onError: (error: any) => { - showToast(error?.response?.data?.detail || 'Failed to delete test result', 'error') - }, - }) + const toggleTestResultSelection = (resultId: string) => { + setSelectedTestResultIds(prev => { + const next = new Set(prev) + if (next.has(resultId)) { + next.delete(resultId) + } else { + next.add(resultId) + } + return next + }) + } + + const toggleSelectAllTestResults = () => { + const allIds = testVoiceAgentResults.map((r: any) => r.id) + const allSelected = allIds.length > 0 && allIds.every((id: string) => selectedTestResultIds.has(id)) + setSelectedTestResultIds(allSelected ? new Set() : new Set(allIds)) + } + + const handleDeleteSelectedTestResults = async () => { + if (selectedTestResultIds.size === 0) return + if (!window.confirm(`Delete ${selectedTestResultIds.size} test result${selectedTestResultIds.size > 1 ? 's' : ''}? This cannot be undone.`)) return + + setIsDeletingSelectedTests(true) + const ids = Array.from(selectedTestResultIds) + try { + const results = await Promise.allSettled(ids.map(id => apiClient.deleteEvaluatorResult(id))) + const successCount = results.filter(r => r.status === 'fulfilled').length + const failCount = results.filter(r => r.status === 'rejected').length - const handleDeleteTestResult = async (resultId: string, e: React.MouseEvent) => { - e.stopPropagation() - if (window.confirm('Are you sure you want to delete this test result? This action cannot be undone.')) { - deleteTestResultMutation.mutate(resultId) + if (successCount > 0) { + queryClient.invalidateQueries({ queryKey: ['test-voice-agent-results'] }) + showToast(`Deleted ${successCount} test result${successCount > 1 ? 's' : ''}`, 'success') + } + if (failCount > 0) { + showToast(`Failed to delete ${failCount} result${failCount > 1 ? 's' : ''}`, 'error') + } + + setSelectedTestResultIds(prev => { + const next = new Set(prev) + ids.filter((_, i) => results[i].status === 'fulfilled').forEach(id => next.delete(id)) + return next + }) + } finally { + setIsDeletingSelectedTests(false) } } - // Delete mutation for call recordings - const deleteMutation = useMutation({ - mutationFn: (callShortId: string) => apiClient.deleteCallRecording(callShortId), - onSuccess: () => { - showToast('Call recording deleted successfully', 'success') - queryClient.invalidateQueries({ queryKey: ['call-recordings'] }) - }, - onError: (error: any) => { - showToast(error?.response?.data?.detail || 'Failed to delete call recording', 'error') - }, - }) const handleViewCallRecording = (callShortId: string) => { navigate(`/playground/call-recordings/${callShortId}`) } - const handleRefreshCallRecording = async (callShortId: string) => { - try { - await apiClient.refreshCallRecording(callShortId) - refetchCallRecordings() - showToast('Call recording refreshed', 'success') - } catch (error: any) { - showToast(error?.response?.data?.detail || 'Failed to refresh call recording', 'error') - } + + const toggleCallSelection = (callShortId: string) => { + setSelectedCallIds(prev => { + const next = new Set(prev) + if (next.has(callShortId)) { + next.delete(callShortId) + } else { + next.add(callShortId) + } + return next + }) + } + + const toggleSelectAllCalls = () => { + const allIds = callRecordings.map((r: any) => r.call_short_id) + const allSelected = allIds.length > 0 && allIds.every((id: string) => selectedCallIds.has(id)) + setSelectedCallIds(allSelected ? new Set() : new Set(allIds)) } - const handleDeleteCallRecording = (callShortId: string, e: React.MouseEvent) => { - e.stopPropagation() - if (window.confirm('Are you sure you want to delete this call recording? This action cannot be undone.')) { - deleteMutation.mutate(callShortId) + const handleDeleteSelectedCalls = async () => { + if (selectedCallIds.size === 0) return + if (!window.confirm(`Delete ${selectedCallIds.size} call recording${selectedCallIds.size > 1 ? 's' : ''}? This cannot be undone.`)) return + + setIsDeletingSelected(true) + const ids = Array.from(selectedCallIds) + try { + const results = await Promise.allSettled(ids.map(id => apiClient.deleteCallRecording(id))) + const successCount = results.filter(r => r.status === 'fulfilled').length + const failCount = results.filter(r => r.status === 'rejected').length + + if (successCount > 0) { + queryClient.invalidateQueries({ queryKey: ['call-recordings'] }) + showToast(`Deleted ${successCount} recording${successCount > 1 ? 's' : ''}`, 'success') + } + if (failCount > 0) { + showToast(`Failed to delete ${failCount} recording${failCount > 1 ? 's' : ''}`, 'error') + } + + setSelectedCallIds(prev => { + const next = new Set(prev) + ids.filter((_, i) => results[i].status === 'fulfilled').forEach(id => next.delete(id)) + return next + }) + } finally { + setIsDeletingSelected(false) } } @@ -696,6 +748,20 @@ export default function AgentPlayground() { {/* Test Agents Tab Content */} {activeTab === 'test_agents' && (
+ {selectedTestResultIds.size > 0 && ( +
+ {selectedTestResultIds.size} selected + +
+ )} {testVoiceAgentResults.length === 0 ? (

No test agent results found

@@ -705,6 +771,20 @@ export default function AgentPlayground() { + @@ -717,70 +797,59 @@ export default function AgentPlayground() { - - {testVoiceAgentResults.map((result: any) => ( - - - - - - handleViewTestResult(result.id)} + > + + + - - ))} + {result.status} + + + + + + ) + })}
+ + Call ID Created - Actions -
- - - - {result.status} - - - {result.agent?.name || 'N/A'} - - {result.created_at - ? new Date(result.created_at).toLocaleString() - : 'N/A'} - -
- + {testVoiceAgentResults.map((result: any) => { + const isSelected = selectedTestResultIds.has(result.id) + return ( +
e.stopPropagation()}> - + + {result.result_id || result.id.substring(0, 8)} + + + - - - -
+ {result.agent?.name || 'N/A'} + + {result.created_at + ? new Date(result.created_at).toLocaleString() + : 'N/A'} +
@@ -791,6 +860,20 @@ export default function AgentPlayground() { {/* Voice AI Agents Tab Content */} {activeTab === 'voice_ai_agents' && (
+ {selectedCallIds.size > 0 && ( +
+ {selectedCallIds.size} selected + +
+ )} {callRecordings.length === 0 ? (

No call recordings found

@@ -800,6 +883,20 @@ export default function AgentPlayground() { + @@ -815,118 +912,104 @@ export default function AgentPlayground() { - - {callRecordings.map((recording: any) => ( - - - - - - - handleViewCallRecording(recording.call_short_id)} + > + + + + - - ))} + + + + + ) + })}
+ + Call ID Created - Actions -
- - - - {recording.status} - - - {recording.evaluator_result_id ? ( - - {recording.evaluation_status || 'queued'} - - ) : recording.status === 'UPDATED' ? ( - - Pending - - ) : ( - - )} - -
- {recording.provider_platform === 'retell' && ( - Retell - )} - {recording.provider_platform === 'vapi' && ( - Vapi - )} - {recording.provider_platform === 'elevenlabs' && ( - ElevenLabs - )} - - {recording.provider_platform || 'N/A'} - -
-
- {recording.created_at - ? new Date(recording.created_at).toLocaleString() - : 'N/A'} - -
+ {callRecordings.map((recording: any) => { + const isSelected = selectedCallIds.has(recording.call_short_id) + return ( +
e.stopPropagation()}> - {recording.status === 'PENDING' && ( - + + {recording.call_short_id} + + + + {recording.status} + + + {recording.evaluator_result_id ? ( + - - + {recording.evaluation_status || 'queued'} + + ) : recording.status === 'UPDATED' ? ( + + Pending + + ) : ( + )} - - -
+
+ {recording.provider_platform === 'retell' && ( + Retell + )} + {recording.provider_platform === 'vapi' && ( + Vapi + )} + {recording.provider_platform === 'elevenlabs' && ( + ElevenLabs + )} + + {recording.provider_platform || 'N/A'} + +
+
+ {recording.created_at + ? new Date(recording.created_at).toLocaleString() + : 'N/A'} +
diff --git a/frontend/src/pages/promptPartials/PromptPartials.tsx b/frontend/src/pages/promptPartials/PromptPartials.tsx index 6a7005be..a142028f 100644 --- a/frontend/src/pages/promptPartials/PromptPartials.tsx +++ b/frontend/src/pages/promptPartials/PromptPartials.tsx @@ -1115,14 +1115,21 @@ function AIGenerateModal({ {/* Review step - name and save options */}
- + setPromptName(e.target.value)} placeholder="e.g., Customer Support System Prompt" - className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent" + className={`w-full px-3 py-2 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent ${ + !promptName.trim() ? 'border-amber-300 bg-amber-50/30' : 'border-gray-300' + }`} /> + {!promptName.trim() && ( +

Required to save the prompt

+ )}
+
+ +

+ First-time runs may take longer as ML models required for audio evaluation metrics are downloaded and cached locally. Subsequent runs will be significantly faster. +

+
+
+
+ +

+ First-time runs may take longer as ML models required for audio evaluation metrics are downloaded and cached locally. Subsequent runs will be significantly faster. +

+
+ {/* Audio File Selection */}
)} +
+ +

+ First-time runs may take longer as ML models required for audio evaluation metrics are downloaded and cached locally. Subsequent runs will be significantly faster. +

+
+
{hasTestAgent && (