eduai / app.py
Shreesha-2011's picture
Fix: use Supabase auth.get_user() for ES256 JWT verification
99b9880
Raw
History Blame Contribute Delete
31 kB
"""
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("<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
# ──────────────────────────────────────────────
@app.api_route("/api/health", methods=["GET", "HEAD"])
async def health():
"""Health check for cron ping / uptime monitoring."""
return {"status": "ok", "model_loaded": "llm" in state}
@app.post("/api/chat")
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}
@app.delete("/api/chat/{index}")
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}
@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",
)