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