"""Build the Learning Engine's authoritative, server-side planning context.""" from __future__ import annotations from dataclasses import dataclass from datetime import date, datetime, timedelta, timezone from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session from app.models.learn_anything_roadmap import LearnAnythingRoadmap from app.models.learning_state import ( Chapter, DailyPlan, DailyTask, LearningEvent, LessonProgress, RepairItem, RevisionItem, StudentProfileState, StudyFollowUp, StudyPlan, Subject, TopicMastery, ) from app.services.content_manifest import ContentManifest, load_content_manifest def aware(value: datetime | None) -> datetime | None: if value is None: return None return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc) @dataclass(frozen=True, slots=True) class ProfileContext: class_level: str board: str exam_date: date | None goal: str daily_minutes: int preferred_time: str | None preferences: dict[str, Any] @dataclass(frozen=True, slots=True) class SubjectContext: id: str name: str priority: int @dataclass(frozen=True, slots=True) class ChapterContext: id: str subject_id: str catalog_id: str title: str order_index: int importance: float status: str curated: bool @dataclass(frozen=True, slots=True) class LessonContext: id: str chapter_id: str mission_id: str status: str progress_percent: int current_step: int last_seen_at: datetime @dataclass(frozen=True, slots=True) class MasteryContext: topic_key: str topic_label: str subject_id: str | None chapter_id: str | None score: float confidence: float attempts_count: int last_result: str | None next_review_at: datetime | None evidence: dict[str, Any] @dataclass(frozen=True, slots=True) class RepairContext: id: str subject_id: str | None chapter_id: str | None concept_key: str concept_label: str mission_id: str | None error_category: str diagnosis: str priority: float estimated_minutes: int failed_attempts: int created_at: datetime @dataclass(frozen=True, slots=True) class RevisionContext: id: str subject_id: str | None chapter_id: str | None mission_id: str | None topic_key: str | None title: str source_kind: str source_ref: str | None status: str due_at: datetime | None metadata: dict[str, Any] created_at: datetime @dataclass(frozen=True, slots=True) class ExistingTaskContext: id: str daily_plan_id: str | None subject_id: str | None chapter_id: str | None task_type: str title: str scheduled_for: datetime duration_minutes: int priority: float href: str | None mission_id: str | None metadata: dict[str, Any] @dataclass(frozen=True, slots=True) class RoadmapContext: row_id: str roadmap_id: str topic: str module_title: str | None task_key: str task_title: str estimated_minutes: int updated_at: datetime @dataclass(frozen=True, slots=True) class FollowUpContext: id: str chat_session_id: str kind: str title: str subject: str | None chapter: str | None topic: str | None due_at: datetime | None estimated_minutes: int source_ref: str | None target_data: dict[str, Any] @dataclass(frozen=True, slots=True) class PlanHistoryContext: date: date generation: int planned_minutes: int completed_minutes: int subject_minutes: tuple[tuple[str, int], ...] @dataclass(frozen=True, slots=True) class LearningContext: user_id: str now: datetime today: date profile: ProfileContext study_plan_id: str subjects: tuple[SubjectContext, ...] chapters: tuple[ChapterContext, ...] lessons: tuple[LessonContext, ...] mastery: tuple[MasteryContext, ...] repairs: tuple[RepairContext, ...] revisions: tuple[RevisionContext, ...] unfinished_tasks: tuple[ExistingTaskContext, ...] roadmaps: tuple[RoadmapContext, ...] followups: tuple[FollowUpContext, ...] recent_plan_history: tuple[PlanHistoryContext, ...] recent_event_types: tuple[str, ...] content: ContentManifest @property def days_to_exam(self) -> int | None: return (self.profile.exam_date - self.today).days if self.profile.exam_date else None def _roadmap_context(row: LearnAnythingRoadmap) -> RoadmapContext | None: data = row.data if isinstance(row.data, dict) else {} modules = data.get("weeklyModules") statuses = data.get("topicStatus") if not isinstance(modules, list): return None statuses = statuses if isinstance(statuses, dict) else {} for module_index, module in enumerate(modules): if not isinstance(module, dict): continue tasks = module.get("tasks") if not isinstance(tasks, list): continue week = module.get("week", module_index + 1) for task_index, raw_title in enumerate(tasks): title = str(raw_title).strip() if not title: continue key = f"Week {week}::{task_index}" if statuses.get(key) in {"covered", "mastered"}: continue today_lesson = data.get("todayLesson") minutes = ( int(today_lesson.get("minutes") or 15) if isinstance(today_lesson, dict) else 15 ) return RoadmapContext( row_id=row.id, roadmap_id=row.roadmap_id, topic=str(data.get("topic") or "Your course"), module_title=str(module.get("title") or "") or None, task_key=key, task_title=title, estimated_minutes=max(5, min(60, minutes)), updated_at=aware(row.updated_at) or datetime.now(timezone.utc), ) return None def load_learning_context( db: Session, *, user_id: str, now: datetime | None = None, ) -> LearningContext | None: current = aware(now) or datetime.now(timezone.utc) profile_row = db.scalar( select(StudentProfileState).where(StudentProfileState.user_id == user_id) ) plan_row = db.scalar( select(StudyPlan) .where(StudyPlan.user_id == user_id, StudyPlan.status == "active") .order_by(StudyPlan.created_at.desc()) ) if profile_row is None or plan_row is None: return None subject_rows = list( db.scalars( select(Subject) .where(Subject.user_id == user_id, Subject.status == "active") .order_by(Subject.priority, Subject.name) ) ) chapter_rows = list(db.scalars(select(Chapter).where(Chapter.user_id == user_id))) lesson_rows = list(db.scalars(select(LessonProgress).where(LessonProgress.user_id == user_id))) mastery_rows = list(db.scalars(select(TopicMastery).where(TopicMastery.user_id == user_id))) repair_rows = list( db.scalars( select(RepairItem) .where(RepairItem.user_id == user_id, RepairItem.status.in_(("open", "escalated"))) .order_by(RepairItem.priority.desc(), RepairItem.created_at) ) ) revision_rows = list( db.scalars( select(RevisionItem) .where(RevisionItem.user_id == user_id, RevisionItem.status.in_(("pending", "due"))) .order_by(RevisionItem.due_at, RevisionItem.created_at) ) ) task_rows = list( db.scalars( select(DailyTask) .where( DailyTask.user_id == user_id, DailyTask.study_plan_id == plan_row.id, DailyTask.daily_plan_id.is_(None), DailyTask.status == "pending", ) .order_by(DailyTask.scheduled_for, DailyTask.priority.desc()) ) ) roadmap_rows = list( db.scalars( select(LearnAnythingRoadmap) .where(LearnAnythingRoadmap.user_id == user_id) .order_by(LearnAnythingRoadmap.updated_at.desc()) ) ) followup_rows = list( db.scalars( select(StudyFollowUp) .where(StudyFollowUp.user_id == user_id, StudyFollowUp.status == "open") .order_by(StudyFollowUp.due_at, StudyFollowUp.created_at) ) ) history_rows = list( db.scalars( select(DailyPlan) .where( DailyPlan.user_id == user_id, DailyPlan.plan_date < current.date(), DailyPlan.plan_date >= current.date() - timedelta(days=7), ) .order_by(DailyPlan.plan_date.desc()) ) ) recent_events = list( db.scalars( select(LearningEvent) .where( LearningEvent.user_id == user_id, LearningEvent.occurred_at >= current - timedelta(days=14), ) .order_by(LearningEvent.occurred_at.desc()) .limit(100) ) ) history: list[PlanHistoryContext] = [] for row in history_rows: subject_minutes: dict[str, int] = {} history_tasks = db.scalars( select(DailyTask).where(DailyTask.daily_plan_id == row.id) ) for task in history_tasks: if task.subject_id: subject_minutes[task.subject_id] = subject_minutes.get(task.subject_id, 0) + task.duration_minutes history.append( PlanHistoryContext( date=row.plan_date, generation=row.generation, planned_minutes=row.planned_minutes, completed_minutes=row.completed_minutes, subject_minutes=tuple(sorted(subject_minutes.items())), ) ) roadmaps = tuple(item for row in roadmap_rows if (item := _roadmap_context(row)) is not None) return LearningContext( user_id=user_id, now=current, today=current.date(), profile=ProfileContext( class_level=profile_row.class_level, board=profile_row.board, exam_date=profile_row.exam_date, goal=profile_row.goal, daily_minutes=max(15, profile_row.daily_minutes), preferred_time=profile_row.preferred_time, preferences=dict(profile_row.preferences or {}), ), study_plan_id=plan_row.id, subjects=tuple(SubjectContext(row.id, row.name, row.priority) for row in subject_rows), chapters=tuple( ChapterContext( row.id, row.subject_id, row.catalog_id, row.title, row.order_index, row.importance, row.status, row.curated, ) for row in chapter_rows ), lessons=tuple( LessonContext( row.id, row.chapter_id, row.mission_id, row.status, row.progress_percent, row.current_step, aware(row.last_seen_at) or current, ) for row in lesson_rows ), mastery=tuple( MasteryContext( row.topic_key, row.topic_label, row.subject_id, row.chapter_id, row.score, row.confidence, row.attempts_count, row.last_result, aware(row.next_review_at), dict(row.evidence or {}), ) for row in mastery_rows ), repairs=tuple( RepairContext( row.id, row.subject_id, row.chapter_id, row.concept_key, row.concept_label, row.mission_id, row.error_category, row.diagnosis, row.priority, row.estimated_minutes, row.failed_attempts, aware(row.created_at) or current, ) for row in repair_rows ), revisions=tuple( RevisionContext( row.id, row.subject_id, row.chapter_id, row.mission_id, row.topic_key, row.title, row.source_kind, row.source_ref, row.status, aware(row.due_at), dict(row.item_data or {}), aware(row.created_at) or current, ) for row in revision_rows ), unfinished_tasks=tuple( ExistingTaskContext( row.id, row.daily_plan_id, row.subject_id, row.chapter_id, row.task_type, row.title, aware(row.scheduled_for) or current, row.duration_minutes, row.priority, row.href, row.mission_id, dict(row.task_metadata or {}), ) for row in task_rows ), roadmaps=roadmaps, followups=tuple( FollowUpContext( row.id, row.chat_session_id, row.kind, row.title, row.subject, row.chapter, row.topic, aware(row.due_at), row.estimated_minutes, row.source_ref, dict(row.target_data or {}), ) for row in followup_rows ), recent_plan_history=tuple(history), recent_event_types=tuple(row.event_type for row in recent_events), content=load_content_manifest(), )