Skip to content

Commit 0a8167f

Browse files
committed
feat: updating mac os images
1 parent 59f4325 commit 0a8167f

11 files changed

Lines changed: 250 additions & 42 deletions

File tree

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ env/
2929
ENV/
3030
.venv
3131
config.yml
32-
config.docker.yml
3332
# uv
3433
.python-version
3534
uv.lock

app/api/v1/routes/data_sources.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async def test_s3_connection(api_key: str = Depends(get_api_key)):
4242
if not enabled:
4343
error = s3_service.get_status_message() or "S3 is not enabled or not configured."
4444
raise HTTPException(
45-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
45+
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
4646
detail=f"S3 connection test failed: {error}",
4747
)
4848

app/api/v1/routes/voice_agent.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -450,8 +450,11 @@ def resolve_api_key_for_provider(provider: ModelProvider) -> str | None:
450450
# Continue to try creating evaluator result if we have metadata
451451

452452
# Create evaluator result if we have the required data (only if no error)
453-
# Create evaluator result for all test calls (with or without persona/scenario)
454-
if call_metadata and call_metadata.get("s3_key") and not call_metadata.get("error") and agent_id and result_id:
453+
# Also create if we have a live transcript even without S3 audio
454+
has_audio = call_metadata and call_metadata.get("s3_key")
455+
has_transcript = call_metadata and call_metadata.get("transcription")
456+
has_usable_data = has_audio or has_transcript
457+
if call_metadata and has_usable_data and not call_metadata.get("error") and agent_id and result_id:
455458
# If we don't have evaluator but have persona/scenario, try to create one
456459
if not evaluator and agent_id and persona_id and scenario_id:
457460
try:
@@ -523,7 +526,9 @@ def resolve_api_key_for_provider(provider: ModelProvider) -> str | None:
523526
name=result_name,
524527
duration_seconds=call_metadata.get("duration"),
525528
status=EvaluatorResultStatus.QUEUED.value, # Use .value to get the string
526-
audio_s3_key=call_metadata.get("s3_key")
529+
audio_s3_key=call_metadata.get("s3_key"),
530+
transcription=call_metadata.get("transcription"),
531+
speaker_segments=call_metadata.get("speaker_segments"),
527532
)
528533
db.add(evaluator_result)
529534
db.commit()

app/services/voice_agent/bot_fast_api.py

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ async def run_bot(websocket_client, google_api_key: str, system_instruction: str
237237
call_start_time = time.time()
238238
s3_key_result = None
239239
duration_result = None
240+
transcript_text = None
241+
conversation_turns = []
240242

241243
try:
242244
ws_transport = imports["FastAPIWebsocketTransport"](
@@ -360,6 +362,34 @@ async def on_client_disconnected(transport, client):
360362
evaluator_id=evaluator_id,
361363
result_id=result_id,
362364
)
365+
366+
# Extract conversation transcript from the LLM context
367+
try:
368+
raw_messages = context.messages if hasattr(context, 'messages') else []
369+
conversation_turns = []
370+
transcript_parts = []
371+
elapsed = 0.0
372+
for msg in raw_messages:
373+
role = msg.get("role", "")
374+
content = msg.get("content", "")
375+
if not content or role == "system":
376+
continue
377+
speaker = "user" if role == "user" else "assistant"
378+
turn_duration = max(1.0, len(content.split()) * 0.4)
379+
conversation_turns.append({
380+
"speaker": speaker,
381+
"text": content,
382+
"start": round(elapsed, 2),
383+
"end": round(elapsed + turn_duration, 2),
384+
})
385+
transcript_parts.append(f"{speaker}: {content}")
386+
elapsed += turn_duration
387+
transcript_text = "\n".join(transcript_parts) if transcript_parts else None
388+
logger.info(f"Captured {len(conversation_turns)} conversation turns from live Gemini pipeline")
389+
except Exception as ctx_err:
390+
logger.warning(f"Failed to extract conversation context: {ctx_err}")
391+
conversation_turns = []
392+
transcript_text = None
363393

364394
except Exception as e:
365395
logger.error(f"Error in run_bot: {e}", exc_info=True)
@@ -369,24 +399,21 @@ async def on_client_disconnected(transport, client):
369399
"agent_id": agent_id,
370400
"persona_id": persona_id,
371401
"scenario_id": scenario_id,
402+
"transcription": transcript_text,
403+
"speaker_segments": conversation_turns if conversation_turns else None,
372404
"error": str(e)
373405
}
374406

375-
if s3_key_result:
376-
return {
377-
"s3_key": s3_key_result,
378-
"duration": duration_result,
379-
"agent_id": agent_id,
380-
"persona_id": persona_id,
381-
"scenario_id": scenario_id
382-
}
383-
else:
384-
return {
385-
"s3_key": None,
386-
"duration": duration_result,
387-
"agent_id": agent_id,
388-
"persona_id": persona_id,
389-
"scenario_id": scenario_id,
390-
"error": "No audio file was uploaded"
391-
}
407+
metadata = {
408+
"s3_key": s3_key_result,
409+
"duration": duration_result,
410+
"agent_id": agent_id,
411+
"persona_id": persona_id,
412+
"scenario_id": scenario_id,
413+
"transcription": transcript_text,
414+
"speaker_segments": conversation_turns if conversation_turns else None,
415+
}
416+
if not s3_key_result and not transcript_text:
417+
metadata["error"] = "No audio file was uploaded and no transcript captured"
418+
return metadata
392419

app/services/voice_agent/voice_bundle.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,8 @@ async def run_voice_bundle_fastapi(
448448
call_start_time = time.time()
449449
s3_key_result = None
450450
duration_result = None
451+
transcript_text = None
452+
conversation_turns = []
451453

452454
# Storage for audio data from the buffer processor
453455
recorded_audio_data = {"audio": None, "sample_rate": None, "num_channels": None}
@@ -643,6 +645,34 @@ async def on_client_disconnected(transport, client):
643645
# Calculate duration
644646
duration_result = time.time() - call_start_time
645647

648+
# Extract conversation transcript from the LLM context
649+
try:
650+
raw_messages = context.messages if hasattr(context, 'messages') else []
651+
conversation_turns = []
652+
transcript_parts = []
653+
elapsed = 0.0
654+
for msg in raw_messages:
655+
role = msg.get("role", "")
656+
content = msg.get("content", "")
657+
if not content or role == "system":
658+
continue
659+
speaker = "user" if role == "user" else "assistant"
660+
turn_duration = max(1.0, len(content.split()) * 0.4)
661+
conversation_turns.append({
662+
"speaker": speaker,
663+
"text": content,
664+
"start": round(elapsed, 2),
665+
"end": round(elapsed + turn_duration, 2),
666+
})
667+
transcript_parts.append(f"{speaker}: {content}")
668+
elapsed += turn_duration
669+
transcript_text = "\n".join(transcript_parts) if transcript_parts else None
670+
logger.info(f"Captured {len(conversation_turns)} conversation turns from live pipeline")
671+
except Exception as ctx_err:
672+
logger.warning(f"Failed to extract conversation context: {ctx_err}")
673+
conversation_turns = []
674+
transcript_text = None
675+
646676
# Merge captured audio chunks
647677
total_input_audio = b''.join(input_audio_chunks) if input_audio_chunks else b''
648678
total_output_audio = b''.join(output_audio_chunks) if output_audio_chunks else b''
@@ -700,26 +730,23 @@ async def on_client_disconnected(transport, client):
700730
"agent_id": agent_id,
701731
"persona_id": persona_id,
702732
"scenario_id": scenario_id,
733+
"transcription": transcript_text,
734+
"speaker_segments": conversation_turns if conversation_turns else None,
703735
"error": str(e),
704736
}
705737

706-
if s3_key_result:
707-
return {
708-
"s3_key": s3_key_result,
709-
"duration": duration_result,
710-
"agent_id": agent_id,
711-
"persona_id": persona_id,
712-
"scenario_id": scenario_id,
713-
}
714-
715-
return {
716-
"s3_key": None,
738+
metadata = {
739+
"s3_key": s3_key_result,
717740
"duration": duration_result,
718741
"agent_id": agent_id,
719742
"persona_id": persona_id,
720743
"scenario_id": scenario_id,
721-
"error": "No audio file was uploaded",
744+
"transcription": transcript_text,
745+
"speaker_segments": conversation_turns if conversation_turns else None,
722746
}
747+
if not s3_key_result and not transcript_text:
748+
metadata["error"] = "No audio file was uploaded and no transcript captured"
749+
return metadata
723750

724751

725752
if __name__ == "__main__":

app/workers/tasks/process_evaluator_result.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,90 @@ def _transcribe_audio(result, ai_providers, db):
8787
)
8888

8989

90+
def _generate_call_analysis(transcription, ai_providers, organization_id, result_id, db, agent=None, scenario=None):
91+
"""Generate call analysis (summary, sentiment, success) using LLM."""
92+
from app.services.ai.llm_service import llm_service
93+
94+
llm_provider = ModelProvider.OPENAI
95+
llm_model = "gpt-4o-mini"
96+
97+
chosen_provider = next(
98+
(p for p in ai_providers if provider_matches(p.provider, llm_provider)),
99+
None,
100+
)
101+
if not chosen_provider:
102+
llm_provider = ModelProvider.GOOGLE
103+
llm_model = "gemini-2.0-flash"
104+
chosen_provider = next(
105+
(p for p in ai_providers if provider_matches(p.provider, llm_provider)),
106+
None,
107+
)
108+
if not chosen_provider:
109+
logger.warning(f"[EvaluatorResult {result_id}] No LLM provider available for call analysis")
110+
return None
111+
112+
agent_context = ""
113+
if agent and agent.description:
114+
agent_context = f"\n\nAgent Description:\n{agent.description}"
115+
if scenario:
116+
scenario_name = getattr(scenario, 'name', '')
117+
scenario_desc = getattr(scenario, 'description', '')
118+
if scenario_name or scenario_desc:
119+
agent_context += f"\n\nScenario: {scenario_name}"
120+
if scenario_desc:
121+
agent_context += f"\nScenario Description: {scenario_desc}"
122+
123+
messages = [
124+
{
125+
"role": "system",
126+
"content": (
127+
"You are a call analysis expert. Analyze the following conversation transcript "
128+
"and provide a structured analysis. Respond ONLY with valid JSON, no markdown."
129+
),
130+
},
131+
{
132+
"role": "user",
133+
"content": f"""Analyze this conversation transcript and provide:
134+
1. A concise summary of the call (2-3 sentences)
135+
2. The user/caller's overall sentiment (one of: Positive, Negative, Neutral, Mixed)
136+
3. Whether the call was successful in achieving its objective (true/false)
137+
{agent_context}
138+
139+
Transcript:
140+
{transcription}
141+
142+
Respond in this exact JSON format:
143+
{{"call_summary": "...", "user_sentiment": "...", "call_successful": true/false}}""",
144+
},
145+
]
146+
147+
try:
148+
llm_result = llm_service.generate_response(
149+
messages=messages,
150+
llm_provider=llm_provider,
151+
llm_model=llm_model,
152+
organization_id=organization_id,
153+
db=db,
154+
temperature=0.3,
155+
max_tokens=500,
156+
)
157+
import json
158+
import re
159+
text = llm_result.get("text", "")
160+
json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
161+
if json_match:
162+
analysis = json.loads(json_match.group())
163+
required_keys = {"call_summary", "user_sentiment", "call_successful"}
164+
if required_keys.issubset(analysis.keys()):
165+
logger.info(f"[EvaluatorResult {result_id}] Call analysis generated successfully")
166+
return analysis
167+
logger.warning(f"[EvaluatorResult {result_id}] Could not parse call analysis from LLM response")
168+
return None
169+
except Exception as e:
170+
logger.error(f"[EvaluatorResult {result_id}] Call analysis failed: {e}", exc_info=True)
171+
return None
172+
173+
90174
def _categorize_metrics(enabled_metrics, has_audio):
91175
"""Split metrics into LLM-evaluable and audio-only categories."""
92176
llm_metrics = []
@@ -247,7 +331,26 @@ def process_evaluator_result_task(self, result_id: str):
247331
"skipping evaluation"
248332
)
249333

250-
# Step 5: Complete
334+
# Step 5: Call Analysis
335+
if transcription and not (result.call_data and result.call_data.get("call_analysis")):
336+
try:
337+
call_analysis = _generate_call_analysis(
338+
transcription=transcription,
339+
ai_providers=ai_providers,
340+
organization_id=result.organization_id,
341+
result_id=result.result_id,
342+
db=db,
343+
agent=agent,
344+
scenario=scenario,
345+
)
346+
if call_analysis:
347+
result.call_data = {"call_analysis": call_analysis}
348+
except Exception as analysis_err:
349+
logger.warning(
350+
f"[EvaluatorResult {result.result_id}] Call analysis failed (non-fatal): {analysis_err}"
351+
)
352+
353+
# Step 6: Complete
251354
result.metric_scores = metric_scores
252355
result.status = EvaluatorResultStatus.COMPLETED.value
253356
db.commit()

docker-compose.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,11 +81,16 @@ services:
8181
# Use host network mode for WebRTC connectivity (Retell/Vapi calls)
8282
network_mode: host
8383
environment:
84+
# Worker is on host network, so it reaches DB/Redis via localhost (exposed ports)
85+
DATABASE_URL: postgresql://${POSTGRES_USER:-efficientai}:${POSTGRES_PASSWORD:-password}@localhost:5432/${POSTGRES_DB:-efficientai}
86+
REDIS_URL: redis://localhost:6379/0
87+
CELERY_BROKER_URL: redis://localhost:6379/0
88+
CELERY_RESULT_BACKEND: redis://localhost:6379/0
8489
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-}
8590
volumes:
8691
- ./uploads:/app/uploads
8792
- ./.encryption_key:/app/.encryption_key:ro
88-
- ./config.yml:/app/config.yml:ro
93+
- ./config.docker.yml:/app/config.yml:ro
8994
command: eai worker --config /app/config.yml --loglevel info
9095

9196
volumes:

frontend/src/components/VoiceAgent.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import { useState, useRef, useEffect } from 'react'
99
import { PipecatClient, PipecatClientOptions, RTVIEvent } from '@pipecat-ai/client-js'
1010
import { WebSocketTransport } from '@pipecat-ai/websocket-transport'
1111
import ReactMarkdown from 'react-markdown'
12-
import { Mic, MicOff, Loader, MessageSquare } from 'lucide-react'
12+
import { Mic, MicOff, Loader, MessageSquare, AlertTriangle } from 'lucide-react'
13+
import { useQuery } from '@tanstack/react-query'
1314
import Button from './Button'
1415
import { useAgentStore } from '../store/agentStore'
16+
import { apiClient } from '../lib/api'
1517

1618
interface VoiceAgentProps {
1719
personaId?: string
@@ -30,6 +32,12 @@ export default function VoiceAgent({ personaId, scenarioId, agentId }: VoiceAgen
3032
const [error, setError] = useState<string | null>(null)
3133
const [logs, setLogs] = useState<Array<{ timestamp: string; message: string; type: 'user' | 'bot' | 'system' }>>([])
3234

35+
const { data: s3Status } = useQuery({
36+
queryKey: ['s3-status'],
37+
queryFn: () => apiClient.getS3Status(),
38+
staleTime: 60_000,
39+
})
40+
3341
const pcClientRef = useRef<PipecatClient | null>(null)
3442
const botAudioRef = useRef<HTMLAudioElement | null>(null)
3543
const isConnectingRef = useRef(false) // Guard to prevent multiple simultaneous connections
@@ -311,6 +319,18 @@ export default function VoiceAgent({ personaId, scenarioId, agentId }: VoiceAgen
311319
</div>
312320
</div>
313321

322+
{s3Status && !s3Status.enabled && (
323+
<div className="flex items-start gap-3 bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4">
324+
<AlertTriangle className="h-5 w-5 text-amber-500 flex-shrink-0 mt-0.5" />
325+
<div>
326+
<p className="text-sm font-medium text-amber-800">Storage not configured</p>
327+
<p className="text-xs text-amber-700 mt-0.5">
328+
Audio recordings will not be saved. Transcripts and analysis will still be captured from the live conversation.
329+
</p>
330+
</div>
331+
</div>
332+
)}
333+
314334
{error && (
315335
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
316336
<p className="text-sm text-red-800">{error}</p>

0 commit comments

Comments
 (0)