rairo commited on
Commit
ab6cf6e
·
verified ·
1 Parent(s): 443a385

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +1091 -0
main.py CHANGED
@@ -0,0 +1,1091 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- Main Application File: app.py ----
2
+
3
+ import io
4
+ import uuid
5
+ import re
6
+ import time
7
+ import tempfile
8
+ import requests
9
+ import json
10
+ import os
11
+ import logging
12
+ import traceback
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ import urllib.parse
16
+
17
+ from flask import Flask, request, jsonify, send_file, Response
18
+ from flask_cors import CORS
19
+ from supabase import create_client, Client
20
+
21
+ # --- Input Processing & AI Libraries ---
22
+ import google.generativeai as genai
23
+ from elevenlabs.client import ElevenLabs
24
+ from elevenlabs import save as save_elevenlabs_audio
25
+ from PyPDF2 import PdfReader
26
+ import wikipedia
27
+ from youtube_transcript_api import YouTubeTranscriptApi
28
+ import arxiv # For ArXiv
29
+
30
+ # --- Environment Variables ---
31
+ # Load environment variables if using a .env file (optional, good practice)
32
+ from dotenv import load_dotenv
33
+ load_dotenv()
34
+
35
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
36
+ SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_KEY") # Use service role key for admin-like backend tasks
37
+ SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY") # Use anon key for client-side actions if needed, but prefer service key for backend logic
38
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
39
+ ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
40
+
41
+ # --- Initialize Flask app and CORS ---
42
+ app = Flask(__name__)
43
+ CORS(app) # Allow all origins for simplicity in development
44
+
45
+ # --- Initialize Supabase Client ---
46
+ try:
47
+ if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
48
+ raise ValueError("Supabase URL and Service Key must be set in environment variables.")
49
+ supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
50
+ print("Supabase client initialized successfully.")
51
+ # Example table check (optional)
52
+ # response = supabase.table('users').select("id", count='exact').limit(0).execute()
53
+ # print("Checked 'users' table connection.")
54
+ except Exception as e:
55
+ print(f"Error initializing Supabase client: {e}")
56
+ # Depending on your setup, you might want to exit or handle this differently
57
+ supabase = None # Indicate client is not available
58
+
59
+ # --- Initialize Gemini API ---
60
+ try:
61
+ if not GEMINI_API_KEY:
62
+ raise ValueError("Gemini API Key must be set in environment variables.")
63
+ genai.configure(api_key=GEMINI_API_KEY)
64
+ # Use a generally available model, adjust if you have access to specific previews
65
+ gemini_model = genai.GenerativeModel('gemini-1.5-flash-latest')
66
+ print("Gemini API initialized successfully.")
67
+ except Exception as e:
68
+ print(f"Error initializing Gemini API: {e}")
69
+ gemini_model = None
70
+
71
+ # --- Initialize ElevenLabs Client ---
72
+ try:
73
+ if not ELEVENLABS_API_KEY:
74
+ raise ValueError("ElevenLabs API Key must be set in environment variables.")
75
+ elevenlabs_client = ElevenLabs(api_key=ELEVENLABS_API_KEY)
76
+ print("ElevenLabs client initialized successfully.")
77
+ # Optional: Check available voices
78
+ # voices = elevenlabs_client.voices.get_all()
79
+ # print(f"Available ElevenLabs voices: {[v.name for v in voices.voices]}")
80
+ except Exception as e:
81
+ print(f"Error initializing ElevenLabs client: {e}")
82
+ elevenlabs_client = None
83
+
84
+ # --- Logging ---
85
+ LOG_FILE_PATH = "/tmp/ai_tutor.log" # Adjust path as needed
86
+ logging.basicConfig(filename=LOG_FILE_PATH, level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
87
+
88
+ # === Database Schema (Example - Create these tables in your Supabase project) ===
89
+ """
90
+ -- users table (Supabase Auth handles this mostly, but you might add custom fields)
91
+ CREATE TABLE IF NOT EXISTS public.profiles (
92
+ id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
93
+ email VARCHAR(255) UNIQUE,
94
+ credits INTEGER DEFAULT 30, -- Example credit system
95
+ is_admin BOOLEAN DEFAULT FALSE,
96
+ created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL,
97
+ suspended BOOLEAN DEFAULT FALSE
98
+ -- Add other profile fields as needed
99
+ );
100
+ -- Function to automatically create a profile when a new user signs up in Auth
101
+ create function public.handle_new_user()
102
+ returns trigger
103
+ language plpgsql
104
+ security definer set search_path = public
105
+ as $$
106
+ begin
107
+ insert into public.profiles (id, email)
108
+ values (new.id, new.email);
109
+ return new;
110
+ end;
111
+ $$;
112
+ -- Trigger to call the function after a user is inserted into auth.users
113
+ create trigger on_auth_user_created
114
+ after insert on auth.users
115
+ for each row execute procedure public.handle_new_user();
116
+
117
+ -- study_materials table
118
+ CREATE TABLE IF NOT EXISTS public.study_materials (
119
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
120
+ user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
121
+ type VARCHAR(50) NOT NULL, -- 'pdf', 'youtube', 'wiki', 'bible', 'arxiv', 'text'
122
+ source_ref TEXT NOT NULL, -- URL, Bible reference, ArXiv ID, filename, or part of the text prompt
123
+ source_content TEXT, -- Store extracted text here (optional, can be large)
124
+ created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL,
125
+ title TEXT -- Optional: Title extracted or generated
126
+ );
127
+
128
+ -- notes table
129
+ CREATE TABLE IF NOT EXISTS public.notes (
130
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
131
+ material_id UUID REFERENCES public.study_materials(id) ON DELETE CASCADE NOT NULL,
132
+ user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
133
+ content TEXT NOT NULL, -- The generated notes
134
+ created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL,
135
+ tts_audio_url TEXT -- URL to the TTS audio file in Supabase Storage
136
+ );
137
+
138
+ -- quizzes table
139
+ CREATE TABLE IF NOT EXISTS public.quizzes (
140
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
141
+ notes_id UUID REFERENCES public.notes(id) ON DELETE CASCADE NOT NULL,
142
+ user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
143
+ difficulty VARCHAR(10) NOT NULL, -- 'easy', 'medium', 'hard'
144
+ questions JSONB NOT NULL, -- Store the list of question objects {question: "", options: {A:"", B:"", C:"", D:""}, correct_answer: "A"}
145
+ created_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL
146
+ );
147
+
148
+ -- quiz_attempts table
149
+ CREATE TABLE IF NOT EXISTS public.quiz_attempts (
150
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
151
+ quiz_id UUID REFERENCES public.quizzes(id) ON DELETE CASCADE NOT NULL,
152
+ user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE NOT NULL,
153
+ score NUMERIC(5, 2) NOT NULL, -- e.g., 85.00 for 85%
154
+ answers JSONB NOT NULL, -- Store the user's answers {question_index: "selected_option"}
155
+ submitted_at TIMESTAMPTZ DEFAULT timezone('utc'::text, now()) NOT NULL
156
+ );
157
+ """
158
+
159
+ # === Helper Functions ===
160
+
161
+ def verify_token(auth_header):
162
+ """Verifies Supabase JWT token from Authorization header."""
163
+ if not supabase:
164
+ raise ConnectionError("Supabase client not initialized.")
165
+ if not auth_header or not auth_header.startswith('Bearer '):
166
+ return None, {'error': 'Missing or invalid Authorization header', 'status': 401}
167
+
168
+ token = auth_header.split(' ')[1]
169
+ try:
170
+ # Verify token and get user data
171
+ response = supabase.auth.get_user(token)
172
+ user = response.user
173
+ if not user:
174
+ return None, {'error': 'Invalid or expired token', 'status': 401}
175
+ # Optionally fetch profile data if needed immediately
176
+ # profile_res = supabase.table('profiles').select('*').eq('id', user.id).maybe_single().execute()
177
+ return user, None
178
+ except Exception as e:
179
+ logging.error(f"Token verification error: {e}")
180
+ # Differentiate between specific Supabase errors if needed
181
+ return None, {'error': f'Token verification failed: {e}', 'status': 401}
182
+
183
+ def verify_admin(user):
184
+ """Checks if the verified user is an admin."""
185
+ if not supabase:
186
+ raise ConnectionError("Supabase client not initialized.")
187
+ if not user:
188
+ return False, {'error': 'User not provided for admin check', 'status': 400}
189
+ try:
190
+ # Check the 'is_admin' flag in the 'profiles' table
191
+ profile_res = supabase.table('profiles').select('is_admin').eq('id', user.id).maybe_single().execute()
192
+ profile_data = profile_res.data
193
+ if profile_data and profile_data.get('is_admin'):
194
+ return True, None
195
+ else:
196
+ return False, {'error': 'Admin access required', 'status': 403} # 403 Forbidden
197
+ except Exception as e:
198
+ logging.error(f"Admin check failed for user {user.id}: {e}")
199
+ return False, {'error': f'Error checking admin status: {e}', 'status': 500}
200
+
201
+
202
+ def upload_to_supabase_storage(bucket_name: str, file_path: str, destination_path: str, content_type: str):
203
+ """Uploads a local file to Supabase Storage."""
204
+ if not supabase:
205
+ raise ConnectionError("Supabase client not initialized.")
206
+ try:
207
+ with open(file_path, 'rb') as f:
208
+ # Use upsert=True to overwrite if file exists, adjust if needed
209
+ supabase.storage.from_(bucket_name).upload(
210
+ path=destination_path,
211
+ file=f,
212
+ file_options={"content-type": content_type, "cache-control": "3600", "upsert": "true"}
213
+ )
214
+ # Get the public URL (ensure bucket has public access enabled or use signed URLs)
215
+ res = supabase.storage.from_(bucket_name).get_public_url(destination_path)
216
+ return res
217
+ except Exception as e:
218
+ logging.error(f"Supabase Storage upload failed: {e}")
219
+ raise # Re-raise the exception to be handled by the caller
220
+
221
+ # === Input Content Extraction Helpers ===
222
+
223
+ def get_pdf_text(pdf_file_storage):
224
+ """Extract text from a PDF file stream."""
225
+ text = ""
226
+ try:
227
+ pdf_reader = PdfReader(pdf_file_storage)
228
+ for page in pdf_reader.pages:
229
+ page_text = page.extract_text()
230
+ if page_text:
231
+ text += page_text + "\n"
232
+ # Simple truncation (consider smarter chunking for very large PDFs)
233
+ MAX_CHARS = 300000 # Adjust as needed based on Gemini context limits
234
+ return text[:MAX_CHARS]
235
+ except Exception as e:
236
+ logging.error(f"Error reading PDF: {e}")
237
+ raise ValueError(f"Could not process PDF file: {e}")
238
+
239
+ def get_youtube_transcript(url):
240
+ """Get transcript text from a YouTube URL."""
241
+ try:
242
+ if "v=" in url:
243
+ video_id = url.split("v=")[1].split("&")[0]
244
+ elif "youtu.be/" in url:
245
+ video_id = url.split("youtu.be/")[1].split("?")[0]
246
+ else:
247
+ raise ValueError("Invalid YouTube URL format.")
248
+
249
+ transcript_list = YouTubeTranscriptApi.get_transcript(video_id)
250
+ transcript_text = " ".join([item['text'] for item in transcript_list])
251
+ MAX_CHARS = 300000 # Adjust as needed
252
+ return transcript_text[:MAX_CHARS]
253
+ except Exception as e:
254
+ logging.error(f"Error getting YouTube transcript for {url}: {e}")
255
+ raise ValueError(f"Could not get transcript: {e}")
256
+
257
+ def get_wiki_content(url):
258
+ """Get summary content from a Wikipedia URL."""
259
+ try:
260
+ # Extract title from URL (simple approach)
261
+ page_title = urllib.parse.unquote(url.rstrip("/").split("/")[-1]).replace("_", " ")
262
+ wikipedia.set_lang("en") # Or configure based on user preference
263
+ page = wikipedia.page(page_title, auto_suggest=False) # Be specific
264
+ content = page.content # Get full content, might be large
265
+ # Alternatively, use summary: content = page.summary
266
+ MAX_CHARS = 300000 # Adjust as needed
267
+ return content[:MAX_CHARS]
268
+ except wikipedia.exceptions.PageError:
269
+ raise ValueError(f"Wikipedia page '{page_title}' not found.")
270
+ except wikipedia.exceptions.DisambiguationError as e:
271
+ raise ValueError(f"'{page_title}' refers to multiple pages: {e.options}")
272
+ except Exception as e:
273
+ logging.error(f"Error getting Wikipedia content for {url}: {e}")
274
+ raise ValueError(f"Could not get Wikipedia content: {e}")
275
+
276
+ def fetch_bible_text(reference):
277
+ """Fetch Bible text from an external API (example using bible-api.com)."""
278
+ # This API is simple but might have limitations. Consider alternatives if needed.
279
+ try:
280
+ # URL encode the reference
281
+ query = urllib.parse.quote(reference)
282
+ api_url = f"https://bible-api.com/{query}?translation=kjv" # King James Version, change if needed
283
+ response = requests.get(api_url, timeout=15) # Add timeout
284
+ response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
285
+ data = response.json()
286
+ # Check if 'text' exists, handle potential variations in API response
287
+ if 'text' in data:
288
+ text = data['text'].strip()
289
+ MAX_CHARS = 300000
290
+ return text[:MAX_CHARS]
291
+ elif 'error' in data:
292
+ raise ValueError(f"Bible API error: {data['error']}")
293
+ else:
294
+ # Attempt to extract verses if the structure is different
295
+ if 'verses' in data and isinstance(data['verses'], list):
296
+ text = " ".join([v.get('text', '').strip() for v in data['verses']])
297
+ MAX_CHARS = 300000
298
+ return text[:MAX_CHARS] if text else ValueError("Bible reference not found or empty.")
299
+ else:
300
+ raise ValueError("Bible API response format not recognized.")
301
+
302
+ except requests.exceptions.RequestException as e:
303
+ logging.error(f"Error fetching Bible text for '{reference}': {e}")
304
+ raise ConnectionError(f"Could not connect to Bible API: {e}")
305
+ except Exception as e:
306
+ logging.error(f"Error processing Bible reference '{reference}': {e}")
307
+ raise ValueError(f"Could not process Bible reference: {e}")
308
+
309
+
310
+ def get_arxiv_content(arxiv_id):
311
+ """Fetch abstract or PDF text from ArXiv."""
312
+ try:
313
+ # Clean up potential URL prefixes
314
+ if 'arxiv.org/abs/' in arxiv_id:
315
+ arxiv_id = arxiv_id.split('/abs/')[-1]
316
+ if 'arxiv.org/pdf/' in arxiv_id:
317
+ arxiv_id = arxiv_id.split('/pdf/')[-1].replace('.pdf', '')
318
+
319
+ search = arxiv.Search(id_list=[arxiv_id])
320
+ paper = next(search.results()) # Get the first (and only) result
321
+
322
+ # Prioritize abstract, as full PDF processing is heavy
323
+ content = f"Title: {paper.title}\n\nAbstract: {paper.summary}"
324
+
325
+ # --- Optional: Download and process PDF (can be slow and resource-intensive) ---
326
+ # pdf_text = ""
327
+ # with tempfile.TemporaryDirectory() as tmpdir:
328
+ # pdf_path = paper.download_pdf(dirpath=tmpdir, filename=f"{arxiv_id}.pdf")
329
+ # print(f"Downloaded ArXiv PDF to: {pdf_path}")
330
+ # with open(pdf_path, "rb") as f:
331
+ # pdf_text = get_pdf_text(f) # Reuse PDF helper
332
+ # if pdf_text:
333
+ # content += "\n\n--- Full Text (Excerpt) ---\n" + pdf_text[:20000] # Limit excerpt size
334
+ # else:
335
+ # content += "\n\n(Could not extract text from PDF)"
336
+ # -----------------------------------------------------------------------------
337
+
338
+ MAX_CHARS = 300000 # Adjust as needed
339
+ return content[:MAX_CHARS], paper.title # Return content and title
340
+ except StopIteration:
341
+ raise ValueError(f"ArXiv paper with ID '{arxiv_id}' not found.")
342
+ except Exception as e:
343
+ logging.error(f"Error fetching ArXiv content for {arxiv_id}: {e}")
344
+ raise ValueError(f"Could not get ArXiv content: {e}")
345
+
346
+
347
+ # === Gemini Interaction Helpers ===
348
+
349
+ def generate_notes_with_gemini(text_content, title=None):
350
+ """Generates study notes using Gemini."""
351
+ if not gemini_model:
352
+ raise ConnectionError("Gemini client not initialized.")
353
+ try:
354
+ prompt = f"""
355
+ Act as an expert educator and study assistant. Based on the following text {'titled "' + title + '" ' if title else ''} , generate comprehensive and well-structured study notes.
356
+
357
+ **Instructions:**
358
+ 1. **Identify Key Concepts:** Extract the main topics, definitions, key figures, dates, arguments, and important takeaways.
359
+ 2. **Structure Logically:** Organize the notes with clear headings (using Markdown ##) and bullet points (* or -) for readability. Use sub-bullets if necessary.
360
+ 3. **Be Concise but Thorough:** Summarize the information accurately without unnecessary jargon. Ensure all critical points are covered.
361
+ 4. **Highlight Importance:** You can use bold text (**bold**) for very important terms or concepts.
362
+ 5. **Focus:** Generate only the notes based on the provided text. Do not add introductions like "Here are the notes..." or conclusions like "These notes cover...".
363
+
364
+ **Source Text:**
365
+ ---
366
+ {text_content}
367
+ ---
368
+
369
+ **Generated Study Notes:**
370
+ """
371
+ response = gemini_model.generate_content(prompt)
372
+ return response.text.strip()
373
+ except Exception as e:
374
+ logging.error(f"Gemini note generation failed: {e}")
375
+ raise RuntimeError(f"AI failed to generate notes: {e}")
376
+
377
+ def generate_quiz_with_gemini(notes_content, difficulty, num_questions=5):
378
+ """Generates multiple-choice quiz using Gemini."""
379
+ if not gemini_model:
380
+ raise ConnectionError("Gemini client not initialized.")
381
+
382
+ difficulty_map = {
383
+ "easy": "basic recall and understanding",
384
+ "medium": "application and interpretation",
385
+ "hard": "analysis, synthesis, and evaluation"
386
+ }
387
+ difficulty_desc = difficulty_map.get(difficulty.lower(), "medium difficulty")
388
+
389
+ try:
390
+ prompt = f"""
391
+ Act as an expert quiz creator. Based on the following study notes, create a multiple-choice quiz.
392
+
393
+ **Instructions:**
394
+ 1. **Number of Questions:** Generate exactly {num_questions} questions.
395
+ 2. **Difficulty Level:** The questions should be of {difficulty_desc} ({difficulty}).
396
+ 3. **Format:** Each question must have exactly four options (A, B, C, D).
397
+ 4. **Clarity:** Questions and options should be clear and unambiguous.
398
+ 5. **Single Correct Answer:** Ensure only one option is the correct answer.
399
+ 6. **JSON Output:** Format the entire output STRICTLY as a JSON list of objects. Each object must have the following keys: "question" (string), "options" (an object with keys "A", "B", "C", "D", all strings), and "correct_answer" (string, either "A", "B", "C", or "D").
400
+ 7. **Focus:** Generate only the JSON output. Do not include any introductory text, explanations, or markdown formatting outside the JSON structure.
401
+
402
+ **Study Notes:**
403
+ ---
404
+ {notes_content}
405
+ ---
406
+
407
+ **Quiz JSON Output:**
408
+ ```json
409
+ [
410
+ {{
411
+ "question": "...",
412
+ "options": {{
413
+ "A": "...",
414
+ "B": "...",
415
+ "C": "...",
416
+ "D": "..."
417
+ }},
418
+ "correct_answer": "..."
419
+ }}
420
+ // ... more question objects
421
+ ]
422
+ ```
423
+ """
424
+ response = gemini_model.generate_content(prompt)
425
+ # Clean potential markdown code block fences
426
+ cleaned_response = response.text.strip().lstrip('```json').rstrip('```').strip()
427
+ # Validate and parse JSON
428
+ quiz_data = json.loads(cleaned_response)
429
+ # Add basic validation (e.g., check if it's a list, check keys in first item)
430
+ if not isinstance(quiz_data, list):
431
+ raise ValueError("AI response is not a list.")
432
+ if quiz_data and not all(k in quiz_data[0] for k in ["question", "options", "correct_answer"]):
433
+ raise ValueError("AI response list items have missing keys.")
434
+ return quiz_data
435
+ except json.JSONDecodeError as e:
436
+ logging.error(f"Gemini quiz generation returned invalid JSON: {cleaned_response[:500]}... Error: {e}")
437
+ raise RuntimeError(f"AI failed to generate a valid quiz format. Please try again.")
438
+ except Exception as e:
439
+ logging.error(f"Gemini quiz generation failed: {e}")
440
+ raise RuntimeError(f"AI failed to generate quiz: {e}")
441
+
442
+
443
+ # === ElevenLabs TTS Helper ===
444
+
445
+ def generate_tts_audio(text_to_speak, voice_id="Rachel"): # Example voice, choose one available
446
+ """Generates TTS audio using ElevenLabs and returns audio bytes."""
447
+ if not elevenlabs_client:
448
+ raise ConnectionError("ElevenLabs client not initialized.")
449
+ try:
450
+ # Stream the audio generation
451
+ audio_stream = elevenlabs_client.generate(
452
+ text=text_to_speak,
453
+ voice=voice_id, # You can customize this
454
+ model="eleven_multilingual_v2", # Or another suitable model
455
+ stream=True
456
+ )
457
+
458
+ # Collect audio bytes from the stream
459
+ audio_bytes = b""
460
+ for chunk in audio_stream:
461
+ audio_bytes += chunk
462
+
463
+ if not audio_bytes:
464
+ raise ValueError("ElevenLabs generated empty audio.")
465
+
466
+ return audio_bytes
467
+
468
+ except Exception as e:
469
+ logging.error(f"ElevenLabs TTS generation failed: {e}")
470
+ raise RuntimeError(f"Failed to generate audio: {e}")
471
+
472
+
473
+ # === Authentication Endpoints ===
474
+
475
+ @app.route('/api/auth/signup', methods=['POST'])
476
+ def signup():
477
+ if not supabase: return jsonify({'error': 'Service unavailable'}), 503
478
+ try:
479
+ data = request.get_json()
480
+ email = data.get('email')
481
+ password = data.get('password')
482
+ if not email or not password:
483
+ return jsonify({'error': 'Email and password are required'}), 400
484
+
485
+ # Create user in Supabase Auth
486
+ res = supabase.auth.sign_up({"email": email, "password": password})
487
+
488
+ # Supabase automatically triggers the function/trigger to create the profile row
489
+ # If it didn't, you'd insert into 'profiles' here using res.user.id
490
+
491
+ # Return minimal info on signup, client should usually sign in after verification
492
+ return jsonify({
493
+ 'success': True,
494
+ 'message': 'Signup successful. Please check your email for verification.',
495
+ # Avoid sending back full user object before verification/signin
496
+ 'user_id': res.user.id if res.user else None
497
+ }), 201
498
+
499
+ except Exception as e:
500
+ # Handle Supabase specific errors if needed (e.g., duplicate email)
501
+ error_message = str(e)
502
+ status_code = 400 # Default bad request
503
+ if "User already registered" in error_message:
504
+ error_message = "Email already exists."
505
+ status_code = 409 # Conflict
506
+ logging.error(f"Signup error: {error_message}")
507
+ return jsonify({'error': error_message}), status_code
508
+
509
+ @app.route('/api/auth/signin', methods=['POST'])
510
+ def signin():
511
+ if not supabase: return jsonify({'error': 'Service unavailable'}), 503
512
+ try:
513
+ data = request.get_json()
514
+ email = data.get('email')
515
+ password = data.get('password')
516
+ if not email or not password:
517
+ return jsonify({'error': 'Email and password are required'}), 400
518
+
519
+ # Sign in user using Supabase Auth
520
+ res = supabase.auth.sign_in_with_password({"email": email, "password": password})
521
+
522
+ # Fetch associated profile data
523
+ profile_res = supabase.table('profiles').select('*').eq('id', res.user.id).maybe_single().execute()
524
+
525
+ return jsonify({
526
+ 'success': True,
527
+ 'access_token': res.session.access_token,
528
+ 'refresh_token': res.session.refresh_token,
529
+ 'user': {
530
+ 'id': res.user.id,
531
+ 'email': res.user.email,
532
+ 'profile': profile_res.data # Include profile details
533
+ }
534
+ }), 200
535
+
536
+ except Exception as e:
537
+ # Handle specific errors like invalid credentials
538
+ error_message = str(e)
539
+ status_code = 401 # Unauthorized
540
+ if "Invalid login credentials" in error_message:
541
+ error_message = "Invalid email or password."
542
+ elif "Email not confirmed" in error_message:
543
+ error_message = "Please verify your email address before signing in."
544
+ status_code = 403 # Forbidden
545
+ logging.error(f"Signin error: {error_message}")
546
+ return jsonify({'error': error_message}), status_code
547
+
548
+ @app.route('/api/auth/google-signin', methods=['POST'])
549
+ def google_signin():
550
+ # This endpoint is tricky without a frontend.
551
+ # Typically, the frontend uses Supabase JS client to handle the Google OAuth flow.
552
+ # The frontend receives an access_token and refresh_token from Supabase after Google redirects.
553
+ # The frontend then sends the access_token (as Bearer token) to this backend.
554
+ # The backend verifies the token using verify_token helper.
555
+ # So, this endpoint might just be for *associating* data *after* frontend login,
556
+ # or it could exchange an auth code (more complex server-side flow).
557
+
558
+ # Assuming frontend handles OAuth and sends Supabase session token:
559
+ user, error = verify_token(request.headers.get('Authorization'))
560
+ if error:
561
+ return jsonify({'error': error['error']}), error['status']
562
+
563
+ try:
564
+ # User is verified via the token. Fetch their profile.
565
+ profile_res = supabase.table('profiles').select('*').eq('id', user.id).maybe_single().execute()
566
+
567
+ if not profile_res.data:
568
+ # This case *shouldn't* happen if the trigger works, but handle defensively
569
+ logging.warning(f"Google Sign-In: Profile not found for verified user {user.id}, attempting to create.")
570
+ # Attempt to create profile (might fail if email exists from password signup)
571
+ insert_res = supabase.table('profiles').insert({
572
+ 'id': user.id,
573
+ 'email': user.email,
574
+ # Set default credits/roles if needed
575
+ }).execute()
576
+ profile_data = insert_res.data[0] if insert_res.data else None
577
+ if not profile_data:
578
+ raise Exception("Failed to create profile entry after Google Sign-In.")
579
+ else:
580
+ profile_data = profile_res.data
581
+
582
+
583
+ # Return user info (don't need to send tokens back usually, frontend manages session)
584
+ return jsonify({
585
+ 'success': True,
586
+ 'message': 'Google sign-in verified successfully.',
587
+ 'user': {
588
+ 'id': user.id,
589
+ 'email': user.email,
590
+ 'profile': profile_data
591
+ }
592
+ }), 200
593
+
594
+ except Exception as e:
595
+ logging.error(f"Google sign-in profile fetch/creation error: {e}")
596
+ return jsonify({'error': f'An error occurred during sign-in: {e}'}), 500
597
+
598
+
599
+ # === User Profile Endpoint ===
600
+
601
+ @app.route('/api/user/profile', methods=['GET'])
602
+ def get_user_profile():
603
+ user, error = verify_token(request.headers.get('Authorization'))
604
+ if error:
605
+ return jsonify({'error': error['error']}), error['status']
606
+
607
+ try:
608
+ # Fetch user's profile data from the 'profiles' table
609
+ profile_res = supabase.table('profiles').select('*').eq('id', user.id).maybe_single().execute()
610
+
611
+ if not profile_res.data:
612
+ # This indicates a potential issue (user exists in auth but not profiles)
613
+ logging.error(f"Profile not found for authenticated user: {user.id} / {user.email}")
614
+ return jsonify({'error': 'User profile not found.'}), 404
615
+
616
+ # Combine auth info (like email) with profile info
617
+ profile_data = profile_res.data
618
+ full_user_data = {
619
+ 'id': user.id,
620
+ 'email': user.email, # Email from auth is usually the source of truth
621
+ 'credits': profile_data.get('credits'),
622
+ 'is_admin': profile_data.get('is_admin'),
623
+ 'created_at': profile_data.get('created_at'),
624
+ 'suspended': profile_data.get('suspended')
625
+ # Add any other fields from 'profiles' table
626
+ }
627
+
628
+ return jsonify(full_user_data), 200
629
+
630
+ except Exception as e:
631
+ logging.error(f"Error fetching user profile for {user.id}: {e}")
632
+ return jsonify({'error': f'Failed to fetch profile: {e}'}), 500
633
+
634
+
635
+ # === AI Tutor Core Endpoints ===
636
+
637
+ @app.route('/api/tutor/process_input', methods=['POST'])
638
+ def process_input_and_generate_notes():
639
+ """
640
+ Handles various input types, extracts content, generates notes,
641
+ and saves material & notes to DB.
642
+ """
643
+ user, error = verify_token(request.headers.get('Authorization'))
644
+ if error: return jsonify({'error': error['error']}), error['status']
645
+ if not supabase or not gemini_model: return jsonify({'error': 'Backend service unavailable'}), 503
646
+
647
+ # --- Check Credits (Example) ---
648
+ # profile_res = supabase.table('profiles').select('credits', 'suspended').eq('id', user.id).single().execute()
649
+ # if profile_res.data['suspended']: return jsonify({'error': 'Account suspended'}), 403
650
+ # if profile_res.data['credits'] < 1: return jsonify({'error': 'Insufficient credits'}), 402
651
+ # ---
652
+
653
+ try:
654
+ input_type = request.form.get('input_type')
655
+ source_ref = request.form.get('source_ref') # URL, Bible ref, ArXiv ID, etc.
656
+ uploaded_file = request.files.get('file') # For PDF
657
+
658
+ if not input_type:
659
+ return jsonify({'error': 'input_type (e.g., pdf, youtube, wiki, bible, arxiv, text) is required'}), 400
660
+
661
+ content = None
662
+ title = None # Optional title
663
+
664
+ if input_type == 'pdf':
665
+ if not uploaded_file: return jsonify({'error': 'File is required for input_type pdf'}), 400
666
+ if not uploaded_file.filename.lower().endswith('.pdf'): return jsonify({'error': 'Only PDF files are allowed'}), 400
667
+ content = get_pdf_text(uploaded_file.stream)
668
+ source_ref = uploaded_file.filename # Use filename as reference
669
+ title = uploaded_file.filename
670
+ elif input_type == 'youtube':
671
+ if not source_ref: return jsonify({'error': 'source_ref (YouTube URL) is required'}), 400
672
+ content = get_youtube_transcript(source_ref)
673
+ # You could fetch the video title using youtube API/libraries if needed
674
+ elif input_type == 'wiki':
675
+ if not source_ref: return jsonify({'error': 'source_ref (Wikipedia URL) is required'}), 400
676
+ content = get_wiki_content(source_ref)
677
+ title = urllib.parse.unquote(source_ref.rstrip("/").split("/")[-1]).replace("_", " ")
678
+ elif input_type == 'bible':
679
+ if not source_ref: return jsonify({'error': 'source_ref (Bible reference) is required'}), 400
680
+ content = fetch_bible_text(source_ref)
681
+ title = source_ref
682
+ elif input_type == 'arxiv':
683
+ if not source_ref: return jsonify({'error': 'source_ref (ArXiv ID or URL) is required'}), 400
684
+ content, title = get_arxiv_content(source_ref) # Gets title too
685
+ elif input_type == 'text':
686
+ content = request.form.get('text_content')
687
+ if not content: return jsonify({'error': 'text_content is required for input_type text'}), 400
688
+ source_ref = content[:100] + "..." # Use beginning of text as ref
689
+ title = "Custom Text"
690
+ else:
691
+ return jsonify({'error': f'Unsupported input_type: {input_type}'}), 400
692
+
693
+ if not content:
694
+ return jsonify({'error': 'Failed to extract content from the source.'}), 500
695
+
696
+ # --- Generate Notes ---
697
+ start_time = time.time()
698
+ logging.info(f"Generating notes for user {user.id}, type: {input_type}, ref: {source_ref[:50]}")
699
+ generated_notes = generate_notes_with_gemini(content, title=title)
700
+ logging.info(f"Notes generation took {time.time() - start_time:.2f}s")
701
+
702
+ # --- Save to Database ---
703
+ # 1. Save Study Material
704
+ material_res = supabase.table('study_materials').insert({
705
+ 'user_id': user.id,
706
+ 'type': input_type,
707
+ 'source_ref': source_ref,
708
+ 'source_content': content if len(content) < 10000 else content[:10000] + "... (truncated)", # Optionally save truncated content
709
+ 'title': title
710
+ }).execute()
711
+ if not material_res.data: raise Exception(f"Failed to save study material: {material_res.error}")
712
+ material_id = material_res.data[0]['id']
713
+
714
+ # 2. Save Notes linked to Material
715
+ notes_res = supabase.table('notes').insert({
716
+ 'material_id': material_id,
717
+ 'user_id': user.id,
718
+ 'content': generated_notes
719
+ }).execute()
720
+ if not notes_res.data: raise Exception(f"Failed to save generated notes: {notes_res.error}")
721
+ notes_id = notes_res.data[0]['id']
722
+
723
+ # --- Deduct Credits (Example) ---
724
+ # supabase.table('profiles').update({'credits': profile_res.data['credits'] - 1}).eq('id', user.id).execute()
725
+ # ---
726
+
727
+ return jsonify({
728
+ 'success': True,
729
+ 'message': 'Content processed and notes generated successfully.',
730
+ 'material_id': material_id,
731
+ 'notes_id': notes_id,
732
+ 'notes': generated_notes # Return notes directly for immediate use
733
+ }), 201
734
+
735
+ except ValueError as e: # Input validation errors
736
+ logging.warning(f"Input processing error for user {user.id}: {e}")
737
+ return jsonify({'error': str(e)}), 400
738
+ except ConnectionError as e: # Service unavailable (Supabase, Gemini, etc.)
739
+ logging.error(f"Connection error during processing: {e}")
740
+ return jsonify({'error': f'A backend service is unavailable: {e}'}), 503
741
+ except RuntimeError as e: # AI generation errors
742
+ logging.error(f"RuntimeError during processing for user {user.id}: {e}")
743
+ return jsonify({'error': str(e)}), 500
744
+ except Exception as e:
745
+ logging.error(f"Unexpected error processing input for user {user.id}: {traceback.format_exc()}")
746
+ return jsonify({'error': f'An unexpected error occurred: {e}'}), 500
747
+
748
+ @app.route('/api/tutor/notes/<uuid:notes_id>/generate_quiz', methods=['POST'])
749
+ def generate_quiz_for_notes(notes_id):
750
+ """Generates a quiz based on existing notes."""
751
+ user, error = verify_token(request.headers.get('Authorization'))
752
+ if error: return jsonify({'error': error['error']}), error['status']
753
+ if not supabase or not gemini_model: return jsonify({'error': 'Backend service unavailable'}), 503
754
+
755
+ try:
756
+ data = request.get_json()
757
+ difficulty = data.get('difficulty', 'medium').lower()
758
+ num_questions = int(data.get('num_questions', 5))
759
+
760
+ if difficulty not in ['easy', 'medium', 'hard']:
761
+ return jsonify({'error': 'difficulty must be easy, medium, or hard'}), 400
762
+ if not 1 <= num_questions <= 10:
763
+ return jsonify({'error': 'num_questions must be between 1 and 10'}), 400
764
+
765
+ # --- Fetch Notes Content ---
766
+ notes_res = supabase.table('notes').select('content, user_id').eq('id', notes_id).maybe_single().execute()
767
+ if not notes_res.data:
768
+ return jsonify({'error': 'Notes not found'}), 404
769
+ # Ensure user owns the notes
770
+ if notes_res.data['user_id'] != user.id:
771
+ return jsonify({'error': 'You do not have permission to access these notes'}), 403
772
+
773
+ notes_content = notes_res.data['content']
774
+
775
+ # --- Generate Quiz ---
776
+ start_time = time.time()
777
+ logging.info(f"Generating {difficulty} quiz ({num_questions}q) for user {user.id}, notes: {notes_id}")
778
+ quiz_questions = generate_quiz_with_gemini(notes_content, difficulty, num_questions)
779
+ logging.info(f"Quiz generation took {time.time() - start_time:.2f}s")
780
+
781
+ # --- Save Quiz to Database ---
782
+ quiz_res = supabase.table('quizzes').insert({
783
+ 'notes_id': notes_id,
784
+ 'user_id': user.id,
785
+ 'difficulty': difficulty,
786
+ 'questions': json.dumps(quiz_questions) # Store questions as JSONB
787
+ }).execute()
788
+ if not quiz_res.data: raise Exception(f"Failed to save generated quiz: {quiz_res.error}")
789
+ quiz_id = quiz_res.data[0]['id']
790
+
791
+ return jsonify({
792
+ 'success': True,
793
+ 'quiz_id': quiz_id,
794
+ 'difficulty': difficulty,
795
+ 'questions': quiz_questions # Return quiz data for immediate use
796
+ }), 201
797
+
798
+ except ValueError as e:
799
+ return jsonify({'error': str(e)}), 400
800
+ except ConnectionError as e:
801
+ logging.error(f"Connection error during quiz generation: {e}")
802
+ return jsonify({'error': f'A backend service is unavailable: {e}'}), 503
803
+ except RuntimeError as e: # AI generation errors
804
+ logging.error(f"RuntimeError during quiz generation for user {user.id}: {e}")
805
+ return jsonify({'error': str(e)}), 500
806
+ except Exception as e:
807
+ logging.error(f"Unexpected error generating quiz for user {user.id}, notes {notes_id}: {traceback.format_exc()}")
808
+ return jsonify({'error': f'An unexpected error occurred: {e}'}), 500
809
+
810
+
811
+ @app.route('/api/tutor/quizzes/<uuid:quiz_id>/submit', methods=['POST'])
812
+ def submit_quiz_attempt(quiz_id):
813
+ """Submits user answers for a quiz and calculates the score."""
814
+ user, error = verify_token(request.headers.get('Authorization'))
815
+ if error: return jsonify({'error': error['error']}), error['status']
816
+ if not supabase: return jsonify({'error': 'Backend service unavailable'}), 503
817
+
818
+ try:
819
+ data = request.get_json()
820
+ user_answers = data.get('answers') # Expected format: { "0": "A", "1": "C", ... } (index as string key)
821
+
822
+ if not isinstance(user_answers, dict):
823
+ return jsonify({'error': 'answers must be provided as a JSON object'}), 400
824
+
825
+ # --- Fetch Quiz Data (including correct answers) ---
826
+ quiz_res = supabase.table('quizzes').select('questions, user_id').eq('id', quiz_id).maybe_single().execute()
827
+ if not quiz_res.data:
828
+ return jsonify({'error': 'Quiz not found'}), 404
829
+ # Optional: Check if user owns the quiz, though submitting attempts might be allowed more broadly
830
+ # if quiz_res.data['user_id'] != user.id:
831
+ # return jsonify({'error': 'Cannot submit attempt for this quiz'}), 403
832
+
833
+ quiz_questions = json.loads(quiz_res.data['questions']) # Load JSONB data
834
+
835
+ # --- Calculate Score ---
836
+ correct_count = 0
837
+ total_questions = len(quiz_questions)
838
+ feedback = {} # Optional: Provide feedback on each question
839
+
840
+ for index, question_data in enumerate(quiz_questions):
841
+ q_index_str = str(index)
842
+ correct_answer = question_data.get('correct_answer')
843
+ user_answer = user_answers.get(q_index_str)
844
+
845
+ is_correct = (user_answer == correct_answer)
846
+ if is_correct:
847
+ correct_count += 1
848
+
849
+ feedback[q_index_str] = {
850
+ "correct": is_correct,
851
+ "correct_answer": correct_answer,
852
+ "user_answer": user_answer
853
+ }
854
+
855
+ score = (correct_count / total_questions) * 100 if total_questions > 0 else 0.0
856
+
857
+ # --- Save Quiz Attempt ---
858
+ attempt_res = supabase.table('quiz_attempts').insert({
859
+ 'quiz_id': quiz_id,
860
+ 'user_id': user.id,
861
+ 'score': score,
862
+ 'answers': json.dumps(user_answers) # Save user's submitted answers
863
+ }).execute()
864
+ if not attempt_res.data: raise Exception(f"Failed to save quiz attempt: {attempt_res.error}")
865
+ attempt_id = attempt_res.data[0]['id']
866
+
867
+ return jsonify({
868
+ 'success': True,
869
+ 'attempt_id': attempt_id,
870
+ 'score': round(score, 2),
871
+ 'correct_count': correct_count,
872
+ 'total_questions': total_questions,
873
+ 'feedback': feedback # Return feedback for the user interface
874
+ }), 201
875
+
876
+ except json.JSONDecodeError:
877
+ return jsonify({'error': 'Invalid format for quiz questions data in database.'}), 500
878
+ except Exception as e:
879
+ logging.error(f"Unexpected error submitting quiz attempt for user {user.id}, quiz {quiz_id}: {traceback.format_exc()}")
880
+ return jsonify({'error': f'An unexpected error occurred: {e}'}), 500
881
+
882
+
883
+ @app.route('/api/tutor/notes/<uuid:notes_id>/speak', methods=['GET'])
884
+ def speak_notes(notes_id):
885
+ """Generates TTS audio for notes and returns it or its URL."""
886
+ user, error = verify_token(request.headers.get('Authorization'))
887
+ if error: return jsonify({'error': error['error']}), error['status']
888
+ if not supabase or not elevenlabs_client: return jsonify({'error': 'Backend service unavailable'}), 503
889
+
890
+ try:
891
+ # --- Fetch Notes Content ---
892
+ notes_res = supabase.table('notes').select('content, user_id, tts_audio_url').eq('id', notes_id).maybe_single().execute()
893
+ if not notes_res.data:
894
+ return jsonify({'error': 'Notes not found'}), 404
895
+ if notes_res.data['user_id'] != user.id:
896
+ return jsonify({'error': 'You do not have permission to access these notes'}), 403
897
+
898
+ # --- Check if audio already exists ---
899
+ # existing_url = notes_res.data.get('tts_audio_url')
900
+ # if existing_url:
901
+ # # Optional: Check if the URL is still valid or regenerate
902
+ # # For simplicity, we'll just return the existing one if present
903
+ # # To force regeneration, add a query param like ?force=true
904
+ # if not request.args.get('force'):
905
+ # print(f"Returning existing TTS URL for notes {notes_id}: {existing_url}")
906
+ # # You might want to redirect or return the URL itself
907
+ # return jsonify({'success': True, 'audio_url': existing_url})
908
+
909
+
910
+ notes_content = notes_res.data['content']
911
+ if not notes_content:
912
+ return jsonify({'error': 'Notes content is empty, cannot generate audio.'}), 400
913
+
914
+ # --- Generate TTS Audio ---
915
+ start_time = time.time()
916
+ logging.info(f"Generating TTS for user {user.id}, notes: {notes_id}")
917
+ audio_bytes = generate_tts_audio(notes_content) # Add voice selection if needed
918
+ logging.info(f"TTS generation took {time.time() - start_time:.2f}s")
919
+
920
+ # --- Save Audio to Supabase Storage ---
921
+ # Naming convention: users/{user_id}/notes_audio/{notes_id}.mp3
922
+ bucket_name = 'notes_audio' # Ensure this bucket exists in Supabase Storage
923
+ destination_path = f'users/{user.id}/{notes_id}.mp3'
924
+ content_type = 'audio/mpeg'
925
+
926
+ # Use a temporary file to upload
927
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".mp3") as tmp_audio_file:
928
+ tmp_audio_file.write(audio_bytes)
929
+ tmp_file_path = tmp_audio_file.name
930
+
931
+ try:
932
+ audio_url = upload_to_supabase_storage(bucket_name, tmp_file_path, destination_path, content_type)
933
+ logging.info(f"Uploaded TTS audio to: {audio_url}")
934
+
935
+ # --- Update notes table with the URL ---
936
+ supabase.table('notes').update({'tts_audio_url': audio_url}).eq('id', notes_id).execute()
937
+
938
+ # --- Return the audio file directly ---
939
+ # Set headers for browser playback/download
940
+ return send_file(
941
+ io.BytesIO(audio_bytes),
942
+ mimetype=content_type,
943
+ as_attachment=False, # Play inline if possible
944
+ download_name=f'notes_{notes_id}.mp3'
945
+ )
946
+ # OR: Return the URL
947
+ # return jsonify({'success': True, 'audio_url': audio_url})
948
+
949
+ finally:
950
+ os.remove(tmp_file_path) # Clean up temporary file
951
+
952
+
953
+ except ConnectionError as e:
954
+ logging.error(f"Connection error during TTS generation: {e}")
955
+ return jsonify({'error': f'A backend service is unavailable: {e}'}), 503
956
+ except RuntimeError as e: # AI generation errors
957
+ logging.error(f"RuntimeError during TTS generation for user {user.id}: {e}")
958
+ return jsonify({'error': str(e)}), 500
959
+ except Exception as e:
960
+ logging.error(f"Unexpected error generating TTS for user {user.id}, notes {notes_id}: {traceback.format_exc()}")
961
+ return jsonify({'error': f'An unexpected error occurred: {e}'}), 500
962
+
963
+ @app.route('/api/user/performance', methods=['GET'])
964
+ def get_user_performance():
965
+ """Retrieves user's quiz performance and provides simple suggestions."""
966
+ user, error = verify_token(request.headers.get('Authorization'))
967
+ if error: return jsonify({'error': error['error']}), error['status']
968
+ if not supabase: return jsonify({'error': 'Backend service unavailable'}), 503
969
+
970
+ try:
971
+ # --- Fetch recent quiz attempts ---
972
+ attempts_res = supabase.table('quiz_attempts')\
973
+ .select('id, quiz_id, score, submitted_at, quizzes(difficulty, notes(material_id, study_materials(type, title))))')\
974
+ .eq('user_id', user.id)\
975
+ .order('submitted_at', desc=True)\
976
+ .limit(20)\
977
+ .execute() # Join to get context
978
+
979
+ if attempts_res.error:
980
+ raise Exception(f"Failed to fetch quiz attempts: {attempts_res.error}")
981
+
982
+ attempts_data = attempts_res.data
983
+
984
+ # --- Basic Analysis ---
985
+ average_score = 0.0
986
+ suggestions = []
987
+ if attempts_data:
988
+ total_score = sum(a['score'] for a in attempts_data)
989
+ average_score = total_score / len(attempts_data)
990
+
991
+ # Simple Suggestion Logic
992
+ if average_score < 60:
993
+ suggestions.append("Your average score is a bit low. Try reviewing the notes more thoroughly before taking quizzes.")
994
+ # Suggest reviewing specific materials from recent low scores
995
+ low_score_attempts = sorted([a for a in attempts_data if a['score'] < 60], key=lambda x: x['submitted_at'], reverse=True)
996
+ if low_score_attempts:
997
+ # Safely access nested data
998
+ quiz_info = low_score_attempts[0].get('quizzes')
999
+ if quiz_info:
1000
+ notes_info = quiz_info.get('notes')
1001
+ if notes_info:
1002
+ material_info = notes_info.get('study_materials')
1003
+ if material_info and material_info.get('title'):
1004
+ suggestions.append(f"Focus on reviewing the material titled: '{material_info['title']}'.")
1005
+
1006
+
1007
+ elif average_score > 85:
1008
+ suggestions.append("Great job on your recent quizzes! Consider trying 'hard' difficulty quizzes or exploring new topics.")
1009
+ else:
1010
+ suggestions.append("You're making good progress! Keep practicing to solidify your understanding.")
1011
+
1012
+ # Add more sophisticated analysis here (e.g., performance by topic/difficulty)
1013
+
1014
+ return jsonify({
1015
+ 'success': True,
1016
+ 'average_score': round(average_score, 2) if attempts_data else None,
1017
+ 'recent_attempts': attempts_data, # Return structured attempt data
1018
+ 'suggestions': suggestions
1019
+ })
1020
+
1021
+ except Exception as e:
1022
+ logging.error(f"Unexpected error fetching performance for user {user.id}: {traceback.format_exc()}")
1023
+ return jsonify({'error': f'An unexpected error occurred: {e}'}), 500
1024
+
1025
+
1026
+ # === Admin Endpoints (Adapted for Supabase) ===
1027
+
1028
+ @app.route('/api/admin/users', methods=['GET'])
1029
+ def admin_list_users():
1030
+ user, error = verify_token(request.headers.get('Authorization'))
1031
+ if error: return jsonify({'error': error['error']}), error['status']
1032
+ is_admin, admin_error = verify_admin(user)
1033
+ if admin_error: return jsonify({'error': admin_error['error']}), admin_error['status']
1034
+
1035
+ try:
1036
+ # Fetch all profiles (which are linked 1-1 with auth users)
1037
+ profiles_res = supabase.table('profiles').select('*').execute()
1038
+ return jsonify({'users': profiles_res.data}), 200
1039
+ except Exception as e:
1040
+ logging.error(f"Admin list users error: {e}")
1041
+ return jsonify({'error': str(e)}), 500
1042
+
1043
+ @app.route('/api/admin/users/<uuid:target_user_id>/suspend', methods=['PUT'])
1044
+ def admin_suspend_user(target_user_id):
1045
+ user, error = verify_token(request.headers.get('Authorization'))
1046
+ if error: return jsonify({'error': error['error']}), error['status']
1047
+ is_admin, admin_error = verify_admin(user)
1048
+ if admin_error: return jsonify({'error': admin_error['error']}), admin_error['status']
1049
+
1050
+ try:
1051
+ data = request.get_json()
1052
+ action = data.get('action') # "suspend" or "unsuspend"
1053
+
1054
+ if action not in ["suspend", "unsuspend"]:
1055
+ return jsonify({'error': 'action must be "suspend" or "unsuspend"'}), 400
1056
+
1057
+ should_suspend = (action == "suspend")
1058
+
1059
+ # Update the 'suspended' flag in the profiles table
1060
+ update_res = supabase.table('profiles').update({'suspended': should_suspend}).eq('id', target_user_id).execute()
1061
+
1062
+ if not update_res.data:
1063
+ # This could mean the user ID doesn't exist or there was another issue
1064
+ # Check if user exists first
1065
+ user_check = supabase.table('profiles').select('id').eq('id', target_user_id).maybe_single().execute()
1066
+ if not user_check.data:
1067
+ return jsonify({'error': 'User not found'}), 404
1068
+ else:
1069
+ raise Exception(f"Failed to update suspension status: {update_res.error}")
1070
+
1071
+
1072
+ # Note: Supabase doesn't have a direct Auth user disable like Firebase.
1073
+ # Suspension is typically handled via flags in your public tables ('profiles').
1074
+ # You'd check this 'suspended' flag during login or sensitive actions.
1075
+
1076
+ return jsonify({'success': True, 'message': f'User {target_user_id} suspension status set to {should_suspend}'}), 200
1077
+ except Exception as e:
1078
+ logging.error(f"Admin suspend user error: {e}")
1079
+ return jsonify({'error': str(e)}), 500
1080
+
1081
+ # Add other admin endpoints (update credits, view specific data) similarly,
1082
+ # using Supabase table methods (.select, .update, .delete, .rpc for database functions).
1083
+
1084
+
1085
+ # === Main Execution ===
1086
+ if __name__ == '__main__':
1087
+ if not all([SUPABASE_URL, SUPABASE_SERVICE_KEY, GEMINI_API_KEY, ELEVENLABS_API_KEY]):
1088
+ print("WARNING: One or more essential environment variables (SUPABASE_URL, SUPABASE_SERVICE_KEY, GEMINI_API_KEY, ELEVENLABS_API_KEY) are missing!")
1089
+ print("Starting Flask server for AI Tutor...")
1090
+ # Use Gunicorn or Waitress for production instead of app.run(debug=True)
1091
+ app.run(debug=True, host="0.0.0.0", port=7860)