headless_lms_mcp/handlers/exercise/
get.rs1use 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
29pub 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
71fn 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}