rairo commited on
Commit
8714bca
·
verified ·
1 Parent(s): 7802371

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +155 -139
main.py CHANGED
@@ -4,23 +4,31 @@ import uuid
4
  import json
5
  import traceback
6
  import threading
7
- from datetime import datetime
 
8
  from flask import Flask, request, jsonify
9
  from flask_cors import CORS
10
  import firebase_admin
11
  from firebase_admin import credentials, db, storage, auth
 
 
12
 
13
  from lesson_gen import (
14
  fetch_arxiv_papers, generate_knowledge_base,
15
  generate_lesson_from_knowledge_base, generate_remedial_lesson,
16
- create_lesson_video, deepgram_tts
17
  )
18
  import logging
19
 
20
- # --- Configuration & Initialization ---
21
  app = Flask(__name__)
22
  CORS(app)
23
 
 
 
 
 
 
24
  try:
25
  credentials_json_string = os.environ.get("FIREBASE")
26
  if not credentials_json_string: raise ValueError("FIREBASE env var not set.")
@@ -36,30 +44,95 @@ except Exception as e:
36
 
37
  bucket = storage.bucket()
38
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
 
 
 
39
 
40
- # --- Helper Functions & Workers ---
41
  def verify_token(token):
42
  try: return auth.verify_id_token(token)['uid']
43
  except Exception as e:
44
  logging.error(f"Token verification failed: {e}")
45
  return None
46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  def generation_worker(course_id, user_id, topic, level, goal):
48
- """Worker for the entire initial course generation process."""
49
- logging.info(f"WORKER [{course_id}]: Starting for topic '{topic}'.")
50
  course_ref = db.reference(f'profai_courses/{course_id}')
51
  try:
52
  arxiv_docs = fetch_arxiv_papers(topic)
53
  knowledge_base = generate_knowledge_base(topic, level, goal, arxiv_docs)
54
  learning_path = knowledge_base.get("learning_path", [])
55
- course_ref.update({"knowledgeBase": knowledge_base, "learningPath": learning_path})
56
 
57
  first_concept = learning_path[0] if learning_path else "Introduction"
58
  lesson_content = generate_lesson_from_knowledge_base(knowledge_base, first_concept)
59
 
60
- narration_bytes = deepgram_tts(lesson_content['script'])
61
- if not narration_bytes: raise Exception("TTS failed, cannot proceed.")
62
- video_bytes = create_lesson_video(lesson_content['script'], narration_bytes)
63
 
64
  blob_name = f"profai_courses/{user_id}/{course_id}/lesson_1.mp4"
65
  blob = bucket.blob(blob_name)
@@ -78,52 +151,46 @@ def generation_worker(course_id, user_id, topic, level, goal):
78
  user_ref = db.reference(f'users/{user_id}')
79
  user_credits = user_ref.child('credits').get()
80
  user_ref.update({'credits': user_credits - 8})
81
- logging.info(f"WORKER [{course_id}]: Success. Charged 8 credits.")
 
 
82
  except Exception as e:
83
- logging.error(f"WORKER [{course_id}]: Failed: {traceback.format_exc()}")
84
  course_ref.update({"status": "failed", "error": str(e)})
85
 
86
  def lesson_worker(course_id, user_id, concept, current_steps, is_remedial=False, regenerate_step_num=None):
87
- """Worker for generating subsequent, remedial, or regenerated lessons."""
88
  action = "Regenerating" if regenerate_step_num else "Generating"
89
- logging.info(f"WORKER [{course_id}]: {action} lesson for concept '{concept}'. Remedial: {is_remedial}")
90
  course_ref = db.reference(f'profai_courses/{course_id}')
91
  try:
92
- knowledge_base = course_ref.child('knowledgeBase').get()
 
 
93
  if not knowledge_base: raise Exception("Knowledge base not found for course.")
94
 
95
  if is_remedial:
96
  lesson_content = generate_remedial_lesson(concept)
97
  cost = 5
98
- else: # Standard or Regenerated lesson
99
  lesson_content = generate_lesson_from_knowledge_base(knowledge_base, concept)
100
  cost = 8
101
 
102
- narration_bytes = deepgram_tts(lesson_content['script'])
103
- if not narration_bytes: raise Exception("TTS failed.")
104
- video_bytes = create_lesson_video(lesson_content['script'], narration_bytes)
105
 
106
  step_num = regenerate_step_num if regenerate_step_num else len(current_steps) + 1
107
  blob_name = f"profai_courses/{user_id}/{course_id}/lesson_{step_num}_{uuid.uuid4().hex[:4]}.mp4"
108
  blob = bucket.blob(blob_name)
109
  blob.upload_from_string(video_bytes, content_type="video/mp4")
110
 
111
- new_video_step = {
112
- "step": step_num, "type": "video", "concept": concept,
113
- "videoUrl": blob.public_url, "status": "unlocked"
114
- }
115
- new_quiz_step = {
116
- "step": step_num + 1, "type": "quiz", "concept": concept,
117
- "quizData": lesson_content['quiz'], "status": "locked"
118
- }
119
 
120
  if regenerate_step_num:
121
- # Replace existing steps
122
  current_steps[regenerate_step_num - 1] = new_video_step
123
  if (regenerate_step_num < len(current_steps)) and current_steps[regenerate_step_num]['type'] == 'quiz':
124
  current_steps[regenerate_step_num] = new_quiz_step
125
  else:
126
- # Append new steps
127
  current_steps.extend([new_video_step, new_quiz_step])
128
 
129
  course_ref.child('steps').set(current_steps)
@@ -131,79 +198,53 @@ def lesson_worker(course_id, user_id, concept, current_steps, is_remedial=False,
131
  user_ref = db.reference(f'users/{user_id}')
132
  user_credits = user_ref.child('credits').get()
133
  user_ref.update({'credits': user_credits - cost})
134
- logging.info(f"WORKER [{course_id}]: Lesson '{concept}' processed. Charged {cost} credits.")
135
 
 
 
136
  except Exception as e:
137
- logging.error(f"WORKER [{course_id}]: Lesson generation for '{concept}' failed: {e}")
138
- # Mark the last unlocked step as failed to signal the frontend
139
  current_steps[-1]['status'] = 'generation_failed'
140
  course_ref.child('steps').set(current_steps)
141
 
142
- # --- USER AUTHENTICATION (EXPANDED) ---
143
- @app.route('/api/auth/signup', methods=['POST'])
144
- def signup():
145
- try:
146
- data = request.get_json()
147
- email = data.get('email')
148
- password = data.get('password')
149
- if not email or not password: return jsonify({'error': 'Email and password are required'}), 400
150
- user = auth.create_user(email=email, password=password)
151
- user_ref = db.reference(f'users/{user.uid}')
152
- user_data = {'email': email, 'credits': 20, 'created_at': datetime.utcnow().isoformat()} # Default credits
153
- user_ref.set(user_data)
154
- return jsonify({'success': True, 'user': {'uid': user.uid, **user_data}}), 201
155
- except Exception as e: return jsonify({'error': str(e)}), 400
156
-
157
  @app.route('/api/auth/google-signin', methods=['POST'])
158
  def google_signin():
159
  try:
160
- auth_header = request.headers.get('Authorization', '')
161
- if not auth_header.startswith('Bearer '): return jsonify({'error': 'Missing or invalid token'}), 401
162
-
163
- token = auth_header.split(' ')[1]
164
  decoded_token = auth.verify_id_token(token)
165
- uid = decoded_token['uid']
166
- email = decoded_token.get('email')
167
 
168
  user_ref = db.reference(f'users/{uid}')
169
- user_data = user_ref.get()
170
- if not user_data:
171
- logging.info(f"New user signed in with Google: {email}, UID: {uid}. Creating profile.")
172
  user_data = {'email': email, 'credits': 20, 'created_at': datetime.utcnow().isoformat()}
173
  user_ref.set(user_data)
174
  else:
175
- logging.info(f"Existing user signed in with Google: {email}, UID: {uid}.")
176
-
177
- return jsonify({'success': True, 'user': {'uid': uid, **user_data}}), 200
 
178
  except Exception as e:
179
- logging.error(f"Google Sign-in failed: {traceback.format_exc()}")
180
  return jsonify({'error': str(e)}), 400
181
 
182
- # --- ProfAI CRUD ENDPOINTS ---
183
  @app.route('/api/profai/courses', methods=['GET'])
184
  def get_user_courses():
185
- """Lists all courses created by the authenticated user."""
186
  try:
187
- token = request.headers.get('Authorization', '').split(' ')[1]
188
- uid = verify_token(token)
189
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
190
-
191
  courses_ref = db.reference('profai_courses')
192
  user_courses = courses_ref.order_by_child('uid').equal_to(uid).get()
193
-
194
  if not user_courses: return jsonify([]), 200
195
-
196
- # Convert dict to a list sorted by creation date
197
  courses_list = sorted(user_courses.values(), key=lambda x: x.get('createdAt', ''), reverse=True)
198
  return jsonify(courses_list), 200
199
- except Exception as e:
200
- return jsonify({'error': str(e)}), 500
201
 
202
  @app.route('/api/profai/start-course', methods=['POST'])
203
  def start_course():
204
  try:
205
- token = request.headers.get('Authorization', '').split(' ')[1]
206
- uid = verify_token(token)
207
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
208
 
209
  user_credits = db.reference(f'users/{uid}/credits').get()
@@ -212,73 +253,51 @@ def start_course():
212
 
213
  data = request.get_json()
214
  topic, level, goal = data.get('topic'), data.get('level'), data.get('goal')
215
- if not all([topic, level, goal]):
216
- return jsonify({'error': 'topic, level, and goal are required'}), 400
217
 
218
  course_id = uuid.uuid4().hex
219
- course_data = {
220
- 'id': course_id, 'uid': uid, 'topic': topic,
221
- 'status': 'generating', 'createdAt': datetime.utcnow().isoformat(),
222
- }
223
  db.reference(f'profai_courses/{course_id}').set(course_data)
224
 
225
- thread = threading.Thread(target=generation_worker, args=(course_id, uid, topic, level, goal))
226
- thread.daemon = True
227
- thread.start()
228
 
229
  return jsonify({'success': True, 'message': 'Your personalized course is being generated!', 'courseId': course_id}), 202
230
- except Exception as e:
231
- return jsonify({'error': str(e)}), 500
232
 
233
  @app.route('/api/profai/course-status/<string:course_id>', methods=['GET'])
234
  def get_course_status(course_id):
235
  try:
236
- token = request.headers.get('Authorization', '').split(' ')[1]
237
- uid = verify_token(token)
238
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
239
-
240
  course_data = db.reference(f'profai_courses/{course_id}').get()
241
  if not course_data or course_data.get('uid') != uid:
242
  return jsonify({'error': 'Course not found or unauthorized'}), 404
243
-
244
  return jsonify(course_data), 200
245
- except Exception as e:
246
- return jsonify({'error': str(e)}), 500
247
 
248
  @app.route('/api/profai/courses/<string:course_id>', methods=['DELETE'])
249
  def delete_course(course_id):
250
- """Deletes a course and all associated videos from storage."""
251
  try:
252
- token = request.headers.get('Authorization', '').split(' ')[1]
253
- uid = verify_token(token)
254
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
255
-
256
  course_ref = db.reference(f'profai_courses/{course_id}')
257
  course_data = course_ref.get()
258
  if not course_data or course_data.get('uid') != uid:
259
  return jsonify({'error': 'Course not found or unauthorized'}), 404
260
 
261
- # Delete associated files from Firebase Storage
262
  storage_prefix = f"profai_courses/{uid}/{course_id}/"
263
  blobs_to_delete = bucket.list_blobs(prefix=storage_prefix)
264
  for blob in blobs_to_delete:
265
- logging.info(f"Deleting file from storage: {blob.name}")
266
  blob.delete()
267
 
268
- # Delete course record from database
269
  course_ref.delete()
270
- logging.info(f"Deleted course {course_id} for user {uid}")
271
-
272
  return jsonify({'success': True, 'message': 'Course deleted successfully.'}), 200
273
- except Exception as e:
274
- return jsonify({'error': str(e)}), 500
275
 
276
  @app.route('/api/profai/courses/<string:course_id>/steps/<int:step_num>/regenerate', methods=['POST'])
277
  def regenerate_step(course_id, step_num):
278
- """Regenerates a specific lesson video and its quiz."""
279
  try:
280
- token = request.headers.get('Authorization', '').split(' ')[1]
281
- uid = verify_token(token)
282
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
283
 
284
  user_credits = db.reference(f'users/{uid}/credits').get()
@@ -286,30 +305,25 @@ def regenerate_step(course_id, step_num):
286
 
287
  course_ref = db.reference(f'profai_courses/{course_id}')
288
  course_data = course_ref.get()
289
- if not course_data or course_data.get('uid') != uid:
290
- return jsonify({'error': 'Course not found or unauthorized'}), 404
291
 
292
  steps = course_data.get('steps', [])
293
  if not (0 < step_num <= len(steps) and steps[step_num - 1]['type'] == 'video'):
294
  return jsonify({'error': 'Invalid step number. You can only regenerate video steps.'}), 400
295
 
296
  concept_to_regenerate = steps[step_num - 1]['concept']
297
- steps[step_num - 1]['status'] = 'regenerating' # Update UI state
298
  course_ref.child('steps').set(steps)
299
 
300
- thread = threading.Thread(target=lesson_worker, args=(course_id, uid, concept_to_regenerate, steps, False, step_num))
301
- thread.daemon = True
302
- thread.start()
303
 
304
  return jsonify({'success': True, 'message': f"Regenerating lesson for '{concept_to_regenerate}'."}), 202
305
- except Exception as e:
306
- return jsonify({'error': str(e)}), 500
307
 
308
  @app.route('/api/profai/submit-quiz/<string:course_id>', methods=['POST'])
309
  def submit_quiz(course_id):
310
  try:
311
- token = request.headers.get('Authorization', '').split(' ')[1]
312
- uid = verify_token(token)
313
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
314
 
315
  data = request.get_json()
@@ -317,8 +331,7 @@ def submit_quiz(course_id):
317
 
318
  course_ref = db.reference(f'profai_courses/{course_id}')
319
  course_data = course_ref.get()
320
- if not course_data or course_data.get('uid') != uid:
321
- return jsonify({'error': 'Course not found'}), 404
322
 
323
  steps = course_data.get('steps', [])
324
  learning_path = course_data.get('learningPath', [])
@@ -335,39 +348,42 @@ def submit_quiz(course_id):
335
 
336
  if score > 0.6: # Pass
337
  user_credits = db.reference(f'users/{uid}/credits').get()
338
- if user_credits < 8: return jsonify({'error': 'Insufficient credits for next lesson (requires 8).'}), 402
339
-
340
- last_concept = steps[quiz_step_index]['concept']
341
- try:
342
- next_concept_index = learning_path.index(last_concept) + 1
343
- if next_concept_index < len(learning_path):
344
- next_concept = learning_path[next_concept_index]
345
- thread = threading.Thread(target=lesson_worker, args=(course_id, uid, next_concept, steps, False))
346
- thread.daemon = True
347
- thread.start()
348
- next_action_message = f"Passed! Generating your next lesson on '{next_concept}'."
349
- else:
350
- steps.append({"step": len(steps) + 1, "type": "course_complete", "status": "unlocked"})
351
- next_action_message = "Congratulations! You've completed the course."
352
- except (ValueError, IndexError):
353
- return jsonify({'error': 'Could not determine the next lesson step.'}), 500
 
354
  else: # Struggle
355
  user_credits = db.reference(f'users/{uid}/credits').get()
356
- if user_credits < 5: return jsonify({'error': 'Insufficient credits for remedial lesson (requires 5).'}), 402
357
-
358
- failed_concept = steps[quiz_step_index]['concept']
359
- thread = threading.Thread(target=lesson_worker, args=(course_id, uid, failed_concept, steps, True))
360
- thread.daemon = True
361
- thread.start()
362
- next_action_message = f"Let's review. Generating a remedial lesson on '{failed_concept}'."
 
363
 
364
  course_ref.child('steps').set(steps)
365
  return jsonify({'success': True, 'score': score, 'message': next_action_message}), 202
366
 
367
  except Exception as e:
 
368
  return jsonify({'error': str(e)}), 500
369
 
370
- # --- Main Execution ---
371
  if __name__ == '__main__':
372
  port = int(os.environ.get("PORT", 7860))
373
  app.run(debug=True, host="0.0.0.0", port=port)
 
4
  import json
5
  import traceback
6
  import threading
7
+ import time
8
+ from datetime import datetime, timezone
9
  from flask import Flask, request, jsonify
10
  from flask_cors import CORS
11
  import firebase_admin
12
  from firebase_admin import credentials, db, storage, auth
13
+ from flask_apscheduler import APScheduler
14
+ import requests
15
 
16
  from lesson_gen import (
17
  fetch_arxiv_papers, generate_knowledge_base,
18
  generate_lesson_from_knowledge_base, generate_remedial_lesson,
19
+ generate_profai_video_from_script
20
  )
21
  import logging
22
 
23
+ # --- 1. CONFIGURATION & INITIALIZATION ---
24
  app = Flask(__name__)
25
  CORS(app)
26
 
27
+ # Initialize Scheduler
28
+ scheduler = APScheduler()
29
+ scheduler.init_app(app)
30
+ scheduler.start()
31
+
32
  try:
33
  credentials_json_string = os.environ.get("FIREBASE")
34
  if not credentials_json_string: raise ValueError("FIREBASE env var not set.")
 
44
 
45
  bucket = storage.bucket()
46
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
47
+ logger = logging.getLogger(__name__)
48
+
49
+ RESEND_API_KEY = os.environ.get("RESEND_API_KEY")
50
 
51
+ # --- 2. HELPER FUNCTIONS & WORKERS ---
52
  def verify_token(token):
53
  try: return auth.verify_id_token(token)['uid']
54
  except Exception as e:
55
  logging.error(f"Token verification failed: {e}")
56
  return None
57
 
58
+ # --- Resend Email Notification System (Ported from Sozo) ---
59
+ class ResendRateLimiter:
60
+ def __init__(self, requests_per_second=1):
61
+ self.requests_per_second = requests_per_second
62
+ self.min_interval = 1.0 / requests_per_second
63
+ self.last_request_time = 0
64
+ self.lock = threading.Lock()
65
+ def wait_if_needed(self):
66
+ with self.lock:
67
+ current_time = time.time()
68
+ time_since_last_request = current_time - self.last_request_time
69
+ if time_since_last_request < self.min_interval:
70
+ sleep_time = self.min_interval - time_since_last_request
71
+ logger.info(f"Rate limiting: waiting {sleep_time:.2f} seconds before sending email")
72
+ time.sleep(sleep_time)
73
+ self.last_request_time = time.time()
74
+
75
+ resend_rate_limiter = ResendRateLimiter(requests_per_second=1)
76
+
77
+ def _send_notification(user_email, email_subject, email_body):
78
+ if not RESEND_API_KEY:
79
+ logger.error("RESEND_API_KEY is not configured. Cannot send email.")
80
+ return False
81
+ resend_rate_limiter.wait_if_needed()
82
+ headers = {"Authorization": f"Bearer {RESEND_API_KEY.strip()}", "Content-Type": "application/json"}
83
+ payload = {"from": "ProfAI <lessons@sozofix.tech>", "to": [user_email], "subject": email_subject, "html": email_body}
84
+ try:
85
+ response = requests.post("https://api.resend.com/emails", headers=headers, json=payload)
86
+ response.raise_for_status()
87
+ logger.info(f"Successfully sent email to {user_email}. Subject: {email_subject}")
88
+ return True
89
+ except requests.exceptions.RequestException as e:
90
+ logger.error(f"Failed to send email to {user_email} via Resend: {e}")
91
+ if hasattr(e, 'response') and e.response is not None:
92
+ logger.error(f"Error response status: {e.response.status_code}, content: {e.response.text}")
93
+ return False
94
+
95
+ def send_lesson_ready_email(user_id, course_id, course_topic, lesson_concept):
96
+ user_data = db.reference(f'users/{user_id}').get()
97
+ if not user_data or not user_data.get('email'):
98
+ logger.warning(f"No email found for user {user_id}, cannot send notification.")
99
+ return
100
+
101
+ email = user_data['email']
102
+ subject = f"✅ Your New ProfAI Lesson is Ready: {lesson_concept}"
103
+ body = f"""
104
+ <div style="font-family: Arial, sans-serif; max-width: 600px; margin: auto; border: 1px solid #ddd; border-radius: 10px; padding: 20px;">
105
+ <h1 style="color: #4A90E2; text-align: center;">ProfAI Lesson Ready!</h1>
106
+ <p>Hi there,</p>
107
+ <p>Great news! Your next video lesson for the course "<strong>{course_topic}</strong>" is now available.</p>
108
+ <p><strong>New Lesson:</strong> {lesson_concept}</p>
109
+ <div style="text-align: center; margin: 30px 0;">
110
+ <a href="https://profai.sozofix.tech/course/{course_id}"
111
+ style="background-color: #4A90E2; color: white; padding: 15px 30px; text-decoration: none; border-radius: 8px; font-weight: bold;">
112
+ Start Learning Now
113
+ </a>
114
+ </div>
115
+ <p>Keep up the great work!</p>
116
+ <p><em>— The ProfAI Team</em></p>
117
+ </div>
118
+ """
119
+ _send_notification(email, subject, body)
120
+
121
+ # --- WORKER FUNCTIONS (Called by APScheduler) ---
122
  def generation_worker(course_id, user_id, topic, level, goal):
123
+ logger.info(f"WORKER [{course_id}]: Starting for topic '{topic}'.")
 
124
  course_ref = db.reference(f'profai_courses/{course_id}')
125
  try:
126
  arxiv_docs = fetch_arxiv_papers(topic)
127
  knowledge_base = generate_knowledge_base(topic, level, goal, arxiv_docs)
128
  learning_path = knowledge_base.get("learning_path", [])
129
+ course_ref.update({"knowledgeBase": knowledge_base, "learningPath": learning_path, "status": "generating_video"})
130
 
131
  first_concept = learning_path[0] if learning_path else "Introduction"
132
  lesson_content = generate_lesson_from_knowledge_base(knowledge_base, first_concept)
133
 
134
+ video_bytes = generate_profai_video_from_script(lesson_content['script'], topic)
135
+ if not video_bytes: raise Exception("Video generation failed, cannot proceed.")
 
136
 
137
  blob_name = f"profai_courses/{user_id}/{course_id}/lesson_1.mp4"
138
  blob = bucket.blob(blob_name)
 
151
  user_ref = db.reference(f'users/{user_id}')
152
  user_credits = user_ref.child('credits').get()
153
  user_ref.update({'credits': user_credits - 8})
154
+
155
+ send_lesson_ready_email(user_id, course_id, topic, first_concept)
156
+ logger.info(f"WORKER [{course_id}]: Success. Charged 8 credits.")
157
  except Exception as e:
158
+ logger.error(f"WORKER [{course_id}]: Failed: {traceback.format_exc()}")
159
  course_ref.update({"status": "failed", "error": str(e)})
160
 
161
  def lesson_worker(course_id, user_id, concept, current_steps, is_remedial=False, regenerate_step_num=None):
 
162
  action = "Regenerating" if regenerate_step_num else "Generating"
163
+ logger.info(f"WORKER [{course_id}]: {action} lesson for concept '{concept}'. Remedial: {is_remedial}")
164
  course_ref = db.reference(f'profai_courses/{course_id}')
165
  try:
166
+ course_data = course_ref.get()
167
+ knowledge_base = course_data.get('knowledgeBase')
168
+ topic = course_data.get('topic', 'your course')
169
  if not knowledge_base: raise Exception("Knowledge base not found for course.")
170
 
171
  if is_remedial:
172
  lesson_content = generate_remedial_lesson(concept)
173
  cost = 5
174
+ else:
175
  lesson_content = generate_lesson_from_knowledge_base(knowledge_base, concept)
176
  cost = 8
177
 
178
+ video_bytes = generate_profai_video_from_script(lesson_content['script'], topic)
179
+ if not video_bytes: raise Exception("Video generation failed.")
 
180
 
181
  step_num = regenerate_step_num if regenerate_step_num else len(current_steps) + 1
182
  blob_name = f"profai_courses/{user_id}/{course_id}/lesson_{step_num}_{uuid.uuid4().hex[:4]}.mp4"
183
  blob = bucket.blob(blob_name)
184
  blob.upload_from_string(video_bytes, content_type="video/mp4")
185
 
186
+ new_video_step = {"step": step_num, "type": "video", "concept": concept, "videoUrl": blob.public_url, "status": "unlocked"}
187
+ new_quiz_step = {"step": step_num + 1, "type": "quiz", "concept": concept, "quizData": lesson_content['quiz'], "status": "locked"}
 
 
 
 
 
 
188
 
189
  if regenerate_step_num:
 
190
  current_steps[regenerate_step_num - 1] = new_video_step
191
  if (regenerate_step_num < len(current_steps)) and current_steps[regenerate_step_num]['type'] == 'quiz':
192
  current_steps[regenerate_step_num] = new_quiz_step
193
  else:
 
194
  current_steps.extend([new_video_step, new_quiz_step])
195
 
196
  course_ref.child('steps').set(current_steps)
 
198
  user_ref = db.reference(f'users/{user_id}')
199
  user_credits = user_ref.child('credits').get()
200
  user_ref.update({'credits': user_credits - cost})
 
201
 
202
+ send_lesson_ready_email(user_id, course_id, topic, concept)
203
+ logger.info(f"WORKER [{course_id}]: Lesson '{concept}' processed. Charged {cost} credits.")
204
  except Exception as e:
205
+ logger.error(f"WORKER [{course_id}]: Lesson generation for '{concept}' failed: {traceback.format_exc()}")
 
206
  current_steps[-1]['status'] = 'generation_failed'
207
  course_ref.child('steps').set(current_steps)
208
 
209
+ # --- 3. USER AUTHENTICATION & MANAGEMENT ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  @app.route('/api/auth/google-signin', methods=['POST'])
211
  def google_signin():
212
  try:
213
+ token = request.headers.get('Authorization', '').split(' ')[1]
 
 
 
214
  decoded_token = auth.verify_id_token(token)
215
+ uid, email = decoded_token['uid'], decoded_token.get('email')
 
216
 
217
  user_ref = db.reference(f'users/{uid}')
218
+ if not user_ref.get():
219
+ logger.info(f"New user signed in with Google: {email}, UID: {uid}. Creating profile.")
 
220
  user_data = {'email': email, 'credits': 20, 'created_at': datetime.utcnow().isoformat()}
221
  user_ref.set(user_data)
222
  else:
223
+ logger.info(f"Existing user signed in with Google: {email}, UID: {uid}.")
224
+
225
+ final_user_data = user_ref.get()
226
+ return jsonify({'success': True, 'user': {'uid': uid, **final_user_data}}), 200
227
  except Exception as e:
228
+ logger.error(f"Google Sign-in failed: {traceback.format_exc()}")
229
  return jsonify({'error': str(e)}), 400
230
 
231
+ # --- 4. ProfAI API ENDPOINTS ---
232
  @app.route('/api/profai/courses', methods=['GET'])
233
  def get_user_courses():
 
234
  try:
235
+ uid = verify_token(request.headers.get('Authorization', '').split(' ')[1])
 
236
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
 
237
  courses_ref = db.reference('profai_courses')
238
  user_courses = courses_ref.order_by_child('uid').equal_to(uid).get()
 
239
  if not user_courses: return jsonify([]), 200
 
 
240
  courses_list = sorted(user_courses.values(), key=lambda x: x.get('createdAt', ''), reverse=True)
241
  return jsonify(courses_list), 200
242
+ except Exception as e: return jsonify({'error': str(e)}), 500
 
243
 
244
  @app.route('/api/profai/start-course', methods=['POST'])
245
  def start_course():
246
  try:
247
+ uid = verify_token(request.headers.get('Authorization', '').split(' ')[1])
 
248
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
249
 
250
  user_credits = db.reference(f'users/{uid}/credits').get()
 
253
 
254
  data = request.get_json()
255
  topic, level, goal = data.get('topic'), data.get('level'), data.get('goal')
256
+ if not all([topic, level, goal]): return jsonify({'error': 'topic, level, and goal are required'}), 400
 
257
 
258
  course_id = uuid.uuid4().hex
259
+ course_data = {'id': course_id, 'uid': uid, 'topic': topic, 'status': 'generating', 'createdAt': datetime.utcnow().isoformat()}
 
 
 
260
  db.reference(f'profai_courses/{course_id}').set(course_data)
261
 
262
+ scheduler.add_job(id=f'start_{course_id}', func=generation_worker, args=[course_id, uid, topic, level, goal])
 
 
263
 
264
  return jsonify({'success': True, 'message': 'Your personalized course is being generated!', 'courseId': course_id}), 202
265
+ except Exception as e: return jsonify({'error': str(e)}), 500
 
266
 
267
  @app.route('/api/profai/course-status/<string:course_id>', methods=['GET'])
268
  def get_course_status(course_id):
269
  try:
270
+ uid = verify_token(request.headers.get('Authorization', '').split(' ')[1])
 
271
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
 
272
  course_data = db.reference(f'profai_courses/{course_id}').get()
273
  if not course_data or course_data.get('uid') != uid:
274
  return jsonify({'error': 'Course not found or unauthorized'}), 404
 
275
  return jsonify(course_data), 200
276
+ except Exception as e: return jsonify({'error': str(e)}), 500
 
277
 
278
  @app.route('/api/profai/courses/<string:course_id>', methods=['DELETE'])
279
  def delete_course(course_id):
 
280
  try:
281
+ uid = verify_token(request.headers.get('Authorization', '').split(' ')[1])
 
282
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
 
283
  course_ref = db.reference(f'profai_courses/{course_id}')
284
  course_data = course_ref.get()
285
  if not course_data or course_data.get('uid') != uid:
286
  return jsonify({'error': 'Course not found or unauthorized'}), 404
287
 
 
288
  storage_prefix = f"profai_courses/{uid}/{course_id}/"
289
  blobs_to_delete = bucket.list_blobs(prefix=storage_prefix)
290
  for blob in blobs_to_delete:
 
291
  blob.delete()
292
 
 
293
  course_ref.delete()
 
 
294
  return jsonify({'success': True, 'message': 'Course deleted successfully.'}), 200
295
+ except Exception as e: return jsonify({'error': str(e)}), 500
 
296
 
297
  @app.route('/api/profai/courses/<string:course_id>/steps/<int:step_num>/regenerate', methods=['POST'])
298
  def regenerate_step(course_id, step_num):
 
299
  try:
300
+ uid = verify_token(request.headers.get('Authorization', '').split(' ')[1])
 
301
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
302
 
303
  user_credits = db.reference(f'users/{uid}/credits').get()
 
305
 
306
  course_ref = db.reference(f'profai_courses/{course_id}')
307
  course_data = course_ref.get()
308
+ if not course_data or course_data.get('uid') != uid: return jsonify({'error': 'Course not found or unauthorized'}), 404
 
309
 
310
  steps = course_data.get('steps', [])
311
  if not (0 < step_num <= len(steps) and steps[step_num - 1]['type'] == 'video'):
312
  return jsonify({'error': 'Invalid step number. You can only regenerate video steps.'}), 400
313
 
314
  concept_to_regenerate = steps[step_num - 1]['concept']
315
+ steps[step_num - 1]['status'] = 'regenerating'
316
  course_ref.child('steps').set(steps)
317
 
318
+ scheduler.add_job(id=f'regen_{course_id}_{step_num}', func=lesson_worker, args=[course_id, uid, concept_to_regenerate, steps, False, step_num])
 
 
319
 
320
  return jsonify({'success': True, 'message': f"Regenerating lesson for '{concept_to_regenerate}'."}), 202
321
+ except Exception as e: return jsonify({'error': str(e)}), 500
 
322
 
323
  @app.route('/api/profai/submit-quiz/<string:course_id>', methods=['POST'])
324
  def submit_quiz(course_id):
325
  try:
326
+ uid = verify_token(request.headers.get('Authorization', '').split(' ')[1])
 
327
  if not uid: return jsonify({'error': 'Unauthorized'}), 401
328
 
329
  data = request.get_json()
 
331
 
332
  course_ref = db.reference(f'profai_courses/{course_id}')
333
  course_data = course_ref.get()
334
+ if not course_data or course_data.get('uid') != uid: return jsonify({'error': 'Course not found'}), 404
 
335
 
336
  steps = course_data.get('steps', [])
337
  learning_path = course_data.get('learningPath', [])
 
348
 
349
  if score > 0.6: # Pass
350
  user_credits = db.reference(f'users/{uid}/credits').get()
351
+ if user_credits < 8:
352
+ steps.append({"step": len(steps) + 1, "type": "insufficient_credits", "status": "unlocked", "message": "You passed, but need more credits for the next lesson."})
353
+ next_action_message = "Passed! But you need more credits to continue."
354
+ else:
355
+ last_concept = steps[quiz_step_index]['concept']
356
+ try:
357
+ next_concept_index = learning_path.index(last_concept) + 1
358
+ if next_concept_index < len(learning_path):
359
+ next_concept = learning_path[next_concept_index]
360
+ steps[quiz_step_index + 1]['status'] = 'generating' # For UI
361
+ scheduler.add_job(id=f'lesson_{course_id}_{next_concept_index}', func=lesson_worker, args=[course_id, uid, next_concept, steps, False])
362
+ next_action_message = f"Passed! Generating your next lesson on '{next_concept}'."
363
+ else:
364
+ steps.append({"step": len(steps) + 1, "type": "course_complete", "status": "unlocked"})
365
+ next_action_message = "Congratulations! You've completed the course."
366
+ except (ValueError, IndexError):
367
+ return jsonify({'error': 'Could not determine the next lesson step.'}), 500
368
  else: # Struggle
369
  user_credits = db.reference(f'users/{uid}/credits').get()
370
+ if user_credits < 5:
371
+ steps.append({"step": len(steps) + 1, "type": "insufficient_credits", "status": "unlocked", "message": "Let's review, but you need more credits for a remedial lesson."})
372
+ next_action_message = "Let's review! But you need more credits to generate a remedial lesson."
373
+ else:
374
+ failed_concept = steps[quiz_step_index]['concept']
375
+ steps.append({"step": len(steps) + 1, "type": "video", "status": "generating"}) # For UI
376
+ scheduler.add_job(id=f'remedial_{course_id}', func=lesson_worker, args=[course_id, uid, failed_concept, steps, True])
377
+ next_action_message = f"Let's review. Generating a remedial lesson on '{failed_concept}'."
378
 
379
  course_ref.child('steps').set(steps)
380
  return jsonify({'success': True, 'score': score, 'message': next_action_message}), 202
381
 
382
  except Exception as e:
383
+ logger.error(traceback.format_exc())
384
  return jsonify({'error': str(e)}), 500
385
 
386
+ # --- 5. MAIN EXECUTION ---
387
  if __name__ == '__main__':
388
  port = int(os.environ.get("PORT", 7860))
389
  app.run(debug=True, host="0.0.0.0", port=port)