DocDoeAI / app /services /planning_context.py
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
d5ee82b
Raw
History Blame Contribute Delete
14.1 kB
"""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(),
)