Spaces:
Running
Running
Update main.py
Browse files
main.py
CHANGED
|
@@ -951,265 +951,105 @@ def delete_project(project_id):
|
|
| 951 |
#------------------------
|
| 952 |
# AI phone call ElevenLabs
|
| 953 |
#-------------------------
|
|
|
|
| 954 |
|
| 955 |
-
# Configuration
|
| 956 |
-
CREDITS_PER_MIN = 3 # Adjust based on ElevenLabs conversational AI pricing
|
| 957 |
-
VOICE_NAME = "Daniel" # British male voice (more reliable than Archer)
|
| 958 |
-
|
| 959 |
-
# Known ElevenLabs voice IDs for common voices
|
| 960 |
-
KNOWN_VOICE_IDS = {
|
| 961 |
-
"Daniel": "onwK4e9ZLuTAKqWW03F9", # British male
|
| 962 |
-
"Charlie": "IKne3meq5aSn9XLyUdCD", # British male
|
| 963 |
-
"George": "JBFqnCBsd6RMkjVDRZzb", # British male
|
| 964 |
-
"Archer": "D38z5RcWu1voky8WS1ja", # British male
|
| 965 |
-
"Adam": "pNInz6obpgDQGcFmaJgB", # American male (fallback)
|
| 966 |
-
"Antoni": "ErXwobaYiN019PkySvjV", # American male (fallback)
|
| 967 |
-
}
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
class ConversationalAIHandler:
|
| 971 |
-
def __init__(self):
|
| 972 |
-
# This is the correct base URL for this specific API
|
| 973 |
-
self.base_url = "https://api.elevenlabs.io/v1/convai"
|
| 974 |
-
self.api_key = os.getenv("ELEVENLABS_API_KEY")
|
| 975 |
-
if not self.api_key:
|
| 976 |
-
logger.critical("[CONVAI] ELEVENLABS_API_KEY environment variable not set.")
|
| 977 |
-
raise ValueError("ELEVENLABS_API_KEY environment variable not set.")
|
| 978 |
-
self.headers = {
|
| 979 |
-
"xi-api-key": self.api_key,
|
| 980 |
-
"Content-Type": "application/json"
|
| 981 |
-
}
|
| 982 |
-
|
| 983 |
-
def get_british_male_voice_id(self):
|
| 984 |
-
"""Gets a known British male voice ID."""
|
| 985 |
-
logger.info(f"[CONVAI] Using known voice ID for Daniel.")
|
| 986 |
-
return KNOWN_VOICE_IDS.get("Daniel", "onwK4e9ZLuTAKqWW03F9")
|
| 987 |
|
| 988 |
-
def create_or_get_agent(self, project_id, project_data):
|
| 989 |
-
"""Creates or retrieves a Convai agent."""
|
| 990 |
-
try:
|
| 991 |
-
logger.info(f"[CONVAI] Starting agent creation/retrieval for project: {project_id}")
|
| 992 |
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
# The Convai API does not have a simple GET /agents/{id} endpoint for verification.
|
| 996 |
-
# We will trust the ID from our database.
|
| 997 |
-
logger.info(f"[CONVAI] Found existing agent ID in DB: {existing_agent_id}. Assuming active.")
|
| 998 |
-
return existing_agent_id
|
| 999 |
|
| 1000 |
-
|
| 1001 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1002 |
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
agent_payload = {
|
| 1008 |
-
"conversation_config": {
|
| 1009 |
-
"name": f"DIY Expert - {project_data.get('projectTitle', project_id)}",
|
| 1010 |
-
"voice_id": voice_id,
|
| 1011 |
-
"model_id": "eleven_turbo_v2",
|
| 1012 |
-
"first_message": "Hello! I'm your Sozo DIY expert assistant. I'm here to help you with your project. What would you like to work on today?",
|
| 1013 |
-
"prompt": diy_expert_prompt
|
| 1014 |
-
}
|
| 1015 |
-
}
|
| 1016 |
-
creation_url = f"{self.base_url}/agents/create"
|
| 1017 |
-
response = requests.post(creation_url, headers=self.headers, json=agent_payload, timeout=30)
|
| 1018 |
-
|
| 1019 |
-
if response.status_code == 200:
|
| 1020 |
-
agent_info = response.json()
|
| 1021 |
-
agent_id = agent_info.get("agent_id")
|
| 1022 |
-
logger.info(f"[CONVAI] New agent created successfully: {agent_id}")
|
| 1023 |
-
db_ref.child(f'projects/{project_id}').update({'agent_id': agent_id})
|
| 1024 |
-
return agent_id
|
| 1025 |
-
else:
|
| 1026 |
-
logger.error(f"[CONVAI] Failed to create agent. Status: {response.status_code}, Response: {response.text}")
|
| 1027 |
-
raise Exception(f"Failed to create agent: HTTP {response.status_code} - {response.text}")
|
| 1028 |
|
| 1029 |
-
|
| 1030 |
-
|
| 1031 |
-
raise
|
| 1032 |
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
This is the only method needed to start the conversation flow.
|
| 1037 |
-
"""
|
| 1038 |
-
try:
|
| 1039 |
-
logger.info(f"[CONVAI] Requesting signed WebSocket URL for agent: {agent_id}")
|
| 1040 |
-
url = f"{self.base_url}/conversation/get-signed-url"
|
| 1041 |
-
params = {"agent_id": agent_id}
|
| 1042 |
-
|
| 1043 |
-
resp = requests.get(url, headers=self.headers, params=params, timeout=15)
|
| 1044 |
|
| 1045 |
-
|
| 1046 |
-
|
| 1047 |
-
raise Exception(f"Could not get signed URL: {resp.text}")
|
| 1048 |
|
| 1049 |
-
|
| 1050 |
-
|
| 1051 |
-
|
| 1052 |
-
logger.error("[CONVAI] Missing 'signed_url' in response.")
|
| 1053 |
-
raise Exception("Invalid signed URL response from ElevenLabs")
|
| 1054 |
|
| 1055 |
-
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
except Exception as e:
|
| 1059 |
-
logger.error(f"[CONVAI] Exception in get_conversation_url: {e}")
|
| 1060 |
-
raise
|
| 1061 |
|
| 1062 |
-
|
|
|
|
|
|
|
| 1063 |
|
| 1064 |
-
def calculate_cost(duration_seconds):
|
| 1065 |
-
"""Calculate cost based on conversation duration"""
|
| 1066 |
-
minutes = (duration_seconds + 59) // 60
|
| 1067 |
-
return minutes * CREDITS_PER_MIN
|
| 1068 |
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
def start_conversation(project_id):
|
| 1072 |
"""
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
cost, and returns the URL. The client is responsible for the actual conversation.
|
| 1076 |
"""
|
| 1077 |
-
|
| 1078 |
-
logger.info(f"[CONVERSATION] Starting conversation initiation process for project: {project_id}")
|
| 1079 |
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 1084 |
-
logger.error(f"[CONVERSATION] ERROR: Unauthorized access for project: {project_id}")
|
| 1085 |
-
return jsonify({'error': 'Unauthorized'}), 401
|
| 1086 |
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
if not user:
|
| 1090 |
-
logger.error(f"[CONVERSATION] ERROR: User not found for uid: {uid}")
|
| 1091 |
-
return jsonify({'error': 'User not found'}), 404
|
| 1092 |
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
logger.error(f"[CONVERSATION] ERROR: Project not found or access denied for user {uid}")
|
| 1096 |
-
return jsonify({'error': 'Project not found'}), 404
|
| 1097 |
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
# The initial message is noted but will be sent by the client, not the backend.
|
| 1104 |
-
initial_message = data.get('message', 'Hello, I need help with my DIY project.')
|
| 1105 |
-
logger.info(f"[CONVERSATION] User's initial message (to be sent by client): {initial_message[:100]}{'...' if len(initial_message) > 100 else ''}")
|
| 1106 |
-
|
| 1107 |
-
# Step 2: Initialize Handler and Get Agent (Unchanged)
|
| 1108 |
-
ai_handler = ConversationalAIHandler()
|
| 1109 |
-
agent_id = ai_handler.create_or_get_agent(project_id, project)
|
| 1110 |
-
logger.info(f"[CONVERSATION] Agent ready: {agent_id}")
|
| 1111 |
-
|
| 1112 |
-
# Step 3: Get the Secure WebSocket URL (This is the new core logic)
|
| 1113 |
-
# This replaces the old start_conversation and send_message calls.
|
| 1114 |
-
websocket_url = ai_handler.get_conversation_url(agent_id)
|
| 1115 |
-
logger.info(f"[CONVERSATION] WebSocket URL received from ElevenLabs.")
|
| 1116 |
-
|
| 1117 |
-
# Step 4: Calculate Cost and Deduct Credits (Logic adapted for initiation fee)
|
| 1118 |
-
# The cost is now for the service of setting up the session.
|
| 1119 |
-
duration = time.time() - start_time
|
| 1120 |
-
cost = calculate_cost(duration) # Or a flat fee, e.g., cost = CREDITS_PER_MIN
|
| 1121 |
-
|
| 1122 |
-
user_credits = user.get('credits', 0)
|
| 1123 |
-
if user_credits < cost:
|
| 1124 |
-
logger.error(f"[CONVERSATION] ERROR: Insufficient credits for session initiation.")
|
| 1125 |
-
return jsonify({'error': 'Insufficient credits', 'needed': cost}), 402
|
| 1126 |
-
|
| 1127 |
-
new_credits = user_credits - cost
|
| 1128 |
-
user_ref.update({'credits': new_credits})
|
| 1129 |
-
logger.info(f"[CONVERSATION] Billed {cost} credits for session initiation. Remaining credits: {new_credits}")
|
| 1130 |
-
|
| 1131 |
-
# Step 5: Log the Session Initiation
|
| 1132 |
-
# We log that a session was started, but not the back-and-forth messages.
|
| 1133 |
-
conversation_log_id = f"{project_id}_{int(start_time)}"
|
| 1134 |
-
conversation_data = {
|
| 1135 |
-
'project_id': project_id,
|
| 1136 |
-
'uid': uid,
|
| 1137 |
-
'agent_id': agent_id,
|
| 1138 |
-
'conversation_log_id': conversation_log_id, # Self-reference
|
| 1139 |
-
'initial_message_prompt': initial_message,
|
| 1140 |
-
'duration_seconds_backend': duration, # Log backend processing time
|
| 1141 |
-
'credits_used': cost,
|
| 1142 |
-
'created_at': int(start_time)
|
| 1143 |
-
}
|
| 1144 |
-
db_ref.child(f'conversations/{conversation_log_id}').set(conversation_data)
|
| 1145 |
-
|
| 1146 |
-
# Step 6: Return the WebSocket URL to the Client
|
| 1147 |
-
# The client will use this URL to establish a direct connection for the conversation.
|
| 1148 |
-
return jsonify({
|
| 1149 |
-
'websocket_url': websocket_url,
|
| 1150 |
-
'agent_id': agent_id,
|
| 1151 |
-
'conversation_log_id': conversation_log_id,
|
| 1152 |
-
'status': 'success',
|
| 1153 |
-
'durationSeconds': round(duration, 1),
|
| 1154 |
-
'creditsDeducted': cost,
|
| 1155 |
-
'remainingCredits': new_credits
|
| 1156 |
-
}), 200
|
| 1157 |
|
| 1158 |
-
except Exception as e:
|
| 1159 |
-
total_duration = time.time() - start_time
|
| 1160 |
-
logger.error(f"[CONVERSATION] CRITICAL ERROR in start_conversation route: {str(e)}")
|
| 1161 |
-
logger.error(f"[CONVERSATION] Exception type: {type(e).__name__}")
|
| 1162 |
-
return jsonify({'error': 'Failed to initiate conversation'}), 500
|
| 1163 |
-
|
| 1164 |
-
@app.route('/api/webhook/agent/<project_id>', methods=['POST'])
|
| 1165 |
-
def agent_webhook(project_id):
|
| 1166 |
-
"""Webhook to handle agent events"""
|
| 1167 |
try:
|
| 1168 |
-
|
| 1169 |
-
|
| 1170 |
-
|
| 1171 |
-
|
| 1172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1173 |
|
| 1174 |
-
#
|
| 1175 |
-
|
| 1176 |
-
'event_type': event_type,
|
| 1177 |
-
'data': data,
|
| 1178 |
-
'timestamp': int(time.time())
|
| 1179 |
-
})
|
| 1180 |
|
| 1181 |
-
|
|
|
|
| 1182 |
|
| 1183 |
-
|
| 1184 |
-
logger.error(f"Webhook error: {str(e)}")
|
| 1185 |
-
return jsonify({'error': 'Webhook processing failed'}), 500
|
| 1186 |
-
|
| 1187 |
-
@app.route('/api/projects/<project_id>/continue-conversation', methods=['POST'])
|
| 1188 |
-
def continue_conversation(project_id):
|
| 1189 |
-
"""Continue an existing conversation"""
|
| 1190 |
-
try:
|
| 1191 |
-
uid = verify_token(request.headers.get('Authorization'))
|
| 1192 |
-
if not uid:
|
| 1193 |
-
return jsonify({'error': 'Unauthorized'}), 401
|
| 1194 |
-
|
| 1195 |
-
data = request.get_json()
|
| 1196 |
-
agent_id = data.get('agent_id')
|
| 1197 |
-
message = data.get('message')
|
| 1198 |
-
|
| 1199 |
-
if not agent_id or not message:
|
| 1200 |
-
return jsonify({'error': 'agent_id and message required'}), 400
|
| 1201 |
-
|
| 1202 |
-
ai_handler = ConversationalAIHandler()
|
| 1203 |
-
result = ai_handler.simulate_conversation(agent_id, message)
|
| 1204 |
|
|
|
|
| 1205 |
return jsonify({
|
| 1206 |
-
|
| 1207 |
-
|
|
|
|
| 1208 |
}), 200
|
| 1209 |
|
| 1210 |
except Exception as e:
|
| 1211 |
-
logger.error(f"
|
| 1212 |
-
return jsonify({'error': '
|
| 1213 |
|
| 1214 |
|
| 1215 |
# -----------------------------------------------------------------------------
|
|
|
|
| 951 |
#------------------------
|
| 952 |
# AI phone call ElevenLabs
|
| 953 |
#-------------------------
|
| 954 |
+
import math
|
| 955 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 956 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 957 |
|
| 958 |
+
AGENT_ID = os.getenv("AGENT_ID", "agent_01jy1vv1hqe2b9x0yyp6ayrdxj")
|
| 959 |
+
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 960 |
|
| 961 |
+
@app.route('/api/projects/<project_id>/initiate-call', methods=['POST'])
|
| 962 |
+
def initiate_call(project_id):
|
| 963 |
+
"""
|
| 964 |
+
Securely fetches a signed URL from ElevenLabs for the client-side SDK.
|
| 965 |
+
"""
|
| 966 |
+
logger.info(f"[INITIATE] Received request for project: {project_id}")
|
| 967 |
+
|
| 968 |
+
# Your real authentication logic should be active
|
| 969 |
+
uid = verify_token(request.headers.get('Authorization'))
|
| 970 |
+
if not uid:
|
| 971 |
+
return jsonify({'error': 'Unauthorized'}), 401
|
| 972 |
|
| 973 |
+
if not ELEVENLABS_API_KEY:
|
| 974 |
+
logger.error("[INITIATE] ELEVENLABS_API_KEY is not set on the server.")
|
| 975 |
+
return jsonify({'error': 'Server configuration error.'}), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
|
| 977 |
+
url = f"https://api.elevenlabs.io/v1/convai/conversation/get-signed-url?agent_id={AGENT_ID}"
|
| 978 |
+
headers = {"xi-api-key": ELEVENLABS_API_KEY}
|
|
|
|
| 979 |
|
| 980 |
+
try:
|
| 981 |
+
response = requests.get(url, headers=headers, timeout=15)
|
| 982 |
+
response.raise_for_status()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 983 |
|
| 984 |
+
data = response.json()
|
| 985 |
+
signed_url = data.get("signed_url")
|
|
|
|
| 986 |
|
| 987 |
+
if not signed_url:
|
| 988 |
+
logger.error("[INITIATE] ElevenLabs response missing 'signed_url'.")
|
| 989 |
+
return jsonify({'error': 'Failed to retrieve session URL from provider.'}), 502
|
|
|
|
|
|
|
| 990 |
|
| 991 |
+
logger.info("[INITIATE] Successfully retrieved signed URL.")
|
| 992 |
+
return jsonify({"signed_url": signed_url}), 200
|
|
|
|
|
|
|
|
|
|
|
|
|
| 993 |
|
| 994 |
+
except requests.exceptions.RequestException as e:
|
| 995 |
+
logger.error(f"[INITIATE] Error calling ElevenLabs API: {e}")
|
| 996 |
+
return jsonify({'error': 'Could not connect to AI service provider.'}), 504
|
| 997 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 998 |
|
| 999 |
+
@app.route('/api/projects/<project_id>/log-call-usage', methods=['POST'])
|
| 1000 |
+
def log_call_usage(project_id):
|
|
|
|
| 1001 |
"""
|
| 1002 |
+
Calculates and deducts credits from a user's account in Firebase
|
| 1003 |
+
after a call is completed. This is the production-ready version.
|
|
|
|
| 1004 |
"""
|
| 1005 |
+
logger.info(f"[LOGGING] Received usage log for project: {project_id}")
|
|
|
|
| 1006 |
|
| 1007 |
+
# Step 1: Authenticate the user and get their unique ID
|
| 1008 |
+
uid = verify_token(request.headers.get('Authorization'))
|
| 1009 |
+
if not uid:
|
| 1010 |
+
return jsonify({'error': 'Unauthorized'}), 401
|
|
|
|
|
|
|
| 1011 |
|
| 1012 |
+
data = request.get_json()
|
| 1013 |
+
duration_seconds = data.get("durationSeconds")
|
|
|
|
|
|
|
|
|
|
| 1014 |
|
| 1015 |
+
if duration_seconds is None or not isinstance(duration_seconds, (int, float)):
|
| 1016 |
+
return jsonify({'error': 'Invalid duration provided.'}), 400
|
|
|
|
|
|
|
| 1017 |
|
| 1018 |
+
# Step 2: Calculate credit cost (3 credits per minute, always rounded up)
|
| 1019 |
+
minutes = math.ceil(duration_seconds / 60)
|
| 1020 |
+
cost = minutes * 3
|
| 1021 |
+
|
| 1022 |
+
logger.info(f"[LOGGING] User '{uid}' call duration: {duration_seconds:.2f}s, rounded to {minutes} minute(s). Cost: {cost} credits.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1024 |
try:
|
| 1025 |
+
# Step 3: Perform the database transaction
|
| 1026 |
+
user_ref = db_ref.child(f'users/{uid}')
|
| 1027 |
+
user_data = user_ref.get()
|
| 1028 |
+
|
| 1029 |
+
if user_data is None:
|
| 1030 |
+
logger.error(f"[LOGGING] User with UID '{uid}' not found in the database.")
|
| 1031 |
+
return jsonify({'error': 'User not found.'}), 404
|
| 1032 |
+
|
| 1033 |
+
current_credits = user_data.get('credits', 0)
|
| 1034 |
|
| 1035 |
+
# Calculate the new balance, ensuring it doesn't go below zero.
|
| 1036 |
+
new_credits = max(0, current_credits - cost)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
|
| 1038 |
+
# Update the user's credit balance in Firebase
|
| 1039 |
+
user_ref.update({'credits': new_credits})
|
| 1040 |
|
| 1041 |
+
logger.info(f"[LOGGING] Successfully updated credits for user '{uid}'. Old: {current_credits}, New: {new_credits}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1042 |
|
| 1043 |
+
# Step 4: Return the successful response with the actual new balance
|
| 1044 |
return jsonify({
|
| 1045 |
+
"status": "success",
|
| 1046 |
+
"creditsDeducted": cost,
|
| 1047 |
+
"remainingCredits": new_credits
|
| 1048 |
}), 200
|
| 1049 |
|
| 1050 |
except Exception as e:
|
| 1051 |
+
logger.error(f"[LOGGING] A database error occurred for user '{uid}': {e}")
|
| 1052 |
+
return jsonify({'error': 'A server error occurred while updating credits.'}), 500
|
| 1053 |
|
| 1054 |
|
| 1055 |
# -----------------------------------------------------------------------------
|