DocDoeAI / app /routes /learning_engine.py
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
6515ef9
Raw
History Blame Contribute Delete
7.72 kB
"""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