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 && (
Date: Wed, 18 Mar 2026 19:06:18 +0000
Subject: [PATCH 6/7] feat: updating some UX improvements
---
frontend/src/components/Layout.tsx | 10 +-
.../evaluators/EvaluateTestAgents.tsx | 57 +-
frontend/src/pages/personas/Personas.tsx | 2 +-
.../playground/agent/AgentPlayground.tsx | 485 ++++++++++--------
.../pages/promptPartials/PromptPartials.tsx | 11 +-
5 files changed, 341 insertions(+), 224 deletions(-)
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 17a179a4..0c1e0e1d 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -51,13 +51,6 @@ interface NavSection {
}
const navigationSections: NavSection[] = [
- {
- title: 'Prompt Partials',
- icon: ScrollText,
- items: [
- { name: 'Prompt Partials', href: '/prompt-partials', icon: ScrollText },
- ],
- },
{
title: 'Simulations',
icon: Play,
@@ -115,6 +108,7 @@ const navigationSections: NavSection[] = [
const otherNavigation: NavItem[] = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
+ { name: 'Prompt Partials', href: '/prompt-partials', icon: ScrollText },
]
const bottomNavigation = [
@@ -373,7 +367,7 @@ function SidebarContent({
}) {
const { isFeatureEnabled } = useLicenseStore()
const [expandedSections, setExpandedSections] = useState>(
- new Set(['Prompt Partials', 'Simulations', 'Playground', 'Evaluations', 'Observability', 'Alerting', 'Configurations'])
+ new Set(['Simulations', 'Playground', 'Evaluations', 'Observability', 'Alerting', 'Configurations'])
)
const toggleSection = (title: string) => {
diff --git a/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx b/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx
index 1f9318d1..3d0b4410 100644
--- a/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx
+++ b/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx
@@ -60,11 +60,17 @@ export default function EvaluateTestAgents() {
const [runCount, setRunCount] = useState(1)
const [showDeleteSelectedModal, setShowDeleteSelectedModal] = useState(false)
const [isDeletingSelected, setIsDeletingSelected] = useState(false)
+ const [modalAgentId, setModalAgentId] = useState('')
const [selectedLlmProvider, setSelectedLlmProvider] = useState(null)
const [selectedLlmModel, setSelectedLlmModel] = useState('')
const [showLlmDropdown, setShowLlmDropdown] = useState(false)
const llmDropdownRef = useRef(null)
+ const { data: agents = [] } = useQuery({
+ queryKey: ['agents'],
+ queryFn: () => apiClient.listAgents(),
+ })
+
const { data: personas = [] } = useQuery({
queryKey: ['personas'],
queryFn: () => apiClient.listPersonas(),
@@ -158,6 +164,7 @@ export default function EvaluateTestAgents() {
queryClient.invalidateQueries({ queryKey: ['evaluators'] })
setShowCreateModal(false)
setStandardName('')
+ setModalAgentId('')
setSelectedScenario('')
setSelectedPersonas([])
setSelectedTags([])
@@ -215,8 +222,8 @@ export default function EvaluateTestAgents() {
return
}
- if (!selectedAgent) {
- alert('Please select an agent first')
+ if (!modalAgentId) {
+ alert('Please select an agent')
return
}
if (!selectedScenario) {
@@ -230,7 +237,7 @@ export default function EvaluateTestAgents() {
createBulkMutation.mutate({
name: standardName.trim() || undefined,
- agent_id: selectedAgent.id,
+ agent_id: modalAgentId,
scenario_id: selectedScenario,
persona_ids: selectedPersonas,
tags: selectedTags.length > 0 ? selectedTags : undefined,
@@ -398,7 +405,10 @@ export default function EvaluateTestAgents() {
)}
setShowCreateModal(true)}
+ onClick={() => {
+ setModalAgentId(selectedAgent?.id || '')
+ setShowCreateModal(true)
+ }}
leftIcon={ }
>
Create Evaluator
@@ -406,14 +416,6 @@ export default function EvaluateTestAgents() {
- {!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() {
>
) : (
<>
+
+
+ Agent *
+
+ setModalAgentId(e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
+ >
+ Select an agent
+ {agents.map((agent: any) => (
+
+ {agent.name}
+
+ ))}
+
+
Evaluator Name
diff --git a/frontend/src/pages/personas/Personas.tsx b/frontend/src/pages/personas/Personas.tsx
index 9666dd13..ff3cd6f0 100644
--- a/frontend/src/pages/personas/Personas.tsx
+++ b/frontend/src/pages/personas/Personas.tsx
@@ -527,7 +527,7 @@ export default function Personas() {
value={formData.name}
onChange={(e) => 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., Grumpy Old Man"
+ placeholder="Name of the persona"
/>
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
+ : undefined}
+ >
+ Delete ({selectedTestResultIds.size})
+
+
+ )}
{testVoiceAgentResults.length === 0 ? (
No test agent results found
@@ -705,6 +771,20 @@ export default function AgentPlayground() {
+
+
+ {testVoiceAgentResults.length > 0 && testVoiceAgentResults.every((r: any) => selectedTestResultIds.has(r.id)) ? (
+
+ ) : (
+
+ )}
+
+
Call ID
@@ -717,70 +797,59 @@ export default function AgentPlayground() {
Created
-
- Actions
-
- {testVoiceAgentResults.map((result: any) => (
-
-
- handleViewTestResult(result.id)}
- className="font-mono text-sm font-semibold text-primary-600 hover:text-primary-800 hover:underline"
- >
- {result.result_id || result.id.substring(0, 8)}
-
-
-
-
- {result.status}
-
-
-
- {result.agent?.name || 'N/A'}
-
-
- {result.created_at
- ? new Date(result.created_at).toLocaleString()
- : 'N/A'}
-
-
-
- handleViewTestResult(result.id)}
- className="text-blue-600 hover:text-blue-900"
- >
-
-
+ {testVoiceAgentResults.map((result: any) => {
+ const isSelected = selectedTestResultIds.has(result.id)
+ return (
+
handleViewTestResult(result.id)}
+ >
+ e.stopPropagation()}>
refetchTestResults()}
- className="text-gray-600 hover:text-gray-900"
- title="Refresh"
+ type="button"
+ onClick={() => toggleTestResultSelection(result.id)}
+ className="flex-shrink-0"
>
-
+ {isSelected ? (
+
+ ) : (
+
+ )}
- handleDeleteTestResult(result.id, e)}
- className="text-red-600 hover:text-red-900"
- title="Delete"
- disabled={deleteTestResultMutation.isPending}
+
+
+
+ {result.result_id || result.id.substring(0, 8)}
+
+
+
+
-
-
-
-
-
- ))}
+ {result.status}
+
+
+
+ {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
+ : undefined}
+ >
+ Delete ({selectedCallIds.size})
+
+
+ )}
{callRecordings.length === 0 ? (
No call recordings found
@@ -800,6 +883,20 @@ export default function AgentPlayground() {
+
+
+ {callRecordings.length > 0 && callRecordings.every((r: any) => selectedCallIds.has(r.call_short_id)) ? (
+
+ ) : (
+
+ )}
+
+
Call ID
@@ -815,118 +912,104 @@ export default function AgentPlayground() {
Created
-
- Actions
-
- {callRecordings.map((recording: any) => (
-
-
- handleViewCallRecording(recording.call_short_id)}
- className="font-mono text-sm font-semibold text-primary-600 hover:text-primary-800 hover:underline"
- >
- {recording.call_short_id}
-
-
-
-
- {recording.status}
-
-
-
- {recording.evaluator_result_id ? (
-
- {recording.evaluation_status || 'queued'}
-
- ) : recording.status === 'UPDATED' ? (
-
- Pending
-
- ) : (
- —
- )}
-
-
-
- {recording.provider_platform === 'retell' && (
-
- )}
- {recording.provider_platform === 'vapi' && (
-
- )}
- {recording.provider_platform === '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 (
+
handleViewCallRecording(recording.call_short_id)}
+ >
+ e.stopPropagation()}>
handleViewCallRecording(recording.call_short_id)}
- className="text-blue-600 hover:text-blue-900"
- title="View call details"
+ type="button"
+ onClick={() => toggleCallSelection(recording.call_short_id)}
+ className="flex-shrink-0"
>
-
+ {isSelected ? (
+
+ ) : (
+
+ )}
- {recording.status === 'PENDING' && (
- handleRefreshCallRecording(recording.call_short_id)}
- className="text-gray-600 hover:text-gray-900"
- title="Refresh"
+
+
+
+ {recording.call_short_id}
+
+
+
+
+ {recording.status}
+
+
+
+ {recording.evaluator_result_id ? (
+
-
-
+ {recording.evaluation_status || 'queued'}
+
+ ) : recording.status === 'UPDATED' ? (
+
+ Pending
+
+ ) : (
+ —
)}
- handleDeleteCallRecording(recording.call_short_id, e)}
- className="text-red-600 hover:text-red-900"
- title="Delete"
- disabled={deleteMutation.isPending}
- >
-
-
-
-
-
- ))}
+
+
+
+ {recording.provider_platform === 'retell' && (
+
+ )}
+ {recording.provider_platform === 'vapi' && (
+
+ )}
+ {recording.provider_platform === '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 */}
-
Name
+
+ Name *
+
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
+ )}
From 6f146101fed04aa3b3e6195e9b605f2d71f8aeac Mon Sep 17 00:00:00 2001
From: Tejas Narayan
Date: Wed, 18 Mar 2026 19:19:59 +0000
Subject: [PATCH 7/7] fix: updating UI fixes
---
.../pages/evaluators/evaluators/EvaluateTestAgents.tsx | 9 ++++++++-
frontend/src/pages/evaluators/results/Results.tsx | 7 +++++++
frontend/src/pages/playground/agent/AgentPlayground.tsx | 7 +++++++
3 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx b/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx
index 3d0b4410..247f0bdd 100644
--- a/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx
+++ b/frontend/src/pages/evaluators/evaluators/EvaluateTestAgents.tsx
@@ -5,7 +5,7 @@ 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 } from 'lucide-react'
+import { Plus, Trash2, Play, X, CheckSquare, Square, Sparkles, Brain, ChevronDown, AlertTriangle } from 'lucide-react'
import { useToast } from '../../../hooks/useToast'
import { getProviderLabel, getProviderLogo } from '../../../config/providers'
@@ -1021,6 +1021,13 @@ export default function EvaluateTestAgents() {
+
+
+
+ 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.
+
+
+
setShowRunModal(false)}>
Cancel
diff --git a/frontend/src/pages/evaluators/results/Results.tsx b/frontend/src/pages/evaluators/results/Results.tsx
index 74c54ab4..832e82b6 100644
--- a/frontend/src/pages/evaluators/results/Results.tsx
+++ b/frontend/src/pages/evaluators/results/Results.tsx
@@ -679,6 +679,13 @@ export default function Results() {
+
+
+
+ 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 */}
diff --git a/frontend/src/pages/playground/agent/AgentPlayground.tsx b/frontend/src/pages/playground/agent/AgentPlayground.tsx
index 8bc228cf..6f5d3596 100644
--- a/frontend/src/pages/playground/agent/AgentPlayground.tsx
+++ b/frontend/src/pages/playground/agent/AgentPlayground.tsx
@@ -1057,6 +1057,13 @@ export default function AgentPlayground() {
)}
+
+
+
+ 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 && (