Update main.py
Browse files
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)
|