diff --git a/.gitignore b/.gitignore index 70e617d2..6fa1bb67 100644 --- a/.gitignore +++ b/.gitignore @@ -44,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/docker-compose.yml b/docker-compose.yml index cb6e3ec0..fc55bc84 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:ro + - ./.data:/app/.data - ./config.docker.yml:/app/config.yml:ro ports: - "8000:8000" @@ -72,14 +72,12 @@ services: # 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) - network_mode: host + build: + context: . + dockerfile: docker/Dockerfile.worker + args: + INSTALL_EXTRAS: "qualitative-voice,reports" + container_name: efficientai_worker environment: # Worker is on host network, so it reaches DB/Redis via localhost (exposed ports) DATABASE_URL: postgresql://${POSTGRES_USER:-efficientai}:${POSTGRES_PASSWORD:-password}@localhost:5432/${POSTGRES_DB:-efficientai} @@ -87,9 +85,14 @@ services: CELERY_BROKER_URL: redis://localhost:6379/0 CELERY_RESULT_BACKEND: redis://localhost:6379/0 ENCRYPTION_KEY: ${ENCRYPTION_KEY:-} + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy volumes: - ./uploads:/app/uploads - - ./.encryption_key:/app/.encryption_key:ro + - ./.data:/app/.data - ./config.docker.yml:/app/config.yml:ro command: eai worker --config /app/config.yml --loglevel info 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..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' @@ -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() { )} + + )} {testVoiceAgentResults.length === 0 ? (

No test agent results found

@@ -702,6 +771,20 @@ export default function AgentPlayground() { + @@ -714,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'} +
@@ -788,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

@@ -797,6 +883,20 @@ export default function AgentPlayground() { + @@ -812,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'} +
@@ -971,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 && (