| """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: |
| |
| 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: |
| |
| 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 |
|
|
| |
| 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]: |
| |
| |
| 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: |
| |
| |
| 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 |
|
|