from fastapi import FastAPI, HTTPException, Query, Path from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from backend.utils import generate_completions from backend import config from backend.db import db from backend.db_init import db_initializer from backend.content_generator import content_generator from backend.db_cache import api_cache from typing import Union, List, Literal, Optional from datetime import datetime import logging import json logging.basicConfig(level=logging.INFO) app = FastAPI(title="AI Language Tutor API", version="2.0.0") # Add CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) class MetadataRequest(BaseModel): query: str user_id: Optional[int] = None class GenerationRequest(BaseModel): user_id: int query: Union[str, List[dict]] native_language: Optional[str] = None target_language: Optional[str] = None proficiency: Optional[str] = None @app.on_event("startup") async def startup_event(): """Initialize database on startup with comprehensive checks""" logging.info("Starting database initialization...") # Initialize database with health checks init_result = await db_initializer.initialize_database() if init_result["success"]: logging.info(f"Database initialization successful: {init_result['action_taken']}") # Log database statistics health = init_result["health_check"] if health.get("record_count"): logging.info(f"Database records: {health['record_count']}") else: logging.error(f"Database initialization failed: {init_result['errors']}") # Try to repair logging.info("Attempting database repair...") repair_result = await db_initializer.repair_database() if repair_result["success"]: logging.info("Database repair successful") else: logging.error(f"Database repair failed: {repair_result['errors']}") raise RuntimeError("Failed to initialize database") @app.get("/") async def root(): return {"message": "Welcome to the AI Language Tutor API v2.0!"} @app.get("/health") async def health_check(): """Comprehensive health check including database status""" try: # Check database health db_health = await db_initializer.check_database_health() # Overall health status is_healthy = ( db_health["database_exists"] and db_health["schema_loaded"] and db_health["can_write"] ) return JSONResponse( content={ "status": "healthy" if is_healthy else "unhealthy", "api_version": "2.0.0", "database": db_health, "timestamp": datetime.now().isoformat() }, status_code=200 if is_healthy else 503 ) except ValueError as ve: logging.error(f"Invalid input: {ve}") raise HTTPException(status_code=400, detail=str(ve)) except Exception as e: return JSONResponse( content={ "status": "error", "error": str(e), "timestamp": datetime.now().isoformat() }, status_code=500 ) @app.post("/admin/database/repair") async def repair_database(): """Repair database issues (admin endpoint)""" try: repair_result = await db_initializer.repair_database() return JSONResponse( content={ "success": repair_result["success"], "repairs_attempted": repair_result["repairs_attempted"], "errors": repair_result["errors"], "timestamp": datetime.now().isoformat() }, status_code=200 if repair_result["success"] else 500 ) except Exception as e: return JSONResponse( content={ "success": False, "error": str(e), "timestamp": datetime.now().isoformat() }, status_code=500 ) @app.post("/admin/database/recreate") async def recreate_database(): """Recreate database from scratch (admin endpoint)""" try: init_result = await db_initializer.initialize_database(force_recreate=True) return JSONResponse( content={ "success": init_result["success"], "action_taken": init_result["action_taken"], "health_check": init_result["health_check"], "errors": init_result["errors"], "timestamp": datetime.now().isoformat() }, status_code=200 if init_result["success"] else 500 ) except Exception as e: return JSONResponse( content={ "success": False, "error": str(e), "timestamp": datetime.now().isoformat() }, status_code=500 ) # ========== POST ENDPOINTS (Generation) ========== @app.post("/extract/metadata") async def extract_metadata(data: MetadataRequest): """Extract language learning metadata from user query""" logging.info(f"Extracting metadata for query: {data.query[:50]}...") try: # Generate metadata using AI, with caching (include user context) metadata_dict = await api_cache.get_or_set( category="metadata", key_text=data.query, coro=generate_completions.get_completions, context={ 'user_id': data.user_id }, prompt=data.query, instructions=config.language_metadata_extraction_prompt ) # Check for existing curriculum first before creating new metadata extraction existing_curriculum = await db.find_existing_curriculum( query=data.query, native_language=metadata_dict['native_language'], target_language=metadata_dict['target_language'], proficiency=metadata_dict['proficiency'], user_id=data.user_id # Use the actual user_id for consistent lookup ) if existing_curriculum: # Found existing curriculum - return it regardless of user logging.info(f"Found existing curriculum for query '{data.query[:50]}...': {existing_curriculum['id']}") return JSONResponse( content={ "message": "Found existing curriculum for your query.", "curriculum_id": existing_curriculum['id'], "status_endpoint": f"/content/status/{existing_curriculum['id']}", "cached": True }, status_code=200 ) # No suitable existing curriculum found, generate new one logging.info(f"No existing curriculum found, generating new one for user {data.user_id}") # Save metadata to database extraction_id = await db.save_metadata_extraction( query=data.query, metadata=metadata_dict, user_id=data.user_id ) # Process extraction (generate curriculum and start content generation) try: processing_result = await content_generator.process_metadata_extraction( extraction_id=extraction_id, query=data.query, metadata=metadata_dict, user_id=data.user_id, generate_content=True, # Automatically generate all content skip_curriculum_lookup=True # Skip lookup since we already did it above ) curriculum_id = processing_result['curriculum_id'] # Update status to generating await db.update_content_generation_status(curriculum_id, 'generating') return JSONResponse( content={ "message": "Content generation has been initiated.", "curriculum_id": curriculum_id, "status_endpoint": f"/content/status/{curriculum_id}", "cached": False }, status_code=202 ) except Exception as content_error: # If content generation fails, update status to failed if 'curriculum_id' in locals(): await db.update_content_generation_status( curriculum_id, 'failed', str(content_error) ) raise content_error except Exception as e: logging.error(f"Error extracting metadata: {e}") raise HTTPException(status_code=500, detail=str(e)) # ========== GET ENDPOINTS (Retrieval) ========== @app.get("/curriculum/{curriculum_id}/metadata") async def get_curriculum_metadata(curriculum_id: str = Path(..., description="Curriculum ID")): """Get metadata for a curriculum""" curriculum = await db.get_curriculum(curriculum_id) if not curriculum: raise HTTPException(status_code=404, detail="Curriculum not found") # Get the full metadata extraction record extraction = await db.get_metadata_extraction(curriculum['metadata_extraction_id']) if not extraction: raise HTTPException(status_code=404, detail="Metadata extraction not found") # Parse JSON fields extraction['metadata'] = json.loads(extraction['metadata_json']) del extraction['metadata_json'] return JSONResponse(content=extraction, status_code=200) @app.get("/curriculum/{curriculum_id}") async def get_curriculum(curriculum_id: str = Path(..., description="Curriculum ID")): """Get curriculum by ID""" curriculum = await db.get_full_curriculum_details(curriculum_id, include_content=False) if not curriculum: raise HTTPException(status_code=404, detail="Curriculum not found") # Get content generation status status = await db.get_curriculum_content_status(curriculum_id) if status: curriculum['content_status'] = status return JSONResponse(content=curriculum, status_code=200) @app.get("/content/status/{curriculum_id}") async def get_content_generation_status(curriculum_id: str = Path(..., description="Curriculum ID")): """Get content generation status for a curriculum""" status = await db.get_content_generation_status(curriculum_id) if not status: raise HTTPException(status_code=404, detail="Curriculum not found") return JSONResponse(content={ "curriculum_id": status['id'], "status": status['content_generation_status'], "error": status['content_generation_error'], "started_at": status['content_generation_started_at'], "completed_at": status['content_generation_completed_at'], "is_content_generated": bool(status['is_content_generated']) }, status_code=200) async def _get_lesson_content_by_type( curriculum_id: str, lesson_index: int, content_type: str ): """Helper to get specific content type for a lesson""" content_list = await db.get_learning_content( curriculum_id=curriculum_id, lesson_index=lesson_index, content_type=content_type ) if not content_list: raise HTTPException( status_code=404, detail=f"{content_type.capitalize()} content not found for lesson {lesson_index}" ) # Assuming one content item per type per lesson content = content_list[0] try: parsed_content = json.loads(content['content_json']) except json.JSONDecodeError: parsed_content = content['content_json'] return JSONResponse( content={ "curriculum_id": curriculum_id, "lesson_index": lesson_index, "content_type": content_type, "id": content['id'], "lesson_topic": content['lesson_topic'], "content": parsed_content, "created_at": content['created_at'] }, status_code=200 ) @app.get("/curriculum/{curriculum_id}/lesson/{lesson_index}/flashcards") async def get_lesson_flashcards( curriculum_id: str = Path(..., description="Curriculum ID"), lesson_index: int = Path(..., ge=0, le=24, description="Lesson index (0-24)") ): """Get flashcards for a specific lesson""" return await _get_lesson_content_by_type(curriculum_id, lesson_index, "flashcards") @app.get("/curriculum/{curriculum_id}/lesson/{lesson_index}/exercises") async def get_lesson_exercises( curriculum_id: str = Path(..., description="Curriculum ID"), lesson_index: int = Path(..., ge=0, le=24, description="Lesson index (0-24)") ): """Get exercises for a specific lesson""" return await _get_lesson_content_by_type(curriculum_id, lesson_index, "exercises") @app.get("/curriculum/{curriculum_id}/lesson/{lesson_index}/simulation") async def get_lesson_simulation( curriculum_id: str = Path(..., description="Curriculum ID"), lesson_index: int = Path(..., ge=0, le=24, description="Lesson index (0-24)") ): """Get simulation for a specific lesson""" return await _get_lesson_content_by_type(curriculum_id, lesson_index, "simulation") @app.get("/user/{user_id}/metadata") async def get_user_metadata_history( user_id: int = Path(..., description="User ID"), limit: int = Query(20, ge=1, le=100, description="Maximum number of results") ): """Get user's metadata extraction history""" extractions = await db.get_user_metadata_extractions(user_id, limit) # Parse JSON fields for extraction in extractions: extraction['metadata'] = json.loads(extraction['metadata_json']) del extraction['metadata_json'] return JSONResponse( content={ "user_id": user_id, "extractions": extractions, "total": len(extractions) }, status_code=200 ) @app.get("/user/{user_id}/curricula") async def get_user_curricula( user_id: int = Path(..., description="User ID"), limit: int = Query(20, ge=1, le=100, description="Maximum number of results") ): """Get user's curricula""" curricula = await db.get_user_curricula(user_id, limit) # Parse JSON fields and get content status for curriculum in curricula: curriculum['curriculum'] = json.loads(curriculum['curriculum_json']) del curriculum['curriculum_json'] # Get content status status = await db.get_curriculum_content_status(curriculum['id']) if status: curriculum['content_status'] = status return JSONResponse( content={ "user_id": user_id, "curricula": curricula, "total": len(curricula) }, status_code=200 ) @app.get("/user/{user_id}/journeys") async def get_user_learning_journeys( user_id: int = Path(..., description="User ID"), limit: int = Query(20, ge=1, le=100, description="Maximum number of results") ): """Get user's complete learning journeys (metadata + curriculum info)""" journeys = await db.get_user_learning_journeys(user_id, limit) return JSONResponse( content={ "user_id": user_id, "journeys": journeys, "total": len(journeys) }, status_code=200 ) @app.get("/search/curricula") async def search_curricula( native_language: str = Query(..., description="Native language"), target_language: str = Query(..., description="Target language"), proficiency: Optional[str] = Query(None, description="Proficiency level"), limit: int = Query(10, ge=1, le=50, description="Maximum number of results") ): """Search for existing curricula by language combination""" curricula = await db.search_curricula_by_languages( native_language=native_language, target_language=target_language, proficiency=proficiency, limit=limit ) # Parse JSON fields for curriculum in curricula: curriculum['curriculum'] = json.loads(curriculum['curriculum_json']) del curriculum['curriculum_json'] return JSONResponse( content={ "search_params": { "native_language": native_language, "target_language": target_language, "proficiency": proficiency }, "curricula": curricula, "total": len(curricula) }, status_code=200 )