File size: 7,715 Bytes
7c6ffa6 6515ef9 7c6ffa6 3bcdb36 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | """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
|