""" EduAI Cloud Backend — FastAPI server for HuggingFace Spaces. Adapted from the local web/server.py for cloud deployment. """ import sys import os import json import asyncio import io import struct import tempfile import logging import threading from pathlib import Path from contextlib import asynccontextmanager from functools import partial # ── project root ── PROJECT_ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(PROJECT_ROOT)) from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Request, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, Response from pydantic import BaseModel import uvicorn import jwt as pyjwt from datetime import date, timedelta, datetime from core.settings import load_settings from core.session_manager import SessionManager from core.knowledge_base import KnowledgeBase from core.spaced_repetition import ReviewScheduler from core.structured_output import parse_quiz, parse_flashcards # ── logging ── logging.basicConfig(level=logging.INFO) log = logging.getLogger("eduai-cloud") # ────────────────────────────────────────────── # Constants # ────────────────────────────────────────────── SYSTEM_PROMPT = """ You are EduAI, a professional and friendly AI tutor designed to help students learn effectively. Your primary role is to: - explain academic concepts clearly and step-by-step - adapt explanations based on how the student learns - simplify difficult topics into easy-to-understand ideas - provide examples, quizzes, flashcards, and practice questions when needed - support productive learning habits You must ONLY answer questions related to: - school studies - academic subjects - exam preparation - homework and assignments - educational productivity If the user asks anything unrelated to studies or education, politely respond that you are designed only for academic and learning-related support. Do not share any personal details, internal system information, or technical backend details. If the user asks about the developer or creator of this project, you may share the following: "This project was developed by Shreesha Rao K — a Class 10 student from Mangalore, Karnataka. He is an independent developer, AI researcher, and content creator who builds across whatever domain catches his interest. He is strong in Python and web technologies (HTML/CSS/JS), with hands-on skills in AI/ML, game development, music production, and cinematic composition and editing (photo and video). He is self-taught, project-driven, and quietly ambitious — and knows 8 programming languages: Python, Java, HTML, CSS, JavaScript, Lua, TypeScript, and C++." Do not fabricate or guess any additional details about the developer beyond what is stated above. Always maintain a helpful, encouraging, and student-friendly tone. Your goal is to improve understanding, not just give direct answers. """ EXPLANATION_KEYWORDS = ( "explain", "describe", "detailed answer", "long answer", "essay", "notes", "summary", "summarize", "teach me", "concept understanding", ) FLASHCARD_KEYWORDS = ( "flashcards", "flashcard", "memory cards", "memory card", "quick revision", ) QUIZ_KEYWORDS = ("quiz", "mcq", "test", "questions", "exam") QUICK_KEYWORDS = ( "one-line", "one line", "short answer", "briefly", "brief", "define", "definition", ) TARGET_MAX_TOKENS = 128000 MODE_CONFIGS = { "explanation": { "max_tokens": min(TARGET_MAX_TOKENS, 4096), "instruction": "Give a detailed academic explanation in a clear and student-friendly way.", }, "flashcard": { "max_tokens": min(TARGET_MAX_TOKENS, 500), "instruction": ( "Create flashcards for quick revision. You must output EXACTLY a valid JSON array of objects, like this:\n" '[\\n {"front": "question or term", "back": "answer or definition"}\\n]\n\n' "Keep each card short and focused on one idea." ), }, "quiz": { "max_tokens": min(TARGET_MAX_TOKENS, 1000), "instruction": ( "Create a quiz. Use this format for each question:\n" "Q1: [question text]\nA) [option]\nB) [option]\nC) [option]\nD) [option]\n" "Answer: [correct letter]\n\nInclude 3-5 questions with clear answer options." ), }, "quick": { "max_tokens": min(TARGET_MAX_TOKENS, 200), "instruction": "Respond with a short, precise, direct answer.", }, } # ────────────────────────────────────────────── # App State # ────────────────────────────────────────────── DATABASE_DIR = PROJECT_ROOT / "database" def _get_protected_sources() -> set: """Scan database/ for pre-loaded source names (raw + transformed).""" protected = set() if DATABASE_DIR.exists(): for f in DATABASE_DIR.rglob("*"): if f.is_file(): protected.add(f.name) protected.add(f.stem.replace("_", " ").title()) return protected # Global state — populated in lifespan state = {} _llm_lock = threading.Lock() @asynccontextmanager async def lifespan(app): """Load settings, LLM, and all core services on startup.""" log.info("Loading settings...") settings = load_settings() # ── Download GGUF model at runtime if not present ── model_path = settings.llm_model_path if not model_path.exists(): log.info(f"GGUF model not found at {model_path}. Downloading...") try: from huggingface_hub import hf_hub_download downloaded = hf_hub_download( repo_id="bartowski/Llama-3.2-1B-Instruct-GGUF", filename="Llama-3.2-1B-Instruct-Q4_K_M.gguf", local_dir=str(settings.models_dir), local_dir_use_symlinks=False, ) log.info(f"Model downloaded to: {downloaded}") # Update model path model_path = Path(downloaded) except Exception as e: log.error(f"Failed to download model: {e}") raise RuntimeError(f"Cannot start without LLM model: {e}") log.info("Loading LLM model (this may take a moment)...") from models.llm_model import create_llm llm = create_llm(model_path, settings.context_size) log.info("Initializing services...") session_mgr = SessionManager(settings.data_dir) kb = KnowledgeBase(settings.data_dir, settings.embedding_model_id, settings.hf_cache_dir) review = ReviewScheduler(settings.data_dir) # TTS model (optional — may fail on free tier due to RAM) tts = None try: from models.tts_model import TTSModel tts = TTSModel(settings) log.info("TTS model loaded.") except Exception as e: log.warning(f"TTS unavailable (will use browser fallback): {e}") # Multimodal manager (optional) mm = None try: from core.multimodal_manager import MultimodalManager mm = MultimodalManager(settings, sample_rate=16000) except Exception as e: log.warning(f"Multimodal manager unavailable: {e}") # Store in global state state.update({ "settings": settings, "llm": llm, "session_mgr": session_mgr, "kb": kb, "review": review, "tts": tts, "mm": mm, "messages": [{"role": "system", "content": SYSTEM_PROMPT}], }) # Supabase client (optional) sb_url = os.getenv("SUPABASE_URL", "") sb_key = os.getenv("SUPABASE_SERVICE_KEY", "") if sb_url and sb_key: try: from supabase import create_client state["sb"] = create_client(sb_url, sb_key) log.info("Supabase client initialized.") except Exception as e: log.warning(f"Supabase init failed (running without auth): {e}") else: log.warning("Supabase not configured — running without auth.") # Start a fresh session session_mgr.start_new_session({"role": "system", "content": SYSTEM_PROMPT}) state["protected_names"] = _get_protected_sources() log.info(f"Protected sources: {len(state['protected_names'])} names from database/") # Auto-ingest database files into knowledge base if DATABASE_DIR.exists(): for f in sorted(DATABASE_DIR.rglob("*")): if f.is_file() and f.suffix in (".txt", ".md", ".csv"): source_name = f.stem.replace("_", " ").title() # Skip if already in KB existing = kb.list_sources() if source_name in existing: log.info(f"Already in KB: {source_name}") continue try: text = f.read_text(encoding="utf-8", errors="ignore") kb.add_document(text, source_name) log.info(f"Ingested: {source_name}") except Exception as e: log.warning(f"Failed to ingest {f.name}: {e}") log.info("EduAI Cloud ready — port 7860") yield log.info("Shutting down EduAI Cloud...") app = FastAPI(title="EduAI Cloud", docs_url="/docs", lifespan=lifespan) # ── CORS for Vercel frontend ── ALLOWED_ORIGINS = [ "https://eduai-by-srk.vercel.app", "https://eduai-web.vercel.app", "https://eduai-web-shreesha-rao-k.vercel.app", "https://edu-ai-web.vercel.app", "http://localhost:3000", "http://localhost:8000", "http://localhost:5500", ] app.add_middleware( CORSMiddleware, allow_origins=ALLOWED_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ── Auth dependency ── async def get_current_user(request: Request) -> str | None: """Extract and verify user_id from Bearer token via Supabase API.""" sb = state.get("sb") if not sb: return None # No auth configured — allow anonymous auth_header = request.headers.get("authorization", "") if not auth_header.startswith("Bearer "): return None # Allow unauthenticated requests to fall back token = auth_header[7:] try: # Verify token via Supabase's own API (handles ES256/HS256 automatically) user_response = sb.auth.get_user(token) if user_response and user_response.user: return user_response.user.id return None except Exception as e: log.warning(f"Auth verification failed: {e}") # Fallback: decode without verification to extract user_id try: payload = pyjwt.decode(token, options={"verify_signature": False}) user_id = payload.get("sub") if user_id: return user_id except Exception: pass raise HTTPException(status_code=401, detail=f"Invalid token: {e}") # ────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────── def detect_mode(prompt: str) -> str: """Detect response mode from prompt keywords.""" lowered = prompt.lower() if any(kw in lowered for kw in FLASHCARD_KEYWORDS): return "flashcard" if any(kw in lowered for kw in QUIZ_KEYWORDS): return "quiz" if any(kw in lowered for kw in EXPLANATION_KEYWORDS): return "explanation" if any(kw in lowered for kw in QUICK_KEYWORDS): return "quick" if lowered.endswith("?") and len(lowered.split()) <= 14: return "quick" if len(lowered.split()) > 14 or lowered.startswith("source:"): return "explanation" return "quick" def build_rag_context(prompt: str) -> str: """Build RAG context from knowledge base.""" kb = state["kb"] if kb.is_empty(): return "" try: results = kb.search(prompt, top_k=3, threshold=0.3) except Exception: return "" if not results: return "" parts = ["[Relevant study material]"] for r in results: parts.append(f"From {r['source']}: \"{r['text']}\"") return "\n".join(parts) def trim_history(messages: list, reserved_tokens: int = 512) -> list: """Trim message history to fit context window.""" llm = state["llm"] settings = state["settings"] trimmed = list(messages) while len(trimmed) > 2: payload = json.dumps(trimmed, ensure_ascii=False) token_count = len(llm.tokenize(payload.encode("utf-8"), add_bos=False)) if token_count + reserved_tokens <= settings.context_size: break if len(trimmed) > 3: del trimmed[1:3] else: trimmed = [trimmed[0], trimmed[-1]] return trimmed def run_inference(prompt: str, mode_override: str | None = None) -> tuple[str, str]: """Run LLM inference — called in a thread pool with a lock.""" mode = mode_override or detect_mode(prompt) if mode == "auto" or mode not in MODE_CONFIGS: mode = detect_mode(prompt) mode_config = MODE_CONFIGS[mode] messages = state["messages"] messages.append({"role": "user", "content": prompt}) state["messages"] = trim_history(messages, mode_config["max_tokens"]) # RAG augmentation work_messages = list(state["messages"]) rag_context = build_rag_context(prompt) if rag_context: augmented = f"{rag_context}\n\nStudent question: {prompt}" work_messages[-1] = {"role": "user", "content": augmented} # Inject mode instruction mode_instruction = {"role": "system", "content": mode_config["instruction"]} final_messages = [work_messages[0], mode_instruction, *work_messages[1:]] # Thread-safe LLM access try: with _llm_lock: response = state["llm"].create_chat_completion( messages=final_messages, max_tokens=mode_config["max_tokens"], ) raw_answer = response["choices"][0]["message"]["content"].strip() except (OSError, RuntimeError, Exception) as e: log.error(f"LLM inference failed: {e}") if state["messages"] and state["messages"][-1]["role"] == "user": state["messages"].pop() raise RuntimeError(f"AI model error: {type(e).__name__}. Please try again.") # Save to history state["messages"].append({"role": "assistant", "content": raw_answer}) state["messages"] = trim_history(state["messages"], 0) state["session_mgr"].save_messages(state["messages"]) return raw_answer, mode def _generate_tts_wav(text: str) -> bytes: """Generate WAV bytes from text using Kokoro, without sounddevice.""" import numpy as np tts = state["tts"] pipeline = tts._get_pipeline() clean_text = tts.prepare_text(text) if not clean_text: return b"" all_audio = [] for _gs, _ps, audio in pipeline(clean_text, voice="af_heart"): if audio is not None and len(audio) > 0: audio_np = tts._prepare_audio(audio) if audio_np.size > 0: all_audio.append(audio_np) if not all_audio: return b"" combined = np.concatenate(all_audio) # Convert to WAV bytes buf = io.BytesIO() sample_rate = 24000 n_samples = len(combined) data_size = n_samples * 2 # WAV header buf.write(b"RIFF") buf.write(struct.pack("= len(messages): raise HTTPException(status_code=400, detail="Invalid message index") deleted_role = messages[index]["role"] del messages[index] state["session_mgr"].save_messages(messages) return {"deleted": True, "role": deleted_role, "remaining": len(messages) - 1} @app.post("/api/upload") async def upload_file(file: UploadFile = File(...)): """Upload a file, extract content, and get AI response.""" suffix = Path(file.filename).suffix with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix="eduai_web_") as tmp: content = await file.read() tmp.write(content) tmp_path = Path(tmp.name) try: if state.get("mm"): from core.file_analyzer import analyze_file file_data = analyze_file(tmp_path, state["mm"]) text_content = file_data["content"] name = file_data["name"] else: # Fallback: read as plain text text_content = tmp_path.read_text(encoding="utf-8", errors="ignore") name = file.filename except Exception as e: tmp_path.unlink(missing_ok=True) raise HTTPException(status_code=400, detail=f"File processing failed: {e}") finally: tmp_path.unlink(missing_ok=True) routed_text = f"Source: {name}\n\n{text_content[:4000]}" loop = asyncio.get_event_loop() answer, mode = await loop.run_in_executor( None, partial(run_inference, routed_text, None), ) return {"answer": answer, "mode": mode, "filename": name} # ── Sessions ── @app.get("/api/sessions") async def list_sessions(user_id: str | None = Depends(get_current_user)): sb = state.get("sb") if sb and user_id: res = sb.table("chat_sessions").select("*").eq("user_id", user_id).order("updated_at", desc=True).execute() return {"sessions": [{"id": s["id"], "title": s["title"], "created_at": s["created_at"], "updated_at": s["updated_at"], "message_count": s["message_count"]} for s in res.data]} # Fallback: file-based sessions = state["session_mgr"].list_sessions() return {"sessions": sessions} @app.post("/api/sessions/new") async def new_session(user_id: str | None = Depends(get_current_user)): sb = state.get("sb") if sb and user_id: sb.table("chat_sessions").insert({"user_id": user_id}).execute() state["messages"] = [{"role": "system", "content": SYSTEM_PROMPT}] return {"status": "ok"} # Fallback: file-based state["session_mgr"].save_messages(state["messages"]) sys_msg = {"role": "system", "content": SYSTEM_PROMPT} state["messages"] = [sys_msg] state["session_mgr"].start_new_session(sys_msg) return {"status": "ok"} @app.get("/api/sessions/{session_id}") async def load_session(session_id: str, user_id: str | None = Depends(get_current_user)): sb = state.get("sb") if sb and user_id: # Verify session belongs to user session = sb.table("chat_sessions").select("id").eq("id", session_id).eq("user_id", user_id).execute() if not session.data: raise HTTPException(status_code=404, detail="Session not found") msgs = sb.table("chat_messages").select("role, content").eq("session_id", session_id).order("created_at", desc=False).execute() return {"messages": [{"role": m["role"], "content": m["content"]} for m in msgs.data if m["role"] != "system"]} # Fallback: file-based (index is an int) try: index = int(session_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid session index") sys_msg = {"role": "system", "content": SYSTEM_PROMPT} loaded = state["session_mgr"].load_session(index, sys_msg) if loaded is None: raise HTTPException(status_code=404, detail="Session not found") state["messages"] = loaded return {"messages": [m for m in loaded if m["role"] != "system"]} @app.delete("/api/sessions/{session_id}") async def delete_session(session_id: str, user_id: str | None = Depends(get_current_user)): sb = state.get("sb") if sb and user_id: sb.table("chat_sessions").delete().eq("id", session_id).eq("user_id", user_id).execute() return {"deleted": session_id} # Fallback: file-based try: index = int(session_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid session index") title = state["session_mgr"].delete_session(index) return {"deleted": title or "unknown"} # ── Knowledge Base ── @app.get("/api/knowledge") async def list_knowledge(): sources = state["kb"].list_sources() total_chunks = sum(info["count"] for info in sources.values()) return { "sources": {name: info for name, info in sources.items()}, "total_chunks": total_chunks, "protected_names": list(state.get("protected_names", set())), } @app.post("/api/knowledge/learn") async def learn_file(file: UploadFile = File(...)): suffix = Path(file.filename).suffix with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, prefix="eduai_kb_") as tmp: content = await file.read() tmp.write(content) tmp_path = Path(tmp.name) try: if state.get("mm"): from core.file_analyzer import analyze_file file_data = analyze_file(tmp_path, state["mm"]) text_content = file_data["content"] name = file_data["name"] else: text_content = tmp_path.read_text(encoding="utf-8", errors="ignore") name = file.filename loop = asyncio.get_event_loop() chunk_count = await loop.run_in_executor( None, partial(state["kb"].add_document, text_content, name), ) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) finally: tmp_path.unlink(missing_ok=True) return {"name": name, "chunks": chunk_count} @app.delete("/api/knowledge/{index}") async def remove_source(index: int): """Delete a knowledge source — refuses if pre-loaded study material.""" sources = state["kb"].list_sources() source_names = list(sources.keys()) protected = state.get("protected_names", set()) protected_lower = {p.lower() for p in protected} if 1 <= index <= len(source_names): target = source_names[index - 1] if target.lower() in protected_lower or target in protected: raise HTTPException( status_code=403, detail="Cannot delete pre-loaded study material", ) name = state["kb"].remove_source(index) return {"removed": name or "unknown"} # ── Review / Flashcards ── @app.get("/api/review/stats") async def review_stats(user_id: str | None = Depends(get_current_user)): sb = state.get("sb") if sb and user_id: total = sb.table("flashcards").select("id", count="exact").eq("user_id", user_id).execute() due = sb.table("flashcards").select("id", count="exact").eq("user_id", user_id).lte("next_review", date.today().isoformat()).execute() mastered = sb.table("flashcards").select("id", count="exact").eq("user_id", user_id).gte("repetitions", 5).execute() return {"total": total.count or 0, "due": due.count or 0, "mastered": mastered.count or 0} return state["review"].get_stats() @app.get("/api/review/due") async def review_due(user_id: str | None = Depends(get_current_user)): sb = state.get("sb") if sb and user_id: res = sb.table("flashcards").select("*").eq("user_id", user_id).lte("next_review", date.today().isoformat()).execute() return {"cards": res.data} cards = state["review"].get_due_cards() return {"cards": cards} @app.post("/api/review/rate") async def review_rate(req: RateRequest, user_id: str | None = Depends(get_current_user)): sb = state.get("sb") if sb and user_id: card_res = sb.table("flashcards").select("*").eq("id", req.card_id).eq("user_id", user_id).execute() if not card_res.data: raise HTTPException(status_code=404, detail="Card not found") card = card_res.data[0] q = req.quality ef = card["easiness"] rep = card["repetitions"] ivl = card["interval"] ef = max(1.3, ef + 0.1 - (5 - q) * (0.08 + (5 - q) * 0.02)) if q < 3: rep = 0 ivl = 1 else: rep += 1 if rep == 1: ivl = 1 elif rep == 2: ivl = 6 else: ivl = round(ivl * ef) next_date = (date.today() + timedelta(days=ivl)).isoformat() sb.table("flashcards").update({ "easiness": ef, "interval": ivl, "repetitions": rep, "next_review": next_date }).eq("id", req.card_id).execute() return {"interval": ivl} interval = state["review"].record_answer(req.card_id, req.quality) return {"interval": interval} # ── TTS ── @app.post("/api/tts") async def text_to_speech(req: ChatRequest): """Generate speech audio from text as WAV bytes.""" tts = state.get("tts") if not tts: raise HTTPException(status_code=501, detail="TTS not available on server") try: loop = asyncio.get_event_loop() audio_bytes = await loop.run_in_executor(None, partial(_generate_tts_wav, req.prompt)) if not audio_bytes: raise HTTPException(status_code=500, detail="TTS produced no audio") return Response(content=audio_bytes, media_type="audio/wav") except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ── Profile ── @app.get("/api/auth/profile") async def get_profile(user_id: str | None = Depends(get_current_user)): sb = state.get("sb") if not sb or not user_id: return {"authenticated": False} try: res = sb.table("profiles").select("*").eq("id", user_id).execute() if not res.data: return {"authenticated": False} p = res.data[0] return {"authenticated": True, "user_id": user_id, "display_name": p["display_name"], "avatar_url": p.get("avatar_url", "")} except Exception as e: log.error(f"Profile fetch error: {e}") return {"authenticated": False} # ────────────────────────────────────────────── # Entry point # ────────────────────────────────────────────── if __name__ == "__main__": uvicorn.run( "app:app", host="0.0.0.0", port=7860, reload=False, log_level="info", )