Skip to main content

headless_lms_mcp/handlers/exercise/
patch.rs

1//! `patch_exercise`: edits of an exercise's name, own deadline, instructions and review config.
2
3use std::collections::BTreeMap;
4
5use async_trait::async_trait;
6use chrono::{DateTime, Utc};
7use headless_lms_models::{
8    error::TryToOptional,
9    exercise_slide_submissions,
10    library::agent_changesets::course_state::{CourseState, CourseView},
11    peer_or_self_review_configs, peer_or_self_review_submissions,
12};
13use serde::Serialize;
14use serde_json::json;
15use sqlx::PgConnection;
16use uuid::Uuid;
17
18use super::paths::{ExercisePart, call_index, group_ops, in_call, part_list};
19use super::review_config::{DroppedReviewSettings, check_review_config, dropped_settings};
20use super::{ExerciseDeadline, missing_exercise, open_course_with_exercise};
21use crate::access::connection;
22use crate::engine::{
23    CreatedIds, EditError, EditErrorCode, PatchTarget, Warning, WarningCode, page_content,
24};
25use crate::hints;
26use crate::ids::{IdGenerator, RandomIds};
27use crate::instruction_lists;
28use crate::staging::{self, WriteMode, WriteRequest};
29use crate::state::McpState;
30use crate::tools::args::{PatchExerciseArgs, ReviewConfigChoice};
31use crate::tools::{RequestContext, ToolError, ToolHandler, ToolOutcome};
32
33/// Handler of the `patch_exercise` tool.
34pub struct PatchExercise;
35
36/// The `deadline` argument as a time, `None` to clear it. Naive times are refused: the server
37/// can't know which time zone they mean.
38fn parse_deadline(raw: Option<&str>) -> Result<Option<DateTime<Utc>>, EditError> {
39    raw.map(|raw| {
40        DateTime::parse_from_rfc3339(raw)
41            .map(|deadline| deadline.with_timezone(&Utc))
42            .map_err(|_| {
43                EditError::new(
44                    EditErrorCode::InvalidValue,
45                    "deadline is an RFC 3339 time with an offset, like \"2027-02-01T21:59:00Z\", or null to clear it",
46                )
47            })
48    })
49    .transpose()
50}
51
52/// What the agent should know about giving the exercise the own deadline `deadline` in `state`.
53fn deadline_warnings(
54    state: &CourseState,
55    exercise_id: Uuid,
56    deadline: DateTime<Utc>,
57) -> Vec<Warning> {
58    let mut warnings = vec![Warning::new(
59        WarningCode::OwnExerciseDeadline,
60        "an exercise's own deadline overrides its chapter's and is easy to miss when dates change; prefer the chapter deadline (patch_course_structure) and add own deadlines only when the teacher asks",
61    )];
62    let chapter = state
63        .exercise_page_id(exercise_id)
64        .and_then(|page| state.page(page)?.chapter_id)
65        .and_then(|chapter| state.chapters.iter().find(|c| c.id == chapter));
66    if let Some(chapter) = chapter {
67        let outside = match (chapter.opens_at, chapter.deadline) {
68            (Some(opens_at), _) if deadline < opens_at => Some(format!("opens at {opens_at}")),
69            (_, Some(end)) if deadline > end => Some(format!("has the deadline {end}")),
70            _ => None,
71        };
72        if let Some(outside) = outside {
73            warnings.push(Warning::new(
74                WarningCode::DeadlineOutsideChapterWindow,
75                format!(
76                    "the deadline is outside the time the chapter is open: chapter \"{}\" {outside}",
77                    chapter.name
78                ),
79            ));
80        }
81    }
82    warnings
83}
84
85/// How many reviews students have given on the exercise.
86async fn review_count(
87    conn: &mut PgConnection,
88    exercise_id: Uuid,
89) -> Result<Option<i64>, ToolError> {
90    Ok(
91        peer_or_self_review_submissions::count_by_exercise_ids(conn, &[exercise_id])
92            .await?
93            .get(&exercise_id)
94            .copied(),
95    )
96}
97
98/// The exercise's own review settings that switching it to the course default would drop.
99async fn settings_dropped_for_course_default(
100    conn: &mut PgConnection,
101    view: &CourseView,
102    exercise_id: Uuid,
103) -> Result<Option<DroppedReviewSettings>, ToolError> {
104    let page_id = view
105        .exercise_page_id(exercise_id)
106        .ok_or_else(|| missing_exercise(view, exercise_id))?;
107    let page = view
108        .current_page_snapshots(conn, &[page_id])
109        .await?
110        .remove(&page_id);
111    let course_default =
112        peer_or_self_review_configs::get_course_default_cms_peer_review(conn, view.course.id)
113            .await
114            .optional()?;
115    Ok(page
116        .as_ref()
117        .and_then(|page| page.exercises.iter().find(|e| e.id == exercise_id))
118        .map(|own| dropped_settings(own, course_default.as_ref())))
119}
120
121#[derive(Serialize)]
122struct ExerciseSummary<'a> {
123    exercise: Uuid,
124    created: Vec<CreatedIds>,
125    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
126    labels: BTreeMap<String, Uuid>,
127    #[serde(skip_serializing_if = "Vec::is_empty")]
128    warnings: Vec<Warning>,
129    /// After the call, when it sets the deadline.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    deadline: Option<ExerciseDeadline>,
132    #[serde(skip_serializing_if = "Option::is_none")]
133    review_config: Option<ReviewConfigChoice>,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    dropped_review_settings: Option<DroppedReviewSettings>,
136    /// Reviews students have given on the exercise, when the call switches its review settings.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    existing_review_count: Option<i64>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    course_default_review_config: Option<&'a str>,
141}
142
143#[async_trait(?Send)]
144impl ToolHandler<McpState> for PatchExercise {
145    type Args = PatchExerciseArgs;
146
147    async fn call(
148        &self,
149        state: &McpState,
150        ctx: &RequestContext,
151        args: PatchExerciseArgs,
152    ) -> Result<ToolOutcome, ToolError> {
153        let raw_ops = args.ops.clone().unwrap_or_default();
154        if args.name.is_none()
155            && args.deadline.is_none()
156            && raw_ops.is_empty()
157            && args.review_config.is_none()
158        {
159            return Err(ToolError::Edit(EditError::new(
160                EditErrorCode::MissingField,
161                "the call changes nothing; give name, deadline, ops or review_config",
162            )));
163        }
164        let name = args
165            .name
166            .as_deref()
167            .map(instruction_lists::checked_name)
168            .transpose()?;
169        let deadline = args
170            .deadline
171            .as_ref()
172            .map(|raw| parse_deadline(raw.as_deref()))
173            .transpose()?;
174        let groups = group_ops(&raw_ops)?;
175        let parsed = groups
176            .iter()
177            .map(|group| {
178                crate::engine::parse_ops(&group.raw, PatchTarget::PageContent)
179                    .map_err(|e| in_call(e, &group.indexes))
180            })
181            .collect::<Result<Vec<_>, _>>()?;
182        let exercise_id = args.exercise.id();
183        let view = open_course_with_exercise(state, ctx, args.course.id(), exercise_id).await?;
184        let has_review_ops = groups
185            .iter()
186            .any(|group| group.part == ExercisePart::ReviewInstructions);
187        check_review_config(&view, exercise_id, args.review_config, has_review_ops)?;
188
189        let ids = RandomIds;
190        let mut after = view.staged.clone();
191        if let Some(name) = name
192            && let Some(record) = after.exercise_mut(exercise_id)
193        {
194            record.name = name;
195        }
196        let mut warnings = Vec::new();
197        if let Some(deadline) = deadline {
198            after.set_exercise_deadline(exercise_id, deadline);
199            if let Some(deadline) = deadline {
200                warnings.extend(deadline_warnings(&after, exercise_id, deadline));
201            }
202        }
203        match args.review_config {
204            Some(ReviewConfigChoice::Fork) => after.fork_review_config(exercise_id, ids.new_id()),
205            Some(ReviewConfigChoice::UseCourseDefault) => {
206                after.use_course_default_review_config(exercise_id)
207            }
208            Some(ReviewConfigChoice::EditCourseDefault) | None => {}
209        }
210
211        let mut created = Vec::new();
212        let mut labels = BTreeMap::new();
213        for (group, ops) in groups.iter().zip(&parsed) {
214            let list = part_list(&after, exercise_id, group.part)?;
215            let document = list
216                .document(&after)
217                .ok_or_else(|| missing_exercise(&view, exercise_id))??;
218            let result = page_content::apply(document, &|_| None, ops, &ids)
219                .map_err(|e| in_call(e, &group.indexes))?;
220            list.set_blocks(&mut after, &result.page.blocks);
221            for mut created_ids in result.created {
222                created_ids.op = call_index(&group.indexes, created_ids.op)?;
223                created.push(created_ids);
224            }
225            labels.extend(result.labels);
226            for mut warning in result.warnings {
227                warning.op = warning
228                    .op
229                    .map(|op| call_index(&group.indexes, op))
230                    .transpose()?;
231                warnings.push(warning);
232            }
233        }
234        created.sort_by_key(|ids| ids.op);
235
236        let (existing_review_count, dropped_review_settings) = match args.review_config {
237            Some(ReviewConfigChoice::Fork) => {
238                let mut conn = connection(state).await?;
239                (review_count(&mut conn, exercise_id).await?, None)
240            }
241            Some(ReviewConfigChoice::UseCourseDefault) => {
242                let mut conn = connection(state).await?;
243                (
244                    review_count(&mut conn, exercise_id).await?,
245                    settings_dropped_for_course_default(&mut conn, &view, exercise_id).await?,
246                )
247            }
248            Some(ReviewConfigChoice::EditCourseDefault) | None => (None, None),
249        };
250        let has_task_ops = groups
251            .iter()
252            .any(|group| matches!(group.part, ExercisePart::Task(_)));
253        let is_answered = has_task_ops
254            && !exercise_slide_submissions::get_exercise_ids_with_submissions_by_course_id(
255                &mut *connection(state).await?,
256                view.course.id,
257                &[exercise_id],
258            )
259            .await?
260            .is_empty();
261        let edits_course_default =
262            after.course_default_review_config != view.staged.course_default_review_config;
263        let deadline_after = deadline.map(|_| ExerciseDeadline::of(&after, exercise_id));
264
265        let report = staging::commit(
266            state,
267            ctx,
268            &view,
269            after,
270            WriteRequest::new(
271                json!(&args),
272                format!("edit of exercise {exercise_id}"),
273                WriteMode::from_dry_run(args.dry_run),
274            ),
275        )
276        .await?;
277        let summary = ExerciseSummary {
278            exercise: exercise_id,
279            created,
280            labels,
281            warnings,
282            deadline: deadline_after,
283            review_config: args.review_config,
284            dropped_review_settings,
285            existing_review_count,
286            course_default_review_config: edits_course_default.then_some(
287                "edited: the change shows in every exercise that uses the course default review config",
288            ),
289        };
290        let mut result = report.into_result(&summary);
291        if let Some(count) = existing_review_count.filter(|count| *count > 0) {
292            result = result.with_hint(hints::review_config_switched_with_reviews(count));
293        }
294        if is_answered {
295            result = result.with_hint(hints::answered_task_instructions_edited());
296        }
297        Ok(result.into())
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn names_are_one_line_of_plain_text() {
307        assert_eq!(
308            instruction_lists::checked_name("  Loops  ").unwrap(),
309            "Loops"
310        );
311        for invalid in ["", "  ", "a\nb", "<b>Loops</b>"] {
312            assert!(
313                instruction_lists::checked_name(invalid).is_err(),
314                "{invalid:?}"
315            );
316        }
317        assert_eq!(instruction_lists::checked_name("x < y").unwrap(), "x < y");
318    }
319}