Spaces:
Sleeping
Sleeping
| """ | |
| FastAPI Backend for NotebookPRO | |
| Handles RAG, LLM, file processing, and chat management | |
| """ | |
| from pymongo import MongoClient | |
| from fastapi import BackgroundTasks | |
| from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from pydantic import BaseModel | |
| from typing import List, Optional, Dict, Any | |
| from pathlib import Path | |
| import json | |
| from datetime import datetime | |
| import uuid | |
| import sys | |
| import warnings | |
| import logging | |
| import os | |
| import shutil | |
| # Suppress warnings | |
| warnings.filterwarnings('ignore') | |
| os.environ['PYTHONWARNINGS'] = 'ignore' | |
| os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' | |
| os.environ['TOKENIZERS_PARALLELISM'] = 'false' | |
| os.environ.setdefault('OMP_NUM_THREADS', '2') | |
| os.environ.setdefault('MKL_NUM_THREADS', '2') | |
| os.environ.setdefault('OPENBLAS_NUM_THREADS', '2') | |
| os.environ.setdefault('NUMEXPR_NUM_THREADS', '2') | |
| #logging.getLogger().setLevel(logging.ERROR) | |
| # Add project root to path | |
| sys.path.append(str(Path(__file__).parent.parent)) | |
| import config | |
| from utils.document_processor import DocumentProcessor | |
| from utils.vector_db import VectorDatabase | |
| from utils.hybrid_retriever import HybridRetriever | |
| from utils.llm_generator import LLMGenerator | |
| from utils.config_manager import ConfigManager | |
| from utils.spaces_manager import SpacesManager | |
| from utils.studio_manager import StudioManager | |
| from utils.studio_generator import StudioGenerator | |
| # Initialize FastAPI | |
| app = FastAPI(title="NotebookPRO API", version="2.0.0") | |
| # --- ADD THIS AFTER app = FastAPI(...) --- | |
| # Initialize MongoDB | |
| MONGO_URI = os.getenv("MONGO_URI") | |
| if MONGO_URI: | |
| mongo_client = MongoClient(MONGO_URI) | |
| db = mongo_client["notebookpro_db"] | |
| chats_collection = db["chats"] | |
| files_collection = db["processed_files"] | |
| else: | |
| print("WARNING: MONGO_URI not found in environment variables.") | |
| # CORS - Allow Flutter web to connect | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], # In production, specify your Flutter web URL | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| # Global instances | |
| config_manager = ConfigManager() | |
| spaces_manager = SpacesManager() | |
| studio_manager = StudioManager() | |
| studio_generator = None # Will be initialized after LLM | |
| vector_db = None | |
| llm_generator = None | |
| current_space = None | |
| # ==================== Pydantic Models ==================== | |
| class ChatMessage(BaseModel): | |
| role: str | |
| content: str | |
| timestamp: str | |
| sources: Optional[List[Dict[str, Any]]] = None | |
| class ChatRequest(BaseModel): | |
| query: str | |
| space_id: str | |
| chat_id: Optional[str] = None | |
| workflow: str = "chat" | |
| class ChatResponse(BaseModel): | |
| response: str | |
| sources: List[Dict[str, Any]] | |
| chat_id: str | |
| timestamp: str | |
| class SpaceCreate(BaseModel): | |
| name: str | |
| class SpaceResponse(BaseModel): | |
| id: str | |
| name: str | |
| created_at: str | |
| file_count: int | |
| class ChatInfo(BaseModel): | |
| id: str | |
| title: str | |
| preview: str | |
| created_at: str | |
| updated_at: str | |
| message_count: int | |
| class ConfigResponse(BaseModel): | |
| groq_api_key: Optional[str] | |
| gemini_api_key: Optional[str] | |
| class ConfigUpdate(BaseModel): | |
| groq_api_key: Optional[str] = None | |
| gemini_api_key: Optional[str] = None | |
| class ChatToNotebookRequest(BaseModel): | |
| space_id: str | |
| question: str | |
| answer: str | |
| chat_id: Optional[str] = None | |
| assistant_timestamp: Optional[str] = None | |
| tags: List[str] = [] | |
| space_name: Optional[str] = None | |
| # ==================== Helper Functions ==================== | |
| def get_data_dir(): | |
| """Get data directory path""" | |
| return Path(__file__).parent.parent / "data" | |
| def get_space_dir(space_id: str): | |
| """Get space-specific directory""" | |
| return get_data_dir() / "spaces" / space_id | |
| def load_chats_for_space(space_id: str) -> List[Dict]: | |
| """Load all chats for a space from MongoDB""" | |
| if not MONGO_URI: return [] | |
| cursor = chats_collection.find({"space_id": space_id}, {"_id": 0}) | |
| return list(cursor) | |
| def save_chat_to_db(space_id: str, chat: Dict): | |
| """Save or update a single chat in MongoDB""" | |
| if not MONGO_URI: return | |
| chat['space_id'] = space_id | |
| chats_collection.update_one( | |
| {"id": chat['id'], "space_id": space_id}, | |
| {"$set": chat}, | |
| upsert=True | |
| ) | |
| def get_chat_title(messages: List[Dict]) -> str: | |
| """Generate chat title from first user message""" | |
| for msg in messages: | |
| if msg['role'] == 'user': | |
| content = msg['content'][:50] | |
| return content + "..." if len(msg['content']) > 50 else content | |
| return "New Chat" | |
| def ensure_notebooks_for_existing_spaces() -> int: | |
| """Ensure every existing space has an associated notebook metadata record.""" | |
| created_count = 0 | |
| spaces = spaces_manager.get_all_spaces() | |
| for space in spaces: | |
| space_id = space.get('id') | |
| if not space_id: | |
| continue | |
| existing_notebook = studio_manager.get_space_notebook(space_id) | |
| if existing_notebook: | |
| continue | |
| studio_manager.ensure_space_notebook(space_id, space.get('name', space_id)) | |
| created_count += 1 | |
| return created_count | |
| def rebuild_space_index_if_missing(space_id: str) -> int: | |
| """Rebuild a space index from uploaded files if the current index is empty.""" | |
| if not vector_db: | |
| return 0 | |
| try: | |
| if vector_db.get_collection_count() > 0: | |
| return 0 | |
| except Exception: | |
| # If count check fails, continue with a best-effort rebuild. | |
| pass | |
| uploads_dir = get_space_dir(space_id) / "uploads" | |
| if not uploads_dir.exists(): | |
| return 0 | |
| files = [ | |
| p for p in uploads_dir.iterdir() | |
| if p.is_file() and p.suffix.lower() in {".pdf", ".docx", ".txt"} | |
| ] | |
| if not files: | |
| return 0 | |
| processor = DocumentProcessor() | |
| texts: List[str] = [] | |
| metadatas: List[Dict[str, Any]] = [] | |
| ids: List[str] = [] | |
| for file_path in files: | |
| try: | |
| file_data = processor.process_file(file_path) | |
| chunks = processor.chunk_text( | |
| file_data['content'], | |
| chunk_size=512, | |
| overlap=50, | |
| semantic=True, | |
| ) | |
| total_chunks = len(chunks) | |
| for idx, chunk in enumerate(chunks): | |
| texts.append(chunk) | |
| metadatas.append({ | |
| 'filename': file_path.name, | |
| 'chunk_index': idx, | |
| 'total_chunks': total_chunks, | |
| 'source_type': file_data['format'], | |
| }) | |
| ids.append(f"{space_id}_rebuild_{len(ids)}_{uuid.uuid4().hex[:8]}") | |
| except Exception as e: | |
| print(f"Index rebuild skipped {file_path.name}: {e}") | |
| if not texts: | |
| return 0 | |
| batch_size = 100 | |
| for i in range(0, len(texts), batch_size): | |
| vector_db.add_documents( | |
| texts[i:i + batch_size], | |
| metadatas[i:i + batch_size], | |
| ids[i:i + batch_size], | |
| ) | |
| print(f"Rebuilt index for space '{space_id}' with {len(texts)} chunks") | |
| return len(texts) | |
| def initialize_space(space_id: str): | |
| """Initialize vector DB and components for a space""" | |
| global vector_db, llm_generator, studio_generator, current_space | |
| # Fast path: reuse already initialized components for the active space. | |
| if current_space == space_id and vector_db is not None and llm_generator is not None: | |
| return | |
| # Get API keys | |
| import os | |
| # Try the config manager first, but fallback to the .env file variables | |
| groq_key = config_manager.get_api_key('groq') or os.getenv('GROQ_API_KEY') | |
| gemini_key = config_manager.get_api_key('gemini') or os.getenv('GOOGLE_API_KEY') or os.getenv('GEMINI_API_KEY') | |
| if not groq_key and not gemini_key: | |
| raise HTTPException(status_code=400, detail="No API keys configured. Please add Groq or Gemini API key.") | |
| # Initialize vector database for this space (space-local persistence path). | |
| # Initialize Qdrant cloud database for this space | |
| vector_db = VectorDatabase( | |
| collection_name=f"space_{space_id}" | |
| ) | |
| # Backward-compatibility: rebuild embeddings from uploaded files if index is empty. | |
| rebuild_space_index_if_missing(space_id) | |
| # Initialize LLM generator - choose provider based on available keys | |
| # Initialize LLM generator - prioritize Gemini for heavy RAG workloads | |
| if gemini_key: | |
| llm_generator = LLMGenerator(provider="gemini", api_key=gemini_key) | |
| elif groq_key: | |
| llm_generator = LLMGenerator(provider="groq", api_key=groq_key) | |
| else: | |
| raise HTTPException(status_code=400, detail="No API keys configured.") | |
| # Initialize studio generator with LLM | |
| studio_generator = StudioGenerator(llm_generator, studio_manager) | |
| current_space = space_id | |
| async def startup_sync_notebooks(): | |
| """Auto-create missing notebooks for pre-existing spaces when backend starts.""" | |
| try: | |
| created = ensure_notebooks_for_existing_spaces() | |
| if created > 0: | |
| print(f"Created {created} missing notebook(s) for existing spaces") | |
| except Exception as e: | |
| # Keep server startup resilient even if sync fails. | |
| print(f"Notebook startup sync failed: {e}") | |
| # ==================== API Endpoints ==================== | |
| async def root(): | |
| """Health check""" | |
| return {"status": "NotebookPRO API is running", "version": "2.0.0"} | |
| async def get_config(): | |
| """Get current API keys (masked)""" | |
| groq_key = config_manager.get_api_key('groq') | |
| gemini_key = config_manager.get_api_key('gemini') | |
| return ConfigResponse( | |
| groq_api_key="***" + groq_key[-4:] if groq_key else None, | |
| gemini_api_key="***" + gemini_key[-4:] if gemini_key else None | |
| ) | |
| async def update_config(config_update: ConfigUpdate): | |
| """Update API keys""" | |
| if config_update.groq_api_key: | |
| config_manager.set_api_key('groq', config_update.groq_api_key) | |
| if config_update.gemini_api_key: | |
| config_manager.set_api_key('gemini', config_update.gemini_api_key) | |
| return {"status": "success", "message": "Configuration updated"} | |
| async def get_spaces(): | |
| """Get all spaces""" | |
| # Self-healing check in case spaces were created externally while server is running. | |
| ensure_notebooks_for_existing_spaces() | |
| spaces = spaces_manager.get_all_spaces() | |
| result = [] | |
| for space in spaces: | |
| space_id = space['id'] | |
| # Ask MongoDB for the file count instead of looking for the local JSON file | |
| file_count = 0 | |
| if MONGO_URI: | |
| file_count = files_collection.count_documents({"space_id": space_id}) | |
| else: | |
| # Fallback for local testing without Mongo | |
| space_dir = get_space_dir(space_id) | |
| processed_file = space_dir / "processed_files.json" | |
| if processed_file.exists(): | |
| with open(processed_file, 'r') as f: | |
| file_count = len(json.load(f)) | |
| result.append(SpaceResponse( | |
| id=space_id, | |
| name=space['name'], | |
| created_at=space['created_at'], | |
| file_count=file_count | |
| )) | |
| return result | |
| async def create_space(space_data: SpaceCreate): | |
| """Create a new space""" | |
| try: | |
| space = spaces_manager.create_space(space_data.name) | |
| # Create associated notebook metadata with the same name as the space. | |
| studio_manager.ensure_space_notebook(space['id'], space['name']) | |
| return SpaceResponse( | |
| id=space['id'], | |
| name=space['name'], | |
| created_at=space['created_at'], | |
| file_count=0 | |
| ) | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| async def delete_space(space_id: str): | |
| """Delete a space""" | |
| try: | |
| spaces_manager.delete_space(space_id) | |
| # Delete space directory | |
| space_dir = get_space_dir(space_id) | |
| if space_dir.exists(): | |
| shutil.rmtree(space_dir) | |
| return {"status": "success", "message": f"Space {space_id} deleted"} | |
| except ValueError as e: | |
| raise HTTPException(status_code=400, detail=str(e)) | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Error deleting space: {str(e)}") | |
| async def get_chats(space_id: str): | |
| """Get all chats for a space""" | |
| chats = load_chats_for_space(space_id) | |
| result = [] | |
| for chat in chats: | |
| messages = chat.get('messages', []) | |
| result.append(ChatInfo( | |
| id=chat['id'], | |
| title=get_chat_title(messages), | |
| preview=messages[0]['content'][:100] if messages else "", | |
| created_at=chat.get('created_at', ''), | |
| updated_at=chat.get('updated_at', ''), | |
| message_count=len(messages) | |
| )) | |
| return result | |
| async def get_chat(space_id: str, chat_id: str): | |
| """Get specific chat by ID""" | |
| chats = load_chats_for_space(space_id) | |
| for chat in chats: | |
| if chat['id'] == chat_id: | |
| return chat | |
| raise HTTPException(status_code=404, detail="Chat not found") | |
| async def delete_chat(space_id: str, chat_id: str): | |
| """Delete a chat""" | |
| chats = load_chats_for_space(space_id) | |
| chats = [c for c in chats if c['id'] != chat_id] | |
| save_chats_for_space(space_id, chats) | |
| return {"status": "success", "message": f"Chat {chat_id} deleted"} | |
| async def chat(request: ChatRequest): | |
| """Process a chat message with RAG""" | |
| try: | |
| # Initialize space if needed | |
| initialize_space(request.space_id) | |
| # Create hybrid retriever with 60% vector, 40% BM25 | |
| hybrid_retriever = HybridRetriever(vector_db, alpha=0.6) | |
| # Retrieve relevant documents | |
| documents, metadatas, scores = hybrid_retriever.retrieve( | |
| query=request.query, | |
| n_results=5 | |
| ) | |
| # Build context from retrieved documents | |
| context_parts = [] | |
| sources = [] | |
| for idx, (doc, meta, score) in enumerate(zip(documents, metadatas, scores), 1): | |
| # Extract clean filename for source citation | |
| filename = meta.get('filename', 'Unknown') | |
| clean_name = filename.replace('.pdf', '').replace('.docx', '').replace('.txt', '') | |
| context_parts.append(f"Source [{idx}] ({clean_name}):\n{doc}\n") | |
| sources.append({ | |
| "content": doc[:200] + "..." if len(doc) > 200 else doc, | |
| "metadata": meta, | |
| "score": float(score) | |
| }) | |
| context = "\n".join(context_parts) | |
| # Use the advanced generate_response method which has the new NotebookLM-style prompt | |
| response = llm_generator.generate_response( | |
| prompt=request.query, | |
| context=context, | |
| use_case=request.workflow if request.workflow in ["summary", "explanation", "qa", "notes"] else "qa", | |
| metadatas=metadatas, | |
| temperature=0.3 | |
| ) | |
| # Create or update chat | |
| chat_id = request.chat_id or str(uuid.uuid4()) | |
| chats = load_chats_for_space(request.space_id) | |
| # Find existing chat or create new | |
| # Fetch specific chat from Mongo or create new | |
| chat = chats_collection.find_one({"id": chat_id, "space_id": request.space_id}, {"_id": 0}) | |
| if not chat: | |
| chat = { | |
| 'id': chat_id, | |
| 'space_id': request.space_id, | |
| 'messages': [], | |
| 'created_at': datetime.now().isoformat(), | |
| 'updated_at': datetime.now().isoformat() | |
| } | |
| # Add messages | |
| timestamp = datetime.now().isoformat() | |
| chat['messages'].extend([ | |
| {'role': 'user', 'content': request.query, 'timestamp': timestamp}, | |
| { | |
| 'role': 'assistant', | |
| 'content': response, | |
| 'timestamp': timestamp, | |
| 'sources': sources | |
| } | |
| ]) | |
| chat['updated_at'] = timestamp | |
| # Save SINGLE chat directly to MongoDB | |
| save_chat_to_db(request.space_id, chat) | |
| # ADD THIS RETURN BLOCK: | |
| return { | |
| "chat_id": chat_id, | |
| "response": response, | |
| "sources": sources, | |
| "timestamp": timestamp | |
| } | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| def process_heavy_files_background(space_id: str, saved_file_paths: List[Dict]): | |
| """Runs in the background, processing and saving ONE file at a time.""" | |
| try: | |
| initialize_space(space_id) | |
| processor = DocumentProcessor() | |
| for file_info in saved_file_paths: | |
| try: | |
| file_path = Path(file_info['path']) | |
| filename = file_info['name'] | |
| print(f"Processing: {filename}...") | |
| # 1. Process just this one file | |
| file_data = processor.process_file(file_path) | |
| chunks = processor.chunk_text(file_data['content'], chunk_size=512, overlap=50, semantic=True) | |
| file_chunks = [] | |
| for idx, chunk in enumerate(chunks): | |
| file_chunks.append({ | |
| 'content': chunk, | |
| 'metadata': { | |
| 'filename': filename, | |
| 'chunk_index': idx, | |
| 'total_chunks': len(chunks), | |
| 'source_type': file_data['format'] | |
| } | |
| }) | |
| # 2. Upload to Qdrant immediately (This clears the RAM for the next file!) | |
| if file_chunks: | |
| texts = [chunk['content'] for chunk in file_chunks] | |
| metadatas = [chunk['metadata'] for chunk in file_chunks] | |
| # Make UUID unique to the file to prevent collisions | |
| ids = [f"{space_id}_{filename}_{idx}_{uuid.uuid4().hex[:8]}" for idx in range(len(file_chunks))] | |
| batch_size = 100 | |
| for i in range(0, len(texts), batch_size): | |
| vector_db.add_documents( | |
| texts[i:i + batch_size], | |
| metadatas[i:i + batch_size], | |
| ids[i:i + batch_size] | |
| ) | |
| # 3. Save metadata directly to MongoDB so it appears in Flutter instantly | |
| if MONGO_URI: | |
| files_collection.insert_one({ | |
| 'filename': filename, | |
| 'space_id': space_id, | |
| 'chunks': len(chunks), | |
| 'processed_at': datetime.now().isoformat() | |
| }) | |
| print(f"Successfully finished: {filename}") | |
| except Exception as file_e: | |
| # If ONE file has a corrupted page, skip it but KEEP GOING for the rest! | |
| print(f"Failed to process file {file_info['name']}: {file_e}") | |
| except Exception as e: | |
| print(f"Background worker completely crashed: {e}") | |
| async def upload_files( | |
| space_id: str, | |
| background_tasks: BackgroundTasks, | |
| files: list[UploadFile] # <-- Lowercase 'list', no '= File(...)' | |
| ): | |
| """Accepts files quickly and processes them in the background""" | |
| try: | |
| space_dir = get_space_dir(space_id) | |
| uploads_dir = space_dir / "uploads" | |
| uploads_dir.mkdir(parents=True, exist_ok=True) | |
| saved_files = [] | |
| # 1. Save files to hard drive (Extremely Fast) | |
| for file in files: | |
| file_path = uploads_dir / file.filename | |
| with open(file_path, "wb") as f: | |
| content = await file.read() | |
| f.write(content) | |
| saved_files.append({ | |
| "name": file.filename, | |
| "path": str(file_path) | |
| }) | |
| # 2. Hand heavy math and Mongo saving to background task | |
| background_tasks.add_task(process_heavy_files_background, space_id, saved_files) | |
| # 3. Reply instantly to prevent timeouts | |
| return { | |
| "status": "processing", | |
| "message": f"Successfully received {len(files)} files. Processing in the background." | |
| } | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_files(space_id: str): | |
| """Get processed files for a space from MongoDB""" | |
| if not MONGO_URI: return [] | |
| cursor = files_collection.find({"space_id": space_id}, {"_id": 0}) | |
| return list(cursor) | |
| async def delete_file(space_id: str, filename: str): | |
| """Delete a specific file from a space""" | |
| try: | |
| # 1. Remove from MongoDB | |
| if MONGO_URI: | |
| files_collection.delete_one({"space_id": space_id, "filename": filename}) | |
| # 2. Delete the actual file | |
| file_path = get_space_dir(space_id) / "uploads" / filename | |
| if file_path.exists(): | |
| file_path.unlink() | |
| # 3. Remove from Qdrant vector database | |
| if vector_db: | |
| try: | |
| # Qdrant supports deleting by payload filter natively | |
| from qdrant_client.http import models | |
| vector_db.client.delete( | |
| collection_name=vector_db.collection_name, | |
| points_selector=models.Filter( | |
| must=[ | |
| models.FieldCondition( | |
| key="filename", | |
| match=models.MatchValue(value=filename) | |
| ) | |
| ] | |
| ) | |
| ) | |
| except Exception as e: | |
| print(f"Error removing from Qdrant DB: {e}") | |
| return {"status": "success", "message": f"File {filename} deleted"} | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=f"Error deleting file: {str(e)}") | |
| # ==================== STUDIO API ROUTES ==================== | |
| # Routes for Notebook, Flashcards, and Quiz features | |
| # Import studio models | |
| from models.studio_models import ( | |
| NotebookEntry, NotebookEntryCreate, NotebookEntryUpdate, | |
| Flashcard, FlashcardCreate, FlashcardUpdate, FlashcardReview, | |
| FlashcardGenerateRequest, | |
| Quiz, QuizCreate, QuizGenerateRequest, QuizSubmission, QuizResult, QuizHistory, | |
| MasteryLevel | |
| ) | |
| # ===== NOTEBOOK ROUTES ===== | |
| async def create_notebook_entry(entry_data: NotebookEntryCreate): | |
| """Create a new notebook entry""" | |
| try: | |
| entry = studio_manager.create_notebook_entry(entry_data) | |
| return entry | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_space_notebook(space_id: str): | |
| """Get or create notebook metadata for a space.""" | |
| try: | |
| space = spaces_manager.get_space(space_id) | |
| space_name = space['name'] if space else space_id | |
| notebook = studio_manager.ensure_space_notebook(space_id, space_name) | |
| return notebook | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def add_chat_to_notebook(request: ChatToNotebookRequest): | |
| """Add a chat question/answer pair into a space notebook.""" | |
| try: | |
| space = spaces_manager.get_space(request.space_id) | |
| resolved_space_name = request.space_name or (space['name'] if space else request.space_id) | |
| entry = studio_manager.create_notebook_entry_from_chat( | |
| space_id=request.space_id, | |
| question=request.question, | |
| answer=request.answer, | |
| chat_id=request.chat_id, | |
| assistant_timestamp=request.assistant_timestamp, | |
| tags=request.tags, | |
| space_name=resolved_space_name | |
| ) | |
| return entry | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def list_notebook_entries(space_id: Optional[str] = None): | |
| """List all notebook entries, optionally filtered by space""" | |
| try: | |
| entries = studio_manager.list_notebook_entries(space_id) | |
| return entries | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_notebook_entry(entry_id: str): | |
| """Get a single notebook entry""" | |
| entry = studio_manager.get_notebook_entry(entry_id) | |
| if not entry: | |
| raise HTTPException(status_code=404, detail="Notebook entry not found") | |
| return entry | |
| async def update_notebook_entry(entry_id: str, update_data: NotebookEntryUpdate): | |
| """Update a notebook entry""" | |
| entry = studio_manager.update_notebook_entry(entry_id, update_data) | |
| if not entry: | |
| raise HTTPException(status_code=404, detail="Notebook entry not found") | |
| return entry | |
| async def delete_notebook_entry(entry_id: str): | |
| """Delete a notebook entry""" | |
| success = studio_manager.delete_notebook_entry(entry_id) | |
| if not success: | |
| raise HTTPException(status_code=404, detail="Notebook entry not found") | |
| return {"status": "success", "message": "Notebook entry deleted"} | |
| # ===== FLASHCARD ROUTES ===== | |
| async def create_flashcard(card_data: FlashcardCreate): | |
| """Create a new flashcard""" | |
| try: | |
| card = studio_manager.create_flashcard(card_data) | |
| return card | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def list_flashcards( | |
| space_id: Optional[str] = None, | |
| mastery: Optional[MasteryLevel] = None | |
| ): | |
| """List all flashcards, optionally filtered""" | |
| try: | |
| cards = studio_manager.list_flashcards(space_id, mastery) | |
| return cards | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_flashcard(card_id: str): | |
| """Get a single flashcard""" | |
| card = studio_manager.get_flashcard(card_id) | |
| if not card: | |
| raise HTTPException(status_code=404, detail="Flashcard not found") | |
| return card | |
| async def update_flashcard(card_id: str, update_data: FlashcardUpdate): | |
| """Update a flashcard""" | |
| card = studio_manager.update_flashcard(card_id, update_data) | |
| if not card: | |
| raise HTTPException(status_code=404, detail="Flashcard not found") | |
| return card | |
| async def review_flashcard(card_id: str, review: FlashcardReview): | |
| """Record a flashcard review""" | |
| card = studio_manager.review_flashcard(card_id, review) | |
| if not card: | |
| raise HTTPException(status_code=404, detail="Flashcard not found") | |
| return card | |
| async def delete_flashcard(card_id: str): | |
| """Delete a flashcard""" | |
| success = studio_manager.delete_flashcard(card_id) | |
| if not success: | |
| raise HTTPException(status_code=404, detail="Flashcard not found") | |
| return {"status": "success", "message": "Flashcard deleted"} | |
| async def generate_flashcards(request: FlashcardGenerateRequest): | |
| """Generate flashcards from content using LLM""" | |
| global studio_generator | |
| if not studio_generator: | |
| raise HTTPException(status_code=503, detail="LLM not initialized") | |
| try: | |
| cards = await studio_generator.generate_flashcards(request) | |
| return cards | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # ===== QUIZ ROUTES ===== | |
| async def create_quiz(quiz_data: QuizCreate): | |
| """Create a new quiz""" | |
| try: | |
| quiz = studio_manager.create_quiz(quiz_data) | |
| return quiz | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def list_quizzes(space_id: Optional[str] = None): | |
| """List all quizzes, optionally filtered by space""" | |
| try: | |
| quizzes = studio_manager.list_quizzes(space_id) | |
| return quizzes | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_quiz(quiz_id: str): | |
| """Get a quiz by ID""" | |
| quiz = studio_manager.get_quiz(quiz_id) | |
| if not quiz: | |
| raise HTTPException(status_code=404, detail="Quiz not found") | |
| return quiz | |
| async def delete_quiz(quiz_id: str): | |
| """Delete a quiz""" | |
| success = studio_manager.delete_quiz(quiz_id) | |
| if not success: | |
| raise HTTPException(status_code=404, detail="Quiz not found") | |
| return {"status": "success", "message": "Quiz deleted"} | |
| async def generate_quiz(request: QuizGenerateRequest): | |
| """Generate a quiz from content using LLM""" | |
| global studio_generator | |
| if not studio_generator: | |
| raise HTTPException(status_code=503, detail="LLM not initialized") | |
| try: | |
| quiz = await studio_generator.generate_quiz(request) | |
| if not quiz: | |
| raise HTTPException(status_code=500, detail="Failed to generate quiz") | |
| return quiz | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def submit_quiz(quiz_id: str, submission: QuizSubmission): | |
| """Submit quiz answers and get results""" | |
| try: | |
| result = studio_manager.submit_quiz(quiz_id, submission.answers) | |
| if not result: | |
| raise HTTPException(status_code=404, detail="Quiz not found") | |
| return result | |
| except Exception as e: | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| async def get_quiz_history(quiz_id: str): | |
| """Get quiz attempt history""" | |
| try: | |
| history = studio_manager.get_quiz_history(quiz_id) | |
| if not history: | |
| raise HTTPException(status_code=404, detail="Quiz not found") | |
| return history | |
| except HTTPException as he: | |
| # If the error is already an HTTPException (like the missing API key error), pass it through directly | |
| raise he | |
| except Exception as e: | |
| # For all other crashes, print the actual traceback to the terminal so you can see what broke | |
| import traceback | |
| traceback.print_exc() | |
| raise HTTPException(status_code=500, detail=str(e)) | |
| # ==================== Run Server ==================== | |
| if __name__ == "__main__": | |
| import uvicorn | |
| uvicorn.run(app, host="0.0.0.0", port=8000, log_level="error") | |