Skip to main content

headless_lms_mcp/handlers/exercise/
list.rs

1//! `list_exercises`: every exercise of a course, chapter or page with the facts course-wide checks
2//! compare, without reading each exercise.
3
4use std::collections::HashMap;
5
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use headless_lms_models::{
9    exercises,
10    library::{
11        agent_changesets::{
12            course_state::{CourseState, CourseView, ExerciseReviewConfig},
13            risk::load_student_activity,
14        },
15        entity_snapshots::{self, PageSnapshot},
16    },
17    pages::CmsPageExercise,
18};
19use serde::Serialize;
20use serde_json::json;
21use uuid::Uuid;
22
23use crate::access::{connection, open_course, state_of};
24use crate::engine::read::ReadState;
25use crate::state::McpState;
26use crate::tools::args::{IdArgument, ListExercisesArgs};
27use crate::tools::{RequestContext, ToolError, ToolHandler, ToolOutcome, ToolResult};
28
29/// Handler of the `list_exercises` tool.
30pub struct ListExercises;
31
32const DEFAULT_LIMIT: usize = 100;
33const MAX_LIMIT: usize = 300;
34
35#[derive(Serialize)]
36struct ListedPage<'a> {
37    id: Uuid,
38    title: &'a str,
39    hidden: bool,
40}
41
42/// Which review settings an exercise with reviews uses.
43#[derive(Serialize, PartialEq)]
44#[serde(rename_all = "snake_case")]
45enum ReviewSource {
46    Own,
47    Default,
48    None,
49}
50
51#[derive(Serialize)]
52struct ListedExercise<'a> {
53    id: Uuid,
54    name: &'a str,
55    page: ListedPage<'a>,
56    /// The chapter whose deadline applies without an own one.
57    chapter_id: Option<Uuid>,
58    /// 1-based position among the page's exercises.
59    order: usize,
60    task_types: Vec<&'a str>,
61    score_maximum: i32,
62    /// Tries per slide; `None` for unlimited.
63    tries: Option<i32>,
64    deadline_own: Option<DateTime<Utc>>,
65    deadline_effective: Option<DateTime<Utc>>,
66    review_source: ReviewSource,
67    #[serde(skip_serializing_if = "std::ops::Not::not")]
68    has_submissions: bool,
69    copied_from: Option<Uuid>,
70    exercise_language_group_id: Option<Uuid>,
71}
72
73/// The pages `scope` covers in course order: chapters by module and number, each chapter's pages
74/// in order, then the top-level pages.
75fn pages_in_scope(
76    state: &CourseState,
77    chapter: Option<Uuid>,
78    page: Option<Uuid>,
79) -> Result<Vec<Uuid>, ToolError> {
80    let course_id = state.course_id();
81    if let Some(page_id) = page {
82        return match state.page(page_id) {
83            Some(page) => Ok(vec![page.id]),
84            None => Err(ToolError::NotFound {
85                message: format!("course {course_id} has no page {page_id} in this state"),
86            }),
87        };
88    }
89    let chapters: Vec<Uuid> = match chapter {
90        Some(chapter_id) if state.chapters.iter().any(|c| c.id == chapter_id) => {
91            vec![chapter_id]
92        }
93        Some(chapter_id) => {
94            return Err(ToolError::NotFound {
95                message: format!("course {course_id} has no chapter {chapter_id} in this state"),
96            });
97        }
98        None => state
99            .modules_in_order()
100            .into_iter()
101            .flat_map(|module| state.chapters_in_order(module.id))
102            .map(|chapter| chapter.id)
103            .collect(),
104    };
105    let mut pages: Vec<Uuid> = chapters
106        .into_iter()
107        .flat_map(|chapter| state.pages_in_order(Some(chapter)))
108        .map(|page| page.id)
109        .collect();
110    if chapter.is_none() {
111        pages.extend(state.pages_in_order(None).into_iter().map(|page| page.id));
112    }
113    Ok(pages)
114}
115
116/// The page snapshots of `page_ids` in the read state, by id.
117async fn page_snapshots(
118    state: &McpState,
119    view: &CourseView,
120    read_state: ReadState,
121    page_ids: &[Uuid],
122) -> Result<HashMap<Uuid, PageSnapshot>, ToolError> {
123    let mut conn = connection(state).await?;
124    Ok(match read_state {
125        ReadState::Staged => view.current_page_snapshots(&mut conn, page_ids).await?,
126        ReadState::Published => entity_snapshots::load_pages(&mut conn, page_ids).await?,
127    })
128}
129
130fn review_source(exercise: &CmsPageExercise) -> ReviewSource {
131    if !exercise.needs_peer_review && !exercise.needs_self_review {
132        return ReviewSource::None;
133    }
134    match ExerciseReviewConfig::of_exercise(exercise) {
135        ExerciseReviewConfig::CourseDefault => ReviewSource::Default,
136        ExerciseReviewConfig::Own { .. } => ReviewSource::Own,
137        ExerciseReviewConfig::Unset => ReviewSource::None,
138    }
139}
140
141#[async_trait(?Send)]
142impl ToolHandler<McpState> for ListExercises {
143    type Args = ListExercisesArgs;
144
145    async fn call(
146        &self,
147        state: &McpState,
148        ctx: &RequestContext,
149        args: ListExercisesArgs,
150    ) -> Result<ToolOutcome, ToolError> {
151        let limit = args.limit.map_or(DEFAULT_LIMIT, |l| l as usize);
152        if !(1..=MAX_LIMIT).contains(&limit) {
153            return Err(ToolError::InvalidArguments {
154                message: format!("limit is 1 to {MAX_LIMIT}"),
155            });
156        }
157        let offset = args.offset.unwrap_or(0) as usize;
158        let read_state = args.state.unwrap_or_default();
159        let view = open_course(state, ctx, args.course.id()).await?;
160        let course_state = state_of(&view, read_state);
161        let page_ids = pages_in_scope(
162            course_state,
163            args.scope.and_then(|s| s.chapter).map(IdArgument::id),
164            args.scope.and_then(|s| s.page).map(IdArgument::id),
165        )?;
166        let listed: Vec<(Uuid, Uuid, usize)> = page_ids
167            .iter()
168            .filter_map(|page_id| course_state.page(*page_id))
169            .flat_map(|page| {
170                page.content
171                    .iter()
172                    .filter_map(|block| block.exercise_id())
173                    .enumerate()
174                    .map(|(index, exercise_id)| (exercise_id, page.id, index + 1))
175            })
176            .collect();
177        let total = listed.len();
178        let window: Vec<(Uuid, Uuid, usize)> =
179            listed.into_iter().skip(offset).take(limit).collect();
180        let window_pages: Vec<Uuid> = window.iter().map(|(_, page, _)| *page).collect();
181        let snapshots = page_snapshots(state, &view, read_state, &window_pages).await?;
182        let window_exercises: Vec<Uuid> = window.iter().map(|(exercise, _, _)| *exercise).collect();
183        let mut conn = connection(state).await?;
184        let rows: HashMap<Uuid, exercises::Exercise> =
185            exercises::get_course_exercises_by_ids(&mut conn, view.course.id, &window_exercises)
186                .await?
187                .into_iter()
188                .map(|exercise| (exercise.id, exercise))
189                .collect();
190        let activity = load_student_activity(&mut conn, view.course.id).await?;
191        drop(conn);
192
193        let mut items = Vec::with_capacity(window.len());
194        for (exercise_id, page_id, order) in &window {
195            let (Some(page), Some(snapshot)) =
196                (course_state.page(*page_id), snapshots.get(page_id))
197            else {
198                continue;
199            };
200            let Some(exercise) = snapshot.exercises.iter().find(|e| e.id == *exercise_id) else {
201                continue;
202            };
203            let slide_ids: Vec<Uuid> = snapshot
204                .exercise_slides
205                .iter()
206                .filter(|slide| slide.exercise_id == exercise.id)
207                .map(|slide| slide.id)
208                .collect();
209            let row = rows.get(exercise_id);
210            items.push(ListedExercise {
211                id: exercise.id,
212                name: &exercise.name,
213                page: ListedPage {
214                    id: page.id,
215                    title: &page.title,
216                    hidden: page.hidden,
217                },
218                chapter_id: course_state
219                    .exercise_deadlines
220                    .get(exercise_id)
221                    .map_or(page.chapter_id, |deadline| deadline.chapter_id),
222                order: *order,
223                task_types: snapshot
224                    .exercise_tasks
225                    .iter()
226                    .filter(|task| slide_ids.contains(&task.exercise_slide_id))
227                    .map(|task| task.exercise_type.as_str())
228                    .collect(),
229                score_maximum: exercise.score_maximum,
230                tries: exercise
231                    .limit_number_of_tries
232                    .then_some(exercise.max_tries_per_slide)
233                    .flatten(),
234                deadline_own: exercise.deadline,
235                deadline_effective: course_state.effective_exercise_deadline(*exercise_id),
236                review_source: review_source(exercise),
237                has_submissions: activity.exercises_with_submissions.contains(exercise_id),
238                copied_from: row.and_then(|row| row.copied_from),
239                exercise_language_group_id: row.and_then(|row| row.exercise_language_group_id),
240            });
241        }
242        let next_offset = offset + window.len();
243        let has_more = next_offset < total;
244        let mut result = json!({
245            "total": total,
246            "exercises": items,
247            "has_more": has_more,
248        });
249        if has_more {
250            result["next"] = json!({"offset": next_offset, "limit": limit});
251        }
252        Ok(ToolResult::success(result).into())
253    }
254}