Skip to main content

headless_lms_mcp/handlers/exercise/
get.rs

1//! `get_exercise`: an exercise's facts and reads of its instructions and task specs.
2
3use std::collections::{HashMap, hash_map::Entry};
4
5use async_trait::async_trait;
6use headless_lms_models::{
7    exercise_slide_submissions,
8    library::{
9        agent_changesets::course_state::{CourseView, ExerciseReviewConfig},
10        entity_snapshots,
11    },
12    pages::CmsPageExercise,
13    peer_or_self_review_configs::CmsPeerOrSelfReviewConfig,
14};
15use serde_json::{Value, json};
16use uuid::Uuid;
17
18use super::paths::{part_list, part_prefix, prefix_paths, split_exercise_path};
19use super::specs::{TaskSpecs, load_task_specs, parse_spec_path, read_specs};
20use super::{ExerciseDeadline, REVIEW_INSTRUCTIONS, missing_exercise, open_course_with_exercise};
21use crate::access::{connection, state_of};
22use crate::engine::read::{ReadOptions, ReadResult, ReadState, Selector, read_page};
23use crate::engine::{EditError, EditErrorCode};
24use crate::instruction_lists;
25use crate::state::McpState;
26use crate::tools::args::GetExerciseArgs;
27use crate::tools::{RequestContext, ToolError, ToolHandler, ToolOutcome, ToolResult};
28
29/// Handler of the `get_exercise` tool.
30pub struct GetExercise;
31
32fn read_part(
33    view: &CourseView,
34    exercise_id: Uuid,
35    selector: &Selector,
36    options: &ReadOptions,
37) -> Result<Value, ToolError> {
38    let state = state_of(view, selector.state.unwrap_or_default());
39    let (part, path) = split_exercise_path(selector.path.as_deref().unwrap_or(""))?;
40    let through = selector
41        .through
42        .as_deref()
43        .map(|through| {
44            let (through_part, through) = split_exercise_path(through)?;
45            if through_part != part {
46                return Err(EditError::new(
47                    EditErrorCode::InvalidRange,
48                    "path and through are in different instructions",
49                ));
50            }
51            Ok(through)
52        })
53        .transpose()?;
54    let list = part_list(state, exercise_id, part)?;
55    let document = list
56        .document(state)
57        .ok_or_else(|| missing_exercise(view, exercise_id))??;
58    let inner = Selector {
59        path: Some(path),
60        through,
61        ..selector.clone()
62    };
63    let read: ReadResult = read_page(&document, &inner, options)?;
64    let mut value = serde_json::to_value(read).unwrap_or_default();
65    let prefix = part_prefix(part);
66    prefix_paths(&mut value, &prefix);
67    value["selector"] = json!(selector);
68    Ok(value)
69}
70
71/// The exercise's settings agents can read but not change.
72fn read_only_settings(
73    exercise: &CmsPageExercise,
74    own_config: Option<&CmsPeerOrSelfReviewConfig>,
75    question_count: usize,
76) -> Value {
77    let mut settings = json!({
78        "score_maximum": exercise.score_maximum,
79        "max_tries_per_slide": exercise.max_tries_per_slide,
80        "limit_number_of_tries": exercise.limit_number_of_tries,
81        "teacher_reviews_answer_after_locking": exercise.teacher_reviews_answer_after_locking,
82    });
83    if let Some(config) = own_config {
84        settings["review"] = json!({
85            "peer_reviews_to_give": config.peer_reviews_to_give,
86            "peer_reviews_to_receive": config.peer_reviews_to_receive,
87            "accepting_threshold": config.accepting_threshold,
88            "processing_strategy": config.processing_strategy,
89            "points_are_all_or_nothing": config.points_are_all_or_nothing,
90            "question_count": question_count,
91        });
92    }
93    settings
94}
95
96#[async_trait(?Send)]
97impl ToolHandler<McpState> for GetExercise {
98    type Args = GetExerciseArgs;
99
100    async fn call(
101        &self,
102        state: &McpState,
103        ctx: &RequestContext,
104        args: GetExerciseArgs,
105    ) -> Result<ToolOutcome, ToolError> {
106        let exercise_id = args.exercise.id();
107        let view = open_course_with_exercise(state, ctx, args.course.id(), exercise_id).await?;
108        let exercise = view
109            .staged
110            .exercise(exercise_id)
111            .or_else(|| view.live.exercise(exercise_id))
112            .ok_or_else(|| missing_exercise(&view, exercise_id))?;
113        let page_id = view
114            .exercise_page_id(exercise_id)
115            .ok_or_else(|| missing_exercise(&view, exercise_id))?;
116        let page = view
117            .current_page_snapshots(&mut *connection(state).await?, &[page_id])
118            .await?
119            .remove(&page_id);
120        let full_exercise = page
121            .as_ref()
122            .and_then(|page| page.exercises.iter().find(|e| e.id == exercise_id));
123
124        let selectors = match args.selectors {
125            Some(selectors) if !selectors.is_empty() => selectors,
126            _ => {
127                let mut outline: Vec<Selector> = exercise
128                    .tasks
129                    .iter()
130                    .map(|task| Selector {
131                        path: Some(format!("/tasks/{}", task.id)),
132                        ..Selector::default()
133                    })
134                    .collect();
135                if instruction_lists::review_list(&view.staged, exercise).is_some() {
136                    outline.push(Selector {
137                        path: Some(format!("/{REVIEW_INSTRUCTIONS}")),
138                        ..Selector::default()
139                    });
140                }
141                outline
142            }
143        };
144        let options = ReadOptions {
145            budget_bytes: ctx.client.result_budget_bytes() / selectors.len().max(1),
146            pending: None,
147        };
148        let mut specs_by_state: HashMap<ReadState, HashMap<Uuid, TaskSpecs>> = HashMap::new();
149        let mut reads = Vec::with_capacity(selectors.len());
150        for selector in &selectors {
151            let spec_path = selector.path.as_deref().and_then(parse_spec_path);
152            let Some(spec_path) = spec_path else {
153                reads.push(read_part(&view, exercise_id, selector, &options)?);
154                continue;
155            };
156            let spec_path = spec_path?;
157            let read_state = selector.state.unwrap_or_default();
158            if let Entry::Vacant(entry) = specs_by_state.entry(read_state) {
159                let mut conn = connection(state).await?;
160                let state_page = match read_state {
161                    ReadState::Staged => page.clone(),
162                    ReadState::Published => match view.live.exercise_page_id(exercise_id) {
163                        Some(live_page) => {
164                            entity_snapshots::load_page(&mut conn, live_page).await?
165                        }
166                        None => None,
167                    },
168                };
169                entry.insert(
170                    load_task_specs(&mut conn, exercise_id, state_page.as_ref(), read_state)
171                        .await?,
172                );
173            }
174            let mut read = read_specs(
175                &spec_path,
176                specs_by_state
177                    .get(&read_state)
178                    .and_then(|specs| specs.get(&spec_path.task_id)),
179                options.budget_bytes,
180            )?;
181            read["selector"] = json!(selector);
182            reads.push(read);
183        }
184
185        let own_config = full_exercise.and_then(|e| {
186            (!e.use_course_default_peer_or_self_review_config)
187                .then_some(e.peer_or_self_review_config.as_ref())
188                .flatten()
189        });
190        let question_count = full_exercise
191            .and_then(|e| e.peer_or_self_review_questions.as_ref())
192            .map_or(0, Vec::len);
193        let review_path = instruction_lists::review_list(&view.staged, exercise)
194            .map(|_| format!("/{REVIEW_INSTRUCTIONS}"));
195        let has_submissions =
196            !exercise_slide_submissions::get_exercise_ids_with_submissions_by_course_id(
197                &mut *connection(state).await?,
198                view.course.id,
199                &[exercise_id],
200            )
201            .await?
202            .is_empty();
203        let mut summary = json!({
204            "id": exercise.id,
205            "name": exercise.name,
206            "page": page_id,
207            "deadline": ExerciseDeadline::of(&view.staged, exercise_id),
208            "tasks": exercise.tasks.iter().map(|task| json!({
209                "id": task.id,
210                "exercise_type": task.exercise_type,
211                "path": format!("/tasks/{}", task.id),
212                "specs_path": format!("/tasks/{}/specs", task.id),
213            })).collect::<Vec<_>>(),
214            "reviews": {
215                "needs_peer_review": exercise.needs_peer_review,
216                "needs_self_review": exercise.needs_self_review,
217                "config": exercise.review_config.kind().map_or(json!("unset"), |kind| json!(kind)),
218                "course_default_settings": (exercise.review_config == ExerciseReviewConfig::CourseDefault)
219                    .then_some("get_course_settings shows them as default_peer_review"),
220                "path": review_path,
221            },
222            "read_only": full_exercise.map(|e| read_only_settings(e, own_config, question_count)),
223            "has_staged_changes": view.staged_row(headless_lms_models::library::entity_snapshots::EntityKey::new(headless_lms_models::library::entity_snapshots::ContentEntityType::Page, page_id)).is_some(),
224        });
225        if has_submissions {
226            summary["has_submissions"] = json!(true);
227        }
228        Ok(ToolResult::success(json!({
229            "exercise": summary,
230            "reads": reads,
231        }))
232        .into())
233    }
234}