Skip to main content

headless_lms_mcp/
instruction_lists.rs

1//! Exercise task and review instructions (own or course default's) as block documents, edited
2//! by the engines like pages under an id of their own.
3
4use std::sync::{Arc, LazyLock};
5
6use headless_lms_models::library::agent_changesets::course_state::{
7    CourseState, ExerciseRecord, ExerciseReviewConfig, review_instructions_for_storage,
8};
9use headless_lms_utils::document_schema_processor::GutenbergBlock;
10use headless_lms_utils::strings::decode_html_entities;
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use uuid::Uuid;
15
16use crate::engine::{Document, DocumentKey, EditError, EditErrorCode, PageDocument, PageType};
17use crate::validator::ExerciseContext;
18use headless_lms_utils::strings::escape_html_text;
19
20/// One list of instruction blocks.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum InstructionsList {
23    Task {
24        exercise_id: Uuid,
25        task_id: Uuid,
26    },
27    /// The exercise's own review instructions.
28    Review {
29        exercise_id: Uuid,
30    },
31    /// The course default review config's instructions, shared by every exercise using it.
32    CourseDefaultReview {
33        config_id: Uuid,
34    },
35}
36
37impl InstructionsList {
38    /// The id the list's document goes by in the engines: the task's, the exercise's or the
39    /// default config's, which no page shares.
40    pub fn document_id(self) -> Uuid {
41        match self {
42            InstructionsList::Task { task_id, .. } => task_id,
43            InstructionsList::Review { exercise_id } => exercise_id,
44            InstructionsList::CourseDefaultReview { config_id } => config_id,
45        }
46    }
47
48    /// The path `patch_exercise` and `get_exercise` address the list's top level with.
49    pub fn path_prefix(self) -> String {
50        match self {
51            InstructionsList::Task { task_id, .. } => format!("/tasks/{task_id}"),
52            InstructionsList::Review { .. } | InstructionsList::CourseDefaultReview { .. } => {
53                "/review_instructions".to_string()
54            }
55        }
56    }
57
58    /// The exercise the list belongs to; `None` for the course default's.
59    pub fn exercise_id(self) -> Option<Uuid> {
60        match self {
61            InstructionsList::Task { exercise_id, .. }
62            | InstructionsList::Review { exercise_id } => Some(exercise_id),
63            InstructionsList::CourseDefaultReview { .. } => None,
64        }
65    }
66
67    /// Where the list is, for results.
68    pub fn location(self) -> InstructionsLocation {
69        InstructionsLocation {
70            exercise: self.exercise_id(),
71            instructions: self.path_prefix(),
72            course_default_review_config: matches!(
73                self,
74                InstructionsList::CourseDefaultReview { .. }
75            ),
76        }
77    }
78
79    /// The page holding the list's exercise in `state`; `None` for the course default's.
80    pub fn page(self, state: &CourseState) -> Option<Uuid> {
81        state.exercise_page_id(self.exercise_id()?)
82    }
83
84    /// What the list's blocks are validated as the children of.
85    pub fn validation_context(self) -> ExerciseContext {
86        match self {
87            InstructionsList::Task { .. } => ExerciseContext::TaskInstructions,
88            InstructionsList::Review { .. } | InstructionsList::CourseDefaultReview { .. } => {
89                ExerciseContext::ReviewInstructions
90            }
91        }
92    }
93
94    /// The stored blocks of the list in `state`; `None` if the state doesn't have it.
95    fn stored_blocks(self, state: &CourseState) -> Option<Option<&Value>> {
96        match self {
97            InstructionsList::Task {
98                exercise_id,
99                task_id,
100            } => state
101                .exercise(exercise_id)?
102                .tasks
103                .iter()
104                .find(|task| task.id == task_id)
105                .map(|task| Some(task.assignment.as_ref())),
106            InstructionsList::Review { exercise_id } => {
107                match &state.exercise(exercise_id)?.review_config {
108                    ExerciseReviewConfig::Own {
109                        review_instructions,
110                        ..
111                    } => Some(review_instructions.as_deref()),
112                    _ => None,
113                }
114            }
115            InstructionsList::CourseDefaultReview { config_id } => state
116                .course_default_review_config
117                .as_ref()
118                .filter(|config| config.id == config_id)
119                .map(|config| config.review_instructions.as_ref()),
120        }
121    }
122
123    /// The list in `state` as a document; `None` if the state doesn't have it.
124    pub fn document(self, state: &CourseState) -> Option<Result<PageDocument, EditError>> {
125        let stored = self.stored_blocks(state)?;
126        Some(parse_blocks(stored).map(|blocks| PageDocument {
127            page_id: self.document_id(),
128            page_type: PageType::CoursePage,
129            blocks,
130        }))
131    }
132
133    /// Sets the list's blocks in `state`. Review instructions without content are stored as `null`,
134    /// as the editors store them.
135    pub fn set_blocks(self, state: &mut CourseState, blocks: &[GutenbergBlock]) {
136        let value = serde_json::to_value(blocks).unwrap_or(Value::Array(Vec::new()));
137        match self {
138            InstructionsList::Task {
139                exercise_id,
140                task_id,
141            } => {
142                if let Some(task) = state
143                    .exercise_mut(exercise_id)
144                    .and_then(|exercise| exercise.tasks.iter_mut().find(|task| task.id == task_id))
145                {
146                    task.assignment = Arc::new(value);
147                }
148            }
149            InstructionsList::Review { exercise_id } => {
150                if let Some(ExerciseReviewConfig::Own {
151                    review_instructions,
152                    ..
153                }) = state
154                    .exercise_mut(exercise_id)
155                    .map(|exercise| &mut exercise.review_config)
156                {
157                    *review_instructions = review_instructions_for_storage(value).map(Arc::new);
158                }
159            }
160            InstructionsList::CourseDefaultReview { config_id } => {
161                if let Some(config) = state
162                    .course_default_review_config
163                    .as_mut()
164                    .filter(|config| config.id == config_id)
165                {
166                    config.review_instructions = review_instructions_for_storage(value);
167                }
168            }
169        }
170    }
171}
172
173static MARKUP: LazyLock<Regex> = LazyLock::new(|| {
174    Regex::new(r"<[a-zA-Z/!]").expect("a constant pattern the unit tests compile")
175});
176
177/// The name as stored: trimmed, non-empty plain text on one line.
178pub fn checked_name(name: &str) -> Result<String, EditError> {
179    let trimmed = name.trim();
180    if trimmed.is_empty() || trimmed.contains(['\n', '\r']) || MARKUP.is_match(trimmed) {
181        return Err(EditError::new(
182            EditErrorCode::InvalidValue,
183            "an exercise name is non-empty plain text on one line, without markup",
184        ));
185    }
186    Ok(trimmed.to_string())
187}
188
189/// The blocks of stored instructions; `null` is no blocks. Errors if they aren't blocks.
190pub fn parse_blocks(stored: Option<&Value>) -> Result<Vec<GutenbergBlock>, EditError> {
191    match stored {
192        None | Some(Value::Null) => Ok(Vec::new()),
193        Some(value) => Vec::<GutenbergBlock>::deserialize(value).map_err(|e| {
194            EditError::new(
195                EditErrorCode::InvalidValue,
196                format!("the stored instructions aren't blocks the server can edit: {e}"),
197            )
198            .with_fix("ask the teacher to edit these instructions in the editor")
199        }),
200    }
201}
202
203/// The review instructions an exercise shows: its own, or the course default's. `None` when it
204/// has no peer or self reviews.
205pub fn review_list(state: &CourseState, exercise: &ExerciseRecord) -> Option<InstructionsList> {
206    if !exercise.has_reviews() {
207        return None;
208    }
209    match &exercise.review_config {
210        ExerciseReviewConfig::Own { .. } => Some(InstructionsList::Review {
211            exercise_id: exercise.id,
212        }),
213        ExerciseReviewConfig::CourseDefault => {
214            state.course_default_review_config.as_ref().map(|config| {
215                InstructionsList::CourseDefaultReview {
216                    config_id: config.id,
217                }
218            })
219        }
220        ExerciseReviewConfig::Unset => None,
221    }
222}
223
224/// The instruction lists that differ from `before`, by exercise id and then the course default's.
225/// Lists `before` lacks count as changed whole.
226pub fn changed_lists(before: &CourseState, after: &CourseState) -> Vec<InstructionsList> {
227    let mut lists = Vec::new();
228    for exercise_id in after.exercises_changed_since(before) {
229        let (Some(exercise), Some(before_exercise)) =
230            (after.exercise(exercise_id), before.exercise(exercise_id))
231        else {
232            continue;
233        };
234        for task in &exercise.tasks {
235            let before_task = before_exercise.tasks.iter().find(|t| t.id == task.id);
236            if before_task.is_none_or(|before_task| before_task.assignment != task.assignment) {
237                lists.push(InstructionsList::Task {
238                    exercise_id,
239                    task_id: task.id,
240                });
241            }
242        }
243        if let ExerciseReviewConfig::Own {
244            review_instructions,
245            ..
246        } = &exercise.review_config
247        {
248            let before_instructions = match &before_exercise.review_config {
249                ExerciseReviewConfig::Own {
250                    review_instructions,
251                    ..
252                } => Some(review_instructions),
253                _ => None,
254            };
255            if before_instructions != Some(review_instructions) {
256                lists.push(InstructionsList::Review { exercise_id });
257            }
258        }
259    }
260    if let Some(config) = &after.course_default_review_config
261        && before.course_default_review_config.as_ref() != Some(config)
262    {
263        lists.push(InstructionsList::CourseDefaultReview {
264            config_id: config.id,
265        });
266    }
267    lists
268}
269
270/// The instruction lists of the exercises on `page_ids`, in page and block order, and the course
271/// default's when `with_course_default` or when one of those exercises uses it. Review
272/// instructions count only for exercises with reviews.
273pub fn lists_of_pages(
274    state: &CourseState,
275    page_ids: &[Uuid],
276    with_course_default: bool,
277) -> Vec<InstructionsList> {
278    let mut lists = Vec::new();
279    let mut uses_course_default = false;
280    for page_id in page_ids {
281        let Some(page) = state.page(*page_id) else {
282            continue;
283        };
284        for exercise_id in page.content.iter().filter_map(|b| b.exercise_id()) {
285            let Some(exercise) = state.exercise(exercise_id) else {
286                continue;
287            };
288            lists.extend(exercise.tasks.iter().map(|task| InstructionsList::Task {
289                exercise_id,
290                task_id: task.id,
291            }));
292            match review_list(state, exercise) {
293                Some(InstructionsList::CourseDefaultReview { .. }) => uses_course_default = true,
294                Some(list) => lists.push(list),
295                None => {}
296            }
297        }
298    }
299    if (with_course_default || uses_course_default)
300        && let Some(config) = &state.course_default_review_config
301    {
302        lists.push(InstructionsList::CourseDefaultReview {
303            config_id: config.id,
304        });
305    }
306    lists
307}
308
309/// Where an instruction list is, for results: its exercise and the path `patch_exercise` takes.
310#[derive(Debug, Clone, PartialEq, Serialize)]
311pub struct InstructionsLocation {
312    /// `None` for the course default review instructions.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub exercise: Option<Uuid>,
315    /// The list's top level, e.g. `/tasks/<task id>`.
316    pub instructions: String,
317    /// Whether the list is the course default review config's, which every exercise without its
318    /// own review settings shows.
319    #[serde(skip_serializing_if = "std::ops::Not::not")]
320    pub course_default_review_config: bool,
321}
322
323/// Which exercise text [`text_documents`] includes.
324#[derive(Debug, Clone, Copy)]
325pub struct ExerciseTextParts {
326    pub instructions: bool,
327    /// The course default review instructions even when no exercise in scope uses them; only
328    /// with `instructions`.
329    pub course_default: bool,
330    pub names: bool,
331}
332
333/// Exercise text on `page_ids` for the course-wide engines: instruction lists (the course
334/// default's too when an exercise uses it; unparseable lists skipped) and names, as `parts`
335/// picks. Names are one paragraph each, escaped like rich text so entities read as on pages.
336pub fn text_documents(
337    state: &CourseState,
338    page_ids: &[Uuid],
339    parts: ExerciseTextParts,
340) -> Vec<Document> {
341    let mut documents = Vec::new();
342    if parts.instructions {
343        let exercise_pages = state.exercise_page_ids();
344        for list in lists_of_pages(state, page_ids, parts.course_default) {
345            if let Some(Ok(document)) = list.document(state) {
346                let page_id = list
347                    .exercise_id()
348                    .and_then(|exercise_id| exercise_pages.get(&exercise_id).copied());
349                documents.push(Document {
350                    key: DocumentKey::Instructions { list, page_id },
351                    page_type: document.page_type,
352                    blocks: document.blocks,
353                });
354            }
355        }
356    }
357    if parts.names {
358        for page_id in page_ids {
359            let Some(page) = state.page(*page_id) else {
360                continue;
361            };
362            for exercise_id in page.content.iter().filter_map(|b| b.exercise_id()) {
363                if let Some(exercise) = state.exercise(exercise_id) {
364                    documents.push(Document {
365                        key: DocumentKey::ExerciseName {
366                            exercise_id,
367                            page_id: *page_id,
368                        },
369                        page_type: PageType::CoursePage,
370                        blocks: vec![
371                            GutenbergBlock::paragraph(&escape_html_text(&exercise.name))
372                                .with_id(exercise_id),
373                        ],
374                    });
375                }
376            }
377        }
378    }
379    documents
380}
381
382/// Writes documents a course-wide engine changed into `after`: page blocks, instruction lists and
383/// exercise names. Errors if a name would no longer be a valid one.
384pub fn apply_documents(after: &mut CourseState, changed: Vec<Document>) -> Result<(), EditError> {
385    for document in changed {
386        match document.key {
387            DocumentKey::Page(page_id) => {
388                if let Some(page) = after.page_mut(page_id) {
389                    page.content = Arc::new(document.blocks);
390                }
391            }
392            DocumentKey::Instructions { list, .. } => list.set_blocks(after, &document.blocks),
393            DocumentKey::ExerciseName { exercise_id, .. } => {
394                let name = document
395                    .blocks
396                    .first()
397                    .and_then(|block| block.attributes.get("content"))
398                    .and_then(Value::as_str)
399                    .unwrap_or_default();
400                let name =
401                    checked_name(&decode_html_entities(name)).map_err(|error| EditError {
402                        message: format!("exercise {exercise_id}: {}", error.message),
403                        ..error
404                    })?;
405                if let Some(exercise) = after.exercise_mut(exercise_id) {
406                    exercise.name = name;
407                }
408            }
409        }
410    }
411    Ok(())
412}
413
414#[cfg(test)]
415#[path = "instruction_lists_tests.rs"]
416mod tests;