Spaces:
Running
Running
| """ | |
| 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() | |
| 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("<I", 36 + data_size)) | |
| buf.write(b"WAVE") | |
| buf.write(b"fmt ") | |
| buf.write(struct.pack("<IHHIIHH", 16, 1, 1, sample_rate, sample_rate * 2, 2, 16)) | |
| buf.write(b"data") | |
| buf.write(struct.pack("<I", data_size)) | |
| # Convert float32 to int16 PCM | |
| pcm = (combined * 32767).astype(np.int16) | |
| buf.write(pcm.tobytes()) | |
| return buf.getvalue() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Request / Response Models | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ChatRequest(BaseModel): | |
| prompt: str | |
| mode: str | None = None | |
| class RateRequest(BaseModel): | |
| card_id: str | |
| quality: int | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| # API Routes | |
| # ββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def health(): | |
| """Health check for cron ping / uptime monitoring.""" | |
| return {"status": "ok", "model_loaded": "llm" in state} | |
| async def chat(req: ChatRequest, user_id: str | None = Depends(get_current_user)): | |
| """Send a message and get an AI response.""" | |
| loop = asyncio.get_event_loop() | |
| answer, mode = await loop.run_in_executor( | |
| None, partial(run_inference, req.prompt, req.mode), | |
| ) | |
| parsed = None | |
| if mode == "quiz": | |
| questions = parse_quiz(answer) | |
| if questions: | |
| parsed = {"type": "quiz", "questions": questions} | |
| elif mode == "flashcard": | |
| cards = parse_flashcards(answer) | |
| if cards: | |
| parsed = {"type": "flashcard", "cards": cards} | |
| # Save flashcards to file-based storage (always) | |
| state["review"].add_cards(cards, req.prompt) | |
| # Also save to Supabase if user is authenticated | |
| sb = state.get("sb") | |
| if sb and user_id: | |
| for card in cards: | |
| try: | |
| sb.table("flashcards").insert({ | |
| "user_id": user_id, | |
| "front": card.get("front", ""), | |
| "back": card.get("back", ""), | |
| "source_prompt": req.prompt[:200], | |
| }).execute() | |
| except Exception as e: | |
| log.error(f"Failed to save flashcard to Supabase: {e}") | |
| # Save chat to Supabase if authenticated | |
| sb = state.get("sb") | |
| if sb and user_id: | |
| try: | |
| # Get most recent session or create one | |
| sessions_res = sb.table("chat_sessions").select("id,title").eq("user_id", user_id).order("updated_at", desc=True).limit(1).execute() | |
| if sessions_res.data: | |
| session_id = sessions_res.data[0]["id"] | |
| session_title = sessions_res.data[0]["title"] | |
| else: | |
| new_s = sb.table("chat_sessions").insert({"user_id": user_id, "title": req.prompt[:60]}).execute() | |
| session_id = new_s.data[0]["id"] | |
| session_title = req.prompt[:60] | |
| # Save user + assistant messages | |
| sb.table("chat_messages").insert([ | |
| {"session_id": session_id, "role": "user", "content": req.prompt}, | |
| {"session_id": session_id, "role": "assistant", "content": answer}, | |
| ]).execute() | |
| # Update session metadata | |
| msg_count = sb.table("chat_messages").select("id", count="exact").eq("session_id", session_id).neq("role", "system").execute() | |
| update_data = {"message_count": msg_count.count or 0, "updated_at": datetime.utcnow().isoformat()} | |
| if session_title == "Untitled Session": | |
| update_data["title"] = req.prompt[:60] | |
| sb.table("chat_sessions").update(update_data).eq("id", session_id).execute() | |
| except Exception as e: | |
| log.error(f"Failed to save chat to Supabase: {e}") | |
| return {"answer": answer, "mode": mode, "parsed": parsed} | |
| async def delete_message(index: int): | |
| """Delete a specific message from the current conversation.""" | |
| messages = state["messages"] | |
| if index < 1 or index >= 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} | |
| 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 ββ | |
| 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} | |
| 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"} | |
| 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"]} | |
| 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 ββ | |
| 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())), | |
| } | |
| 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} | |
| 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 ββ | |
| 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() | |
| 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} | |
| 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 ββ | |
| 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 ββ | |
| 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", | |
| ) | |