backend.py (20513B)
1 import os 2 import json 3 import datetime 4 from flask import Flask, request, jsonify, Response, stream_with_context 5 from flask_cors import CORS 6 import google.generativeai as genai 7 import firebase_admin 8 from firebase_admin import credentials, auth, firestore 9 10 # --- INIT APP & CONFIG --- 11 app = Flask(__name__) 12 # Allow CORS for your domain and localhost for testing 13 CORS(app, resources={r"/api/*": {"origins": [ 14 "https://ada.amit.is-a.dev", 15 "https://amit.is-a.dev", 16 "http://127.0.0.1:5500", 17 "http://localhost:5000", 18 "http://localhost:5500" 19 ]}}) 20 21 # 1. Firebase Admin Init (Server-Side Security) 22 # Ensure you have your service account json or environment variables set up in Render 23 try: 24 if not firebase_admin._apps: 25 # Check for FIREBASE_CREDENTIALS environment variable (JSON string) 26 firebase_creds_json = os.environ.get("FIREBASE_CREDENTIALS") 27 28 if firebase_creds_json: 29 # Parse JSON string from environment variable 30 cred_dict = json.loads(firebase_creds_json) 31 cred = credentials.Certificate(cred_dict) 32 print("Using Firebase credentials from FIREBASE_CREDENTIALS environment variable") 33 elif os.path.exists("firebase-adminsdk.json"): 34 cred = credentials.Certificate("firebase-adminsdk.json") 35 print("Using Firebase credentials from firebase-adminsdk.json file") 36 else: 37 # Try application default credentials 38 cred = credentials.ApplicationDefault() 39 print("Using Firebase Application Default Credentials") 40 41 firebase_admin.initialize_app(cred) 42 print("Firebase Admin SDK initialized successfully") 43 db = firestore.client() 44 print("Firestore client initialized successfully") 45 except Exception as e: 46 print(f"Firebase initialization error: {type(e).__name__}: {e}") 47 print("API will run but Firestore features will not work") 48 db = None 49 50 # 2. Gemini Init 51 GENAI_API_KEY = os.environ.get("GEMINI_API_KEY") 52 if not GENAI_API_KEY: 53 print("CRITICAL: GEMINI_API_KEY not found in env variables.") 54 else: 55 genai.configure(api_key=GENAI_API_KEY) 56 57 # Generation Config 58 generation_config = { 59 "temperature": 0.2, # Low temp for precise coding 60 "top_p": 0.95, 61 "top_k": 64, 62 "max_output_tokens": 8192, 63 "response_mime_type": "text/plain", 64 } 65 66 # System Instruction - THE STRICT CODING GATEKEEPER 67 SYSTEM_PROMPT = """ 68 69 """ 70 71 model = genai.GenerativeModel( 72 model_name="models/gemini-3.1-flash-lite-preview", 73 generation_config=generation_config, 74 system_instruction=SYSTEM_PROMPT, 75 ) 76 77 # --- HELPER: Verify Firebase Token & Check Guest Status --- 78 def verify_token(auth_header): 79 """ 80 Verifies the Firebase ID Token sent from the client. 81 Returns a dictionary {'uid': str, 'is_guest': bool} if valid, None otherwise. 82 Enhanced to detect Anonymous (Guest) users. 83 """ 84 if not auth_header: 85 print("No Authorization header provided") 86 return None 87 88 if not auth_header.startswith("Bearer "): 89 print("Invalid Authorization header format (must start with 'Bearer ')") 90 return None 91 92 token = auth_header.split("Bearer ")[1] 93 94 try: 95 decoded_token = auth.verify_id_token(token) 96 user_id = decoded_token['uid'] 97 98 # Check the sign-in provider to see if it's an anonymous user 99 firebase_claim = decoded_token.get('firebase', {}) 100 provider = firebase_claim.get('sign_in_provider') 101 is_guest = (provider == 'anonymous') 102 103 return {'uid': user_id, 'is_guest': is_guest} 104 105 except auth.InvalidIdTokenError: 106 print("Invalid ID token - token is malformed or invalid") 107 return None 108 except auth.ExpiredIdTokenError: 109 print("Token has expired - user needs to refresh their token") 110 return None 111 except auth.RevokedIdTokenError: 112 print("Token has been revoked") 113 return None 114 except Exception as e: 115 print(f"Token verification error: {type(e).__name__}: {e}") 116 return None 117 118 def get_user_ref(user_data): 119 """ 120 Returns the Firestore Document Reference for the user. 121 Routes guests to 'temp-users' and registered users to 'users'. 122 """ 123 if not db: 124 raise Exception("Database unavailable") 125 126 # TRAFFIC ROUTING LOGIC 127 collection_name = 'temp-users' if user_data['is_guest'] else 'users' 128 return db.collection(collection_name).document(user_data['uid']) 129 130 # --- ROUTES --- 131 132 @app.route('/') 133 def home(): 134 return jsonify({ 135 "status": "online", 136 "service": "Ada AI Coding Backend", 137 "version": "1.1.0", 138 "model": "gemini-3.1-flash-lite-preview" 139 }) 140 141 @app.route('/health') 142 def health(): 143 """Health check endpoint for UptimeRobot and monitoring services""" 144 return jsonify({ 145 "status": "healthy", 146 "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat() 147 }) 148 149 @app.route('/api/generate-title', methods=['POST']) 150 def generate_title(): 151 """Generates a short 3-5 word title for the chat history.""" 152 # Verify auth even for titles to prevent abuse 153 user_data = verify_token(request.headers.get('Authorization')) 154 if not user_data: 155 return jsonify({ 156 "error": "Unauthorized", 157 "message": "Invalid or missing authentication token. Please sign in again." 158 }), 401 159 160 data = request.json 161 message = data.get('message', '') 162 163 try: 164 title_model = genai.GenerativeModel("models/gemini-3.1-flash-lite-preview") 165 res = title_model.generate_content(f"Summarize this coding query into a 3-5 word title: '{message}'") 166 return jsonify({"title": res.text.strip()}) 167 except Exception as e: 168 print(f"Error generating title: {type(e).__name__}: {e}") 169 return jsonify({"title": "New Chat"}) 170 171 @app.route('/api/profile', methods=['GET']) 172 def get_profile(): 173 """Get user profile from Firestore.""" 174 user_data = verify_token(request.headers.get('Authorization')) 175 if not user_data: 176 return jsonify({"error": "Unauthorized"}), 401 177 178 if not db: 179 return jsonify({"error": "Database unavailable"}), 503 180 181 try: 182 # Use helper to get correct collection path 183 user_ref = get_user_ref(user_data) 184 doc = user_ref.get() 185 186 if doc.exists: 187 return jsonify(doc.to_dict()) 188 else: 189 # Create default profile 190 default_profile = { 191 "uid": user_data['uid'], 192 "isGuest": user_data['is_guest'], 193 "displayName": "Guest User" if user_data['is_guest'] else "", 194 "email": "", 195 "photoURL": "", 196 "age": "", 197 "location": "", 198 "bio": "", 199 "totalChats": 0, 200 "totalMessages": 0, 201 "totalCodeSnippets": 0, 202 "theme": "dark", 203 "codeTheme": "dracula", 204 "fontSize": 13, 205 "createdAt": datetime.datetime.now(datetime.timezone.utc) 206 } 207 user_ref.set(default_profile) 208 return jsonify(default_profile) 209 except Exception as e: 210 print(f"Error getting profile: {type(e).__name__}: {e}") 211 return jsonify({"error": "Failed to get profile"}), 500 212 213 @app.route('/api/profile', methods=['PUT']) 214 def update_profile(): 215 """Update user profile in Firestore.""" 216 user_data = verify_token(request.headers.get('Authorization')) 217 if not user_data: 218 return jsonify({"error": "Unauthorized"}), 401 219 220 if not db: 221 return jsonify({"error": "Database unavailable"}), 503 222 223 try: 224 data = request.json 225 allowed_fields = ['displayName', 'age', 'location', 'bio', 'photoURL', 'theme', 'codeTheme', 'fontSize'] 226 update_data = {k: v for k, v in data.items() if k in allowed_fields} 227 update_data['updatedAt'] = datetime.datetime.now(datetime.timezone.utc) 228 229 user_ref = get_user_ref(user_data) 230 user_ref.update(update_data) 231 232 return jsonify({"success": True, "message": "Profile updated"}) 233 except Exception as e: 234 print(f"Error updating profile: {type(e).__name__}: {e}") 235 return jsonify({"error": "Failed to update profile"}), 500 236 237 @app.route('/api/chats', methods=['GET']) 238 def get_chats(): 239 """Get all user's chat sessions from Firestore.""" 240 user_data = verify_token(request.headers.get('Authorization')) 241 if not user_data: 242 return jsonify({"error": "Unauthorized"}), 401 243 244 if not db: 245 return jsonify({"error": "Database unavailable"}), 503 246 247 try: 248 user_ref = get_user_ref(user_data) 249 chats_ref = user_ref.collection('chats') 250 251 # Order by updatedAt descending (most recent first) 252 docs = chats_ref.order_by('updatedAt', direction='DESCENDING').stream() 253 254 chats = [] 255 for doc in docs: 256 chat_data = doc.to_dict() 257 chat_data['id'] = doc.id 258 # Only send metadata, not full messages 259 chats.append({ 260 'id': chat_data.get('id'), 261 'title': chat_data.get('title', 'New Chat'), 262 'createdAt': chat_data.get('createdAt'), 263 'updatedAt': chat_data.get('updatedAt'), 264 'isPinned': chat_data.get('isPinned', False), 265 'messageCount': len(chat_data.get('messages', [])) 266 }) 267 268 return jsonify({"chats": chats}) 269 except Exception as e: 270 print(f"Error getting chats: {type(e).__name__}: {e}") 271 return jsonify({"error": "Failed to get chats"}), 500 272 273 @app.route('/api/chats/<chat_id>', methods=['GET']) 274 def get_chat(chat_id): 275 """Get specific chat session.""" 276 user_data = verify_token(request.headers.get('Authorization')) 277 if not user_data: 278 return jsonify({"error": "Unauthorized"}), 401 279 280 if not db: 281 return jsonify({"error": "Database unavailable"}), 503 282 283 try: 284 user_ref = get_user_ref(user_data) 285 chat_ref = user_ref.collection('chats').document(chat_id) 286 doc = chat_ref.get() 287 288 if not doc.exists: 289 return jsonify({"error": "Chat not found"}), 404 290 291 chat_data = doc.to_dict() 292 chat_data['id'] = doc.id 293 return jsonify(chat_data) 294 except Exception as e: 295 print(f"Error getting chat: {type(e).__name__}: {e}") 296 return jsonify({"error": "Failed to get chat"}), 500 297 298 @app.route('/api/chats/<chat_id>', methods=['DELETE']) 299 def delete_chat(chat_id): 300 """Delete a chat session.""" 301 user_data = verify_token(request.headers.get('Authorization')) 302 if not user_data: 303 return jsonify({"error": "Unauthorized"}), 401 304 305 if not db: 306 return jsonify({"error": "Database unavailable"}), 503 307 308 try: 309 user_ref = get_user_ref(user_data) 310 chat_ref = user_ref.collection('chats').document(chat_id) 311 chat_ref.delete() 312 return jsonify({"success": True, "message": "Chat deleted"}) 313 except Exception as e: 314 print(f"Error deleting chat: {type(e).__name__}: {e}") 315 return jsonify({"error": "Failed to delete chat"}), 500 316 317 @app.route('/api/chats/<chat_id>/rename', methods=['PUT']) 318 def rename_chat(chat_id): 319 """Rename a chat session.""" 320 user_data = verify_token(request.headers.get('Authorization')) 321 if not user_data: 322 return jsonify({"error": "Unauthorized"}), 401 323 324 if not db: 325 return jsonify({"error": "Database unavailable"}), 503 326 327 try: 328 data = request.json 329 new_title = data.get('title', 'New Chat') 330 331 user_ref = get_user_ref(user_data) 332 chat_ref = user_ref.collection('chats').document(chat_id) 333 chat_ref.update({ 334 'title': new_title, 335 'updatedAt': datetime.datetime.now(datetime.timezone.utc) 336 }) 337 338 return jsonify({"success": True, "message": "Chat renamed"}) 339 except Exception as e: 340 print(f"Error renaming chat: {type(e).__name__}: {e}") 341 return jsonify({"error": "Failed to rename chat"}), 500 342 343 @app.route('/api/chats/<chat_id>/export', methods=['GET']) 344 def export_chat(chat_id): 345 """Export chat as JSON.""" 346 user_data = verify_token(request.headers.get('Authorization')) 347 if not user_data: 348 return jsonify({"error": "Unauthorized"}), 401 349 350 if not db: 351 return jsonify({"error": "Database unavailable"}), 503 352 353 try: 354 user_ref = get_user_ref(user_data) 355 chat_ref = user_ref.collection('chats').document(chat_id) 356 doc = chat_ref.get() 357 358 if not doc.exists: 359 return jsonify({"error": "Chat not found"}), 404 360 361 chat_data = doc.to_dict() 362 chat_data['id'] = doc.id 363 364 # Convert timestamps to ISO format for JSON serialization 365 if 'createdAt' in chat_data: 366 chat_data['createdAt'] = chat_data['createdAt'].isoformat() if hasattr(chat_data['createdAt'], 'isoformat') else str(chat_data['createdAt']) 367 if 'updatedAt' in chat_data: 368 chat_data['updatedAt'] = chat_data['updatedAt'].isoformat() if hasattr(chat_data['updatedAt'], 'isoformat') else str(chat_data['updatedAt']) 369 370 for msg in chat_data.get('messages', []): 371 if 'timestamp' in msg: 372 msg['timestamp'] = msg['timestamp'].isoformat() if hasattr(msg['timestamp'], 'isoformat') else str(msg['timestamp']) 373 374 return jsonify(chat_data) 375 except Exception as e: 376 print(f"Error exporting chat: {type(e).__name__}: {e}") 377 return jsonify({"error": "Failed to export chat"}), 500 378 379 @app.route('/api/chats/<chat_id>/pin', methods=['PUT']) 380 def pin_chat(chat_id): 381 """Toggle pin status of a chat.""" 382 user_data = verify_token(request.headers.get('Authorization')) 383 if not user_data: 384 return jsonify({"error": "Unauthorized"}), 401 385 386 if not db: 387 return jsonify({"error": "Database unavailable"}), 503 388 389 try: 390 data = request.json 391 is_pinned = data.get('isPinned', False) 392 393 user_ref = get_user_ref(user_data) 394 chat_ref = user_ref.collection('chats').document(chat_id) 395 chat_ref.update({ 396 'isPinned': is_pinned, 397 'updatedAt': datetime.datetime.now(datetime.timezone.utc) 398 }) 399 400 return jsonify({"success": True, "isPinned": is_pinned}) 401 except Exception as e: 402 print(f"Error pinning chat: {type(e).__name__}: {e}") 403 return jsonify({"error": "Failed to pin chat"}), 500 404 405 @app.route('/api/chat', methods=['POST']) 406 def chat(): 407 """ 408 Main Chat Endpoint (Streaming). 409 Headers: Authorization: Bearer <firebase_id_token> 410 Body: { "message": str, "history": list, "codeContext": str, "fileContext": str, "sessionId": str } 411 """ 412 # 1. Verify User & Get Identity Info 413 user_data = verify_token(request.headers.get('Authorization')) 414 if not user_data: 415 return jsonify({ 416 "error": "Unauthorized", 417 "message": "Invalid or missing authentication token. Please sign in again." 418 }), 401 419 420 data = request.json 421 user_msg = data.get('message') 422 history = data.get('history', []) 423 code_ctx = data.get('codeContext', '') 424 file_ctx = data.get('fileContext', '') 425 session_id = data.get('sessionId') 426 427 if not user_msg: 428 return jsonify({ 429 "error": "Bad Request", 430 "message": "Message cannot be empty" 431 }), 400 432 433 # 2. Construct Prompt with Context 434 context_str = "" 435 if code_ctx: 436 context_str += f"\n\n[CURRENT EDITOR CONTENT]:\n{code_ctx}\n" 437 if file_ctx: 438 # Handle both string and object formats 439 if isinstance(file_ctx, dict): 440 file_name = file_ctx.get('name', 'uploaded_file') 441 file_content = file_ctx.get('content', '') 442 context_str += f"\n\n[UPLOADED FILE: {file_name}]:\n{file_content}\n" 443 else: 444 context_str += f"\n\n[UPLOADED FILE CONTENT]:\n{file_ctx}\n" 445 446 # Add History (Gemini format) 447 chat_history = [] 448 for turn in history: 449 # Gemini expects 'user' and 'model' roles 450 chat_history.append({"role": "user", "parts": [turn.get('user', '')]}) 451 chat_history.append({"role": "model", "parts": [turn.get('model', '')]}) 452 453 try: 454 chat_session = model.start_chat(history=chat_history) 455 except Exception as e: 456 print(f"Error starting chat session: {type(e).__name__}: {e}") 457 return jsonify({ 458 "error": "Internal Server Error", 459 "message": "Failed to initialize chat session" 460 }), 500 461 462 # 3. Stream Response 463 def generate(): 464 final_text_acc = "" 465 466 prompt_with_ctx = user_msg + context_str 467 468 try: 469 response = chat_session.send_message(prompt_with_ctx, stream=True) 470 471 for chunk in response: 472 if chunk.text: 473 yield chunk.text 474 final_text_acc += chunk.text 475 476 # 4. Save Interaction to Firestore (Specific Path) 477 # We save this asynchronously (conceptually) after streaming is done. 478 if session_id and db: 479 try: 480 # Get correct reference (users vs temp-users) 481 user_ref = get_user_ref(user_data) 482 doc_ref = user_ref.collection('chats').document(session_id) 483 484 msg_data = { 485 "role": "user", 486 "content": user_msg, 487 "timestamp": datetime.datetime.now(datetime.timezone.utc) 488 } 489 490 # We store the raw AI response. The client parses the code blocks. 491 ai_data = { 492 "role": "model", 493 "content": final_text_acc, 494 "timestamp": datetime.datetime.now(datetime.timezone.utc) 495 } 496 497 doc = doc_ref.get() 498 if not doc.exists: 499 # First message - generate title 500 chat_title = "New Chat" 501 try: 502 title_model = genai.GenerativeModel("models/gemini-3.1-flash-lite-preview") 503 title_response = title_model.generate_content(f"Summarize this coding query into a 3-5 word title: '{user_msg}'") 504 chat_title = title_response.text.strip() 505 except Exception as title_err: 506 print(f"⚠️ Title generation failed: {title_err}") 507 508 doc_ref.set({ 509 "title": chat_title, 510 "createdAt": datetime.datetime.now(datetime.timezone.utc), 511 "updatedAt": datetime.datetime.now(datetime.timezone.utc), 512 "userId": user_data['uid'], 513 "isGuest": user_data['is_guest'], # Mark for easy identification 514 "isPinned": False, 515 "messages": [msg_data, ai_data] 516 }) 517 else: 518 doc_ref.update({ 519 "messages": firestore.ArrayUnion([msg_data, ai_data]), 520 "updatedAt": datetime.datetime.now(datetime.timezone.utc) 521 }) 522 print(f"Chat saved to Firestore for session: {session_id} (Guest: {user_data['is_guest']})") 523 except Exception as db_err: 524 print(f"Database Save Error: {type(db_err).__name__}: {db_err}") 525 526 except Exception as e: 527 error_msg = f"Gemini API Error: {type(e).__name__}: {str(e)}" 528 print(error_msg) 529 yield f"\n\nError: I encountered an issue while processing your request. Please try again." 530 531 return Response(stream_with_context(generate()), mimetype='text/plain') 532 533 if __name__ == '__main__': 534 port = int(os.environ.get('PORT', 5000)) 535 # Use debug=False in production (Render) 536 # Gunicorn will be used for production: gunicorn backend:app 537 debug_mode = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true' 538 app.run(host='0.0.0.0', port=port, debug=debug_mode)