headless_lms_mcp/handlers/exercise/
list.rs1use 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
29pub 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#[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 chapter_id: Option<Uuid>,
58 order: usize,
60 task_types: Vec<&'a str>,
61 score_maximum: i32,
62 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
73fn 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
116async 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}