| """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(), |
| ) |
|
|