rairo commited on
Commit
29d83b8
·
verified ·
1 Parent(s): 4969318

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +463 -1512
main.py CHANGED
@@ -1,1557 +1,508 @@
1
  # main.py
2
- import os
3
- import io
4
- import uuid
5
- import re
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import json
7
- import traceback
8
- from datetime import datetime, timedelta, timezone
9
- import requests
10
- from flask import Flask, request, jsonify, Response
11
- from flask_cors import CORS
12
- import firebase_admin
13
- from firebase_admin import credentials, db, storage, auth
14
  from pathlib import Path
15
- # Import the refactored Sozo business logic
16
- from sozo_gen import (
17
- generate_report_draft,
18
- generate_single_chart,
19
- generate_video_from_project,
20
- load_dataframe_safely,
21
- deepgram_tts
22
- )
23
- import logging
24
- import time
25
- import threading
26
-
27
- # -----------------------------------------------------------------------------
28
- # 1. CONFIGURATION & INITIALIZATION
29
- # -----------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
- app = Flask(__name__)
32
- CORS(app)
33
-
34
- try:
35
- credentials_json_string = os.environ.get("FIREBASE")
36
- if not credentials_json_string: raise ValueError("FIREBASE env var not set.")
37
- credentials_json = json.loads(credentials_json_string)
38
- firebase_db_url = os.environ.get("Firebase_DB")
39
- firebase_storage_bucket = os.environ.get("Firebase_Storage")
40
- if not firebase_db_url or not firebase_storage_bucket: raise ValueError("Firebase DB/Storage env vars must be set.")
41
- cred = credentials.Certificate(credentials_json)
42
- firebase_admin.initialize_app(cred, {'databaseURL': firebase_db_url, 'storageBucket': firebase_storage_bucket})
43
- print("Firebase Admin SDK initialized successfully.")
44
- except Exception as e:
45
- print(f"FATAL: Error initializing Firebase: {e}")
46
-
47
- bucket = storage.bucket()
48
-
49
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
50
- logger = logging.getLogger(__name__)
51
-
52
- RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
53
- # -----------------------------------------------------------------------------
54
- # 2. HELPER FUNCTIONS
55
- # -----------------------------------------------------------------------------
56
-
57
- def verify_token(token):
58
- try: return auth.verify_id_token(token)['uid']
59
- except Exception: return None
60
-
61
- def verify_admin(auth_header):
62
- if not auth_header or not auth_header.startswith('Bearer '): raise ValueError('Invalid token')
63
- token = auth_header.split(' ')[1]
64
- uid = verify_token(token)
65
- if not uid: raise PermissionError('Invalid user')
66
- user_data = db.reference(f'users/{uid}').get()
67
- if not user_data or not user_data.get('is_admin', False): raise PermissionError('Admin access required')
68
- return uid
69
-
70
- def is_valid_email(email):
71
- """Simple regex for basic email validation."""
72
- regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
73
- return re.match(regex, email) is not None
74
-
75
-
76
-
77
- # Global rate limiter for Resend API
78
- class ResendRateLimiter:
79
- def __init__(self, requests_per_second=1):
80
- self.requests_per_second = requests_per_second
81
- self.min_interval = 1.0 / requests_per_second
82
- self.last_request_time = 0
83
- self.lock = threading.Lock()
84
-
85
- def wait_if_needed(self):
86
- with self.lock:
87
- current_time = time.time()
88
- time_since_last_request = current_time - self.last_request_time
89
-
90
- if time_since_last_request < self.min_interval:
91
- sleep_time = self.min_interval - time_since_last_request
92
- logger.info(f"Rate limiting: waiting {sleep_time:.2f} seconds before sending email")
93
- time.sleep(sleep_time)
94
-
95
- self.last_request_time = time.time()
96
-
97
- # Global instance - initialize once
98
- resend_rate_limiter = ResendRateLimiter(requests_per_second=1)
99
-
100
- def _send_notification(user_id, user_email, message_content, send_email=False, email_subject=None, email_body=None):
101
- """
102
- Internal helper to send notifications.
103
- Creates an in-app notification in Firebase and optionally sends an email via Resend.
104
- If user_id is None, it will only attempt to send an email.
105
- Rate limited to 1 email per second to respect Resend API limits.
106
- """
107
- timestamp = datetime.now(timezone.utc).isoformat()
108
-
109
- # 1. Send In-App Notification (if user_id is provided)
110
- if user_id:
111
- try:
112
- notif_ref = db.reference(f'notifications/{user_id}').push()
113
- notif_data = {
114
- 'id': notif_ref.key,
115
- 'message': message_content,
116
- 'created_at': timestamp,
117
- 'read': False,
118
- 'read_at': None
119
  }
120
- notif_ref.set(notif_data)
121
- logger.info(f"Successfully sent in-app notification to UID {user_id}")
122
- except Exception as e:
123
- logger.error(f"Failed to send in-app notification to UID {user_id}: {e}")
124
- return False # Fail the whole operation if in-app fails for a registered user
125
-
126
- # 2. Send Email via Resend (if requested)
127
- if send_email and user_email:
128
- if not RESEND_API_KEY:
129
- logger.error("RESEND_API_KEY is not configured. Cannot send email.")
130
- return False
131
-
132
- # Apply rate limiting before making the request
133
- resend_rate_limiter.wait_if_needed()
134
-
135
- # Clean the API key (remove any whitespace)
136
- api_key = RESEND_API_KEY.strip()
137
-
138
- # Debug logging (be careful in production)
139
- logger.debug(f"API key format check - starts with 're_': {api_key.startswith('re_')}")
140
- logger.debug(f"API key length: {len(api_key)}")
141
- logger.debug(f"User email: {user_email}")
142
-
143
- headers = {
144
- "Authorization": f"Bearer {api_key}",
145
- "Content-Type": "application/json"
146
- }
147
- payload = {
148
- "from": "Sozo Business Studio <onboarding@sozofix.tech>",
149
- "to": [user_email],
150
- "subject": email_subject,
151
- "html": email_body
152
- }
153
-
154
- # Log the request details (excluding sensitive info)
155
- logger.debug(f"Request URL: https://api.resend.com/emails")
156
- logger.debug(f"Request payload keys: {list(payload.keys())}")
157
- logger.debug(f"From address: {payload['from']}")
158
- logger.debug(f"To address: {payload['to']}")
159
-
160
- try:
161
- response = requests.post("https://api.resend.com/emails", headers=headers, json=payload)
162
-
163
- # Log response details for debugging
164
- logger.info(f"Resend API response status: {response.status_code}")
165
- logger.debug(f"Response headers: {dict(response.headers)}")
166
- logger.debug(f"Response content: {response.text}")
167
-
168
- # Handle rate limiting response
169
- if response.status_code == 429:
170
- logger.warning(f"Rate limit hit despite internal limiting. Response: {response.text}")
171
- # Extract retry-after if available
172
- retry_after = response.headers.get('retry-after')
173
- if retry_after:
174
- logger.info(f"Server requested retry after {retry_after} seconds")
175
- time.sleep(float(retry_after))
176
- # Retry once
177
- response = requests.post("https://api.resend.com/emails", headers=headers, json=payload)
178
- response.raise_for_status()
179
- else:
180
- return False
181
- elif response.status_code == 401:
182
- logger.error("=== RESEND 401 UNAUTHORIZED DEBUG ===")
183
- logger.error(f"API Key starts correctly: {api_key.startswith('re_')}")
184
- logger.error(f"API Key length: {len(api_key)} (should be ~40 chars)")
185
- logger.error(f"Authorization header: Bearer {api_key[:10]}...")
186
- logger.error("Possible issues:")
187
- logger.error("1. API key copied incorrectly (extra chars/spaces)")
188
- logger.error("2. API key was regenerated in dashboard but not updated in env")
189
- logger.error("3. API key permissions were changed")
190
- logger.error("4. Account billing issue")
191
- logger.error(f"Full response: {response.text}")
192
- return False
193
- elif response.status_code == 422:
194
- logger.error(f"Resend API validation error (422): {response.text}")
195
- return False
196
-
197
- response.raise_for_status()
198
- response_data = response.json()
199
- logger.info(f"Successfully sent email to {user_email}. Email ID: {response_data.get('id')}")
200
-
201
- except requests.exceptions.RequestException as e:
202
- logger.error(f"Failed to send email to {user_email} via Resend: {e}")
203
- if hasattr(e, 'response') and e.response is not None:
204
- logger.error(f"Error response status: {e.response.status_code}")
205
- logger.error(f"Error response content: {e.response.text}")
206
- return False
207
-
208
- return True
209
-
210
-
211
- # Alternative: Batch email sending function for multiple emails
212
- def send_batch_notifications(notifications_list):
213
  """
214
- Send multiple notifications with proper rate limiting.
215
-
216
- Args:
217
- notifications_list: List of dicts with keys:
218
- - user_id (optional)
219
- - user_email
220
- - message_content
221
- - send_email (bool)
222
- - email_subject (optional)
223
- - email_body (optional)
224
-
225
- Returns:
226
- dict: Results with success/failure counts
227
  """
228
- results = {
229
- 'total': len(notifications_list),
230
- 'successful': 0,
231
- 'failed': 0,
232
- 'errors': []
233
- }
234
-
235
- logger.info(f"Starting batch notification send for {results['total']} notifications")
236
- start_time = time.time()
237
-
238
- for i, notification in enumerate(notifications_list):
239
- try:
240
- success = _send_notification(
241
- user_id=notification.get('user_id'),
242
- user_email=notification.get('user_email'),
243
- message_content=notification.get('message_content'),
244
- send_email=notification.get('send_email', False),
245
- email_subject=notification.get('email_subject'),
246
- email_body=notification.get('email_body')
247
- )
248
-
249
- if success:
250
- results['successful'] += 1
251
- else:
252
- results['failed'] += 1
253
- results['errors'].append(f"Notification {i+1} failed")
254
-
255
- except Exception as e:
256
- results['failed'] += 1
257
- results['errors'].append(f"Notification {i+1} error: {str(e)}")
258
- logger.error(f"Unexpected error processing notification {i+1}: {e}")
259
-
260
- elapsed_time = time.time() - start_time
261
- logger.info(f"Batch notification completed in {elapsed_time:.2f} seconds. "
262
- f"Success: {results['successful']}, Failed: {results['failed']}")
263
-
264
- return results
265
- # -----------------------------------------------------------------------------
266
- # 3. AUTHENTICATION & USER MANAGEMENT
267
- # -----------------------------------------------------------------------------
268
-
269
- @app.route('/api/auth/signup', methods=['POST'])
270
- def signup():
271
- try:
272
- data = request.get_json()
273
- email = data.get('email')
274
- password = data.get('password')
275
- if not email or not password: return jsonify({'error': 'Email and password are required'}), 400
276
- user = auth.create_user(email=email, password=password)
277
- user_ref = db.reference(f'users/{user.uid}')
278
- user_data = {'email': email, 'credits': 15, 'is_admin': False, 'created_at': datetime.utcnow().isoformat()}
279
- user_ref.set(user_data)
280
- return jsonify({'success': True, 'user': {'uid': user.uid, **user_data}}), 201
281
- except Exception as e: return jsonify({'error': str(e)}), 400
282
-
283
- @app.route('/api/user/profile', methods=['GET'])
284
- def get_user_profile():
285
- try:
286
- auth_header = request.headers.get('Authorization', '')
287
- if not auth_header.startswith('Bearer '): return jsonify({'error': 'Missing or invalid token'}), 401
288
- token = auth_header.split(' ')[1]
289
- uid = verify_token(token)
290
- if not uid: return jsonify({'error': 'Invalid or expired token'}), 401
291
- user_data = db.reference(f'users/{uid}').get()
292
- if not user_data: return jsonify({'error': 'User not found'}), 404
293
- return jsonify({'uid': uid, **user_data})
294
- except Exception as e: return jsonify({'error': str(e)}), 500
295
-
296
- @app.route('/api/auth/google-signin', methods=['POST'])
297
- def google_signin():
298
- try:
299
- auth_header = request.headers.get('Authorization', '')
300
- if not auth_header.startswith('Bearer '): return jsonify({'error': 'Missing or invalid token'}), 401
301
- token = auth_header.split(' ')[1]
302
- decoded_token = auth.verify_id_token(token)
303
- uid = decoded_token['uid']
304
- email = decoded_token.get('email')
305
- user_ref = db.reference(f'users/{uid}')
306
- user_data = user_ref.get()
307
- if not user_data:
308
- user_data = {'email': email, 'credits': 15, 'is_admin': False, 'created_at': datetime.utcnow().isoformat()}
309
- user_ref.set(user_data)
310
- return jsonify({'success': True, 'user': {'uid': uid, **user_data}}), 200
311
- except Exception as e: return jsonify({'error': str(e)}), 400
312
-
313
-
314
- # -----------------------------------------------------------------------------
315
- # 3. SOZO BUSINESS STUDIO API ENDPOINTS
316
- # -----------------------------------------------------------------------------
317
-
318
- @app.route('/api/sozo/projects', methods=['POST'])
319
- def create_sozo_project():
320
- logger.info("Endpoint /api/sozo/projects POST: Received request to create new project.")
321
- try:
322
- auth_header = request.headers.get('Authorization', '')
323
- if not auth_header.startswith('Bearer '):
324
- logger.warning("Create project failed: Missing or invalid auth header.")
325
- return jsonify({'error': 'Missing or invalid token'}), 401
326
-
327
- token = auth_header.split(' ')[1]
328
- uid = verify_token(token)
329
- if not uid:
330
- logger.warning("Create project failed: Invalid token.")
331
- return jsonify({'error': 'Unauthorized'}), 401
332
- logger.info(f"Token verified for user UID: {uid}")
333
-
334
- if 'file' not in request.files:
335
- logger.warning(f"User {uid}: Create project failed: No file part in request.")
336
- return jsonify({'error': 'No file part'}), 400
337
-
338
- file = request.files['file']
339
- context = request.form.get('context', '')
340
- project_id = uuid.uuid4().hex
341
- logger.info(f"User {uid}: Generated new project ID: {project_id}")
342
-
343
- file_bytes = file.read()
344
- ext = Path(file.filename).suffix
345
-
346
- blob_name = f"sozo_projects/{uid}/{project_id}/data{ext}"
347
- logger.info(f"User {uid}: Uploading raw data to storage at {blob_name}")
348
- blob = bucket.blob(blob_name)
349
- blob.upload_from_string(file_bytes, content_type=file.content_type)
350
- logger.info(f"User {uid}: Successfully uploaded raw data for project {project_id}")
351
-
352
- project_ref = db.reference(f'sozo_projects/{project_id}')
353
- project_data = {
354
- 'uid': uid,
355
- 'id': project_id,
356
- 'status': 'uploaded',
357
- 'createdAt': datetime.utcnow().isoformat(),
358
- 'updatedAt': datetime.utcnow().isoformat(),
359
- 'userContext': context,
360
- 'originalDataUrl': blob.public_url,
361
- 'originalFilename': file.filename
362
- }
363
- logger.info(f"User {uid}: Saving project metadata to database for project {project_id}")
364
- project_ref.set(project_data)
365
-
366
- df = load_dataframe_safely(io.BytesIO(file_bytes), file.filename)
367
- preview_json = df.head().to_json(orient='records')
368
-
369
- logger.info(f"User {uid}: Project {project_id} created successfully.")
370
- return jsonify({
371
- 'success': True,
372
- 'project': project_data,
373
- 'preview': json.loads(preview_json)
374
- }), 201
375
-
376
- except Exception as e:
377
- logger.error(f"CRITICAL ERROR during project creation: {traceback.format_exc()}")
378
- return jsonify({'error': str(e)}), 500
379
 
380
- @app.route('/api/sozo/projects', methods=['GET'])
381
- def get_sozo_projects():
382
- logger.info("Endpoint /api/sozo/projects GET: Received request to list projects.")
383
  try:
384
- auth_header = request.headers.get('Authorization', '')
385
- if not auth_header.startswith('Bearer '):
386
- logger.warning("List projects failed: Missing or invalid auth header.")
387
- return jsonify({'error': 'Missing or invalid token'}), 401
388
-
389
- token = auth_header.split(' ')[1]
390
- uid = verify_token(token)
391
- if not uid:
392
- logger.warning("List projects failed: Invalid token.")
393
- return jsonify({'error': 'Unauthorized'}), 401
394
- logger.info(f"Token verified for user UID: {uid}. Fetching projects.")
395
-
396
- projects_ref = db.reference('sozo_projects')
397
- user_projects = projects_ref.order_by_child('uid').equal_to(uid).get()
398
-
399
- if not user_projects:
400
- logger.info(f"User {uid}: No projects found.")
401
- return jsonify([]), 200
402
-
403
- # Firebase returns a dictionary, convert it to a list for the client
404
- projects_list = [project for project in user_projects.values()]
405
- logger.info(f"User {uid}: Found and returning {len(projects_list)} projects.")
406
- return jsonify(projects_list), 200
407
-
 
 
 
 
 
 
 
 
 
 
408
  except Exception as e:
409
- logger.error(f"CRITICAL ERROR during project list: {traceback.format_exc()}")
410
- return jsonify({'error': str(e)}), 500
411
-
412
- @app.route('/api/sozo/projects/<string:project_id>', methods=['GET'])
413
- def get_sozo_project(project_id):
414
- logger.info(f"Endpoint /api/sozo/projects/{project_id} GET: Received request for single project.")
415
- try:
416
- auth_header = request.headers.get('Authorization', '')
417
- if not auth_header.startswith('Bearer '):
418
- logger.warning(f"Get project {project_id} failed: Missing or invalid auth header.")
419
- return jsonify({'error': 'Missing or invalid token'}), 401
420
-
421
- token = auth_header.split(' ')[1]
422
- uid = verify_token(token)
423
- if not uid:
424
- logger.warning(f"Get project {project_id} failed: Invalid token.")
425
- return jsonify({'error': 'Unauthorized'}), 401
426
- logger.info(f"Token verified for user UID: {uid}. Fetching project {project_id}.")
427
-
428
- project_ref = db.reference(f'sozo_projects/{project_id}')
429
- project_data = project_ref.get()
430
 
431
- if not project_data:
432
- logger.warning(f"User {uid}: Attempted to access non-existent project {project_id}.")
433
- return jsonify({'error': 'Project not found'}), 404
434
 
435
- if project_data.get('uid') != uid:
436
- logger.error(f"User {uid}: UNAUTHORIZED attempt to access project {project_id} owned by {project_data.get('uid')}.")
437
- return jsonify({'error': 'Unauthorized to access this project'}), 403
438
 
439
- logger.info(f"User {uid}: Successfully fetched project {project_id}.")
440
- return jsonify(project_data), 200
441
 
442
- except Exception as e:
443
- logger.error(f"CRITICAL ERROR during get project {project_id}: {traceback.format_exc()}")
444
- return jsonify({'error': str(e)}), 500
445
-
446
- @app.route('/api/sozo/projects/<string:project_id>', methods=['PUT'])
447
- def update_sozo_project(project_id):
448
- logger.info(f"Endpoint /api/sozo/projects/{project_id} PUT: Received request to update project.")
449
- try:
450
- auth_header = request.headers.get('Authorization', '')
451
- if not auth_header.startswith('Bearer '):
452
- logger.warning(f"Update project {project_id} failed: Missing or invalid auth header.")
453
- return jsonify({'error': 'Missing or invalid token'}), 401
454
-
455
- token = auth_header.split(' ')[1]
456
- uid = verify_token(token)
457
- if not uid:
458
- logger.warning(f"Update project {project_id} failed: Invalid token.")
459
- return jsonify({'error': 'Unauthorized'}), 401
460
- logger.info(f"Token verified for user UID: {uid}. Updating project {project_id}.")
461
-
462
- project_ref = db.reference(f'sozo_projects/{project_id}')
463
- project_data = project_ref.get()
464
-
465
- if not project_data:
466
- logger.warning(f"User {uid}: Attempted to update non-existent project {project_id}.")
467
- return jsonify({'error': 'Project not found'}), 404
468
-
469
- if project_data.get('uid') != uid:
470
- logger.error(f"User {uid}: UNAUTHORIZED attempt to update project {project_id} owned by {project_data.get('uid')}.")
471
- return jsonify({'error': 'Unauthorized to update this project'}), 403
472
-
473
- logger.info(f"User {uid}: Ownership of project {project_id} verified.")
474
-
475
- update_data = request.get_json()
476
- if not update_data:
477
- return jsonify({'error': 'No update data provided'}), 400
478
-
479
- # Define fields the user is allowed to update
480
- allowed_updates = ['userContext', 'originalFilename']
481
- final_updates = {key: update_data[key] for key in update_data if key in allowed_updates}
482
-
483
- if not final_updates:
484
- logger.warning(f"User {uid}: Update for project {project_id} contained no valid fields.")
485
- return jsonify({'error': 'No valid fields to update'}), 400
486
-
487
- final_updates['updatedAt'] = datetime.utcnow().isoformat()
488
-
489
- logger.info(f"User {uid}: Applying updates to project {project_id}: {final_updates}")
490
- project_ref.update(final_updates)
491
-
492
- updated_project = project_ref.get()
493
- logger.info(f"User {uid}: Successfully updated project {project_id}.")
494
- return jsonify(updated_project), 200
495
 
496
- except Exception as e:
497
- logger.error(f"CRITICAL ERROR during update project {project_id}: {traceback.format_exc()}")
498
- return jsonify({'error': str(e)}), 500
499
 
500
- @app.route('/api/sozo/projects/<string:project_id>', methods=['DELETE'])
501
- def delete_sozo_project(project_id):
502
- logger.info(f"Endpoint /api/sozo/projects/{project_id} DELETE: Received request to delete project.")
503
- try:
504
- auth_header = request.headers.get('Authorization', '')
505
- if not auth_header.startswith('Bearer '):
506
- logger.warning(f"Delete project {project_id} failed: Missing or invalid auth header.")
507
- return jsonify({'error': 'Missing or invalid token'}), 401
508
-
509
- token = auth_header.split(' ')[1]
510
- uid = verify_token(token)
511
- if not uid:
512
- logger.warning(f"Delete project {project_id} failed: Invalid token.")
513
- return jsonify({'error': 'Unauthorized'}), 401
514
- logger.info(f"Token verified for user UID: {uid}. Deleting project {project_id}.")
515
-
516
- project_ref = db.reference(f'sozo_projects/{project_id}')
517
- project_data = project_ref.get()
518
-
519
- if not project_data:
520
- logger.warning(f"User {uid}: Attempted to delete non-existent project {project_id}.")
521
- return jsonify({'error': 'Project not found'}), 404
522
-
523
- if project_data.get('uid') != uid:
524
- logger.error(f"User {uid}: UNAUTHORIZED attempt to delete project {project_id} owned by {project_data.get('uid')}.")
525
- return jsonify({'error': 'Unauthorized to delete this project'}), 403
526
-
527
- logger.info(f"User {uid}: Ownership of project {project_id} verified. Proceeding with deletion.")
528
-
529
- # Delete all associated files from Firebase Storage
530
- project_folder_prefix = f"sozo_projects/{uid}/{project_id}/"
531
- logger.info(f"User {uid}: Deleting all files from storage folder: {project_folder_prefix}")
532
- blobs_to_delete = bucket.list_blobs(prefix=project_folder_prefix)
533
- deleted_files_count = 0
534
- for blob in blobs_to_delete:
535
- logger.info(f"User {uid}: Deleting file {blob.name} from storage.")
536
- blob.delete()
537
- deleted_files_count += 1
538
- logger.info(f"User {uid}: Deleted {deleted_files_count} files from storage for project {project_id}.")
539
-
540
- # Delete project from Realtime Database
541
- logger.info(f"User {uid}: Deleting project {project_id} from database.")
542
- project_ref.delete()
543
-
544
- logger.info(f"User {uid}: Successfully deleted project {project_id}.")
545
- return jsonify({'success': True, 'message': f'Project {project_id} and all associated files deleted.'}), 200
546
 
547
- except Exception as e:
548
- logger.error(f"CRITICAL ERROR during delete project {project_id}: {traceback.format_exc()}")
549
- return jsonify({'error': str(e)}), 500
550
 
551
- @app.route('/api/sozo/projects/<string:project_id>/generate-report', methods=['POST'])
552
- def generate_sozo_report(project_id):
553
- logger.info(f"POST /api/sozo/projects/{project_id}/generate-report - Received request")
554
-
555
  try:
556
- token = request.headers.get('Authorization', '').split(' ')[1]
557
- uid = verify_token(token)
558
- if not uid:
559
- return jsonify({'error': 'Unauthorized'}), 401
560
-
561
- user_ref = db.reference(f'users/{uid}')
562
- user_data = user_ref.get()
563
- if not user_data:
564
- return jsonify({'error': 'User not found'}), 404
565
-
566
- current_credits = user_data.get('credits', 0)
567
- if current_credits < 2:
568
- return jsonify({'error': 'Insufficient credits. Report generation requires 2 credits.'}), 402
569
-
570
- project_ref = db.reference(f'sozo_projects/{project_id}')
571
- project_data = project_ref.get()
572
-
573
- if not project_data or project_data.get('uid') != uid:
574
- return jsonify({'error': 'Project not found or unauthorized'}), 404
575
-
576
- current_status = project_data.get('status')
577
- if current_status in ['generating_report', 'generating_video', 'generating_slides']:
578
- logger.warning(f"User {uid} attempted to generate a report for project {project_id} which is already in progress (status: {current_status}).")
579
- return jsonify({'error': 'A process is already running for this project.'}), 409
580
-
581
- project_ref.update({'status': 'generating_report'})
582
- logger.info(f"Project {project_id} status locked to 'generating_report'.")
583
-
584
- blob_path = f"sozo_projects/{uid}/{project_id}/data{Path(project_data['originalFilename']).suffix}"
585
- blob = bucket.blob(blob_path)
586
- file_bytes = blob.download_as_bytes()
587
-
588
- draft_data = generate_report_draft(
589
- io.BytesIO(file_bytes),
590
- project_data['originalFilename'],
591
- project_data['userContext'],
592
- uid,
593
- project_id,
594
- bucket
595
  )
596
-
597
- # --- Graceful Fallback Save & Charge Logic ---
598
- try:
599
- # Happy Path: Try to save everything
600
- full_update_data = {
601
- 'status': 'draft',
602
- 'rawMarkdown': draft_data.get('raw_md'),
603
- 'chartUrls': draft_data.get('chartUrls'),
604
- 'dataContext': draft_data.get('data_context')
605
- }
606
- project_ref.update(full_update_data)
607
- user_ref.update({'credits': current_credits - 2})
608
- logger.info(f"Project {project_id} successfully updated with full data. User {uid} charged 2 credits.")
609
- return jsonify({
610
- 'success': True,
611
- 'project': {**project_data, **full_update_data},
612
- 'credits_remaining': current_credits - 2
613
- }), 200
614
- except Exception as save_error:
615
- # Fallback Path: Save only the essentials if the full save fails
616
- logger.warning(f"Failed to save full project data for {project_id} due to: {save_error}. Saving degraded version.")
617
- degraded_update_data = {
618
- 'status': 'draft',
619
- 'rawMarkdown': draft_data.get('raw_md'),
620
- 'chartUrls': draft_data.get('chartUrls'),
621
- 'dataContext': None,
622
- 'warning': 'Report generated, but context data failed to save. Downstream features may be affected.'
623
- }
624
- project_ref.update(degraded_update_data)
625
- user_ref.update({'credits': current_credits - 2})
626
- logger.info(f"Project {project_id} successfully updated with DEGRADED data. User {uid} charged 2 credits.")
627
- return jsonify({
628
- 'success': True,
629
- 'project': {**project_data, **degraded_update_data},
630
- 'credits_remaining': current_credits - 2
631
- }), 200
632
 
633
- except Exception as e:
634
- logger.error(f"CRITICAL error generating report for project {project_id}: {traceback.format_exc()}")
635
- db.reference(f'sozo_projects/{project_id}').update({
636
- 'status': 'failed',
637
- 'error': str(e)
638
- })
639
- return jsonify({'error': str(e)}), 500
640
 
 
 
 
 
 
 
641
 
642
- def send_video_generation_notification(user_id, user_email, project_name, video_url, send_email=False):
643
- """
644
- Send notification when video generation is completed successfully.
645
-
646
- Args:
647
- user_id (str): Firebase user ID
648
- user_email (str): User's email address
649
- project_name (str): Name of the project
650
- video_url (str): URL of the generated video
651
- send_email (bool): Whether to send email notification
652
-
653
- Returns:
654
- bool: True if successful, False otherwise
655
- """
656
- logger.info(f"=== VIDEO GENERATION NOTIFICATION START ===")
657
- logger.info(f"User ID: {user_id}")
658
- logger.info(f"User Email: {user_email}")
659
- logger.info(f"Project Name: {project_name}")
660
- logger.info(f"Video URL: {video_url}")
661
- logger.info(f"Send Email: {send_email}")
662
-
663
- try:
664
- # Create the notification message
665
- message_content = f"Your video for project '{project_name}' has been generated successfully!"
666
- logger.info(f"Created notification message: {message_content}")
667
-
668
- # Create email subject and body if email notification is requested
669
- email_subject = None
670
- email_body = None
671
-
672
- if send_email:
673
- logger.info("Preparing email notification content...")
674
- email_subject = f"🎥 Video Generation Complete - {project_name}"
675
- logger.info(f"Email subject: {email_subject}")
676
-
677
- email_body = f"""
678
- <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background-color: #f9f9f9;">
679
- <div style="background-color: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
680
- <div style="text-align: center; margin-bottom: 30px;">
681
- <h1 style="color: #2563eb; margin: 0; font-size: 28px;">🎉 Video Generation Complete!</h1>
682
- </div>
683
-
684
- <div style="background-color: #f0f9ff; padding: 20px; border-radius: 8px; border-left: 4px solid #2563eb; margin-bottom: 25px;">
685
- <h2 style="color: #1e40af; margin: 0 0 10px 0; font-size: 20px;">Your video is ready!</h2>
686
- <p style="color: #374151; margin: 0; font-size: 16px;">
687
- Great news! Your video for project <strong>"{project_name}"</strong> has been successfully generated and is ready for viewing.
688
- </p>
689
- </div>
690
-
691
- <div style="text-align: center; margin: 30px 0;">
692
- <a href="{video_url}"
693
- style="display: inline-block; background-color: #2563eb; color: white; padding: 15px 30px;
694
- text-decoration: none; border-radius: 8px; font-weight: bold; font-size: 16px;
695
- box-shadow: 0 2px 4px rgba(37, 99, 235, 0.3);">
696
- 🎬 Watch Your Video
697
- </a>
698
- </div>
699
-
700
- <div style="background-color: #f8fafc; padding: 20px; border-radius: 8px; margin-top: 25px;">
701
- <h3 style="color: #374151; margin: 0 0 15px 0; font-size: 18px;">What's Next?</h3>
702
- <ul style="color: #6b7280; margin: 0; padding-left: 20px; line-height: 1.6;">
703
- <li>Click the button above to view your generated video</li>
704
- <li>Share your video with your team or clients</li>
705
- <li>Download the video for offline use</li>
706
- <li>Create more videos with your remaining credits</li>
707
- </ul>
708
- </div>
709
-
710
- <div style="text-align: center; margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
711
- <p style="color: #9ca3af; font-size: 14px; margin: 0;">
712
- This video was generated by <strong>Sozo Business Studio</strong><br>
713
- Need help? Contact us at <a href="mailto:support@sozofix.tech" style="color: #2563eb;">support@sozofix.tech</a>
714
- </p>
715
- </div>
716
- </div>
717
- </div>
718
- """
719
- logger.info(f"Email body prepared (length: {len(email_body)} chars)")
720
- else:
721
- logger.info("Email notification not requested, skipping email content preparation")
722
-
723
- # Use the existing _send_notification function
724
- logger.info("Calling _send_notification function...")
725
- result = _send_notification(
726
- user_id=user_id,
727
- user_email=user_email,
728
- message_content=message_content,
729
- send_email=send_email,
730
- email_subject=email_subject,
731
- email_body=email_body
732
- )
733
-
734
- if result:
735
- logger.info("✅ Video generation notification sent successfully")
736
- else:
737
- logger.error("❌ Video generation notification failed")
738
-
739
- logger.info(f"=== VIDEO GENERATION NOTIFICATION END (Result: {result}) ===")
740
- return result
741
-
742
  except Exception as e:
743
- logger.error(f"❌ EXCEPTION in send_video_generation_notification: {e}")
744
- logger.error(f"Exception type: {type(e).__name__}")
745
- import traceback
746
- logger.error(f"Traceback: {traceback.format_exc()}")
747
- logger.info(f"=== VIDEO GENERATION NOTIFICATION END (Exception) ===")
748
- return False
749
 
750
 
751
- @app.route('/api/sozo/projects/<string:project_id>/generate-video', methods=['POST'])
752
- def generate_sozo_video(project_id):
753
- try:
754
- token = request.headers.get('Authorization', '').split(' ')[1]
755
- uid = verify_token(token)
756
- if not uid:
757
- return jsonify({'error': 'Unauthorized'}), 401
758
-
759
- # Check user credits first
760
- user_ref = db.reference(f'users/{uid}')
761
- user_data = user_ref.get()
762
- if not user_data:
763
- return jsonify({'error': 'User not found'}), 404
764
-
765
- current_credits = user_data.get('credits', 0)
766
- if current_credits < 5:
767
- return jsonify({'error': 'Insufficient credits. Video generation requires 5 credits.'}), 402
768
-
769
- project_ref = db.reference(f'sozo_projects/{project_id}')
770
- project_data = project_ref.get()
771
- if not project_data or project_data.get('uid') != uid:
772
- return jsonify({'error': 'Project not found or unauthorized'}), 404
773
-
774
- data = request.get_json()
775
- voice_model = data.get('voice_model', 'aura-2-andromeda-en')
776
-
777
- # NEW: Get notification preference from request
778
- send_email_notification = data.get('send_email_notification', False)
779
-
780
- project_ref.update({'status': 'generating_video'})
781
-
782
- blob_path = f"sozo_projects/{uid}/{project_id}/data{Path(project_data['originalFilename']).suffix}"
783
- blob = bucket.blob(blob_path)
784
- file_bytes = blob.download_as_bytes()
785
- df = load_dataframe_safely(io.BytesIO(file_bytes), project_data['originalFilename'])
786
-
787
- # Generate the video
788
- video_url = generate_video_from_project(
789
- df,
790
- project_data.get('rawMarkdown', ''),
791
- project_data.get('dataContext', {}),
792
- uid,
793
- project_id,
794
- voice_model,
795
- bucket
796
- )
797
-
798
- if not video_url:
799
- raise Exception("Video generation failed in core function.")
800
-
801
- project_ref.update({'status': 'video_complete', 'videoUrl': video_url})
802
-
803
- # Deduct credits ONLY after successful generation
804
- user_ref.update({'credits': current_credits - 5})
805
-
806
- # NEW: Send notification if requested
807
- if send_email_notification:
808
- logger.info(f"Email notification requested for project {project_id}")
809
- project_name = project_data.get('name', 'Unnamed Project')
810
- user_email = user_data.get('email')
811
-
812
- logger.info(f"Project name: {project_name}")
813
- logger.info(f"User email available: {user_email is not None}")
814
-
815
- if user_email:
816
- logger.info(f"Sending video generation notification to {user_email}")
817
- notification_sent = send_video_generation_notification(
818
- user_id=uid,
819
- user_email=user_email,
820
- project_name=project_name,
821
- video_url=video_url,
822
- send_email=True
823
- )
824
-
825
- if not notification_sent:
826
- logger.error(f"❌ Failed to send notification for video generation completion to user {uid}")
827
- else:
828
- logger.info(f"✅ Successfully sent notification for video generation completion to user {uid}")
829
- else:
830
- logger.warning(f"⚠️ No email found for user {uid}, skipping email notification")
831
- else:
832
- logger.info("Email notification not requested")
833
- notification_sent = False
834
-
835
- return jsonify({
836
- 'success': True,
837
- 'video_url': video_url,
838
- 'credits_remaining': current_credits - 5,
839
- 'notification_sent': notification_sent if send_email_notification and user_data.get('email') else False
840
- }), 200
841
-
842
- except Exception as e:
843
- db.reference(f'sozo_projects/{project_id}').update({'status': 'failed', 'error': str(e)})
844
- traceback.print_exc()
845
- return jsonify({'error': str(e)}), 500
846
 
 
847
 
848
- @app.route('/api/sozo/projects/<string:project_id>/generate-slides', methods=['POST'])
849
- def generate_sozo_slides(project_id):
850
- logger.info(f"POST /api/sozo/projects/{project_id}/generate-slides - Generating slides")
851
- try:
852
- token = request.headers.get('Authorization', '').split(' ')
853
- uid = verify_token(token)
854
- if not uid:
855
- return jsonify({'error': 'Unauthorized'}), 401
856
-
857
- # Check user credits first
858
- user_ref = db.reference(f'users/{uid}')
859
- user_data = user_ref.get()
860
- if not user_data:
861
- return jsonify({'error': 'User not found'}), 404
862
-
863
- current_credits = user_data.get('credits', 0)
864
- if current_credits < 5:
865
- return jsonify({'error': 'Insufficient credits. Slide generation requires 5 credits.'}), 402
866
-
867
- project_ref = db.reference(f'sozo_projects/{project_id}')
868
- project_data = project_ref.get()
869
-
870
- if not project_data or project_data.get('uid') != uid:
871
- return jsonify({'error': 'Project not found or unauthorized'}), 404
872
-
873
- raw_md = project_data.get('rawMarkdown')
874
- chart_urls = project_data.get('chartUrls', {})
875
-
876
- if not raw_md:
877
- return jsonify({'error': 'Report must be generated before slides can be created.'}), 400
878
-
879
- # The planner AI needs an LLM instance
880
- llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", google_api_key=os.getenv("GOOGLE_API_KEY"), temperature=0.2)
881
-
882
- slides_data = generate_slides_from_report(
883
- raw_md,
884
- chart_urls,
885
- uid,
886
- project_id,
887
- bucket,
888
- llm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
889
  )
890
-
891
- if not slides_data:
892
- raise Exception("Slide generation failed in core function.")
893
-
894
- # Save the slides data to the project in Firebase
895
- project_ref.update({'slides': slides_data})
896
-
897
- # Deduct credits ONLY after successful generation
898
- user_ref.update({'credits': current_credits - 5})
899
-
900
- logger.info(f"Project {project_id} successfully updated with {len(slides_data)} slides. User {uid} charged 5 credits.")
901
-
902
- return jsonify({
903
- 'success': True,
904
- 'slides': slides_data,
905
- 'credits_remaining': current_credits - 5
906
- }), 200
907
-
908
- except Exception as e:
909
- logger.error(f"CRITICAL error generating slides for project {project_id}: {traceback.format_exc()}")
910
- db.reference(f'sozo_projects/{project_id}').update({'status': 'failed_slides', 'error': str(e)})
911
- return jsonify({'error': str(e)}), 500
912
-
913
- @app.route('/api/image-proxy', methods=['GET'])
914
- def image_proxy():
915
- image_url = request.args.get('url')
916
- logger.info(f"[IMAGE PROXY] Received URL: {image_url}")
917
-
918
- if not image_url:
919
- logger.error("[IMAGE PROXY] ERROR: URL parameter is missing")
920
- return jsonify({'error': 'URL parameter is missing.'}), 400
921
 
922
- try:
923
- # Parse Firebase Storage URL
924
- # Expected format: https://storage.googleapis.com/bucket-name/path/to/file.ext
925
- if 'storage.googleapis.com' not in image_url:
926
- logger.error(f"[IMAGE PROXY] ERROR: Invalid Firebase Storage URL: {image_url}")
927
- return jsonify({'error': 'Invalid Firebase Storage URL.'}), 400
928
-
929
- logger.info(f"[IMAGE PROXY] Parsing URL: {image_url}")
930
-
931
- # Extract bucket name and blob path from the URL
932
- url_parts = image_url.split('storage.googleapis.com/')[1]
933
- logger.info(f"[IMAGE PROXY] URL parts after split: {url_parts}")
934
-
935
- # Remove query parameters if present
936
- url_parts = url_parts.split('?')[0]
937
- logger.info(f"[IMAGE PROXY] URL parts after removing query params: {url_parts}")
938
-
939
- # Split into bucket name and blob path
940
- path_components = url_parts.split('/', 1)
941
- logger.info(f"[IMAGE PROXY] Path components: {path_components}")
942
-
943
- if len(path_components) < 2:
944
- logger.error(f"[IMAGE PROXY] ERROR: Invalid URL format - path_components: {path_components}")
945
- return jsonify({'error': 'Invalid URL format.'}), 400
946
-
947
- url_bucket_name = path_components[0]
948
- blob_path = path_components[1]
949
-
950
- logger.info(f"[IMAGE PROXY] Extracted bucket name: {url_bucket_name}")
951
- logger.info(f"[IMAGE PROXY] Extracted blob path: {blob_path}")
952
-
953
- # Verify bucket name matches (optional security check)
954
- expected_bucket_name = bucket.name
955
- logger.info(f"[IMAGE PROXY] Expected bucket name: {expected_bucket_name}")
956
-
957
- if url_bucket_name != expected_bucket_name:
958
- logger.error(f"[IMAGE PROXY] ERROR: Bucket name mismatch - URL: {url_bucket_name}, Expected: {expected_bucket_name}")
959
- return jsonify({'error': 'Bucket name mismatch.'}), 403
960
-
961
- logger.info(f"[IMAGE PROXY] Creating blob object for path: {blob_path}")
962
-
963
- # Get the blob
964
- blob = bucket.blob(blob_path)
965
-
966
- logger.info(f"[IMAGE PROXY] Checking if blob exists...")
967
- if not blob.exists():
968
- logger.error(f"[IMAGE PROXY] ERROR: Image not found at path: {blob_path}")
969
- return jsonify({'error': 'Image not found.'}), 404
970
-
971
- logger.info(f"[IMAGE PROXY] Downloading blob...")
972
- # Download and return the image
973
- image_bytes = blob.download_as_bytes()
974
- content_type = blob.content_type or 'application/octet-stream'
975
-
976
- logger.info(f"[IMAGE PROXY] Successfully downloaded {len(image_bytes)} bytes, content-type: {content_type}")
977
-
978
- # Add cache headers for better performance
979
- response = Response(image_bytes, content_type=content_type)
980
- response.headers['Cache-Control'] = 'public, max-age=3600' # Cache for 1 hour
981
- return response
982
-
983
- except IndexError as e:
984
- logger.error(f"[IMAGE PROXY] URL parsing IndexError: {e}")
985
- logger.error(f"[IMAGE PROXY] URL was: {image_url}")
986
- return jsonify({'error': 'Invalid URL format.'}), 400
987
- except Exception as e:
988
- # This will catch parsing errors or other unexpected issues.
989
- logger.error(f"[IMAGE PROXY] Unexpected error: {e}")
990
- logger.error(f"[IMAGE PROXY] Error type: {type(e).__name__}")
991
- logger.error(f"[IMAGE PROXY] URL was: {image_url}")
992
- import traceback
993
- logger.error(f"[IMAGE PROXY] Full traceback: {traceback.format_exc()}")
994
- return jsonify({'error': 'Internal server error processing the image request.'}), 500
995
-
996
- @app.route('/api/sozo/projects/<string:project_id>/charts', methods=['POST'])
997
- def regenerate_sozo_chart(project_id):
998
- logger.info(f"Endpoint /charts POST for project {project_id}")
999
- try:
1000
- token = request.headers.get('Authorization', '').split(' ')[1]
1001
- uid = verify_token(token)
1002
- if not uid: return jsonify({'error': 'Unauthorized'}), 401
1003
- logger.info(f"Token verified for user {uid} for chart regeneration.")
1004
-
1005
- project_ref = db.reference(f'sozo_projects/{project_id}')
1006
- project_data = project_ref.get()
1007
- if not project_data or project_data.get('uid') != uid:
1008
- logger.warning(f"User {uid} failed to regenerate chart: Project {project_id} not found or not owned.")
1009
- return jsonify({'error': 'Project not found or unauthorized'}), 404
1010
-
1011
- data = request.get_json()
1012
- description = data.get('description')
1013
- chart_id_to_replace = data.get('chart_id')
1014
- if not description or not chart_id_to_replace:
1015
- return jsonify({'error': 'Chart description and chart_id are required'}), 400
1016
-
1017
- logger.info(f"User {uid}: Regenerating chart '{chart_id_to_replace}' for project {project_id} with new description: '{description}'")
1018
- blob_path = f"sozo_projects/{uid}/{project_id}/data{Path(project_data['originalFilename']).suffix}"
1019
- blob = bucket.blob(blob_path)
1020
- file_bytes = blob.download_as_bytes()
1021
- df = load_dataframe_safely(io.BytesIO(file_bytes), project_data['originalFilename'])
1022
-
1023
- new_chart_spec = generate_single_chart(df, description)
1024
-
1025
- logger.info(f"User {uid}: Updating chart spec in database for project {project_id}.")
1026
- report_content_ref = project_ref.child('report_content')
1027
- report_content = report_content_ref.get()
1028
- chart_specs = report_content.get('chart_specs', [])
1029
-
1030
- chart_found = False
1031
- for i, spec in enumerate(chart_specs):
1032
- if spec.get('id') == chart_id_to_replace:
1033
- chart_specs[i] = new_chart_spec
1034
- chart_found = True
1035
- break
1036
-
1037
- if not chart_found:
1038
- logger.warning(f"User {uid}: Chart with id {chart_id_to_replace} not found in project {project_id}.")
1039
- return jsonify({'error': f'Chart with id {chart_id_to_replace} not found'}), 404
1040
-
1041
- report_content_ref.child('chart_specs').set(chart_specs)
1042
- project_ref.update({'updatedAt': datetime.utcnow().isoformat()})
1043
-
1044
- logger.info(f"User {uid}: Successfully regenerated chart {chart_id_to_replace} for project {project_id}.")
1045
- return jsonify({'success': True, 'new_chart_spec': new_chart_spec}), 200
1046
- except Exception as e:
1047
- logger.error(f"CRITICAL ERROR regenerating chart for {project_id}: {traceback.format_exc()}")
1048
- return jsonify({'error': str(e)}), 500
1049
 
1050
- @app.route('/api/sozo/projects/<string:project_id>/update-narration-audio', methods=['POST'])
1051
- def update_narration_audio(project_id):
1052
- logger.info(f"Endpoint /update-narration-audio POST for project {project_id}")
1053
- try:
1054
- token = request.headers.get('Authorization', '').split(' ')[1]
1055
- uid = verify_token(token)
1056
- if not uid: return jsonify({'error': 'Unauthorized'}), 401
1057
- logger.info(f"Token verified for user {uid} for narration update.")
1058
-
1059
- data = request.get_json()
1060
- scene_id = data.get('scene_id')
1061
- narration_text = data.get('narration_text')
1062
- voice_model = data.get('voice_model', 'aura-2-andromeda-en')
1063
- if not scene_id or narration_text is None:
1064
- return jsonify({'error': 'scene_id and narration_text are required'}), 400
1065
-
1066
- logger.info(f"User {uid}: Updating narration for scene {scene_id} in project {project_id}.")
1067
- audio_bytes = deepgram_tts(narration_text, voice_model)
1068
- if not audio_bytes:
1069
- logger.error(f"User {uid}: Deepgram TTS failed for project {project_id}, scene {scene_id}.")
1070
- return jsonify({'error': 'Failed to generate audio'}), 500
1071
-
1072
- audio_blob_name = f"sozo_projects/{uid}/{project_id}/audio/{scene_id}.mp3"
1073
- logger.info(f"User {uid}: Uploading new audio to {audio_blob_name}.")
1074
- audio_blob = bucket.blob(audio_blob_name)
1075
- audio_blob.upload_from_string(audio_bytes, content_type="audio/mpeg")
1076
- new_audio_url = audio_blob.public_url
1077
-
1078
- logger.info(f"User {uid}: Updating database with new narration and audio URL for project {project_id}.")
1079
- scene_ref = db.reference(f'sozo_projects/{project_id}/video_script/scenes')
1080
- scenes = scene_ref.get()
1081
- scene_found = False
1082
- if scenes:
1083
- for i, scene in enumerate(scenes):
1084
- if scene.get('scene_id') == scene_id:
1085
- scene_ref.child(str(i)).update({
1086
- 'narration': narration_text,
1087
- 'audio_storage_path': new_audio_url
1088
- })
1089
- scene_found = True
1090
- break
1091
-
1092
- if not scene_found:
1093
- logger.warning(f"User {uid}: Scene {scene_id} not found in database for project {project_id} during narration update.")
1094
- return jsonify({'error': 'Scene not found in database'}), 404
1095
-
1096
- project_ref = db.reference(f'sozo_projects/{project_id}')
1097
- project_ref.update({'updatedAt': datetime.utcnow().isoformat()})
1098
-
1099
- logger.info(f"User {uid}: Successfully updated narration for scene {scene_id} in project {project_id}.")
1100
- return jsonify({'success': True, 'new_audio_url': new_audio_url}), 200
1101
- except Exception as e:
1102
- logger.error(f"CRITICAL ERROR updating narration for {project_id}: {traceback.format_exc()}")
1103
- return jsonify({'error': str(e)}), 500
1104
 
1105
- # -----------------------------------------------------------------------------
1106
- # 5. UNIVERSAL ENDPOINTS (Waitlist, Feedback, Credits)
1107
- # -----------------------------------------------------------------------------
1108
 
1109
- @app.route('/join-waitlist', methods=['POST'])
1110
- def join_waitlist():
1111
- try:
1112
- data = request.get_json()
1113
- if not data: return jsonify({"status": "error", "message": "Invalid request. JSON payload expected."}), 400
1114
- email = data.get('email')
1115
- if not email or not is_valid_email(email): return jsonify({"status": "error", "message": "A valid email is required."}), 400
1116
- email = email.lower()
1117
- waitlist_ref = db.reference('sozo_waitlist')
1118
- if waitlist_ref.order_by_child('email').equal_to(email).get():
1119
- return jsonify({"status": "success", "message": "You are already on the waitlist!"}), 200
1120
- new_entry_ref = waitlist_ref.push()
1121
- new_entry_ref.set({'email': email, 'timestamp': datetime.utcnow().isoformat() + 'Z'})
1122
- return jsonify({"status": "success", "message": "Thank you for joining the waitlist!"}), 201
1123
- except Exception as e:
1124
- traceback.print_exc()
1125
- return jsonify({"status": "error", "message": "An internal server error occurred."}), 500
1126
 
1127
- @app.route('/api/feedback', methods=['POST'])
1128
- def submit_feedback():
1129
- try:
1130
- token = request.headers.get('Authorization', '').split(' ')[1]
1131
- uid = verify_token(token)
1132
- if not uid: return jsonify({'error': 'Invalid or expired token'}), 401
1133
- data = request.get_json()
1134
- message = data.get('message')
1135
- if not message: return jsonify({'error': 'message is required'}), 400
1136
- user_email = (db.reference(f'users/{uid}').get() or {}).get('email', 'unknown')
1137
- feedback_ref = db.reference('feedback').push()
1138
- feedback_ref.set({"user_id": uid, "user_email": user_email, "type": data.get('type', 'general'), "message": message, "created_at": datetime.utcnow().isoformat(), "status": "open"})
1139
- return jsonify({"success": True, "feedback_id": feedback_ref.key}), 201
1140
- except Exception as e: return jsonify({'error': str(e)}), 500
1141
-
1142
- @app.route('/api/user/request-credits', methods=['POST'])
1143
- def request_credits():
1144
- try:
1145
- token = request.headers.get('Authorization', '').split(' ')[1]
1146
- uid = verify_token(token)
1147
- if not uid: return jsonify({'error': 'Invalid token'}), 401
1148
- requested_credits = request.get_json().get('requested_credits')
1149
- if requested_credits is None: return jsonify({'error': 'requested_credits is required'}), 400
1150
- credit_request_ref = db.reference('credit_requests').push()
1151
- credit_request_ref.set({'user_id': uid, 'requested_credits': requested_credits, 'status': 'pending', 'requested_at': datetime.utcnow().isoformat()})
1152
- return jsonify({'success': True, 'request_id': credit_request_ref.key})
1153
- except Exception as e: return jsonify({'error': str(e)}), 500
1154
-
1155
- # -----------------------------------------------------------------------------
1156
- # 6. ADMIN ENDPOINTS
1157
- # -----------------------------------------------------------------------------
1158
-
1159
- @app.route('/api/admin/dashboard-stats', methods=['GET'])
1160
- def get_admin_dashboard_stats():
1161
- """A singular endpoint to fetch all key metrics for the admin dashboard."""
1162
- try:
1163
- verify_admin(request.headers.get('Authorization', ''))
1164
-
1165
- # Fetch all necessary data in one go
1166
- all_users = db.reference('users').get() or {}
1167
- all_projects = db.reference('sozo_projects').get() or {}
1168
- all_feedback = db.reference('feedback').get() or {}
1169
- all_credit_requests = db.reference('credit_requests').get() or {}
1170
- waitlist = db.reference('sozo_waitlist').get() or {}
1171
-
1172
- # --- Initialize Stats ---
1173
- stats = {
1174
- "user_stats": {"total": 0, "new_24h": 0, "new_7d": 0},
1175
- "project_stats": {"total": 0, "new_24h": 0, "failed": 0, "videos_generated": 0},
1176
- "action_items": {"open_feedback": 0, "pending_credit_requests": 0},
1177
- "growth_stats": {"waitlist_total": 0}
1178
- }
1179
 
1180
- now = datetime.utcnow()
1181
- one_day_ago = now - timedelta(days=1)
1182
- seven_days_ago = now - timedelta(days=7)
1183
-
1184
- # --- Process Users ---
1185
- stats["user_stats"]["total"] = len(all_users)
1186
- for user_data in all_users.values():
1187
- created_at_str = user_data.get('created_at')
1188
- if created_at_str:
1189
- created_at_dt = datetime.fromisoformat(created_at_str)
1190
- if created_at_dt > one_day_ago:
1191
- stats["user_stats"]["new_24h"] += 1
1192
- if created_at_dt > seven_days_ago:
1193
- stats["user_stats"]["new_7d"] += 1
1194
-
1195
- # --- Process Projects ---
1196
- stats["project_stats"]["total"] = len(all_projects)
1197
- for project_data in all_projects.values():
1198
- created_at_str = project_data.get('createdAt')
1199
- if created_at_str:
1200
- created_at_dt = datetime.fromisoformat(created_at_str)
1201
- if created_at_dt > one_day_ago:
1202
- stats["project_stats"]["new_24h"] += 1
1203
- if project_data.get('status') == 'failed':
1204
- stats["project_stats"]["failed"] += 1
1205
- if project_data.get('status') == 'video_complete':
1206
- stats["project_stats"]["videos_generated"] += 1
1207
-
1208
- # --- Process Action Items ---
1209
- stats["action_items"]["open_feedback"] = sum(1 for fb in all_feedback.values() if fb.get('status') == 'open')
1210
- stats["action_items"]["pending_credit_requests"] = sum(1 for cr in all_credit_requests.values() if cr.get('status') == 'pending')
1211
-
1212
- # --- Process Growth ---
1213
- stats["growth_stats"]["waitlist_total"] = len(waitlist)
1214
-
1215
- return jsonify(stats), 200
1216
 
1217
- except Exception as e:
1218
- traceback.print_exc()
1219
- return jsonify({'error': str(e)}), 500
1220
 
1221
- @app.route('/api/admin/credit_requests', methods=['GET'])
1222
- def list_credit_requests():
1223
- try:
1224
- verify_admin(request.headers.get('Authorization', ''))
1225
- requests_list = [{'id': req_id, **data} for req_id, data in (db.reference('credit_requests').get() or {}).items()]
1226
- return jsonify({'credit_requests': requests_list})
1227
- except Exception as e: return jsonify({'error': str(e)}), 500
1228
 
1229
- @app.route('/api/admin/credit_requests/<string:request_id>', methods=['PUT'])
1230
- def process_credit_request(request_id):
1231
- try:
1232
- admin_uid = verify_admin(request.headers.get('Authorization', ''))
1233
- req_ref = db.reference(f'credit_requests/{request_id}')
1234
- req_data = req_ref.get()
1235
- if not req_data: return jsonify({'error': 'Credit request not found'}), 404
1236
- decision = request.get_json().get('decision')
1237
- if decision not in ['approved', 'declined']: return jsonify({'error': 'decision must be "approved" or "declined"'}), 400
1238
- if decision == 'approved':
1239
- user_ref = db.reference(f'users/{req_data["user_id"]}')
1240
- new_total = (user_ref.get() or {}).get('credits', 0) + float(req_data.get('requested_credits', 0))
1241
- user_ref.update({'credits': new_total})
1242
- req_ref.update({'status': 'approved', 'processed_by': admin_uid, 'processed_at': datetime.utcnow().isoformat()})
1243
- return jsonify({'success': True, 'new_user_credits': new_total})
1244
- else:
1245
- req_ref.update({'status': 'declined', 'processed_by': admin_uid, 'processed_at': datetime.utcnow().isoformat()})
1246
- return jsonify({'success': True, 'message': 'Credit request declined'})
1247
- except Exception as e: return jsonify({'error': str(e)}), 500
1248
 
1249
- @app.route('/api/admin/users', methods=['GET'])
1250
- def admin_list_users():
1251
- try:
1252
- verify_admin(request.headers.get('Authorization', ''))
1253
- all_users = db.reference('users').get() or {}
1254
- user_list = [{'uid': uid, **data} for uid, data in all_users.items()]
1255
- return jsonify({'users': user_list}), 200
1256
- except Exception as e: return jsonify({'error': str(e)}), 500
1257
-
1258
- @app.route('/api/admin/users/<string:uid>/credits', methods=['PUT'])
1259
- def admin_update_credits(uid):
1260
- try:
1261
- verify_admin(request.headers.get('Authorization', ''))
1262
- add_credits = request.get_json().get('add_credits')
1263
- if add_credits is None: return jsonify({'error': 'add_credits is required'}), 400
1264
- user_ref = db.reference(f'users/{uid}')
1265
- if not user_ref.get(): return jsonify({'error': 'User not found'}), 404
1266
- new_total = user_ref.get().get('credits', 0) + float(add_credits)
1267
- user_ref.update({'credits': new_total})
1268
- return jsonify({'success': True, 'new_total_credits': new_total})
1269
- except Exception as e: return jsonify({'error': str(e)}), 500
1270
-
1271
- @app.route('/api/admin/feedback', methods=['GET'])
1272
- def list_feedback():
1273
- """List all feedback submissions for admin review."""
1274
- try:
1275
- verify_admin(request.headers.get('Authorization', ''))
1276
-
1277
- # Get all feedback from Firebase
1278
- all_feedback = db.reference('feedback').get() or {}
1279
-
1280
- # Convert to list format with feedback IDs
1281
- feedback_list = []
1282
- for feedback_id, feedback_data in all_feedback.items():
1283
- feedback_item = {
1284
- 'id': feedback_id,
1285
- **feedback_data
1286
- }
1287
- feedback_list.append(feedback_item)
1288
-
1289
- # Sort by created_at (most recent first) if timestamp exists
1290
- feedback_list.sort(key=lambda x: x.get('created_at', ''), reverse=True)
1291
-
1292
- return jsonify({'feedback': feedback_list}), 200
1293
-
1294
- except Exception as e:
1295
- traceback.print_exc()
1296
- return jsonify({'error': str(e)}), 500
1297
 
1298
 
1299
- @app.route('/api/admin/feedback/<string:feedback_id>', methods=['PUT'])
1300
- def update_feedback_status(feedback_id):
1301
- """Update feedback status (e.g., mark as reviewed, resolved, etc.)"""
1302
- try:
1303
- admin_uid = verify_admin(request.headers.get('Authorization', ''))
1304
-
1305
- # Check if feedback exists
1306
- feedback_ref = db.reference(f'feedback/{feedback_id}')
1307
- feedback_data = feedback_ref.get()
1308
-
1309
- if not feedback_data:
1310
- return jsonify({'error': 'Feedback not found'}), 404
1311
-
1312
- # Get the new status from request
1313
- request_data = request.get_json()
1314
- new_status = request_data.get('status')
1315
-
1316
- # Validate status
1317
- valid_statuses = ['open', 'reviewed', 'resolved', 'closed']
1318
- if new_status not in valid_statuses:
1319
- return jsonify({'error': f'Status must be one of: {", ".join(valid_statuses)}'}), 400
1320
-
1321
- # Update feedback with new status and admin info
1322
- update_data = {
1323
- 'status': new_status,
1324
- 'processed_by': admin_uid,
1325
- 'processed_at': datetime.utcnow().isoformat()
1326
- }
1327
-
1328
- # Add admin notes if provided
1329
- admin_notes = request_data.get('admin_notes')
1330
- if admin_notes:
1331
- update_data['admin_notes'] = admin_notes
1332
-
1333
- feedback_ref.update(update_data)
1334
-
1335
- return jsonify({
1336
- 'success': True,
1337
- 'message': f'Feedback status updated to {new_status}',
1338
- 'feedback_id': feedback_id
1339
- }), 200
1340
-
1341
- except Exception as e:
1342
- traceback.print_exc()
1343
- return jsonify({'error': str(e)}), 500
1344
 
 
 
 
 
 
 
 
 
 
 
1345
 
1346
- @app.route('/api/admin/feedback/<string:feedback_id>', methods=['GET'])
1347
- def get_feedback_details(feedback_id):
1348
- """Get detailed view of a specific feedback item."""
1349
- try:
1350
- verify_admin(request.headers.get('Authorization', ''))
1351
-
1352
- # Get feedback details
1353
- feedback_ref = db.reference(f'feedback/{feedback_id}')
1354
- feedback_data = feedback_ref.get()
1355
-
1356
- if not feedback_data:
1357
- return jsonify({'error': 'Feedback not found'}), 404
1358
-
1359
- # Add the feedback ID to the response
1360
- feedback_details = {
1361
- 'id': feedback_id,
1362
- **feedback_data
1363
- }
1364
-
1365
- # Optionally get user details if user_id is present
1366
- if 'user_id' in feedback_data:
1367
- user_ref = db.reference(f'users/{feedback_data["user_id"]}')
1368
- user_data = user_ref.get()
1369
- if user_data:
1370
- feedback_details['user_details'] = {
1371
- 'email': user_data.get('email'),
1372
- 'name': user_data.get('name'),
1373
- 'created_at': user_data.get('created_at')
1374
- }
1375
-
1376
- return jsonify({'feedback': feedback_details}), 200
1377
-
1378
- except Exception as e:
1379
- traceback.print_exc()
1380
- return jsonify({'error': str(e)}), 500
1381
- # -----------------------------------------------------------------------------
1382
- # 4. NOTIFICATION ENDPOINTS
1383
- # -----------------------------------------------------------------------------
1384
-
1385
- @app.route('/api/admin/notifications/send', methods=['POST'])
1386
- def admin_send_notification():
1387
- logger.info("Endpoint /api/admin/notifications/send POST: Received request.")
1388
- try:
1389
- verify_admin(request.headers.get('Authorization'))
1390
-
1391
- data = request.get_json()
1392
- message_content = data.get('message')
1393
- target_group = data.get('target_group', 'all')
1394
- target_users_list = data.get('target_users', [])
1395
-
1396
- send_as_email = data.get('send_as_email', False)
1397
- email_subject = data.get('email_subject')
1398
- email_body_html = data.get('email_body_html')
1399
-
1400
- if not message_content:
1401
- return jsonify({'error': 'In-app notification message is required'}), 400
1402
- if send_as_email and (not email_subject or not email_body_html):
1403
- return jsonify({'error': 'Email subject and body are required when sending as email.'}), 400
1404
-
1405
- recipients = [] # List of tuples (uid, email)
1406
-
1407
- if target_group == 'all':
1408
- all_users = db.reference('users').get() or {}
1409
- for uid, user_data in all_users.items():
1410
- recipients.append((uid, user_data.get('email')))
1411
- elif target_group == 'waitlist':
1412
- waitlist_users = db.reference('sozo_waitlist').get() or {}
1413
- for _, user_data in waitlist_users.items():
1414
- # For waitlist, UID is None as they are not registered users yet
1415
- recipients.append((None, user_data.get('email')))
1416
- elif target_users_list:
1417
- all_users = db.reference('users').get() or {}
1418
- for uid in target_users_list:
1419
- if uid in all_users:
1420
- recipients.append((uid, all_users[uid].get('email')))
1421
- else:
1422
- return jsonify({'error': 'Invalid target specified'}), 400
1423
-
1424
- sent_count = 0
1425
- for uid_recipient, email_recipient in recipients:
1426
- if _send_notification(
1427
- user_id=uid_recipient,
1428
- user_email=email_recipient,
1429
- message_content=message_content,
1430
- send_email=send_as_email,
1431
- email_subject=email_subject,
1432
- email_body=email_body_html
1433
- ):
1434
- sent_count += 1
1435
-
1436
- return jsonify({'success': True, 'message': f"Notification dispatched for {sent_count} recipient(s)."}), 200
1437
-
1438
- except PermissionError as e:
1439
- return jsonify({'error': str(e)}), 403
1440
- except Exception as e:
1441
- logger.error(f"CRITICAL ERROR during notification send: {traceback.format_exc()}")
1442
- return jsonify({'error': str(e)}), 500
1443
 
1444
- @app.route('/api/user/notifications', methods=['GET'])
1445
- def get_user_notifications():
1446
- try:
1447
- logger.info("Getting user notifications - start")
1448
-
1449
- # Get and validate authorization token
1450
- auth_header = request.headers.get('Authorization', '')
1451
- logger.info(f"Authorization header present: {bool(auth_header)}")
1452
-
1453
- if not auth_header or not auth_header.startswith('Bearer '):
1454
- logger.warning("Missing or invalid Authorization header format")
1455
- return jsonify({'error': 'Missing or invalid authorization header'}), 401
1456
-
1457
- token = auth_header.split(' ')[1]
1458
- logger.info(f"Token extracted, length: {len(token) if token else 0}")
1459
-
1460
- # Verify token and get user ID
1461
- uid = verify_token(token)
1462
- if not uid:
1463
- logger.warning(f"Token verification failed for token: {token[:20]}...")
1464
- return jsonify({'error': 'Unauthorized'}), 401
1465
-
1466
- logger.info(f"User authenticated: {uid}")
1467
-
1468
- # Get notifications reference
1469
- notifications_ref = db.reference(f'notifications/{uid}')
1470
- logger.info(f"Notifications reference created for path: notifications/{uid}")
1471
-
1472
- # Try to get notifications with error handling
1473
- try:
1474
- user_notifications = notifications_ref.order_by_child('created_at').get()
1475
- logger.info(f"Notifications query successful, raw result type: {type(user_notifications)}")
1476
- logger.info(f"Raw notifications data: {user_notifications}")
1477
- except Exception as db_error:
1478
- logger.error(f"Database query failed: {str(db_error)}")
1479
- # Fallback to unordered query
1480
- logger.info("Attempting fallback to unordered query")
1481
- user_notifications = notifications_ref.get()
1482
- logger.info(f"Fallback query result: {user_notifications}")
1483
-
1484
- # Handle empty or None results
1485
- if not user_notifications:
1486
- logger.info("No notifications found for user")
1487
- return jsonify([]), 200
1488
-
1489
- # Convert to list and handle different data structures
1490
- notifications_list = []
1491
-
1492
- if isinstance(user_notifications, dict):
1493
- logger.info(f"Processing dict with {len(user_notifications)} items")
1494
- for key, notification in user_notifications.items():
1495
- if isinstance(notification, dict):
1496
- # Add the key as id if not present
1497
- notification_copy = notification.copy()
1498
- if 'id' not in notification_copy:
1499
- notification_copy['id'] = key
1500
- notifications_list.append(notification_copy)
1501
- logger.debug(f"Added notification: {key}")
1502
- else:
1503
- logger.warning(f"Unexpected notification format for key {key}: {type(notification)}")
1504
- else:
1505
- logger.warning(f"Unexpected notifications data type: {type(user_notifications)}")
1506
- return jsonify({'error': 'Unexpected data format'}), 500
1507
-
1508
- logger.info(f"Processed {len(notifications_list)} notifications")
1509
-
1510
- # Sort notifications by created_at (newest first)
1511
- try:
1512
- sorted_notifications = sorted(
1513
- notifications_list,
1514
- key=lambda item: item.get('created_at', 0),
1515
- reverse=True
1516
- )
1517
- logger.info(f"Notifications sorted successfully, count: {len(sorted_notifications)}")
1518
-
1519
- # Log first notification for debugging
1520
- if sorted_notifications:
1521
- logger.debug(f"First notification: {sorted_notifications[0]}")
1522
-
1523
- except Exception as sort_error:
1524
- logger.error(f"Error sorting notifications: {str(sort_error)}")
1525
- # Return unsorted if sorting fails
1526
- sorted_notifications = notifications_list
1527
-
1528
- logger.info(f"Returning {len(sorted_notifications)} notifications")
1529
- return jsonify(sorted_notifications), 200
1530
-
1531
- except Exception as e:
1532
- logger.error(f"CRITICAL ERROR getting notifications: {traceback.format_exc()}")
1533
- logger.error(f"Exception type: {type(e).__name__}")
1534
- logger.error(f"Exception message: {str(e)}")
1535
- return jsonify({'error': 'Internal server error'}), 500
1536
 
1537
- @app.route('/api/user/notifications/<string:notification_id>/read', methods=['POST'])
1538
- def mark_notification_read(notification_id):
1539
- try:
1540
- token = request.headers.get('Authorization', '').split(' ')[1]
1541
- uid = verify_token(token)
1542
- if not uid: return jsonify({'error': 'Unauthorized'}), 401
1543
-
1544
- notif_ref = db.reference(f'notifications/{uid}/{notification_id}')
1545
- if not notif_ref.get():
1546
- return jsonify({'error': 'Notification not found'}), 404
1547
-
1548
- notif_ref.update({'read': True, 'read_at': datetime.now(timezone.utc).isoformat()})
1549
- return jsonify({'success': True, 'message': 'Notification marked as read.'}), 200
1550
- except Exception as e:
1551
- logger.error(f"CRITICAL ERROR marking notification read: {traceback.format_exc()}")
1552
- return jsonify({'error': str(e)}), 500
1553
- # -----------------------------------------------------------------------------
1554
- # 7. MAIN EXECUTION
1555
- # -----------------------------------------------------------------------------
1556
- if __name__ == '__main__':
1557
- app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 7860)))
 
1
  # main.py
2
+ #
3
+ # DreamBiomeMCP – Flask server exposing HTTP "tools" over your processed JSON
4
+ # Files expected in the same directory:
5
+ # - dream_entries.json
6
+ # - sleep_profiles.json
7
+ #
8
+ # Endpoints (all JSON):
9
+ # GET /health
10
+ # GET /dream/series
11
+ # POST /dream/samples
12
+ # POST /dream/cluster-stats
13
+ # GET /sleep/profiles
14
+ # GET /sleep/profile/<profile_id>
15
+ # POST /sleep/profile-arc
16
+ # POST /seed/random-dream-biome
17
+ #
18
+ # NEW (LlamaIndex, backwards compatible):
19
+ # POST /llama/query
20
+ # body: { "query": "...", "series": "jasmine1" | null, "top_k": 5 }
21
+ # reply: { "enabled": bool, "answer": str, "sources": [ {id, series, text}, ... ], "error": optional }
22
+
23
  import json
24
+ import random
25
+ import statistics
 
 
 
 
 
26
  from pathlib import Path
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from flask import Flask, jsonify, request
30
+
31
+ # -------------------------------------------------------------------
32
+ # Data loading
33
+ # -------------------------------------------------------------------
34
+
35
+ BASE_DIR = Path(__file__).resolve().parent
36
+ DREAM_FILE = BASE_DIR / "dream_entries.json"
37
+ SLEEP_FILE = BASE_DIR / "sleep_profiles.json"
38
+
39
+ def load_json(path: Path) -> Any:
40
+ with path.open("r", encoding="utf-8") as f:
41
+ return json.load(f)
42
+
43
+ print(f"[DreamBiomeMCP] Loading data from {DREAM_FILE} and {SLEEP_FILE} ...")
44
+ ALL_DREAMS: List[Dict[str, Any]] = load_json(DREAM_FILE)
45
+ ALL_SLEEP_PROFILES: List[Dict[str, Any]] = load_json(SLEEP_FILE)
46
+ print(f"[DreamBiomeMCP] Loaded {len(ALL_DREAMS)} dreams, {len(ALL_SLEEP_PROFILES)} sleep profiles.")
47
+
48
+ # Build quick lookup index for sleep profiles by id
49
+ SLEEP_INDEX: Dict[str, Dict[str, Any]] = {p["id"]: p for p in ALL_SLEEP_PROFILES}
50
+
51
+
52
+ # -------------------------------------------------------------------
53
+ # Helper functions
54
+ # -------------------------------------------------------------------
55
+
56
+ def filter_dreams(
57
+ source: Optional[str] = None,
58
+ series: Optional[str] = None,
59
+ min_words: Optional[int] = None,
60
+ max_words: Optional[int] = None,
61
+ ) -> List[Dict[str, Any]]:
62
+ dreams = ALL_DREAMS
63
+ if source:
64
+ dreams = [d for d in dreams if str(d.get("source")) == source]
65
+ if series:
66
+ dreams = [d for d in dreams if str(d.get("series")) == series]
67
+ if min_words is not None:
68
+ dreams = [d for d in dreams if d.get("length_words", 0) >= min_words]
69
+ if max_words is not None:
70
+ dreams = [d for d in dreams if d.get("length_words", 0) <= max_words]
71
+ return dreams
72
+
73
+
74
+ def summarise_dream_cluster(dreams: List[Dict[str, Any]]) -> Dict[str, Any]:
75
+ if not dreams:
76
+ return {
77
+ "count": 0,
78
+ "avg_length_words": 0,
79
+ "length_words_std": 0,
80
+ "metrics_means": {},
81
+ }
82
 
83
+ lengths = [d.get("length_words", 0) for d in dreams]
84
+ avg_len = statistics.mean(lengths)
85
+ std_len = statistics.pstdev(lengths) if len(lengths) > 1 else 0.0
86
+
87
+ # Aggregate any numeric metrics if present (from Dryad)
88
+ numeric_keys = set()
89
+ for d in dreams:
90
+ metrics = d.get("metrics") or {}
91
+ for k, v in metrics.items():
92
+ if isinstance(v, (int, float)):
93
+ numeric_keys.add(k)
94
+
95
+ metrics_means: Dict[str, float] = {}
96
+ for key in sorted(numeric_keys):
97
+ vals = []
98
+ for d in dreams:
99
+ m = (d.get("metrics") or {})
100
+ v = m.get(key)
101
+ if isinstance(v, (int, float)):
102
+ vals.append(float(v))
103
+ if vals:
104
+ metrics_means[key] = statistics.mean(vals)
105
+
106
+ return {
107
+ "count": len(dreams),
108
+ "avg_length_words": round(avg_len, 2),
109
+ "length_words_std": round(std_len, 2),
110
+ "metrics_means": metrics_means,
111
+ }
112
+
113
+
114
+ def compute_sleep_arc(profile: Dict[str, Any]) -> Dict[str, Any]:
115
+ """Derive a simple "arc" from the stage sequence for storytelling."""
116
+ stages = profile.get("stages", [])
117
+ if not stages:
118
+ return {"segments": []}
119
+
120
+ total = len(stages)
121
+ step = max(total // 4, 1)
122
+ segments = []
123
+ for i in range(4):
124
+ start = i * step
125
+ end = min((i + 1) * step, total)
126
+ if start >= end:
127
+ break
128
+ segment = stages[start:end]
129
+ # simple dominant stage for this quarter
130
+ counts: Dict[str, int] = {}
131
+ for s in segment:
132
+ counts[s] = counts.get(s, 0) + 1
133
+ dominant = max(counts.items(), key=lambda kv: kv[1])[0]
134
+ segments.append(
135
+ {
136
+ "index": i,
137
+ "start_epoch": start,
138
+ "end_epoch": end - 1,
139
+ "dominant_stage": dominant,
140
+ "counts": counts,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  }
142
+ )
143
+
144
+ return {"segments": segments, "total_epochs": total}
145
+
146
+
147
+ def pick_random_region_context() -> Dict[str, Any]:
148
+ # Extremely small, hard-coded region stats for now.
149
+ # You can expand this or swap to a JSON file later.
150
+ regions = [
151
+ {
152
+ "region": "Global",
153
+ "insomnia_prevalence": 0.16,
154
+ "severe_insomnia": 0.08,
155
+ "notes": "Global adult insomnia meta-analysis.",
156
+ "reference": "global_insomnia_meta_2016",
157
+ },
158
+ {
159
+ "region": "UK",
160
+ "insomnia_prevalence": 0.29,
161
+ "severe_insomnia": 0.06,
162
+ "notes": "Symptom-based estimate from large UK cohort.",
163
+ "reference": "uk_biobank_insomnia",
164
+ },
165
+ {
166
+ "region": "East Asia",
167
+ "insomnia_prevalence": 0.20,
168
+ "severe_insomnia": 0.07,
169
+ "notes": "Approximate pooled prevalence from regional studies.",
170
+ "reference": "east_asia_insomnia_review",
171
+ },
172
+ ]
173
+ return random.choice(regions)
174
+
175
+
176
+ # -------------------------------------------------------------------
177
+ # OPTIONAL: LlamaIndex RAG over dreams
178
+ # -------------------------------------------------------------------
179
+
180
+ LLAMA_ENABLED: bool = False
181
+ LLAMA_INDEX = None
182
+ LLAMA_INIT_ERROR: Optional[str] = None
183
+
184
+ def init_llama_index() -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  """
186
+ Build a lightweight in-memory LlamaIndex over ALL_DREAMS.
187
+ Retrieval-only (no LLM generation) so we don't need extra keys.
 
 
 
 
 
 
 
 
 
 
 
188
  """
189
+ global LLAMA_ENABLED, LLAMA_INDEX, LLAMA_INIT_ERROR
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
+ print("[LlamaIndex] Initialising dream index ...")
 
 
192
  try:
193
+ from llama_index.core import VectorStoreIndex, Document, Settings
194
+ from llama_index.embeddings.huggingface import HuggingFaceEmbedding
195
+ # Use a small local embedding model (downloads once on first run).
196
+ embed_model = HuggingFaceEmbedding(model_name="sentence-transformers/all-MiniLM-L6-v2")
197
+ Settings.embed_model = embed_model
198
+ Settings.llm = None # retrieval-only; no built-in LLM
199
+
200
+ docs = []
201
+ for d in ALL_DREAMS:
202
+ text = d.get("text") or d.get("dream") or ""
203
+ if not text:
204
+ continue
205
+ metadata = {
206
+ "id": d.get("id"),
207
+ "series": str(d.get("series") or ""),
208
+ "source": str(d.get("source") or ""),
209
+ }
210
+ docs.append(Document(text=text, metadata=metadata))
211
+
212
+ if not docs:
213
+ LLAMA_INIT_ERROR = "No dream texts found to index."
214
+ print("[LlamaIndex][WARN]", LLAMA_INIT_ERROR)
215
+ return
216
+
217
+ LLAMA_INDEX = VectorStoreIndex.from_documents(docs)
218
+ LLAMA_ENABLED = True
219
+ LLAMA_INIT_ERROR = None
220
+ print(f"[LlamaIndex] Index built over {len(docs)} dreams.")
221
+
222
+ except ImportError as e:
223
+ LLAMA_ENABLED = False
224
+ LLAMA_INDEX = None
225
+ LLAMA_INIT_ERROR = f"ImportError: {e}"
226
+ print("[LlamaIndex][WARN] LlamaIndex not installed. Skipping RAG layer.")
227
  except Exception as e:
228
+ LLAMA_ENABLED = False
229
+ LLAMA_INDEX = None
230
+ LLAMA_INIT_ERROR = f"{type(e).__name__}: {e}"
231
+ print("[LlamaIndex][ERROR] Failed to build index:", e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
 
 
 
 
233
 
234
+ # Initialise LlamaIndex once at startup (but don't crash if it fails).
235
+ init_llama_index()
 
236
 
 
 
237
 
238
+ def llama_query_impl(query: str, series: Optional[str], top_k: int = 5) -> Dict[str, Any]:
239
+ """
240
+ Internal helper: run a similarity search over dream texts.
241
+ Returns answer+sources, but still completely side-channel to the existing API.
242
+ """
243
+ if not LLAMA_ENABLED or LLAMA_INDEX is None:
244
+ return {
245
+ "enabled": False,
246
+ "answer": "",
247
+ "sources": [],
248
+ "error": LLAMA_INIT_ERROR or "LlamaIndex is not enabled on this server.",
249
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
 
251
+ from llama_index.core import VectorStoreIndex # type: ignore
 
 
252
 
253
+ # Basic safety
254
+ query = (query or "").strip()
255
+ if not query:
256
+ return {
257
+ "enabled": True,
258
+ "answer": "No query text provided.",
259
+ "sources": [],
260
+ "error": None,
261
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
+ top_k = max(1, min(int(top_k or 5), 10))
 
 
264
 
 
 
 
 
265
  try:
266
+ filters = None
267
+ if series:
268
+ try:
269
+ # Newer LlamaIndex metadata filter API
270
+ from llama_index.core.vector_stores.types import (
271
+ MetadataFilters,
272
+ ExactMatchFilter,
273
+ )
274
+ filters = MetadataFilters(
275
+ filters=[ExactMatchFilter(key="series", value=str(series))]
276
+ )
277
+ except Exception:
278
+ # If metadata filter types move around, just ignore filters and search globally.
279
+ filters = None
280
+
281
+ # Retrieval-only
282
+ retriever = LLAMA_INDEX.as_retriever(
283
+ similarity_top_k=top_k,
284
+ filters=filters,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  )
286
+ nodes = retriever.retrieve(query)
287
+
288
+ sources = []
289
+ answer_snippets = []
290
+ for n in nodes:
291
+ meta = getattr(n, "metadata", {}) or {}
292
+ text = getattr(n, "text", "") or ""
293
+ sources.append(
294
+ {
295
+ "id": meta.get("id"),
296
+ "series": meta.get("series"),
297
+ "source": meta.get("source"),
298
+ "text": text,
299
+ }
300
+ )
301
+ answer_snippets.append(text[:500])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
 
303
+ if not answer_snippets:
304
+ answer = "No relevant dreams were retrieved for this query."
305
+ else:
306
+ answer = (
307
+ "Top matching dream snippets (retrieved via LlamaIndex):\n\n"
308
+ + "\n\n---\n\n".join(answer_snippets)
309
+ )
310
 
311
+ return {
312
+ "enabled": True,
313
+ "answer": answer,
314
+ "sources": sources,
315
+ "error": None,
316
+ }
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  except Exception as e:
319
+ return {
320
+ "enabled": True,
321
+ "answer": "",
322
+ "sources": [],
323
+ "error": f"LlamaIndex retrieval failed: {e}",
324
+ }
325
 
326
 
327
+ # -------------------------------------------------------------------
328
+ # Flask app
329
+ # -------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
 
331
+ app = Flask(__name__)
332
 
333
+ # ---------------- HOME ROUTE (NEW) ----------------
334
+
335
+ @app.route("/", methods=["GET"])
336
+ def home() -> Any:
337
+ """Landing page describing the MCP server and its tools."""
338
+ return jsonify({
339
+ "status": "DreamBiomeMCP is running",
340
+ "description": "Flask server exposing real dream and sleep data via HTTP tools.",
341
+ "endpoints": [
342
+ {"method": "GET", "path": "/health", "desc": "Server health and data counts"},
343
+ {"method": "GET", "path": "/dream/series", "desc": "List available dream series"},
344
+ {"method": "POST", "path": "/dream/samples", "desc": "Get random dream samples (filters: series, min/max words)"},
345
+ {"method": "POST", "path": "/dream/cluster-stats", "desc": "Get statistical summary of a dream cluster"},
346
+ {"method": "GET", "path": "/sleep/profiles", "desc": "List available sleep profile IDs"},
347
+ {"method": "GET", "path": "/sleep/profile/<id>", "desc": "Get full hypnogram data for a specific profile"},
348
+ {"method": "POST", "path": "/sleep/profile-arc", "desc": "Get narrative 4-act arc derived from sleep stages"},
349
+ {"method": "POST", "path": "/seed/random-dream-biome", "desc": "Get a random seed (dream + sleep + region)"},
350
+ {"method": "POST", "path": "/llama/query", "desc": "RAG search over dream texts"},
351
+ ]
352
+ })
353
+
354
+
355
+ @app.route("/health", methods=["GET"])
356
+ def health() -> Any:
357
+ return jsonify({
358
+ "status": "ok",
359
+ "dreams": len(ALL_DREAMS),
360
+ "sleep_profiles": len(ALL_SLEEP_PROFILES),
361
+ "llama_enabled": LLAMA_ENABLED,
362
+ "llama_error": LLAMA_INIT_ERROR,
363
+ })
364
+
365
+
366
+ # ---------------- DREAM ENDPOINTS ----------------
367
+
368
+ @app.route("/dream/series", methods=["GET"])
369
+ def list_dream_series() -> Any:
370
+ series_counts: Dict[str, int] = {}
371
+ for d in ALL_DREAMS:
372
+ series = str(d.get("series") or "unknown")
373
+ series_counts[series] = series_counts.get(series, 0) + 1
374
+
375
+ data = [{"series": s, "count": c} for s, c in sorted(series_counts.items(), key=lambda kv: kv[0])]
376
+ return jsonify({"series": data})
377
+
378
+
379
+ @app.route("/dream/samples", methods=["POST"])
380
+ def dream_samples() -> Any:
381
+ payload = request.get_json(force=True, silent=True) or {}
382
+ source = payload.get("source")
383
+ series = payload.get("series")
384
+ n = int(payload.get("n", 5))
385
+ min_words = payload.get("min_words")
386
+ max_words = payload.get("max_words")
387
+
388
+ dreams = filter_dreams(
389
+ source=source,
390
+ series=series,
391
+ min_words=int(min_words) if min_words is not None else None,
392
+ max_words=int(max_words) if max_words is not None else None,
393
+ )
394
+
395
+ if not dreams:
396
+ return jsonify({"samples": [], "note": "No dreams matched the filter."})
397
+
398
+ random.shuffle(dreams)
399
+ samples = dreams[: max(1, n)]
400
+ return jsonify({"samples": samples})
401
+
402
+
403
+ @app.route("/dream/cluster-stats", methods=["POST"])
404
+ def dream_cluster_stats() -> Any:
405
+ payload = request.get_json(force=True, silent=True) or {}
406
+ source = payload.get("source")
407
+ series = payload.get("series")
408
+ min_words = payload.get("min_words")
409
+ max_words = payload.get("max_words")
410
+
411
+ dreams = filter_dreams(
412
+ source=source,
413
+ series=series,
414
+ min_words=int(min_words) if min_words is not None else None,
415
+ max_words=int(max_words) if max_words is not None else None,
416
+ )
417
+
418
+ stats = summarise_dream_cluster(dreams)
419
+ stats["source"] = source
420
+ stats["series"] = series
421
+ return jsonify(stats)
422
+
423
+
424
+ # ---------------- SLEEP ENDPOINTS ----------------
425
+
426
+ @app.route("/sleep/profiles", methods=["GET"])
427
+ def list_sleep_profiles() -> Any:
428
+ # Return only IDs + high-level summary to keep payload small
429
+ summaries = []
430
+ for p in ALL_SLEEP_PROFILES:
431
+ summaries.append(
432
+ {
433
+ "id": p["id"],
434
+ "total_sleep_time_min": p.get("total_sleep_time_min"),
435
+ "sleep_efficiency": p.get("sleep_efficiency"),
436
+ "rem_percentage": p.get("rem_percentage"),
437
+ "awakenings": p.get("awakenings"),
438
+ }
439
  )
440
+ return jsonify({"profiles": summaries})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
 
443
+ @app.route("/sleep/profile/<profile_id>", methods=["GET"])
444
+ def get_sleep_profile(profile_id: str) -> Any:
445
+ profile = SLEEP_INDEX.get(profile_id)
446
+ if not profile:
447
+ return jsonify({"error": f"Sleep profile '{profile_id}' not found."}), 404
448
+ return jsonify(profile)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
 
 
 
 
450
 
451
+ @app.route("/sleep/profile-arc", methods=["POST"])
452
+ def sleep_profile_arc() -> Any:
453
+ payload = request.get_json(force=True, silent=True) or {}
454
+ profile_id = payload.get("profile_id")
455
+ if not profile_id:
456
+ return jsonify({"error": "profile_id is required"}), 400
 
 
 
 
 
 
 
 
 
 
 
457
 
458
+ profile = SLEEP_INDEX.get(profile_id)
459
+ if not profile:
460
+ return jsonify({"error": f"Sleep profile '{profile_id}' not found."}), 404
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
+ arc = compute_sleep_arc(profile)
463
+ return jsonify({"profile_id": profile_id, "arc": arc})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
 
 
 
 
465
 
466
+ # ---------------- COMBINED SEED ----------------
 
 
 
 
 
 
467
 
468
+ @app.route("/seed/random-dream-biome", methods=["POST", "GET"])
469
+ def random_dream_biome_seed() -> Any:
470
+ """Return a bundle: one random dream, one random sleep profile, and one region context."""
471
+ dream = random.choice(ALL_DREAMS) if ALL_DREAMS else None
472
+ sleep_profile = random.choice(ALL_SLEEP_PROFILES) if ALL_SLEEP_PROFILES else None
473
+ region = pick_random_region_context()
 
 
 
 
 
 
 
 
 
 
 
 
 
474
 
475
+ return jsonify(
476
+ {
477
+ "dream_sample": dream,
478
+ "sleep_profile": sleep_profile,
479
+ "region_sleep": region,
480
+ }
481
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
 
484
+ # ---------------- LLAMAINDEX QUERY (NEW) ----------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
 
486
+ @app.route("/llama/query", methods=["POST"])
487
+ def llama_query() -> Any:
488
+ """
489
+ LlamaIndex-backed retrieval over dream texts.
490
+ This is additive – frontend doesn't need to change unless you want to use it.
491
+ """
492
+ payload = request.get_json(force=True, silent=True) or {}
493
+ query = payload.get("query", "")
494
+ series = payload.get("series")
495
+ top_k = payload.get("top_k", 5)
496
 
497
+ result = llama_query_impl(query, series, top_k=top_k)
498
+ status = 200 if result.get("error") is None else 500
499
+ return jsonify(result), status
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
501
 
502
+ # -------------------------------------------------------------------
503
+ # Entry point
504
+ # -------------------------------------------------------------------
505
+
506
+ if __name__ == "__main__":
507
+ # For local testing; HF Spaces will just run `python main.py`
508
+ app.run(host="0.0.0.0", port=7860, debug=False)