Skip to main content

headless_lms_mcp/staging/
validation.rs

1//! Validating the blocks a call changed with the CMS, and writing back their normalised form.
2
3use std::collections::{BTreeSet, HashMap, HashSet};
4use std::sync::Arc;
5
6use headless_lms_models::library::agent_changesets::course_state::CourseState;
7use headless_lms_utils::document_schema_processor::{
8    GutenbergBlock, blocks_not_allowed_in_top_level_pages,
9};
10use serde::Serialize;
11use serde_json::Value;
12use uuid::Uuid;
13
14use crate::diff::changed_roots;
15use crate::engine::{EditError, EditErrorCode};
16use crate::engine::{PageDocument, PageType};
17use crate::instruction_lists::{self, InstructionsList, InstructionsLocation};
18use crate::page_readiness::pages_with_content_changes;
19use crate::protocol::progress::ProgressReporter;
20use crate::snapshots;
21use crate::tools::ToolError;
22use crate::validator::{
23    BlockValidationError, BlockValidator, CourseBlockFeatures, PageValidationRequest,
24    ValidationRequest, ValidationTarget,
25};
26
27/// A validator finding with the page or instruction list it was found in: an error, or a warning
28/// on a teacher's block the call left as it was.
29#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct BlockFinding {
31    /// `None` for the course default review instructions.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub page: Option<Uuid>,
34    #[serde(flatten)]
35    pub instructions: Option<InstructionsLocation>,
36    #[serde(flatten)]
37    pub finding: BlockValidationError,
38}
39
40/// What validating a write's blocks found besides errors.
41#[derive(Debug, Default)]
42pub struct ValidatedBlocks {
43    /// The block types of the blocks the call created.
44    pub block_names: Vec<String>,
45    /// Problems the validator found in the teacher's blocks that the call didn't change.
46    pub unchanged_block_warnings: Vec<BlockFinding>,
47}
48
49/// A list of blocks the validator checks.
50#[derive(Clone, Copy)]
51enum ValidatedList {
52    Page { page_id: Uuid, page_type: PageType },
53    Instructions(InstructionsList),
54}
55
56impl ValidatedList {
57    /// `findings` located in this list of `state`.
58    fn findings(
59        self,
60        state: &CourseState,
61        findings: Vec<BlockValidationError>,
62    ) -> impl Iterator<Item = BlockFinding> {
63        let (page, instructions) = match self {
64            ValidatedList::Page { page_id, .. } => (Some(page_id), None),
65            ValidatedList::Instructions(list) => (list.page(state), Some(list.location())),
66        };
67        findings.into_iter().map(move |finding| BlockFinding {
68            page,
69            instructions: instructions.clone(),
70            finding,
71        })
72    }
73}
74
75/// The blocks of one page or instruction list that go to the validator.
76struct PageToValidate {
77    list: ValidatedList,
78    roots: Vec<GutenbergBlock>,
79    /// Blocks sent as unchanged, whose problems only warn.
80    unchanged_client_ids: Vec<Uuid>,
81}
82
83/// Every block in `blocks` by client id.
84fn blocks_by_id(blocks: &[GutenbergBlock]) -> HashMap<Uuid, &GutenbergBlock> {
85    let mut by_id = HashMap::new();
86    crate::engine::blocks::walk(blocks, &mut |block, _| {
87        by_id.insert(block.client_id, block);
88    });
89    by_id
90}
91
92/// The ids of the blocks in `roots` that `is_unchanged` holds for, given the block and whether
93/// `live_blocks` has it as it is.
94fn unchanged_blocks(
95    roots: &[GutenbergBlock],
96    live_blocks: &HashMap<Uuid, &GutenbergBlock>,
97    is_unchanged: impl Fn(&GutenbergBlock, bool) -> bool,
98) -> Vec<Uuid> {
99    let mut unchanged_client_ids = Vec::new();
100    crate::engine::blocks::walk(roots, &mut |block, _| {
101        let is_as_published = live_blocks
102            .get(&block.client_id)
103            .is_some_and(|live| live.name == block.name && live.attributes == block.attributes);
104        if is_unchanged(block, is_as_published) {
105            unchanged_client_ids.push(block.client_id);
106        }
107    });
108    unchanged_client_ids
109}
110
111/// Validates the top-level trees holding blocks that changed between `before` and `after` and
112/// replaces them with the validator's normalised form; every tree of a page whose page type
113/// changed. Blocks as `live` has them, and the `generated_blocks` the server wrote, are sent as
114/// unchanged, so that problems in them only warn.
115pub async fn validate_changed_blocks(
116    validator: &dyn BlockValidator,
117    live: &CourseState,
118    before: &CourseState,
119    after: &mut CourseState,
120    generated_blocks: &HashSet<Uuid>,
121    progress: &ProgressReporter,
122) -> Result<ValidatedBlocks, ToolError> {
123    let mut validated = ValidatedBlocks::default();
124    let pages = collect_validation_targets(live, before, after, generated_blocks)?;
125    if pages.is_empty() {
126        return Ok(validated);
127    }
128    // Moved and edited blocks keep their ids; only blocks this call created get format tips.
129    let earlier_block_ids: HashSet<Uuid> = after
130        .pages_changed_since(before)
131        .filter_map(|page| before.page(page.id))
132        .flat_map(|page| blocks_by_id(&page.content).into_keys())
133        .collect();
134    let mut block_names: BTreeSet<&str> = BTreeSet::new();
135    for page in &pages {
136        crate::engine::blocks::walk(&page.roots, &mut |block, _| {
137            if !earlier_block_ids.contains(&block.client_id) {
138                block_names.insert(&block.name);
139            }
140        });
141    }
142    validated.block_names = block_names.into_iter().map(str::to_owned).collect();
143    if pages.len() > 1 {
144        progress.report(0.0, Some(pages.len() as f64), Some("validating pages"));
145    }
146    let outcomes = validator
147        .validate_blocks(&ValidationRequest {
148            pages: pages
149                .iter()
150                .map(|page| PageValidationRequest {
151                    target: match page.list {
152                        ValidatedList::Page { page_type, .. } => {
153                            ValidationTarget::Page { page_type }
154                        }
155                        ValidatedList::Instructions(list) => ValidationTarget::Exercise {
156                            exercise_context: list.validation_context(),
157                        },
158                    },
159                    blocks: &page.roots,
160                    unchanged_client_ids: &page.unchanged_client_ids,
161                })
162                .collect(),
163            course_features: CourseBlockFeatures {
164                chapter_locking_enabled: after.settings.chapter_locking_enabled,
165                can_add_chatbot: after.settings.can_add_chatbot,
166            },
167        })
168        .await?;
169    if outcomes.len() != pages.len() {
170        return Err(ToolError::Internal(format!(
171            "the validator answered for {} pages of {}",
172            outcomes.len(),
173            pages.len()
174        )));
175    }
176    let mut errors = Vec::new();
177    let mut normalised: Vec<(ValidatedList, Vec<GutenbergBlock>)> = Vec::new();
178    for (page, outcome) in pages.iter().zip(outcomes) {
179        validated
180            .unchanged_block_warnings
181            .extend(page.list.findings(after, outcome.warnings));
182        if outcome.errors.is_empty() {
183            normalised.push((page.list, outcome.blocks));
184        } else {
185            errors.extend(page.list.findings(after, outcome.errors));
186        }
187    }
188    if let Some(error) = block_error(&errors) {
189        return Err(error);
190    }
191    write_back_normalised(after, normalised);
192    Ok(validated)
193}
194
195/// The block trees of `after` that [`validate_changed_blocks`] sends.
196fn collect_validation_targets(
197    live: &CourseState,
198    before: &CourseState,
199    after: &CourseState,
200    generated_blocks: &HashSet<Uuid>,
201) -> Result<Vec<PageToValidate>, ToolError> {
202    let changed_page_ids: Vec<(Uuid, bool)> = after
203        .pages_changed_since(before)
204        .filter_map(|page| {
205            let is_type_changed = before
206                .page(page.id)
207                .is_some_and(|before| snapshots::page_type(before) != snapshots::page_type(page));
208            let is_content_changed =
209                before.page(page.id).map(|p| &p.content) != Some(&page.content);
210            (is_type_changed || is_content_changed).then_some((page.id, is_type_changed))
211        })
212        .collect();
213    top_level_pages_hold_no_chapter_blocks(after, changed_page_ids.iter().map(|(id, _)| *id))?;
214    let mut live_blocks: HashMap<Uuid, &GutenbergBlock> = HashMap::new();
215    if !changed_page_ids.is_empty() {
216        // A block can only have come from a page this call or an earlier one changed.
217        let mut source_pages: HashSet<Uuid> = live
218            .pages_changed_since(before)
219            .map(|page| page.id)
220            .collect();
221        source_pages.extend(changed_page_ids.iter().map(|(id, _)| *id));
222        for page in live.pages.iter().filter(|p| source_pages.contains(&p.id)) {
223            live_blocks.extend(blocks_by_id(&page.content));
224        }
225    }
226    let mut pages: Vec<PageToValidate> = Vec::new();
227    for (page_id, is_type_changed) in &changed_page_ids {
228        let Some(after_page) = after.page(*page_id).map(snapshots::page_ref) else {
229            continue;
230        };
231        let roots = if *is_type_changed {
232            after_page.blocks.to_vec()
233        } else {
234            changed_roots(before.page(*page_id).map(snapshots::page_ref), after_page)
235        };
236        if roots.is_empty() {
237            continue;
238        }
239        let unchanged_client_ids = unchanged_blocks(
240            &roots,
241            &live_blocks,
242            // Blocks in a new kind of page are checked against what that page allows.
243            |block, is_as_published| {
244                (is_as_published && !is_type_changed) || generated_blocks.contains(&block.client_id)
245            },
246        );
247        pages.push(PageToValidate {
248            list: ValidatedList::Page {
249                page_id: *page_id,
250                page_type: after_page.page_type,
251            },
252            roots,
253            unchanged_client_ids,
254        });
255    }
256    for list in instruction_lists::changed_lists(before, after) {
257        let Some(after_document) = list.document(after).transpose()? else {
258            continue;
259        };
260        let before_document = list.document(before).and_then(Result::ok);
261        let roots = changed_roots(
262            before_document.as_ref().map(PageDocument::as_page_ref),
263            after_document.as_page_ref(),
264        );
265        if roots.is_empty() {
266            continue;
267        }
268        // A fork's own review instructions start as a copy of the live course default's.
269        let live_document = list.document(live).or_else(|| {
270            let config = live.course_default_review_config.as_ref()?;
271            matches!(list, InstructionsList::Review { .. }).then_some(())?;
272            InstructionsList::CourseDefaultReview {
273                config_id: config.id,
274            }
275            .document(live)
276        });
277        let live_blocks = live_document
278            .and_then(Result::ok)
279            .map(|document| document.blocks)
280            .unwrap_or_default();
281        let unchanged_client_ids =
282            unchanged_blocks(&roots, &blocks_by_id(&live_blocks), |_, is_as_published| {
283                is_as_published
284            });
285        pages.push(PageToValidate {
286            list: ValidatedList::Instructions(list),
287            roots,
288            unchanged_client_ids,
289        });
290    }
291    Ok(pages)
292}
293
294/// The block types in the trees `staged` changed from `live`, which earlier calls of the
295/// changeset validated.
296pub fn changeset_block_names(live: &CourseState, staged: &CourseState) -> HashSet<String> {
297    let mut roots = Vec::new();
298    for page in pages_with_content_changes(staged, live) {
299        roots.extend(changed_roots(
300            live.page(page.id).map(snapshots::page_ref),
301            snapshots::page_ref(page),
302        ));
303    }
304    for list in instruction_lists::changed_lists(live, staged) {
305        let Some(Ok(staged_document)) = list.document(staged) else {
306            continue;
307        };
308        let live_document = list.document(live).and_then(Result::ok);
309        roots.extend(changed_roots(
310            live_document.as_ref().map(PageDocument::as_page_ref),
311            staged_document.as_page_ref(),
312        ));
313    }
314    let mut names = HashSet::new();
315    crate::engine::blocks::walk(&roots, &mut |block, _| {
316        names.insert(block.name.clone());
317    });
318    names
319}
320
321/// The error rejecting the call for the validator's `errors`; `None` when there are none.
322fn block_error(errors: &[BlockFinding]) -> Option<ToolError> {
323    let first = &errors.first()?.finding;
324    let message = format!(
325        "{} block error(s), nothing was staged; first: {}{}",
326        errors.len(),
327        first.message,
328        first
329            .client_id
330            .map(|id| format!(" (block {id})"))
331            .unwrap_or_default()
332    );
333    let details: Vec<Value> = errors
334        .iter()
335        .map(|error| serde_json::to_value(error).unwrap_or_default())
336        .collect();
337    let mut error =
338        EditError::new(EditErrorCode::InvalidBlocks, message).with_details(Value::Array(details));
339    if !first.fix.is_empty() {
340        error = error.with_fix(first.fix.clone());
341    }
342    Some(ToolError::Edit(error))
343}
344
345/// Puts the validator's normalised trees in place of the ones sent.
346fn write_back_normalised(
347    after: &mut CourseState,
348    normalised: Vec<(ValidatedList, Vec<GutenbergBlock>)>,
349) {
350    for (list, blocks) in normalised {
351        match list {
352            ValidatedList::Page { page_id, .. } => {
353                if let Some(page) = after.page_mut(page_id) {
354                    replace_roots(Arc::make_mut(&mut page.content).as_mut_slice(), blocks);
355                }
356            }
357            ValidatedList::Instructions(list) => {
358                if let Some(Ok(mut document)) = list.document(after) {
359                    replace_roots(&mut document.blocks, blocks);
360                    list.set_blocks(after, &document.blocks);
361                }
362            }
363        }
364    }
365}
366
367/// Puts each of `roots` in place of the top-level block with its id.
368fn replace_roots(content: &mut [GutenbergBlock], roots: Vec<GutenbergBlock>) {
369    let root_index: HashMap<Uuid, usize> = content
370        .iter()
371        .enumerate()
372        .map(|(index, block)| (block.client_id, index))
373        .collect();
374    for block in roots {
375        if let Some(index) = root_index.get(&block.client_id) {
376            content[*index] = block;
377        }
378    }
379}
380
381/// Publishing refuses pages outside chapters that hold exercises or chapter listings.
382fn top_level_pages_hold_no_chapter_blocks(
383    after: &CourseState,
384    page_ids: impl Iterator<Item = Uuid>,
385) -> Result<(), ToolError> {
386    for page_id in page_ids {
387        let Some(page) = after.page(page_id) else {
388            continue;
389        };
390        if page.chapter_id.is_some() {
391            continue;
392        }
393        let disallowed: Vec<String> = blocks_not_allowed_in_top_level_pages(&page.content)
394            .iter()
395            .map(|block| format!("/{} ({})", block.client_id, block.name))
396            .collect();
397        if !disallowed.is_empty() {
398            return Err(ToolError::Edit(
399                EditError::new(
400                    EditErrorCode::InvalidBlocks,
401                    format!(
402                        "page {page_id} is outside chapters, where exercises and chapter listings can't be: {}",
403                        disallowed.join(", ")
404                    ),
405                )
406                .with_fix("keep the page in a chapter, or remove those blocks from it first"),
407            ));
408        }
409    }
410    Ok(())
411}