diff --git a/.gitignore b/.gitignore index 6fa1bb67..e3b01339 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ env/ ENV/ .venv config.yml +config.docker.yml # uv .python-version uv.lock diff --git a/app/api/v1/api.py b/app/api/v1/api.py index dc327dde..5317f035 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -31,6 +31,7 @@ cron_jobs, voice_playground, prompt_partials, + prompt_optimization, ) api_router = APIRouter() @@ -65,4 +66,5 @@ api_router.include_router(cron_jobs.router) api_router.include_router(voice_playground.router) api_router.include_router(prompt_partials.router) +api_router.include_router(prompt_optimization.router) diff --git a/app/api/v1/routes/agents.py b/app/api/v1/routes/agents.py index 8d481c95..7767121a 100644 --- a/app/api/v1/routes/agents.py +++ b/app/api/v1/routes/agents.py @@ -258,6 +258,17 @@ async def create_agent( db.add(db_agent) db.commit() db.refresh(db_agent) + + if agent.voice_ai_integration_id and agent.voice_ai_agent_id: + try: + from app.services.voice_providers.prompt_sync import sync_provider_prompt + integration = db.query(Integration).filter(Integration.id == agent.voice_ai_integration_id).first() + if integration: + sync_provider_prompt(db_agent, integration, db) + db.refresh(db_agent) + except Exception as e: + logger.warning(f"[Agents] Best-effort provider prompt sync failed on create: {e}") + return db_agent @@ -391,9 +402,72 @@ async def update_agent( db.commit() db.refresh(db_agent) + + if "voice_ai_agent_id" in update_data or "voice_ai_integration_id" in update_data: + integration_id = db_agent.voice_ai_integration_id + if integration_id and db_agent.voice_ai_agent_id: + try: + from app.services.voice_providers.prompt_sync import sync_provider_prompt + integration = db.query(Integration).filter(Integration.id == integration_id).first() + if integration: + sync_provider_prompt(db_agent, integration, db) + db.refresh(db_agent) + except Exception as e: + logger.warning(f"[Agents] Best-effort provider prompt sync failed on update: {e}") + return db_agent +@router.post("/{agent_id}/sync-provider-prompt") +async def sync_agent_provider_prompt( + agent_id: str, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + """Fetch and store the current system prompt from the voice provider.""" + try: + agent_uuid = UUID(agent_id) + db_agent = db.query(Agent).filter( + and_(Agent.id == agent_uuid, Agent.organization_id == organization_id) + ).first() + except ValueError: + db_agent = db.query(Agent).filter( + and_(Agent.agent_id == agent_id, Agent.organization_id == organization_id) + ).first() + + if not db_agent: + raise HTTPException(status_code=404, detail=f"Agent {agent_id} not found") + + if not db_agent.voice_ai_integration_id or not db_agent.voice_ai_agent_id: + raise HTTPException( + status_code=400, + detail="Agent is not linked to an external voice provider", + ) + + integration = db.query(Integration).filter( + and_( + Integration.id == db_agent.voice_ai_integration_id, + Integration.organization_id == organization_id, + Integration.is_active == True, + ) + ).first() + if not integration: + raise HTTPException(status_code=404, detail="Integration not found or inactive") + + try: + from app.services.voice_providers.prompt_sync import sync_provider_prompt + prompt = sync_provider_prompt(db_agent, integration, db) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Failed to fetch prompt from provider: {str(e)}") + + db.refresh(db_agent) + return { + "provider_prompt": db_agent.provider_prompt, + "provider_prompt_synced_at": db_agent.provider_prompt_synced_at.isoformat() if db_agent.provider_prompt_synced_at else None, + } + + @router.delete("/{agent_id}") async def delete_agent( agent_id: str, diff --git a/app/api/v1/routes/prompt_optimization.py b/app/api/v1/routes/prompt_optimization.py new file mode 100644 index 00000000..24bda345 --- /dev/null +++ b/app/api/v1/routes/prompt_optimization.py @@ -0,0 +1,336 @@ +""" +API routes for GEPA prompt optimization (Enterprise feature). + +Allows users to trigger optimization runs for voice agents, view candidates, +accept the best prompt, and push it to the voice provider. +""" + +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session +from loguru import logger + +from app.dependencies import get_db, get_organization_id, get_api_key, require_enterprise_feature +from app.models.database import ( + Agent, + Evaluator, + Integration, + PromptOptimizationCandidate, + PromptOptimizationRun, + VoiceBundle, +) +from app.models.enums import PromptOptimizationStatus +from app.core.encryption import decrypt_api_key +from app.services.voice_providers import get_voice_provider + + +router = APIRouter( + prefix="/prompt-optimization", + tags=["Prompt Optimization"], + dependencies=[Depends(require_enterprise_feature("gepa_optimization"))], +) + + +# --------------------------------------------------------------------------- +# Schemas +# --------------------------------------------------------------------------- + + +class OptimizationRunCreate(BaseModel): + agent_id: UUID + evaluator_id: Optional[UUID] = None + voice_bundle_id: Optional[UUID] = None + config: Optional[Dict[str, Any]] = Field( + default=None, + description="GEPA hyperparameter overrides (max_metric_calls, minibatch_size, etc.)", + ) + + +class OptimizationRunResponse(BaseModel): + id: UUID + agent_id: UUID + evaluator_id: Optional[UUID] = None + voice_bundle_id: Optional[UUID] = None + seed_prompt: str + best_prompt: Optional[str] = None + best_score: Optional[float] = None + status: str + config: Optional[Dict[str, Any]] = None + metric_history: Optional[List[Dict[str, Any]]] = None + num_iterations: Optional[int] = None + num_metric_calls: Optional[int] = None + error_message: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class CandidateResponse(BaseModel): + id: UUID + optimization_run_id: UUID + prompt_text: str + score: Optional[float] = None + metric_breakdown: Optional[Dict[str, Any]] = None + reflection_summary: Optional[str] = None + parent_candidate_id: Optional[UUID] = None + is_accepted: bool = False + pushed_to_provider_at: Optional[datetime] = None + created_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@router.post("/runs", response_model=OptimizationRunResponse, status_code=201) +def create_optimization_run( + data: OptimizationRunCreate, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + """Start a new GEPA prompt optimization run for an agent.""" + agent = db.query(Agent).filter( + Agent.id == data.agent_id, + Agent.organization_id == organization_id, + ).first() + if not agent: + raise HTTPException(404, "Agent not found") + + if not agent.provider_prompt and not agent.description: + raise HTTPException(400, "Agent has no provider prompt or description to optimize") + + evaluator = None + if data.evaluator_id: + evaluator = db.query(Evaluator).filter( + Evaluator.id == data.evaluator_id, + Evaluator.organization_id == organization_id, + ).first() + if not evaluator: + raise HTTPException(404, "Evaluator not found") + + voice_bundle_id = data.voice_bundle_id or agent.voice_bundle_id + if voice_bundle_id: + vb = db.query(VoiceBundle).filter(VoiceBundle.id == voice_bundle_id).first() + if not vb: + raise HTTPException(404, "VoiceBundle not found") + + run = PromptOptimizationRun( + organization_id=organization_id, + agent_id=agent.id, + evaluator_id=data.evaluator_id, + voice_bundle_id=voice_bundle_id, + seed_prompt=agent.provider_prompt or agent.description, + status=PromptOptimizationStatus.PENDING.value, + config=data.config, + ) + db.add(run) + db.commit() + db.refresh(run) + + from app.workers.tasks.run_prompt_optimization import run_prompt_optimization_task + + task = run_prompt_optimization_task.delay(str(run.id)) + run.celery_task_id = task.id + db.commit() + + logger.info(f"[GEPA] Created optimization run {run.id} for agent {agent.name}") + return run + + +@router.get("/runs", response_model=List[OptimizationRunResponse]) +def list_optimization_runs( + agent_id: Optional[UUID] = None, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + """List prompt optimization runs, optionally filtered by agent.""" + query = db.query(PromptOptimizationRun).filter( + PromptOptimizationRun.organization_id == organization_id, + ) + if agent_id: + query = query.filter(PromptOptimizationRun.agent_id == agent_id) + + return query.order_by(PromptOptimizationRun.created_at.desc()).limit(50).all() + + +@router.get("/runs/{run_id}", response_model=OptimizationRunResponse) +def get_optimization_run( + run_id: UUID, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + """Get details for a specific optimization run.""" + run = db.query(PromptOptimizationRun).filter( + PromptOptimizationRun.id == run_id, + PromptOptimizationRun.organization_id == organization_id, + ).first() + if not run: + raise HTTPException(404, "Optimization run not found") + return run + + +@router.delete("/runs/{run_id}", status_code=204) +def delete_optimization_run( + run_id: UUID, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + """Delete an optimization run and all its candidates.""" + run = db.query(PromptOptimizationRun).filter( + PromptOptimizationRun.id == run_id, + PromptOptimizationRun.organization_id == organization_id, + ).first() + if not run: + raise HTTPException(404, "Optimization run not found") + + db.query(PromptOptimizationCandidate).filter( + PromptOptimizationCandidate.optimization_run_id == run_id, + ).delete() + db.delete(run) + db.commit() + logger.info(f"[GEPA] Deleted optimization run {run_id}") + return + + +@router.get("/runs/{run_id}/candidates", response_model=List[CandidateResponse]) +def list_run_candidates( + run_id: UUID, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + """List all candidates for an optimization run.""" + run = db.query(PromptOptimizationRun).filter( + PromptOptimizationRun.id == run_id, + PromptOptimizationRun.organization_id == organization_id, + ).first() + if not run: + raise HTTPException(404, "Optimization run not found") + + return ( + db.query(PromptOptimizationCandidate) + .filter(PromptOptimizationCandidate.optimization_run_id == run_id) + .order_by(PromptOptimizationCandidate.score.desc().nullslast()) + .all() + ) + + +@router.post("/runs/{run_id}/candidates/{candidate_id}/accept") +def accept_candidate( + run_id: UUID, + candidate_id: UUID, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + """Accept a candidate prompt for deployment.""" + candidate = _get_candidate(db, run_id, candidate_id, organization_id) + + db.query(PromptOptimizationCandidate).filter( + PromptOptimizationCandidate.optimization_run_id == run_id, + ).update({"is_accepted": False}) + + candidate.is_accepted = True + db.commit() + return {"message": "Candidate accepted", "candidate_id": str(candidate.id)} + + +@router.post("/runs/{run_id}/candidates/{candidate_id}/push") +def push_candidate_to_provider( + run_id: UUID, + candidate_id: UUID, + organization_id: UUID = Depends(get_organization_id), + api_key: str = Depends(get_api_key), + db: Session = Depends(get_db), +): + """Push an accepted candidate prompt to the voice provider (Vapi/Retell/ElevenLabs).""" + candidate = _get_candidate(db, run_id, candidate_id, organization_id) + + if not candidate.is_accepted: + raise HTTPException(400, "Candidate must be accepted before pushing to provider") + + run = candidate.optimization_run + agent = db.query(Agent).filter(Agent.id == run.agent_id).first() + if not agent: + raise HTTPException(404, "Agent not found") + + if not agent.voice_ai_integration_id or not agent.voice_ai_agent_id: + raise HTTPException(400, "Agent is not linked to an external voice provider") + + integration = db.query(Integration).filter( + Integration.id == agent.voice_ai_integration_id, + ).first() + if not integration: + raise HTTPException(404, "Voice integration not found") + + try: + decrypted_key = decrypt_api_key(integration.api_key) + provider_class = get_voice_provider(integration.platform) + + platform_val = integration.platform.value if hasattr(integration.platform, "value") else integration.platform + if platform_val.lower() == "vapi": + provider = provider_class(api_key=decrypted_key, public_key=integration.public_key) + else: + provider = provider_class(api_key=decrypted_key) + + result = provider.update_agent_prompt( + agent_id=agent.voice_ai_agent_id, + system_prompt=candidate.prompt_text, + ) + except Exception as e: + raise HTTPException(500, f"Failed to push prompt to provider: {str(e)}") + + candidate.pushed_to_provider_at = datetime.now(timezone.utc) + + agent.description = candidate.prompt_text + agent.provider_prompt = candidate.prompt_text + agent.provider_prompt_synced_at = datetime.now(timezone.utc) + db.commit() + + return { + "message": f"Prompt pushed to {platform_val} and agent description updated", + "candidate_id": str(candidate.id), + "provider_response": result, + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _get_candidate( + db: Session, + run_id: UUID, + candidate_id: UUID, + organization_id: UUID, +) -> PromptOptimizationCandidate: + run = db.query(PromptOptimizationRun).filter( + PromptOptimizationRun.id == run_id, + PromptOptimizationRun.organization_id == organization_id, + ).first() + if not run: + raise HTTPException(404, "Optimization run not found") + + candidate = db.query(PromptOptimizationCandidate).filter( + PromptOptimizationCandidate.id == candidate_id, + PromptOptimizationCandidate.optimization_run_id == run_id, + ).first() + if not candidate: + raise HTTPException(404, "Candidate not found") + + return candidate diff --git a/app/core/license.py b/app/core/license.py index d6ab1364..6fab3aa9 100644 --- a/app/core/license.py +++ b/app/core/license.py @@ -33,6 +33,11 @@ "description": "A/B test TTS providers with blind tests and quality analytics.", "category": "playground", }, + "gepa_optimization": { + "title": "Prompt Optimization", + "description": "Self-improving voice agents via reflective prompt evolution.", + "category": "optimization", + }, } # Backward-compatible export used by existing API response shape. diff --git a/app/migrations/015_add_prompt_optimization_tables.py b/app/migrations/015_add_prompt_optimization_tables.py new file mode 100644 index 00000000..a9d041bd --- /dev/null +++ b/app/migrations/015_add_prompt_optimization_tables.py @@ -0,0 +1,90 @@ +""" +Migration: Add GEPA prompt optimization tables. + +Creates prompt_optimization_runs and prompt_optimization_candidates +for the enterprise self-improving voice agents feature. +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = "Add prompt_optimization_runs and prompt_optimization_candidates tables" + + +def upgrade(db: Session): + db.execute(text(""" + CREATE TABLE IF NOT EXISTS prompt_optimization_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id), + agent_id UUID NOT NULL REFERENCES agents(id), + evaluator_id UUID REFERENCES evaluators(id), + voice_bundle_id UUID REFERENCES voicebundles(id), + + seed_prompt TEXT NOT NULL, + best_prompt TEXT, + best_score DOUBLE PRECISION, + + status VARCHAR(20) NOT NULL DEFAULT 'pending', + config JSONB, + reflection_trace JSONB, + metric_history JSONB, + + num_iterations INTEGER, + num_metric_calls INTEGER, + + celery_task_id VARCHAR, + error_message TEXT, + + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + created_by VARCHAR + ) + """)) + + db.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_prompt_optimization_runs_org + ON prompt_optimization_runs (organization_id) + """)) + db.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_prompt_optimization_runs_agent + ON prompt_optimization_runs (agent_id) + """)) + db.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_prompt_optimization_runs_celery + ON prompt_optimization_runs (celery_task_id) + """)) + + db.execute(text(""" + CREATE TABLE IF NOT EXISTS prompt_optimization_candidates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + optimization_run_id UUID NOT NULL + REFERENCES prompt_optimization_runs(id) ON DELETE CASCADE, + + prompt_text TEXT NOT NULL, + score DOUBLE PRECISION, + metric_breakdown JSONB, + reflection_summary TEXT, + + parent_candidate_id UUID REFERENCES prompt_optimization_candidates(id), + + is_accepted BOOLEAN NOT NULL DEFAULT FALSE, + pushed_to_provider_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT now() + ) + """)) + + db.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_prompt_optimization_candidates_run + ON prompt_optimization_candidates (optimization_run_id) + """)) + + db.commit() + print("Created prompt_optimization_runs and prompt_optimization_candidates tables") + + +def downgrade(db: Session): + db.execute(text("DROP TABLE IF EXISTS prompt_optimization_candidates CASCADE")) + db.execute(text("DROP TABLE IF EXISTS prompt_optimization_runs CASCADE")) + db.commit() + print("Dropped prompt optimization tables") diff --git a/app/migrations/016_add_provider_prompt_to_agents.py b/app/migrations/016_add_provider_prompt_to_agents.py new file mode 100644 index 00000000..ad384f34 --- /dev/null +++ b/app/migrations/016_add_provider_prompt_to_agents.py @@ -0,0 +1,32 @@ +""" +Migration: Add provider_prompt columns to agents table. + +Stores the system prompt fetched from the voice provider (Vapi, Retell, +ElevenLabs) so the optimization pipeline can use the actual production +prompt as the seed rather than the local description field. +""" + +from sqlalchemy import text +from sqlalchemy.orm import Session + +description = "Add provider_prompt and provider_prompt_synced_at to agents" + + +def upgrade(db: Session): + db.execute(text(""" + ALTER TABLE agents + ADD COLUMN IF NOT EXISTS provider_prompt TEXT, + ADD COLUMN IF NOT EXISTS provider_prompt_synced_at TIMESTAMPTZ + """)) + db.commit() + print("Added provider_prompt and provider_prompt_synced_at columns to agents") + + +def downgrade(db: Session): + db.execute(text(""" + ALTER TABLE agents + DROP COLUMN IF EXISTS provider_prompt, + DROP COLUMN IF EXISTS provider_prompt_synced_at + """)) + db.commit() + print("Dropped provider_prompt columns from agents") diff --git a/app/models/database.py b/app/models/database.py index e03c2793..3fa85e5d 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -11,7 +11,8 @@ LanguageEnum, CallTypeEnum, CallMediumEnum, GenderEnum, AccentEnum, BackgroundNoiseEnum, IntegrationPlatform, ModelProvider, VoiceBundleType, TestAgentConversationStatus, MetricType, MetricTrigger, CallRecordingStatus, AlertMetricType, AlertAggregation, - AlertOperator, AlertNotifyFrequency, AlertStatus, AlertHistoryStatus, CronJobStatus + AlertOperator, AlertNotifyFrequency, AlertStatus, AlertHistoryStatus, CronJobStatus, + PromptOptimizationStatus, ) def get_enum_values(enum_class): @@ -215,6 +216,8 @@ class Agent(Base): phone_number = Column(String, nullable=True) # Optional, required only for phone_call language = Column(String, nullable=False, default=LanguageEnum.ENGLISH.value) description = Column(String) + provider_prompt = Column(Text, nullable=True) + provider_prompt_synced_at = Column(DateTime(timezone=True), nullable=True) call_type = Column(String, nullable=False, default=CallTypeEnum.OUTBOUND.value) call_medium = Column(String, nullable=False, default=CallMediumEnum.PHONE_CALL.value) @@ -750,6 +753,9 @@ class TTSComparison(Base): blind_test_results = Column(JSON, nullable=True) evaluation_summary = Column(JSON, nullable=True) + eval_stt_provider = Column(String(100), nullable=True) + eval_stt_model = Column(String(100), nullable=True) + celery_task_id = Column(String, nullable=True, index=True) error_message = Column(String, nullable=True) @@ -870,4 +876,58 @@ class CustomTTSVoice(Base): description = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) \ No newline at end of file + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +class PromptOptimizationRun(Base): + """A single GEPA prompt optimization run for an agent.""" + __tablename__ = "prompt_optimization_runs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + organization_id = Column(UUID(as_uuid=True), ForeignKey("organizations.id"), nullable=False, index=True) + agent_id = Column(UUID(as_uuid=True), ForeignKey("agents.id"), nullable=False, index=True) + evaluator_id = Column(UUID(as_uuid=True), ForeignKey("evaluators.id"), nullable=True) + voice_bundle_id = Column(UUID(as_uuid=True), ForeignKey("voicebundles.id"), nullable=True) + + seed_prompt = Column(Text, nullable=False) + best_prompt = Column(Text, nullable=True) + best_score = Column(Float, nullable=True) + + status = Column(String(20), nullable=False, default=PromptOptimizationStatus.PENDING.value) + config = Column(JSON, nullable=True) + reflection_trace = Column(JSON, nullable=True) + metric_history = Column(JSON, nullable=True) + + num_iterations = Column(Integer, nullable=True) + num_metric_calls = Column(Integer, nullable=True) + + celery_task_id = Column(String, nullable=True, index=True) + error_message = Column(Text, nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + created_by = Column(String, nullable=True) + + candidates = relationship("PromptOptimizationCandidate", back_populates="optimization_run", cascade="all, delete-orphan") + + +class PromptOptimizationCandidate(Base): + """A candidate prompt generated during an optimization run.""" + __tablename__ = "prompt_optimization_candidates" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + optimization_run_id = Column(UUID(as_uuid=True), ForeignKey("prompt_optimization_runs.id", ondelete="CASCADE"), nullable=False, index=True) + + prompt_text = Column(Text, nullable=False) + score = Column(Float, nullable=True) + metric_breakdown = Column(JSON, nullable=True) + reflection_summary = Column(Text, nullable=True) + + parent_candidate_id = Column(UUID(as_uuid=True), ForeignKey("prompt_optimization_candidates.id"), nullable=True) + + is_accepted = Column(Boolean, nullable=False, default=False) + pushed_to_provider_at = Column(DateTime(timezone=True), nullable=True) + + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + optimization_run = relationship("PromptOptimizationRun", back_populates="candidates") \ No newline at end of file diff --git a/app/models/enums.py b/app/models/enums.py index 10cf1766..418b0b7f 100644 --- a/app/models/enums.py +++ b/app/models/enums.py @@ -202,3 +202,11 @@ class CronJobStatus(str, enum.Enum): ACTIVE = "active" PAUSED = "paused" COMPLETED = "completed" + + +class PromptOptimizationStatus(str, enum.Enum): + """Prompt optimization run status.""" + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" diff --git a/app/models/schemas.py b/app/models/schemas.py index 1f49beab..d12582fe 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -275,6 +275,8 @@ class AgentResponse(BaseModel): ai_provider_id: Optional[UUID] voice_ai_integration_id: Optional[UUID] voice_ai_agent_id: Optional[str] + provider_prompt: Optional[str] = None + provider_prompt_synced_at: Optional[datetime] = None created_at: datetime updated_at: datetime diff --git a/app/services/optimization/__init__.py b/app/services/optimization/__init__.py new file mode 100644 index 00000000..3899d4a5 --- /dev/null +++ b/app/services/optimization/__init__.py @@ -0,0 +1,11 @@ +""" +GEPA prompt optimization service package. + +Public API:: + + from app.services.optimization import run_optimization +""" + +from app.services.optimization.gepa_service import run_optimization + +__all__ = ["run_optimization"] diff --git a/app/services/optimization/data_preparation.py b/app/services/optimization/data_preparation.py new file mode 100644 index 00000000..cc90f52f --- /dev/null +++ b/app/services/optimization/data_preparation.py @@ -0,0 +1,61 @@ +""" +Convert historical EvaluatorResult rows into the training data format +expected by GEPA's DefaultAdapter (``DefaultDataInst``). +""" + +from typing import Any, Dict, List + +from app.models.database import EvaluatorResult, Metric + + +def build_trainset( + training_data: List[EvaluatorResult], + metrics: List[Metric], +) -> List[Dict[str, Any]]: + """ + Each EvaluatorResult becomes one GEPA training example: + + * ``input`` -- the conversation transcript (truncated to 3 000 chars) + * ``additional_context`` -- historical metric scores keyed as + ``metric_`` + * ``answer`` -- a qualitative label derived from the average score + """ + trainset: List[Dict[str, Any]] = [] + + for result in training_data: + if not result.transcription: + continue + + metric_context: Dict[str, str] = {} + if result.metric_scores: + scores = result.metric_scores if isinstance(result.metric_scores, dict) else {} + for m in metrics: + if m.name in scores: + metric_context[f"metric_{m.name}"] = str(scores[m.name]) + + answer = _label_from_scores(result.metric_scores) + + trainset.append({ + "input": result.transcription[:3000], + "additional_context": metric_context, + "answer": answer, + }) + + return trainset + + +def _label_from_scores(metric_scores: Any) -> str: + """Derive a qualitative answer string from numeric metric scores.""" + if not metric_scores or not isinstance(metric_scores, dict): + return "High quality conversation achieving all metric targets." + + numeric = [float(v) for v in metric_scores.values() if isinstance(v, (int, float))] + if not numeric: + return "High quality conversation achieving all metric targets." + + avg = sum(numeric) / len(numeric) + if avg >= 0.8: + return "Excellent: all metrics above threshold." + if avg >= 0.5: + return "Acceptable: some metrics need improvement." + return "Poor: significant improvement needed." diff --git a/app/services/optimization/evaluator.py b/app/services/optimization/evaluator.py new file mode 100644 index 00000000..87ca1d53 --- /dev/null +++ b/app/services/optimization/evaluator.py @@ -0,0 +1,115 @@ +""" +Build a GEPA-compatible evaluator callable. + +The evaluator scores how well an LLM response (generated from a candidate +system prompt) aligns with metric targets for a historical voice AI +conversation transcript. It delegates to ``LLMService.generate_response`` +so that API keys are passed per-call -- consistent with the rest of the +codebase. +""" + +import json +from typing import Any, Callable, Dict, List +from uuid import UUID + +from loguru import logger + +from app.models.database import AIProvider, Metric +from app.workers.tasks.helpers.score_utils import get_metric_type_value + + +def _get_evaluation_result_class(): + """Lazy-import EvaluationResult; gepa will already be installed by the time this is called.""" + try: + from gepa.adapters.default_adapter.default_adapter import EvaluationResult + return EvaluationResult + except ImportError: + return None + + +def build_evaluator( + metrics: List[Metric], + ai_providers: List[AIProvider], + organization_id: UUID, + db, +) -> Callable[[Dict[str, Any], str], Any]: + """ + Return a function with the signature GEPA's ``Evaluator`` protocol + expects:: + + (data: DefaultDataInst, response: str) -> EvaluationResult + """ + metrics_str = "\n".join( + f'- "{m.name}" ({get_metric_type_value(m)}): {m.description or f"Evaluate {m.name}"}' + for m in metrics + ) + + EvaluationResult = _get_evaluation_result_class() + + def evaluator_fn(data: Dict[str, Any], response: str) -> Any: + transcript = data["input"] + additional = data.get("additional_context", {}) + historical_scores = { + k.replace("metric_", ""): v + for k, v in additional.items() + if k.startswith("metric_") + } + + score_context = "" + if historical_scores: + score_context = ( + "\n\nHistorical metric scores for this conversation:\n" + + "\n".join(f"- {k}: {v}" for k, v in historical_scores.items()) + ) + + eval_prompt = ( + "You are evaluating whether a voice agent's generated response is " + "consistent with the system prompt instructions and handles the " + "conversation well.\n\n" + f"## Agent's Generated Response\n{response[:2000]}\n\n" + f"## Historical Conversation Transcript\n{transcript[:2000]}\n\n" + f"## Metrics\n{metrics_str}\n" + f"{score_context}\n\n" + "Rate the quality of the agent's response. Consider:\n" + "1. Does it follow the system prompt instructions?\n" + "2. Would it score well on the listed metrics?\n" + "3. Is it professional and helpful?\n\n" + "Respond with ONLY a JSON object: " + '{\"score\": , \"feedback\": \"\"}' + ) + + from app.services.ai.llm_service import llm_service + from app.models.database import ModelProvider + + provider = next( + (p for p in ai_providers if p.provider.lower() in ("openai", "anthropic")), + ai_providers[0] if ai_providers else None, + ) + if not provider: + return EvaluationResult(score=0.5, feedback="No AI provider available") + + try: + result = llm_service.generate_response( + messages=[ + {"role": "system", "content": "You are an expert voice AI evaluator. Respond with JSON only."}, + {"role": "user", "content": eval_prompt}, + ], + llm_provider=ModelProvider(provider.provider.lower()), + llm_model="gpt-4o", + organization_id=organization_id, + db=db, + temperature=0.3, + max_tokens=500, + ) + text = result.get("text", "").strip() + if text.startswith("```"): + text = text.replace("```json", "").replace("```", "").strip() + parsed = json.loads(text) + score = float(parsed.get("score", 0.5)) + feedback = parsed.get("feedback", "") + return EvaluationResult(score=score, feedback=feedback) + except Exception as e: + logger.warning(f"GEPA evaluation failed: {e}") + return EvaluationResult(score=0.5, feedback=f"Evaluation error: {e}") + + return evaluator_fn diff --git a/app/services/optimization/gepa_service.py b/app/services/optimization/gepa_service.py new file mode 100644 index 00000000..35a344b4 --- /dev/null +++ b/app/services/optimization/gepa_service.py @@ -0,0 +1,164 @@ +""" +GEPA Prompt Optimization Service -- orchestrator. + +Wires together LM resolution, data preparation, evaluation, and the GEPA +engine. Each concern lives in its own module; this file is the thin +entry-point consumed by the Celery task. +""" + +from typing import Any, Dict, List, Optional +from uuid import UUID + +import litellm +from loguru import logger + +from app.models.database import ( + Agent, + AIProvider, + Evaluator, + EvaluatorResult, + Metric, + VoiceBundle, +) +from app.services.optimization.data_preparation import build_trainset +from app.services.optimization.evaluator import build_evaluator +from app.services.optimization.lm_resolver import resolve_api_key, resolve_lm + +_gepa_install_attempted = False + + +def _lazy_install_gepa() -> bool: + """One-shot attempt to pip-install gepa + dspy at runtime.""" + global _gepa_install_attempted + if _gepa_install_attempted: + return False + _gepa_install_attempted = True + + logger.info("[GEPA] gepa not found – attempting auto-install (this may take a few minutes)...") + try: + import subprocess, sys + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "--quiet", "gepa", "dspy"], + timeout=300, + ) + logger.info("[GEPA] gepa + dspy installed successfully") + return True + except Exception as install_err: + logger.warning(f"[GEPA] Auto-install failed: {install_err}") + return False + + +def _ensure_gepa(): + """Import gepa, auto-installing on first failure. Returns (gepa_optimize, DefaultAdapter).""" + try: + from gepa import optimize as gepa_optimize + from gepa.adapters.default_adapter.default_adapter import DefaultAdapter + return gepa_optimize, DefaultAdapter + except ImportError: + if _lazy_install_gepa(): + try: + from gepa import optimize as gepa_optimize + from gepa.adapters.default_adapter.default_adapter import DefaultAdapter + return gepa_optimize, DefaultAdapter + except ImportError as e: + raise ImportError( + f"[GEPA] Still not importable after install: {e}. " + "Try manually: pip install gepa dspy" + ) from e + raise ImportError( + "[GEPA] Not installed and auto-install failed. " + "Install manually: pip install gepa dspy" + ) + + +def run_optimization( + agent: Agent, + evaluator: Optional[Evaluator], + voice_bundle: Optional[VoiceBundle], + training_data: List[EvaluatorResult], + metrics: List[Metric], + ai_providers: List[AIProvider], + organization_id: UUID, + db, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + Run a full GEPA optimization loop. + + Returns a dict with keys: ``best_candidate``, ``best_score``, + ``candidates``, ``metric_history``, ``total_metric_calls``. + """ + gepa_optimize, DefaultAdapter = _ensure_gepa() + + config = config or {} + max_metric_calls = config.get("max_metric_calls", 20) + minibatch_size = config.get("minibatch_size", 5) + + seed_prompt = agent.provider_prompt or agent.description or "" + lm_identifier = resolve_lm(voice_bundle, evaluator) + api_key = resolve_api_key(lm_identifier, ai_providers) + + trainset = build_trainset(training_data, metrics) + if not trainset: + raise ValueError("No evaluator results with transcripts available for optimization") + + evaluator_fn = build_evaluator(metrics, ai_providers, organization_id, db) + + logger.info( + f"[GEPA] Starting optimization for agent '{agent.name}' " + f"with {len(trainset)} training examples, LM={lm_identifier}" + ) + + adapter = DefaultAdapter( + model=lm_identifier, + evaluator=evaluator_fn, + litellm_batch_completion_kwargs={"api_key": api_key}, + ) + + def reflection_lm(prompt: str) -> str: + resp = litellm.completion( + model=lm_identifier, + messages=[{"role": "user", "content": prompt}], + api_key=api_key, + ) + return resp.choices[0].message.content + + result = gepa_optimize( + seed_candidate={"system_prompt": seed_prompt}, + trainset=trainset, + adapter=adapter, + reflection_lm=reflection_lm, + max_metric_calls=max_metric_calls, + reflection_minibatch_size=min(minibatch_size, len(trainset)), + candidate_selection_strategy="pareto", + ) + + return _format_result(result, seed_prompt) + + +def _format_result(result, seed_prompt: str) -> Dict[str, Any]: + """Normalise a ``GEPAResult`` into a serialisable dict.""" + candidates = [] + for i, cand_dict in enumerate(result.candidates): + prompt_text = cand_dict.get("system_prompt", next(iter(cand_dict.values()), "")) + candidates.append({ + "prompt_text": prompt_text, + "score": result.val_aggregate_scores[i] if i < len(result.val_aggregate_scores) else None, + "parent_idx": result.parents[i] if i < len(result.parents) else None, + "reflection_summary": None, + }) + + best = result.best_candidate + best_prompt = best.get("system_prompt", next(iter(best.values()), seed_prompt)) + best_score = result.val_aggregate_scores[result.best_idx] + + return { + "best_candidate": best_prompt, + "best_score": best_score, + "candidates": candidates, + "metric_history": [ + {"candidate_idx": i, "score": s} + for i, s in enumerate(result.val_aggregate_scores) + ], + "total_metric_calls": result.total_metric_calls, + } diff --git a/app/services/optimization/lm_resolver.py b/app/services/optimization/lm_resolver.py new file mode 100644 index 00000000..55e79534 --- /dev/null +++ b/app/services/optimization/lm_resolver.py @@ -0,0 +1,44 @@ +""" +Resolve the LM identifier and API key for GEPA optimization runs. + +The LM is derived from the agent's VoiceBundle (or the Evaluator's config as +fallback). The API key is decrypted from the matching AIProvider row and +passed explicitly on every LiteLLM call -- no environment variables mutated. +""" + +from typing import List, Optional + +from app.core.encryption import decrypt_api_key +from app.models.database import AIProvider, Evaluator, VoiceBundle + + +def resolve_lm( + voice_bundle: Optional[VoiceBundle] = None, + evaluator: Optional[Evaluator] = None, +) -> str: + """ + Return a ``"{provider}/{model}"`` string suitable for LiteLLM, resolved + from the VoiceBundle first, then the Evaluator, with a sensible fallback. + """ + if voice_bundle and voice_bundle.llm_provider and voice_bundle.llm_model: + return f"{voice_bundle.llm_provider}/{voice_bundle.llm_model}" + if evaluator and evaluator.llm_provider and evaluator.llm_model: + return f"{evaluator.llm_provider}/{evaluator.llm_model}" + return "openai/gpt-4o" + + +def resolve_api_key(lm_identifier: str, ai_providers: List[AIProvider]) -> str: + """ + Given ``"openai/gpt-5.4"`` and the org's provider list, decrypt and + return the matching API key. + """ + provider_prefix = lm_identifier.split("/")[0].lower() + for p in ai_providers: + if not p.is_active or not p.api_key: + continue + if p.provider.lower() == provider_prefix: + return decrypt_api_key(p.api_key) + raise RuntimeError( + f"No active AI provider matching '{provider_prefix}' found. " + "Add one in Settings > AI Providers." + ) diff --git a/app/services/voice_providers/base.py b/app/services/voice_providers/base.py index 390be3ed..76248ade 100644 --- a/app/services/voice_providers/base.py +++ b/app/services/voice_providers/base.py @@ -2,7 +2,7 @@ Base Voice Provider Interface All voice providers should inherit from this class """ -from abc import ABC, abstractmethod +from abc import ABC, abstractmethod from typing import Dict, Any, Optional @@ -78,6 +78,32 @@ def retrieve_call_metrics(self, call_id: str) -> Dict[str, Any]: """ pass + @abstractmethod + def extract_agent_prompt(self, agent_id: str) -> Optional[str]: + """ + Fetch the current system prompt / instructions from the provider + for the given agent. + + Returns: + The system prompt string, or None if it cannot be determined. + """ + pass + + @abstractmethod + def update_agent_prompt(self, agent_id: str, system_prompt: str, **kwargs) -> Dict[str, Any]: + """ + Update the agent's system prompt / instructions in the provider platform. + + Args: + agent_id: The agent ID from the voice provider + system_prompt: The new system prompt text + **kwargs: Additional provider-specific parameters + + Returns: + Dictionary containing the updated agent information + """ + pass + @abstractmethod def test_connection(self) -> bool: """ diff --git a/app/services/voice_providers/elevenlabs.py b/app/services/voice_providers/elevenlabs.py index ccdac2ec..969c38ee 100644 --- a/app/services/voice_providers/elevenlabs.py +++ b/app/services/voice_providers/elevenlabs.py @@ -207,6 +207,84 @@ def retrieve_call_metrics(self, call_id: str) -> Dict[str, Any]: logger.error(f"[ElevenLabsProvider] Error getting conversation: {e}", exc_info=True) raise ValueError(f"Failed to retrieve ElevenLabs conversation: {str(e)}") + def extract_agent_prompt(self, agent_id: str) -> Optional[str]: + """Extract the system prompt from an ElevenLabs Conversational AI agent.""" + try: + data = self.get_agent(agent_id) + conv_config = data.get("conversation_config") or {} + agent_config = conv_config.get("agent") or {} + prompt_config = agent_config.get("prompt") or {} + prompt = prompt_config.get("prompt") + if prompt: + prompt = self._strip_code_fences(prompt) + return prompt + except Exception as e: + logger.warning(f"[ElevenLabsProvider] Failed to extract agent prompt: {e}") + return None + + @staticmethod + def _strip_code_fences(text: str) -> str: + """Remove wrapping triple-backtick code fences that some providers add. + + Handles both complete fences (opening + closing) and prompts that + only start with an opening fence (e.g. truncated or provider quirk). + """ + import re + trimmed = text.strip() + # Try complete fence first (opening + closing) + m = re.match(r'^```[\w]*\n?([\s\S]*?)```\s*$', trimmed) + if m: + return m.group(1).strip() + # Opening fence only (no closing) + m = re.match(r'^```[\w]*\n?([\s\S]*)$', trimmed) + if m: + return m.group(1).strip() + return trimmed + + def update_agent_prompt(self, agent_id: str, system_prompt: str, **kwargs) -> Dict[str, Any]: + """ + Update an ElevenLabs Conversational AI agent's system prompt. + + Args: + agent_id: ElevenLabs agent ID + system_prompt: New system prompt text + + Returns: + Updated agent data from ElevenLabs + """ + try: + url = f"{self.api_url}/convai/agents/{agent_id}" + headers = { + "xi-api-key": self.api_key, + "Content-Type": "application/json", + } + payload = { + "conversation_config": { + "agent": { + "prompt": { + "prompt": system_prompt, + }, + }, + }, + } + logger.info(f"[ElevenLabsProvider] Updating agent prompt: PATCH {url}") + response = requests.patch(url, headers=headers, json=payload, timeout=30) + + if not response.ok: + try: + error_body = response.json() + except Exception: + error_body = response.text[:500] + raise ValueError( + f"ElevenLabs API error ({response.status_code}): {error_body}" + ) + + data = response.json() + logger.info(f"[ElevenLabsProvider] Agent {agent_id} prompt updated") + return data + except requests.exceptions.RequestException as e: + raise ValueError(f"Failed to update ElevenLabs agent prompt: {str(e)}") + def test_connection(self) -> bool: """Test the ElevenLabs API connection.""" try: diff --git a/app/services/voice_providers/prompt_sync.py b/app/services/voice_providers/prompt_sync.py new file mode 100644 index 00000000..62500167 --- /dev/null +++ b/app/services/voice_providers/prompt_sync.py @@ -0,0 +1,63 @@ +""" +Shared helper for syncing the provider prompt into the local Agent row. + +Used by agent create/update routes and the manual sync endpoint. +""" + +from datetime import datetime, timezone +from typing import Optional + +from loguru import logger +from sqlalchemy.orm import Session + +from app.core.encryption import decrypt_api_key +from app.models.database import Agent, Integration +from app.services.voice_providers import get_voice_provider + + +def sync_provider_prompt( + agent: Agent, + integration: Integration, + db: Session, +) -> Optional[str]: + """ + Fetch the system prompt from the voice provider and persist it on the + agent row. + + Returns the fetched prompt string, or None if extraction fails. + Callers that want best-effort semantics should wrap this in try/except. + """ + decrypted_key = decrypt_api_key(integration.api_key) + provider_class = get_voice_provider( + integration.platform.value + if hasattr(integration.platform, "value") + else integration.platform + ) + + platform_val = ( + integration.platform.value + if hasattr(integration.platform, "value") + else integration.platform + ) + if platform_val.lower() == "vapi": + provider = provider_class(api_key=decrypted_key, public_key=integration.public_key) + else: + provider = provider_class(api_key=decrypted_key) + + prompt = provider.extract_agent_prompt(agent.voice_ai_agent_id) + + if prompt is not None: + agent.provider_prompt = prompt + agent.provider_prompt_synced_at = datetime.now(timezone.utc) + db.commit() + logger.info( + f"[PromptSync] Synced provider prompt for agent {agent.name} " + f"({len(prompt)} chars)" + ) + else: + logger.warning( + f"[PromptSync] No prompt returned for agent {agent.name} " + f"on {platform_val}" + ) + + return prompt diff --git a/app/services/voice_providers/retell.py b/app/services/voice_providers/retell.py index 731d2910..aa394d43 100644 --- a/app/services/voice_providers/retell.py +++ b/app/services/voice_providers/retell.py @@ -4,6 +4,7 @@ """ from typing import Dict, Any, Optional from retell import Retell +from loguru import logger from app.services.voice_providers.base import BaseVoiceProvider @@ -279,6 +280,130 @@ def retrieve_call_metrics(self, call_id: str) -> Dict[str, Any]: except Exception as e: raise ValueError(f"Failed to retrieve Retell call metrics: {str(e)}") + def extract_agent_prompt(self, agent_id: str) -> Optional[str]: + """Extract the system prompt from a Retell agent. + + Handles all three response engine types: + - retell-llm: fetch LLM by llm_id, read general_prompt + - conversation-flow: fetch flow by conversation_flow_id, read global_prompt + - custom-llm: no extractable prompt (websocket-based) + """ + try: + agent_response = self.client.agent.retrieve(agent_id=agent_id) + + response_engine = getattr(agent_response, "response_engine", None) + if response_engine is None: + logger.warning("[RetellProvider] No response_engine on agent") + return None + + engine_type = getattr(response_engine, "type", None) + if isinstance(response_engine, dict): + engine_type = response_engine.get("type") + + logger.debug(f"[RetellProvider] response_engine type={engine_type}") + + # --- retell-llm: fetch the LLM and read general_prompt --- + llm_id = getattr(response_engine, "llm_id", None) + if isinstance(response_engine, dict): + llm_id = response_engine.get("llm_id", llm_id) + + if llm_id: + logger.debug(f"[RetellProvider] Fetching LLM {llm_id}") + llm_response = self.client.llm.retrieve(llm_id=llm_id) + + prompt = getattr(llm_response, "general_prompt", None) + if isinstance(llm_response, dict): + prompt = llm_response.get("general_prompt", prompt) + if prompt: + return prompt + + if hasattr(llm_response, "model_dump"): + prompt = llm_response.model_dump().get("general_prompt") + if prompt: + return prompt + + logger.warning(f"[RetellProvider] LLM {llm_id} returned no general_prompt") + return None + + # --- conversation-flow: fetch the flow and read global_prompt --- + flow_id = getattr(response_engine, "conversation_flow_id", None) + if isinstance(response_engine, dict): + flow_id = response_engine.get("conversation_flow_id", flow_id) + + if flow_id: + logger.debug(f"[RetellProvider] Fetching conversation flow {flow_id}") + flow_response = self.client.conversation_flow.retrieve( + conversation_flow_id=flow_id + ) + + prompt = getattr(flow_response, "global_prompt", None) + if isinstance(flow_response, dict): + prompt = flow_response.get("global_prompt", prompt) + if prompt: + return prompt + + if hasattr(flow_response, "model_dump"): + prompt = flow_response.model_dump().get("global_prompt") + if prompt: + return prompt + + logger.warning(f"[RetellProvider] Conversation flow {flow_id} returned no global_prompt") + return None + + # --- custom-llm or unknown: try system_prompt fallback --- + if isinstance(response_engine, dict): + return response_engine.get("system_prompt") + return getattr(response_engine, "system_prompt", None) + + except Exception as e: + logger.warning(f"[RetellProvider] Failed to extract agent prompt: {e}") + return None + + def update_agent_prompt(self, agent_id: str, system_prompt: str, **kwargs) -> Dict[str, Any]: + """ + Update a Retell agent's system prompt. + + Retrieves the current agent to find its LLM configuration, then updates + the prompt via the agent update endpoint. + + Args: + agent_id: Retell agent ID + system_prompt: New system prompt text + + Returns: + Updated agent data from Retell + """ + try: + current = self.get_agent(agent_id) + response_engine = current.get("response_engine") or {} + + llm_id = response_engine.get("llm_id") + if llm_id: + llm_response = self.client.llm.update( + llm_id=llm_id, + general_prompt=system_prompt, + ) + if isinstance(llm_response, dict): + return llm_response + elif hasattr(llm_response, "model_dump"): + return llm_response.model_dump() + return {"llm_id": llm_id, "updated": True} + + update_response = self.client.agent.update( + agent_id=agent_id, + response_engine={ + **response_engine, + "system_prompt": system_prompt, + }, + ) + if isinstance(update_response, dict): + return update_response + elif hasattr(update_response, "model_dump"): + return update_response.model_dump() + return {"agent_id": agent_id, "updated": True} + except Exception as e: + raise ValueError(f"Failed to update Retell agent prompt: {str(e)}") + def test_connection(self) -> bool: """ Test Retell connection by attempting to list agents. diff --git a/app/services/voice_providers/vapi.py b/app/services/voice_providers/vapi.py index 97b81a47..abbc2f2f 100644 --- a/app/services/voice_providers/vapi.py +++ b/app/services/voice_providers/vapi.py @@ -169,20 +169,106 @@ def create_agent( def get_agent(self, agent_id: str) -> Dict[str, Any]: """ - Get Vapi agent details. - + Get Vapi assistant details via GET /assistant/{id}. + Args: - agent_id: Vapi agent ID - + agent_id: Vapi assistant ID + Returns: - Dictionary containing agent information + Dictionary containing assistant information """ try: - # agent_response = self.client.agents.get(agent_id) - return {"agent_id": agent_id, "name": "Vapi Agent"} - except Exception as e: + url = f"{self.api_url}/assistant/{agent_id}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + logger.debug(f"[VapiProvider] Fetching assistant: GET {url}") + response = requests.get(url, headers=headers, timeout=15) + + if not response.ok: + try: + error_body = response.json() + except Exception: + error_body = response.text[:500] + raise ValueError( + f"Vapi API error ({response.status_code}): {error_body}" + ) + + return response.json() + except requests.exceptions.RequestException as e: raise ValueError(f"Failed to get Vapi agent: {str(e)}") + def extract_agent_prompt(self, agent_id: str) -> Optional[str]: + """Extract the system prompt from a Vapi assistant.""" + try: + data = self.get_agent(agent_id) + model = data.get("model") or {} + messages = model.get("messages") or [] + for msg in messages: + if msg.get("role") == "system": + return msg.get("content") + return None + except Exception as e: + logger.warning(f"[VapiProvider] Failed to extract agent prompt: {e}") + return None + + def update_agent_prompt(self, agent_id: str, system_prompt: str, **kwargs) -> Dict[str, Any]: + """ + Update a Vapi assistant's system prompt via PATCH /assistant/{id}. + + Fetches the current assistant first to preserve required model fields + (provider, model, temperature, etc.) that Vapi requires on the model object. + + Args: + agent_id: Vapi assistant ID + system_prompt: New system prompt text + + Returns: + Updated assistant data from Vapi + """ + try: + current_data = self.get_agent(agent_id) + existing_model = current_data.get("model") or {} + + updated_messages = [] + system_replaced = False + for msg in existing_model.get("messages") or []: + if msg.get("role") == "system" and not system_replaced: + updated_messages.append({"role": "system", "content": system_prompt}) + system_replaced = True + else: + updated_messages.append(msg) + if not system_replaced: + updated_messages.insert(0, {"role": "system", "content": system_prompt}) + + model_payload = {k: v for k, v in existing_model.items() if k != "messages"} + model_payload["messages"] = updated_messages + + url = f"{self.api_url}/assistant/{agent_id}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + payload = {"model": model_payload} + logger.info(f"[VapiProvider] Updating assistant prompt: PATCH {url}") + response = requests.patch(url, headers=headers, json=payload, timeout=30) + + if not response.ok: + try: + error_body = response.json() + except Exception: + error_body = response.text[:500] + raise ValueError( + f"Vapi API error ({response.status_code}): {error_body}" + ) + + data = response.json() + logger.info(f"[VapiProvider] Assistant {agent_id} prompt updated") + return data + except requests.exceptions.RequestException as e: + raise ValueError(f"Failed to update Vapi assistant prompt: {str(e)}") + def _make_json_serializable(self, obj: Any) -> Any: """ Recursively convert NumPy types and other non-JSON-serializable types to native Python types. diff --git a/app/services/voice_providers/voicemaker.py b/app/services/voice_providers/voicemaker.py index 0139bcbc..aa163d83 100644 --- a/app/services/voice_providers/voicemaker.py +++ b/app/services/voice_providers/voicemaker.py @@ -2,7 +2,7 @@ VoiceMaker Provider (API-key validation support). """ -from typing import Any, Dict +from typing import Any, Dict, Optional import requests @@ -24,6 +24,12 @@ def get_agent(self, agent_id: str) -> Dict[str, Any]: def retrieve_call_metrics(self, call_id: str) -> Dict[str, Any]: raise NotImplementedError("VoiceMaker call metrics are not supported in this app") + def extract_agent_prompt(self, agent_id: str) -> Optional[str]: + raise NotImplementedError("VoiceMaker does not support agent prompt extraction") + + def update_agent_prompt(self, agent_id: str, system_prompt: str, **kwargs) -> Dict[str, Any]: + raise NotImplementedError("VoiceMaker does not support agent prompt updates") + def test_connection(self) -> bool: """Validate API key via a tiny TTS conversion request.""" url = "https://developer.voicemaker.in/api/v1/voice/convert" diff --git a/app/workers/celery_app.py b/app/workers/celery_app.py index 44705353..9a7242b4 100644 --- a/app/workers/celery_app.py +++ b/app/workers/celery_app.py @@ -16,6 +16,7 @@ generate_tts_comparison_task, evaluate_tts_comparison_task, generate_tts_report_pdf_task, + run_prompt_optimization_task, ) __all__ = [ @@ -26,4 +27,5 @@ "generate_tts_comparison_task", "evaluate_tts_comparison_task", "generate_tts_report_pdf_task", + "run_prompt_optimization_task", ] diff --git a/app/workers/tasks/__init__.py b/app/workers/tasks/__init__.py index 3bcf3fb2..acd3fd81 100644 --- a/app/workers/tasks/__init__.py +++ b/app/workers/tasks/__init__.py @@ -8,6 +8,7 @@ from . import run_evaluator from . import tts_comparison from . import tts_report +from . import run_prompt_optimization __all__ = [ "celery_app", @@ -17,6 +18,7 @@ "generate_tts_comparison_task", "evaluate_tts_comparison_task", "generate_tts_report_pdf_task", + "run_prompt_optimization_task", ] process_evaluation_task = process_evaluation.process_evaluation_task @@ -25,3 +27,4 @@ generate_tts_comparison_task = tts_comparison.generate_tts_comparison_task evaluate_tts_comparison_task = tts_comparison.evaluate_tts_comparison_task generate_tts_report_pdf_task = tts_report.generate_tts_report_pdf_task +run_prompt_optimization_task = run_prompt_optimization.run_prompt_optimization_task diff --git a/app/workers/tasks/run_prompt_optimization.py b/app/workers/tasks/run_prompt_optimization.py new file mode 100644 index 00000000..a0ef1047 --- /dev/null +++ b/app/workers/tasks/run_prompt_optimization.py @@ -0,0 +1,146 @@ +""" +Celery task: Run a GEPA prompt optimization for a voice agent. + +Loads the optimization run config, collects training data from historical +evaluator results, invokes GEPAOptimizationService, and persists the +resulting candidates back to the database. +""" + +from loguru import logger + +from app.workers.config import celery_app +from app.database import SessionLocal +from app.models.database import ( + Agent, + AIProvider, + Evaluator, + EvaluatorResult, + Metric, + PromptOptimizationCandidate, + PromptOptimizationRun, + VoiceBundle, +) +from app.models.enums import PromptOptimizationStatus + + +@celery_app.task(bind=True, max_retries=1, time_limit=3600, name="run_prompt_optimization") +def run_prompt_optimization_task(self, optimization_run_id: str): + """ + Execute a GEPA prompt optimization run asynchronously. + + Args: + optimization_run_id: UUID (as string) of the PromptOptimizationRun row. + """ + db = SessionLocal() + try: + run = db.query(PromptOptimizationRun).filter( + PromptOptimizationRun.id == optimization_run_id + ).first() + if not run: + logger.error(f"[GEPA] Optimization run {optimization_run_id} not found") + return + + run.status = PromptOptimizationStatus.RUNNING.value + db.commit() + + agent = db.query(Agent).filter(Agent.id == run.agent_id).first() + if not agent: + _fail_run(db, run, "Agent not found") + return + + evaluator = None + if run.evaluator_id: + evaluator = db.query(Evaluator).filter(Evaluator.id == run.evaluator_id).first() + + voice_bundle = None + if run.voice_bundle_id: + voice_bundle = db.query(VoiceBundle).filter(VoiceBundle.id == run.voice_bundle_id).first() + elif agent.voice_bundle_id: + voice_bundle = db.query(VoiceBundle).filter(VoiceBundle.id == agent.voice_bundle_id).first() + + training_data = ( + db.query(EvaluatorResult) + .filter( + EvaluatorResult.organization_id == run.organization_id, + EvaluatorResult.agent_id == run.agent_id, + EvaluatorResult.transcription.isnot(None), + EvaluatorResult.status == "completed", + ) + .order_by(EvaluatorResult.created_at.desc()) + .limit(50) + .all() + ) + + if not training_data: + _fail_run(db, run, "No completed evaluator results with transcripts found for this agent") + return + + enabled_metrics = ( + db.query(Metric) + .filter(Metric.organization_id == run.organization_id, Metric.enabled == True) + .all() + ) + if not enabled_metrics: + _fail_run(db, run, "No enabled metrics found for this organization") + return + + ai_providers = ( + db.query(AIProvider) + .filter(AIProvider.organization_id == run.organization_id, AIProvider.is_active == True) + .all() + ) + + from app.services.optimization import run_optimization + + result = run_optimization( + agent=agent, + evaluator=evaluator, + voice_bundle=voice_bundle, + training_data=training_data, + metrics=enabled_metrics, + ai_providers=ai_providers, + organization_id=run.organization_id, + db=db, + config=run.config, + ) + + run.best_prompt = result["best_candidate"] + run.best_score = result["best_score"] + run.metric_history = result["metric_history"] + run.reflection_trace = None + run.num_metric_calls = result.get("total_metric_calls") or len(result.get("metric_history", [])) + run.status = PromptOptimizationStatus.COMPLETED.value + + for i, cand in enumerate(result.get("candidates", [])): + db.add(PromptOptimizationCandidate( + optimization_run_id=run.id, + prompt_text=cand["prompt_text"], + score=cand.get("score"), + reflection_summary=cand.get("reflection_summary"), + )) + + db.commit() + logger.info( + f"[GEPA] Optimization run {optimization_run_id} completed. " + f"Best score: {run.best_score}" + ) + + except Exception as e: + logger.error(f"[GEPA] Optimization run {optimization_run_id} failed: {e}", exc_info=True) + try: + run = db.query(PromptOptimizationRun).filter( + PromptOptimizationRun.id == optimization_run_id + ).first() + if run: + _fail_run(db, run, str(e)) + except Exception: + pass + finally: + db.close() + + +def _fail_run(db, run: PromptOptimizationRun, message: str): + run.status = PromptOptimizationStatus.FAILED.value + run.error_message = message + db.commit() + logger.error(f"[GEPA] Run {run.id} failed: {message}") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 89f5bfcf..c637db9d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -66,6 +66,9 @@ import IAM from './pages/iam/IAM' // Profile import Profile from './pages/profile/Profile' +// Prompt Optimization (Enterprise) +import PromptOptimization from './pages/promptOptimization/PromptOptimization' + // Enterprise import EnterpriseUpgrade from './pages/enterprise/EnterpriseUpgrade' @@ -141,6 +144,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0c1e0e1d..c6218ea6 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -33,6 +33,7 @@ import { Lock, ScrollText, Github, + Sparkles, } from 'lucide-react' import { useState, useEffect } from 'react' import Logo from './Logo' @@ -77,6 +78,14 @@ const navigationSections: NavSection[] = [ { name: 'Evaluation Results', href: '/results', icon: BarChart3 }, ], }, + { + title: 'Prompts', + icon: ScrollText, + items: [ + { name: 'Partials', href: '/prompt-partials', icon: FileText }, + { name: 'Optimization', href: '/prompt-optimization', icon: Sparkles, enterpriseFeature: 'gepa_optimization' }, + ], + }, { title: 'Observability', icon: BarChart3, @@ -108,7 +117,6 @@ const navigationSections: NavSection[] = [ const otherNavigation: NavItem[] = [ { name: 'Dashboard', href: '/', icon: LayoutDashboard }, - { name: 'Prompt Partials', href: '/prompt-partials', icon: ScrollText }, ] const bottomNavigation = [ @@ -367,7 +375,7 @@ function SidebarContent({ }) { const { isFeatureEnabled } = useLicenseStore() const [expandedSections, setExpandedSections] = useState>( - new Set(['Simulations', 'Playground', 'Evaluations', 'Observability', 'Alerting', 'Configurations']) + new Set(['Simulations', 'Playground', 'Evaluations', 'Prompts', 'Observability', 'Alerting', 'Configurations']) ) const toggleSection = (title: string) => { @@ -397,13 +405,16 @@ function SidebarContent({ {/* Other Navigation */} {otherNavigation.map((item) => { const isActive = location.pathname === item.href + const isGated = item.enterpriseFeature && !isFeatureEnabled(item.enterpriseFeature) return ( {item.name} + {isGated && } ) })} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6e989d9d..fba69298 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1504,6 +1504,59 @@ class ApiClient { const response = await this.client.get('/api/v1/settings/license-info') return response.data } + + // GEPA Prompt Optimization (Enterprise) + async createOptimizationRun(data: { + agent_id: string + evaluator_id?: string + voice_bundle_id?: string + config?: Record + }): Promise { + const response = await this.client.post('/api/v1/prompt-optimization/runs', data) + return response.data + } + + async deleteOptimizationRun(runId: string): Promise { + await this.client.delete(`/api/v1/prompt-optimization/runs/${runId}`) + } + + async listOptimizationRuns(agentId?: string): Promise { + const params = agentId ? { agent_id: agentId } : {} + const response = await this.client.get('/api/v1/prompt-optimization/runs', { params }) + return response.data + } + + async getOptimizationRun(runId: string): Promise { + const response = await this.client.get(`/api/v1/prompt-optimization/runs/${runId}`) + return response.data + } + + async listOptimizationCandidates(runId: string): Promise { + const response = await this.client.get(`/api/v1/prompt-optimization/runs/${runId}/candidates`) + return response.data + } + + async acceptCandidate(runId: string, candidateId: string): Promise { + const response = await this.client.post( + `/api/v1/prompt-optimization/runs/${runId}/candidates/${candidateId}/accept` + ) + return response.data + } + + async pushCandidateToProvider(runId: string, candidateId: string): Promise { + const response = await this.client.post( + `/api/v1/prompt-optimization/runs/${runId}/candidates/${candidateId}/push` + ) + return response.data + } + + async syncProviderPrompt(agentId: string): Promise<{ + provider_prompt: string | null + provider_prompt_synced_at: string | null + }> { + const response = await this.client.post(`/api/v1/agents/${agentId}/sync-provider-prompt`) + return response.data + } } // Factory function to create ApiClient instance diff --git a/frontend/src/pages/agents/AgentDetail.tsx b/frontend/src/pages/agents/AgentDetail.tsx index 10850c1e..d1a218ae 100644 --- a/frontend/src/pages/agents/AgentDetail.tsx +++ b/frontend/src/pages/agents/AgentDetail.tsx @@ -124,6 +124,21 @@ export default function AgentDetail() { }, }) + const syncPromptMutation = useMutation({ + mutationFn: () => apiClient.syncProviderPrompt(id!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['agent', id] }) + queryClient.invalidateQueries({ queryKey: ['agents'] }) + showToast('Provider prompt synced successfully!', 'success') + }, + onError: (error: any) => { + showToast( + `Failed to sync provider prompt: ${error.response?.data?.detail || error.message}`, + 'error' + ) + }, + }) + const savePromptPartialMutation = useMutation({ mutationFn: (data: { name: string; description?: string; content: string; tags?: string[] }) => apiClient.createPromptPartial(data), @@ -279,6 +294,8 @@ export default function AgentDetail() { agent={agent} voiceBundles={voiceBundles} integrations={integrations} + onSyncProviderPrompt={() => syncPromptMutation.mutate()} + isSyncingPrompt={syncPromptMutation.isPending} /> ) : ( onChange({ ...formData, call_type: type })} - className={`px-4 py-2 text-sm font-medium transition-colors focus:outline-none ${ + className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors focus:outline-none ${ formData.call_type === type ? 'bg-primary-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50' } ${type === 'outbound' ? 'border-r border-gray-300' : ''}`} > + {type === 'outbound' ? : } {type === 'outbound' ? 'Outbound' : 'Inbound'} ))} @@ -321,7 +322,7 @@ export default function AgentEditForm({
- +
+ )} +
+
+ {agent.provider_prompt ? ( +
+ {stripCodeFences(agent.provider_prompt)} +
+ ) : agent.voice_ai_integration_id && agent.voice_ai_agent_id ? ( +

+ No {providerLabel.toLowerCase()} prompt synced yet. Click "Sync Now" to fetch it. +

+ ) : ( +

+ Link a voice AI provider to see the live prompt here. +

+ )}
diff --git a/frontend/src/pages/agents/components/CreateAgentModal.tsx b/frontend/src/pages/agents/components/CreateAgentModal.tsx index 47370557..a558cefd 100644 --- a/frontend/src/pages/agents/components/CreateAgentModal.tsx +++ b/frontend/src/pages/agents/components/CreateAgentModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { useQuery, useMutation } from '@tanstack/react-query' -import { X, Sparkles, Loader2, Bot, Eye, Code, FileText } from 'lucide-react' +import { X, Sparkles, Loader2, Bot, Eye, Code, FileText, PhoneOutgoing, PhoneIncoming } from 'lucide-react' import ReactMarkdown from 'react-markdown' import Button from '../../../components/Button' import { apiClient } from '../../../lib/api' @@ -123,7 +123,7 @@ export default function CreateAgentModal({ setShowUseSavedModal(false) setSavedPromptSearch('') setSelectedSavedPromptId('') - showToast('Saved prompt applied to System Prompt', 'success') + showToast('Saved prompt applied to Test Agent Prompt', 'success') }, onError: (err: any) => { showToast(err?.response?.data?.detail || 'Failed to load saved prompt', 'error') @@ -315,12 +315,13 @@ export default function CreateAgentModal({ key={type} type="button" onClick={() => setFormData({ ...formData, call_type: type })} - className={`px-4 py-2 text-sm font-medium transition-colors focus:outline-none ${ + className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium transition-colors focus:outline-none ${ formData.call_type === type ? 'bg-primary-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50' } ${type === 'outbound' ? 'border-r border-gray-300' : ''}`} > + {type === 'outbound' ? : } {type === 'outbound' ? 'Outbound' : 'Inbound'} ))} @@ -330,7 +331,7 @@ export default function CreateAgentModal({ {/* Description with AI Generate */}
- +
+
+
+ {runsLoading ? ( +
+ +
+ ) : runs.length === 0 ? ( +
+ +

No runs yet

+ +
+ ) : ( + runs.map(run => { + const status = STATUS_CONFIG[run.status] || STATUS_CONFIG.pending + const StatusIcon = status.icon + const isSelected = selectedRunId === run.id + return ( + +
+
+ {run.best_score != null && ( + + {(run.best_score * 100).toFixed(0)}% + + )} + + {status.label} + + {format(new Date(run.created_at), 'MMM d')} +
+ + ) + }) + )} +
+
+ + {/* Right content — full remaining width */} +
+ {selectedRun ? ( + <> + {/* Run info strip */} +
+
+

+ {getAgentName(selectedRun.agent_id)} +

+ + {STATUS_CONFIG[selectedRun.status]?.label || selectedRun.status} + +
+
+
+ Best + + {selectedRun.best_score != null ? `${(selectedRun.best_score * 100).toFixed(1)}%` : '--'} + +
+
+ Calls + + {selectedRun.num_metric_calls ?? '--'} + {selectedRun.config?.max_metric_calls && ( + /{selectedRun.config.max_metric_calls} + )} + +
+ {selectedRun.config?.minibatch_size && ( +
+ Batch + {selectedRun.config.minibatch_size} +
+ )} + + {format(new Date(selectedRun.created_at), 'MMM d, HH:mm')} + + {selectedRun.metric_history && selectedRun.metric_history.length > 0 && ( +
+ {selectedRun.metric_history.map((entry, i) => ( +
+ ))} +
+ )} +
+
+ + {selectedRun.error_message && ( +
+ {selectedRun.error_message} +
+ )} + + {/* Content Area — candidates sidebar + prompt panels */} +
+ + {/* Candidates Sidebar */} + {candidates.length > 0 && ( +
+
+

+ Candidates ({candidates.length}) +

+
+
+ {candidates.map((candidate, idx) => { + const isSelected = compareCandidateId === candidate.id + return ( + + )} + {candidate.is_accepted && !candidate.pushed_to_provider_at && ( + + )} +
+ )} + {candidate.reflection_summary && ( +

{candidate.reflection_summary}

+ )} + + ) + })} +
+
+ )} + + {/* Prompt panels */} +
+ {/* Seed / Provider Prompt */} +
+
+
+ + {(() => { + const agent = getSelectedAgent() + if (agent?.provider_prompt) { + return ( + <> + + {getProviderLabel(agent)} Prompt + {agent.provider_prompt_synced_at && ( + + · {format(new Date(agent.provider_prompt_synced_at), 'MMM d, HH:mm')} + + )} + + ) + } + return ( + <> + + EfficientAI Test Agent Prompt + + ) + })()} +
+ {(() => { + const agent = getSelectedAgent() + if (agent?.voice_ai_integration_id && agent?.voice_ai_agent_id) { + return ( + + ) + } + return null + })()} +
+
+ {stripCodeFences( + getSelectedAgent()?.provider_prompt || selectedRun.seed_prompt + )} +
+
+ + {/* Selected Candidate Prompt */} + {compareCandidateId && compareCandidate ? ( +
+
+
+ + Optimized Candidate + {compareCandidate.score != null && ( + + {(compareCandidate.score * 100).toFixed(1)}% + + )} + {compareCandidate.is_accepted && ( + + Accepted + + )} +
+ +
+
+ {stripCodeFences(compareCandidate.prompt_text)} +
+
+ ) : ( +
+ {candidates.length > 0 ? ( + <> + +

Select a candidate to compare

+ + ) : selectedRun.status === 'running' ? ( + <> + +

Optimization in progress...

+ + ) : selectedRun.status === 'pending' ? ( + <> + +

Waiting to start...

+ + ) : ( + <> + +

No candidates generated

+ + )} +
+ )} +
+
+ + ) : ( +
+ +

Select an optimization run

+

+ Choose a run from the list to view candidates, compare prompts, and push results. +

+
+ )} +
+
+ + {/* Delete Confirmation Dialog */} + {deleteConfirmId && ( +
+
+
+
+ +
+

Delete Run

+
+

+ This will permanently delete this optimization run and all its candidates. This action cannot be undone. +

+
+ + +
+
+
+ )} + + {/* New Run Dialog */} + {showNewRunDialog && ( +
+
+
+

New Optimization Run

+ +
+
+
+ + +
+
+ + +
+ +
+

Optimization Settings

+
+
+ + setNewRunMaxMetricCalls(Math.max(1, parseInt(e.target.value) || 1))} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> +

Total evaluation budget

+
+
+ + setNewRunMinibatchSize(Math.max(1, parseInt(e.target.value) || 1))} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500" + /> +

Examples per iteration

+
+
+

+ ~{Math.max(1, Math.floor(newRunMaxMetricCalls / newRunMinibatchSize))} iterations + {' / ~'}{newRunMaxMetricCalls + Math.max(1, Math.floor(newRunMaxMetricCalls / newRunMinibatchSize))} LLM calls +

+
+
+
+ + +
+ {createRunMutation.isError && ( +

+ {(createRunMutation.error as any)?.response?.data?.detail || 'Failed to create run'} +

+ )} +
+
+ )} + + ) +} diff --git a/pyproject.toml b/pyproject.toml index 25271249..2b8b5153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -111,6 +111,11 @@ google = [ "google-cloud-texttospeech>=2.16.0", "google-cloud-speech>=2.26.0", ] +# GEPA prompt optimization (enterprise feature) +gepa = [ + "gepa", + "dspy", +] # Optional: Speaker Consistency metric (has version conflicts with torchaudio) # speechbrain = ["speechbrain>=1.0.0"]