rairo commited on
Commit
d5b6c91
·
verified ·
1 Parent(s): d8086ad

Create main.py

Browse files
Files changed (1) hide show
  1. main.py +373 -0
main.py ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py
2
+ import os
3
+ 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.")
27
+ credentials_json = json.loads(credentials_json_string)
28
+ firebase_db_url = os.environ.get("Firebase_DB")
29
+ firebase_storage_bucket = os.environ.get("Firebase_Storage")
30
+ if not all([firebase_db_url, firebase_storage_bucket]): raise ValueError("Firebase DB/Storage env vars must be set.")
31
+ cred = credentials.Certificate(credentials_json)
32
+ firebase_admin.initialize_app(cred, {'databaseURL': firebase_db_url, 'storageBucket': firebase_storage_bucket})
33
+ logging.info("Firebase Admin SDK initialized successfully for ProfAI.")
34
+ except Exception as e:
35
+ logging.fatal(f"FATAL: Error initializing Firebase: {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)
66
+ blob.upload_from_string(video_bytes, content_type="video/mp4")
67
+
68
+ steps = [{
69
+ "step": 1, "type": "video", "concept": first_concept,
70
+ "videoUrl": blob.public_url, "status": "unlocked"
71
+ }, {
72
+ "step": 2, "type": "quiz", "concept": first_concept,
73
+ "quizData": lesson_content['quiz'], "status": "locked"
74
+ }]
75
+
76
+ course_ref.update({"status": "ready", "steps": steps})
77
+
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)
130
+
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()
210
+ if not isinstance(user_credits, (int, float)) or user_credits < 8:
211
+ return jsonify({'error': 'Insufficient credits. Requires 8 credits to start a new course.'}), 402
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()
285
+ if user_credits < 8: return jsonify({'error': 'Insufficient credits. Regeneration requires 8 credits.'}), 402
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()
316
+ answers = data.get('answers', {})
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', [])
325
+
326
+ quiz_step_index = next((i for i, step in enumerate(steps) if step.get('status') == 'unlocked' and step.get('type') == 'quiz'), -1)
327
+ if quiz_step_index == -1: return jsonify({'error': 'No active quiz found'}), 400
328
+
329
+ quiz_data = steps[quiz_step_index]['quizData']
330
+ correct_count = sum(1 for q in quiz_data if answers.get(q['question']) == q['answer'])
331
+ score = correct_count / len(quiz_data)
332
+
333
+ steps[quiz_step_index]['status'] = 'completed'
334
+ next_action_message = ""
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)