"""Learning Engine routes: Course Builder, Exam Coach Session, and Learn Anything lessons.""" from __future__ import annotations from typing import Any from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session from app.core.auth import require_user from app.core.database import get_db from app.models.document import Document from app.models.user import User from app.schemas.learning_engine import ( CoursePlanRequest, CoursePlanResult, ExamCoachSessionRequest, ExamCoachSessionResult, ) from app.services.retrieval import chunks_to_context, retrieve_relevant_chunks from app.services.course_builder import build_course_plan, course_plan_to_output from app.services.exam_coach_session import ( build_exam_coach_session, exam_coach_session_to_output, ) router = APIRouter() def _load_source_context( db: Session, user_id: str, source_ids: list[str] | None, ) -> tuple[str, list[str]]: """Load and merge source context from document IDs.""" if not source_ids: return "", [] context_parts: list[str] = [] source_titles: list[str] = [] skipped: list[str] = [] for sid in source_ids: document = db.get(Document, sid) if document is None or document.user_id != user_id: skipped.append(sid) continue if document.status not in {"ready"}: skipped.append(sid) continue chunks = retrieve_relevant_chunks( db=db, document_id=document.id, query="course content syllabus topics", limit=4, user_id=user_id, ) ctx = chunks_to_context(chunks, fallback_text=document.extracted_text, max_chars=3600) if ctx and ctx.strip(): context_parts.append(ctx) source_titles.append(document.title) merged = "\n\n---\n\n".join(context_parts) if context_parts else "" return merged[:4000], source_titles @router.post( "/course-plan", response_model=CoursePlanResult, summary="Generate a course plan", description=( "Generate a structured course plan from a raw request. " "Supports school topics, degree semesters, skill courses, and playlist-based learning." ), ) def generate_course_plan( payload: CoursePlanRequest, db: Session = Depends(get_db), current_user: User = Depends(require_user), ) -> CoursePlanResult: # Load source context if source_ids provided source_context = payload.source_context or "" if payload.source_ids: loaded_ctx, _ = _load_source_context(db, current_user.id, payload.source_ids) if loaded_ctx: source_context = loaded_ctx result = build_course_plan( raw_request=payload.raw_request, source_ids=payload.source_ids or [], source_context=source_context, playlist_metadata=payload.playlist_metadata, current_level=payload.current_level or "", weak_topics=payload.weak_topics or [], class_level=payload.class_level or "", syllabus=payload.syllabus or "", board=payload.board or "", semester=payload.semester or "", degree=payload.degree or "", goal=payload.goal or "", time_available=payload.time_available or "", daily_study_time=payload.daily_study_time or "", subject=payload.subject or "", chapter=payload.chapter or "", topic=payload.topic or "", exam_date=payload.exam_date or "", ) output = course_plan_to_output(result) return CoursePlanResult(**output) @router.post( "/exam-coach-session", response_model=ExamCoachSessionResult, summary="Generate a daily exam coach session", description=( "Generate a structured daily exam preparation session with study blocks, " "concept teaching, derivation/numerical tasks, PYQ practice, answer writing, " "revision, and next-day planning." ), ) def generate_exam_coach_session( payload: ExamCoachSessionRequest, db: Session = Depends(get_db), current_user: User = Depends(require_user), ) -> ExamCoachSessionResult: # Load source context if source_ids provided source_context = payload.source_context or "" if payload.source_ids: loaded_ctx, _ = _load_source_context(db, current_user.id, payload.source_ids) if loaded_ctx: source_context = loaded_ctx # Load weak topics from profile if available weak_topics = payload.weak_topics or [] result = build_exam_coach_session( class_level=payload.class_level or "", board=payload.board or "", university=payload.university or "", subject=payload.subject or "", chapter=payload.chapter or "", topic=payload.topic or "", exam_date=payload.exam_date, daily_time=payload.daily_time, goal=payload.goal or "", weak_topics=weak_topics, pyq_data=payload.pyq_data or [], source_context=source_context, source_ids=payload.source_ids, ) output = exam_coach_session_to_output(result) return ExamCoachSessionResult(**output) class LearnLessonRequest(BaseModel): topic: str = Field(..., description="The overall thing being learned, e.g. 'Machine Learning in 6 months'.") lesson_title: str = Field(..., description="The specific lesson to teach, e.g. 'What is a machine learning model?'.") level: str = Field(default="beginner") medium: str = Field(default="english", description="'english' or 'malayalam' (Manglish teaching voice).") source_ids: list[str] | None = None @router.post( "/learn-lesson", summary="Build or fetch a playable Learn Anything lesson", description=( "Returns a browser-playable lesson manifest (narration beats with synced board, " "per-beat audio, notes, and flashcards). Generated once per unique lesson and cached " "by content hash, so repeat requests are free and instant." ), ) def generate_learn_lesson( payload: LearnLessonRequest, db: Session = Depends(get_db), current_user: User = Depends(require_user), ) -> dict[str, Any]: # Imported lazily: authoring pulls in TTS/LLM clients that should not load # for every learning-engine request. from app.services.learn_lesson_builder import LessonBuildError, build_lesson source_context = "" if payload.source_ids: loaded_ctx, _ = _load_source_context(db, current_user.id, payload.source_ids) source_context = loaded_ctx or "" try: return build_lesson( topic=payload.topic, lesson_title=payload.lesson_title, level=payload.level, medium=payload.medium, context=source_context, user_id=current_user.id, ) except LessonBuildError as exc: raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc except Exception as exc: # Surface a clear student-safe message instead of the global 500 # "internal error" envelope (common when cache paths fail on HF). import logging logging.getLogger(__name__).exception( "Unhandled learn-lesson failure for topic=%s lesson=%s", payload.topic, payload.lesson_title, ) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail=( f"Could not prepare this class ({type(exc).__name__}). " "Your plan is still saved — retry in a moment." ), ) from exc