Skip to main content

headless_lms_mcp/handlers/
patches.rs

1//! The four patch tools: page content, course structure, settings, glossary and references.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4
5use async_trait::async_trait;
6use serde::Serialize;
7use serde_json::Value;
8use sqlx::PgConnection;
9use uuid::Uuid;
10
11use crate::access::{
12    connection, load_page_facts, open_course, page_in_course, staged_page_document,
13};
14use crate::engine::blocks::ExerciseCopy;
15use crate::engine::blocks::{copy_blocks, walk};
16use crate::engine::course_structure::{self, ChapterRenumber, LinkToOldPath};
17use crate::engine::glossary_references::Usage;
18use crate::engine::page_content::BlockPageMove;
19use crate::engine::read::{ReadOptions, ReadResult, Selector, read_page, read_structure};
20use crate::engine::{
21    CreatedIds, Document, DocumentKey, DocumentRef, EditError, EditErrorCode, PatchTarget, Warning,
22    course_settings, glossary_references, page_content, parse_ops,
23};
24use crate::hints;
25use crate::ids::RandomIds;
26use crate::instruction_lists::{self, ExerciseTextParts};
27use crate::page_readiness::PAGE_TITLE_BLOCKS;
28use crate::snapshots;
29use crate::staging::{self, WriteMode, WriteRequest, WriteSource};
30use crate::state::McpState;
31use crate::tools::args::{PatchCourseArgs, PatchCourseStructureArgs, PatchPageContentArgs};
32use crate::tools::{RequestContext, ToolError, ToolHandler, ToolOutcome};
33use headless_lms_models::library::agent_changesets::course_state::{CourseState, CourseView};
34use headless_lms_models::library::content_management::ChapterFrontPage;
35use headless_lms_models::page_language_groups::{self, PairingRefusal};
36use headless_lms_models::url_redirections::CreatedRedirect;
37use headless_lms_utils::document_schema_processor::GutenbergBlock;
38
39/// Handler of the `patch_page_content` tool.
40pub struct PatchPageContent;
41/// Handler of the `patch_course_structure` tool.
42pub struct PatchCourseStructure;
43/// Handler of the `patch_course_settings` tool.
44pub struct PatchCourseSettings;
45/// Handler of the `patch_course_glossary_and_references` tool.
46pub struct PatchGlossaryAndReferences;
47
48fn pairing_refused(page_id: Uuid, message: String) -> ToolError {
49    ToolError::Edit(
50        EditError::new(EditErrorCode::InvalidValue, format!("pair_with of new page {page_id}: {message}"))
51            .with_fix("pair_with takes the id of the published page this page translates, in another language version of the course (list_courses language_versions, get_course_structure page ids)"),
52    )
53}
54
55/// Puts each new page of `pairings` (new page id to the page it translates) into the page
56/// language group of the page it translates, which publishing joins. Refuses a page of a course
57/// outside this course's language group, one not published yet, and a group that already has a
58/// page in this course.
59async fn pair_pages(
60    conn: &mut PgConnection,
61    view: &CourseView,
62    pairings: &BTreeMap<Uuid, Uuid>,
63    page_language_groups: &HashMap<Uuid, Uuid>,
64    after: &mut CourseState,
65) -> Result<(), ToolError> {
66    let mut paired_groups: HashSet<Uuid> = page_language_groups
67        .iter()
68        .filter(|(page, _)| after.page(**page).is_some())
69        .map(|(_, group)| *group)
70        .chain(
71            after
72                .pages
73                .iter()
74                .filter_map(|page| page.joins_page_language_group_id),
75        )
76        .collect();
77    for (&page_id, &source_id) in pairings {
78        if view.staged.page(source_id).is_some() || view.live.page(source_id).is_some() {
79            return Err(pairing_refused(
80                page_id,
81                format!("page {source_id} is in this course, not in another language version"),
82            ));
83        }
84        let group = page_language_groups::resolve_pairing(
85            conn,
86            source_id,
87            view.course.course_language_group_id,
88        )
89        .await?
90        .map_err(|refusal| {
91            pairing_refused(
92                page_id,
93                match refusal {
94                    PairingRefusal::NotPublished => format!("no published page {source_id}; if it is a new page staged in its course, publish the source page first"),
95                    PairingRefusal::NotInCourseLanguageGroup => format!("page {source_id} isn't in a language version of this course (a course in its language group)"),
96                },
97            )
98        })?;
99        if paired_groups.contains(&group) {
100            return Err(pairing_refused(
101                page_id,
102                format!("this course already has its version of page {source_id}"),
103            ));
104        }
105        if let Some(page) = after.page_mut(page_id) {
106            page.joins_page_language_group_id = Some(group);
107            paired_groups.insert(group);
108        }
109    }
110    Ok(())
111}
112
113/// `read_back` reads the result of the write, never another state.
114fn check_read_back(selector: &Option<Selector>) -> Result<(), ToolError> {
115    if selector.as_ref().is_some_and(|s| s.state.is_some()) {
116        return Err(ToolError::Edit(
117            EditError::new(
118                EditErrorCode::FieldNotAllowed,
119                "read_back always reads the result of this write; it takes no state",
120            )
121            .with_fix("drop state from read_back"),
122        ));
123    }
124    Ok(())
125}
126
127fn read_back_options(ctx: &RequestContext) -> ReadOptions<'static> {
128    ReadOptions {
129        budget_bytes: ctx.client.read_back_budget_bytes(),
130        pending: None,
131    }
132}
133
134fn resolve_read_back(
135    selector: &Selector,
136    labels: &BTreeMap<String, Uuid>,
137) -> Result<Selector, ToolError> {
138    let mut selector = selector.clone();
139    if let Some(path) = &selector.path {
140        selector.path = Some(page_content::resolve_label_path(path, labels)?);
141    }
142    if let Some(through) = &selector.through {
143        selector.through = Some(page_content::resolve_label_path(through, labels)?);
144    }
145    Ok(selector)
146}
147
148/// The read-back, or why it failed. The write is staged by then, so a failed read mustn't turn
149/// the result into an error.
150#[derive(Serialize)]
151enum ReadBack {
152    #[serde(rename = "read_back")]
153    Read(Box<ReadResult>),
154    #[serde(rename = "read_back_error")]
155    Failed(Value),
156}
157
158impl From<Result<ReadResult, ToolError>> for ReadBack {
159    fn from(read: Result<ReadResult, ToolError>) -> Self {
160        match read {
161            Ok(read) => ReadBack::Read(Box::new(read)),
162            Err(error) => ReadBack::Failed(error.into_json()),
163        }
164    }
165}
166
167#[derive(Serialize)]
168struct PageContentSummary<'a> {
169    page: Uuid,
170    created: &'a [CreatedIds],
171    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
172    labels: &'a BTreeMap<String, Uuid>,
173    #[serde(skip_serializing_if = "<[_]>::is_empty")]
174    warnings: &'a [Warning],
175    #[serde(skip_serializing_if = "<[_]>::is_empty")]
176    moved_between_pages: &'a [BlockPageMove],
177    #[serde(skip_serializing_if = "<[_]>::is_empty")]
178    exercise_copies: &'a [ExerciseCopy],
179    #[serde(flatten)]
180    read_back: Option<ReadBack>,
181}
182
183/// Ids an op created, with the blocks the server added to its new pages.
184#[derive(Serialize)]
185struct CreatedStructure<'a> {
186    #[serde(flatten)]
187    ids: &'a CreatedIds,
188    #[serde(skip_serializing_if = "Vec::is_empty")]
189    template_blocks: Vec<TemplateBlock>,
190}
191
192/// A top-level block the server added to a new page: its hero section, or a chapter front page's
193/// standard blocks.
194#[derive(Serialize)]
195struct TemplateBlock {
196    page: Uuid,
197    block: Uuid,
198    block_name: String,
199}
200
201#[derive(Serialize)]
202struct StructureSummary<'a> {
203    #[serde(skip_serializing_if = "Option::is_none")]
204    outline: Option<&'a course_structure::StructureOutline>,
205    created: Vec<CreatedStructure<'a>>,
206    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
207    labels: &'a BTreeMap<String, Uuid>,
208    #[serde(skip_serializing_if = "<[_]>::is_empty")]
209    warnings: &'a [Warning],
210    #[serde(skip_serializing_if = "<[_]>::is_empty")]
211    changed_paths: &'a [CreatedRedirect],
212    #[serde(skip_serializing_if = "<[_]>::is_empty")]
213    renumbered_chapters: &'a [ChapterRenumber],
214    #[serde(skip_serializing_if = "<[_]>::is_empty")]
215    links_to_old_paths: &'a [LinkToOldPath],
216    #[serde(flatten)]
217    read_back: Option<ReadBack>,
218}
219
220#[derive(Serialize)]
221struct CreatedSummary<'a> {
222    created: &'a [CreatedIds],
223}
224
225#[derive(Serialize)]
226struct GlossarySummary<'a> {
227    created: &'a [CreatedIds],
228    #[serde(skip_serializing_if = "Vec::is_empty")]
229    rewritten_pages: Vec<Uuid>,
230    #[serde(skip_serializing_if = "Vec::is_empty")]
231    rewritten_exercise_instructions: Vec<DocumentKey>,
232    #[serde(skip_serializing_if = "<[_]>::is_empty")]
233    usages: &'a [Usage],
234}
235
236#[async_trait(?Send)]
237impl ToolHandler<McpState> for PatchPageContent {
238    type Args = PatchPageContentArgs;
239
240    async fn call(
241        &self,
242        state: &McpState,
243        ctx: &RequestContext,
244        args: PatchPageContentArgs,
245    ) -> Result<ToolOutcome, ToolError> {
246        check_read_back(&args.read_back)?;
247        let ops = parse_ops(&args.ops, PatchTarget::PageContent)?;
248        let view = open_course(state, ctx, args.course.id()).await?;
249        let page_id = page_in_course(&view, args.page.id())?;
250        let page = staged_page_document(&view.staged, page_id)?;
251        let result = page_content::apply(
252            page,
253            &|to_page| snapshots::page_document(&view.staged, to_page),
254            &ops,
255            &RandomIds,
256        )?;
257
258        let mut after = view.staged.clone();
259        snapshots::apply_page_documents(
260            &mut after,
261            std::iter::once(result.page).chain(result.other_pages),
262        );
263        let report = staging::commit(
264            state,
265            ctx,
266            &view,
267            after,
268            WriteRequest {
269                source: WriteSource::Derived {
270                    exercise_copies: result.exercise_copies.clone(),
271                    generated_blocks: HashSet::new(),
272                },
273                ..WriteRequest::new(
274                    Value::Array(args.ops),
275                    format!("{} op(s) on page {page_id}", ops.len()),
276                    WriteMode::from_dry_run(args.dry_run),
277                )
278            },
279        )
280        .await?;
281
282        let read_back = args.read_back.as_ref().map(|selector| {
283            ReadBack::from(
284                resolve_read_back(selector, &result.labels).and_then(|selector| {
285                    let document = staged_page_document(&report.after, page_id)?;
286                    Ok(read_page(&document, &selector, &read_back_options(ctx))?)
287                }),
288            )
289        });
290        let summary = PageContentSummary {
291            page: page_id,
292            created: &result.created,
293            labels: &result.labels,
294            warnings: &result.warnings,
295            moved_between_pages: &result.moved_between_pages,
296            exercise_copies: &result.exercise_copies,
297            read_back,
298        };
299        let mut consequences = Vec::new();
300        if !result.exercise_copies.is_empty() {
301            consequences.push(hints::exercises_copied(result.exercise_copies.len()));
302        }
303        if !result.moved_between_pages.is_empty() {
304            consequences.push(hints::blocks_moved_between_pages(
305                result.moved_between_pages.len(),
306            ));
307        }
308        Ok(report.into_result(&summary).with_hints(consequences).into())
309    }
310}
311
312#[async_trait(?Send)]
313impl ToolHandler<McpState> for PatchCourseStructure {
314    type Args = PatchCourseStructureArgs;
315
316    async fn call(
317        &self,
318        state: &McpState,
319        ctx: &RequestContext,
320        args: PatchCourseStructureArgs,
321    ) -> Result<ToolOutcome, ToolError> {
322        check_read_back(&args.read_back)?;
323        let ops = parse_ops(&args.ops, PatchTarget::CourseStructure)?;
324        let view = open_course(state, ctx, args.course.id()).await?;
325        let facts = load_page_facts(state, view.course.id).await?;
326        let before = snapshots::structure(&view.staged, &facts);
327        let ids = RandomIds;
328        let mut result = course_structure::apply(&before, &ops, view.staged.is_live(), &ids)?;
329
330        let mut contents = std::mem::take(&mut result.new_pages);
331        let mut generated_blocks = HashSet::new();
332        let mut template_blocks = Vec::new();
333        for (page_id, blocks) in contents.iter_mut() {
334            if result.structure.is_chapter_front_page(*page_id) {
335                if blocks.is_empty()
336                    && let Some(chapter) = result.structure.chapter_of_page(*page_id)
337                {
338                    *blocks = ChapterFrontPage::new(&chapter.name, chapter.chapter_number).content;
339                    walk(blocks, &mut |block, _| {
340                        generated_blocks.insert(block.client_id);
341                    });
342                    template_blocks.extend(blocks.iter().map(|block| TemplateBlock {
343                        page: *page_id,
344                        block: block.client_id,
345                        block_name: block.name.clone(),
346                    }));
347                }
348            } else if !blocks
349                .iter()
350                .any(|block| PAGE_TITLE_BLOCKS.contains(&block.name.as_str()))
351                && let Some(page) = result.structure.page(*page_id)
352            {
353                let hero = GutenbergBlock::hero_section(page.title.trim(), "");
354                generated_blocks.insert(hero.client_id);
355                template_blocks.push(TemplateBlock {
356                    page: *page_id,
357                    block: hero.client_id,
358                    block_name: hero.name.clone(),
359                });
360                blocks.insert(0, hero);
361            }
362        }
363        let mut copies = Vec::new();
364        for copy in &result.page_copies {
365            // Pages added or copied earlier in the call aren't staged yet.
366            let source = match contents.get(&copy.source_page_id) {
367                Some(blocks) => blocks.clone(),
368                None => view
369                    .staged
370                    .page(copy.source_page_id)
371                    .map(|p| p.content.to_vec())
372                    .ok_or_else(|| {
373                        ToolError::Internal(format!(
374                            "the copied page {} has no blocks to copy",
375                            copy.source_page_id
376                        ))
377                    })?,
378            };
379            let (blocks, exercise_copies) = copy_blocks(&source, &ids, copy.new_page_id);
380            copies.extend(exercise_copies);
381            contents.insert(copy.new_page_id, blocks);
382        }
383        let mut after =
384            snapshots::apply_structure(&view.staged, &before, &result.structure, &contents);
385        pair_pages(
386            &mut *connection(state).await?,
387            &view,
388            &result.page_pairings,
389            &facts.page_language_groups,
390            &mut after,
391        )
392        .await?;
393        let links = result.links_to_old_paths(&snapshots::page_refs(&after), &view.course.slug);
394        let outline = args
395            .dry_run
396            .then(|| course_structure::outline(&before, &result.structure, !args.detail));
397        let copy_count = copies.len();
398        let report = staging::commit(
399            state,
400            ctx,
401            &view,
402            after,
403            WriteRequest {
404                source: WriteSource::Derived {
405                    exercise_copies: copies,
406                    generated_blocks,
407                },
408                is_dry_run_brief: !args.detail,
409                ..WriteRequest::new(
410                    Value::Array(args.ops),
411                    format!("{} structure op(s)", ops.len()),
412                    WriteMode::from_dry_run(args.dry_run),
413                )
414            },
415        )
416        .await?;
417
418        let read_back = args.read_back.as_ref().map(|selector| {
419            ReadBack::from(
420                resolve_read_back(selector, &result.labels).and_then(|selector| {
421                    let structure = snapshots::structure(&report.after, &facts);
422                    Ok(read_structure(
423                        &structure,
424                        &selector,
425                        &read_back_options(ctx),
426                    )?)
427                }),
428            )
429        });
430        let created = result
431            .created
432            .iter()
433            .map(|ids| {
434                let (mut own, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut template_blocks)
435                    .into_iter()
436                    .partition(|block| ids.ids.contains(&block.page));
437                template_blocks = rest;
438                own.sort_by_key(|block| ids.ids.iter().position(|id| *id == block.page));
439                CreatedStructure {
440                    ids,
441                    template_blocks: own,
442                }
443            })
444            .collect();
445        let summary = StructureSummary {
446            outline: outline.as_ref(),
447            created,
448            labels: &result.labels,
449            warnings: &result.warnings,
450            changed_paths: &result.changed_paths,
451            renumbered_chapters: &result.renumbered_chapters,
452            links_to_old_paths: &links,
453            read_back,
454        };
455        let mut consequences = Vec::new();
456        if !result.changed_paths.is_empty() {
457            consequences.push(hints::redirects_created(result.changed_paths.len()));
458        }
459        let reused_links = links.iter().filter(|l| l.now_opens_page.is_some()).count();
460        if links.len() > reused_links {
461            consequences.push(hints::links_to_old_paths(links.len() - reused_links));
462        }
463        if reused_links > 0 {
464            consequences.push(hints::links_to_reused_paths(reused_links));
465        }
466        if copy_count > 0 {
467            consequences.push(hints::exercises_copied(copy_count));
468        }
469        Ok(report.into_result(&summary).with_hints(consequences).into())
470    }
471}
472
473#[async_trait(?Send)]
474impl ToolHandler<McpState> for PatchCourseSettings {
475    type Args = PatchCourseArgs;
476
477    async fn call(
478        &self,
479        state: &McpState,
480        ctx: &RequestContext,
481        args: PatchCourseArgs,
482    ) -> Result<ToolOutcome, ToolError> {
483        let ops = parse_ops(&args.ops, PatchTarget::CourseSettings)?;
484        let view = open_course(state, ctx, args.course.id()).await?;
485        let ids = RandomIds;
486        let result = course_settings::apply(
487            &snapshots::settings_document(&view.staged, &view.course),
488            &ops,
489            &ids,
490        )?;
491        let mut after = view.staged.clone();
492        snapshots::apply_settings(&mut after, &result.settings);
493        let report = staging::commit(
494            state,
495            ctx,
496            &view,
497            after,
498            WriteRequest::new(
499                Value::Array(args.ops),
500                format!("{} settings op(s)", ops.len()),
501                WriteMode::from_dry_run(args.dry_run),
502            ),
503        )
504        .await?;
505        Ok(report
506            .into_result(&CreatedSummary {
507                created: &result.created,
508            })
509            .into())
510    }
511}
512
513#[async_trait(?Send)]
514impl ToolHandler<McpState> for PatchGlossaryAndReferences {
515    type Args = PatchCourseArgs;
516
517    async fn call(
518        &self,
519        state: &McpState,
520        ctx: &RequestContext,
521        args: PatchCourseArgs,
522    ) -> Result<ToolOutcome, ToolError> {
523        let ops = parse_ops(&args.ops, PatchTarget::GlossaryAndReferences)?;
524        let mut view = open_course(state, ctx, args.course.id()).await?;
525        let ids = RandomIds;
526        let document = snapshots::glossary_document(&view.staged);
527        let edit = glossary_references::apply(&document, &ops, &ids)?;
528        let texts = if edit.affects_pages() {
529            view.load_exercises(&mut *connection(state).await?).await?;
530            let page_ids: Vec<Uuid> = view.staged.pages.iter().map(|page| page.id).collect();
531            instruction_lists::text_documents(
532                &view.staged,
533                &page_ids,
534                ExerciseTextParts {
535                    instructions: true,
536                    course_default: true,
537                    names: false,
538                },
539            )
540        } else {
541            Vec::new()
542        };
543        let documents: Vec<DocumentRef> = snapshots::page_refs(&view.staged)
544            .into_iter()
545            .map(DocumentRef::from)
546            .chain(texts.iter().map(Document::as_document_ref))
547            .collect();
548        let result = edit.with_page_effects(&documents);
549        let (rewritten_pages, rewritten_exercise_instructions) = result
550            .rewritten_pages
551            .iter()
552            .map(|document| document.key)
553            .partition::<Vec<_>, _>(|key| matches!(key, DocumentKey::Page(_)));
554        let mut after = view.staged.clone();
555        snapshots::apply_glossary(&mut after, &result.document);
556        instruction_lists::apply_documents(&mut after, result.rewritten_pages)?;
557        let report = staging::commit(
558            state,
559            ctx,
560            &view,
561            after,
562            WriteRequest::new(
563                Value::Array(args.ops),
564                format!("{} glossary or reference op(s)", ops.len()),
565                WriteMode::from_dry_run(args.dry_run),
566            ),
567        )
568        .await?;
569        Ok(report
570            .into_result(&GlossarySummary {
571                created: &result.created,
572                rewritten_pages: rewritten_pages
573                    .into_iter()
574                    .filter_map(DocumentKey::page_id)
575                    .collect(),
576                rewritten_exercise_instructions,
577                usages: &result.usages,
578            })
579            .into())
580    }
581}