Skip to main content

headless_lms_mcp/
guide.rs

1//! Content of the `guide` tool: per block type from the CMS schema export, per topic hand-written.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value, json};
6
7use crate::engine::{EditError, EditErrorCode, PageType};
8use crate::validator::BlockSchemas;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
11#[serde(rename_all = "kebab-case")]
12pub enum GuideTopic {
13    Editing,
14    CourseStructure,
15    PublishingAndRisk,
16    PreviewsAndChecks,
17    CitationsAndGlossary,
18    RichText,
19    Exercises,
20}
21
22impl GuideTopic {
23    pub const ALL: [GuideTopic; 7] = [
24        GuideTopic::Editing,
25        GuideTopic::CourseStructure,
26        GuideTopic::PublishingAndRisk,
27        GuideTopic::PreviewsAndChecks,
28        GuideTopic::CitationsAndGlossary,
29        GuideTopic::RichText,
30        GuideTopic::Exercises,
31    ];
32
33    fn text(self) -> &'static str {
34        match self {
35            GuideTopic::Editing => EDITING,
36            GuideTopic::CourseStructure => COURSE_STRUCTURE,
37            GuideTopic::PublishingAndRisk => PUBLISHING_AND_RISK,
38            GuideTopic::PreviewsAndChecks => PREVIEWS_AND_CHECKS,
39            GuideTopic::CitationsAndGlossary => CITATIONS_AND_GLOSSARY,
40            GuideTopic::RichText => RICH_TEXT,
41            GuideTopic::Exercises => EXERCISES,
42        }
43    }
44}
45
46const EDITING: &str = "\
47patch_page_content takes ops {op, path, from, through, value, position, to_page, label, at_text}.
48- add: value is a block or a list of blocks; position before/after a block path or start/end of a children list (\"\" or /<id>/innerBlocks).
49- replace /<id>: a block, keeping its id; fields you leave out keep their values; a container's replacement needs innerBlocks (use {\"clientId\":\"…\"} stubs to keep children).
50- replace /<id>/attributes/<name>: sets one attribute at any depth; a missing key is added. remove on it deletes the attribute.
51- replace on a children list or \"\": array rules: a stub keeps a block, a block with clientId replaces it, a block without one is new; leaving out an existing block or naming an unknown id is an error. Use this to reorder.
52- move/copy: from (+through) to path+position; to_page moves or copies to another page. move keeps ids, copy assigns new ones (a copied exercise becomes a new exercise without submissions).
53- remove: a block or a range (path + through).
54- wrap (path, through, value: the container), unwrap (path), split (path, at_text: the second block starts there), merge (from into path; from must be the next sibling).
55- Exercise blocks can be moved, copied and removed, not replaced or edited; patch_exercise edits an exercise's name, own deadline and instructions.
56- dry_run: true stages nothing and returns the diff; read_back reads the result in the same call.";
57
58const COURSE_STRUCTURE: &str = "\
59get_course_structure returns {course: {pages, modules: [{chapters: [{pages}]}]}}. The first page of a chapter is its front page (is_chapter_front_page: true); the course front page (/) is always first in /course/pages; the default module is always first and has no name.
60patch_course_structure ops: replace fields (module name; chapter name, color, opens_at, deadline; page title, url_path, hidden), remove (field to null, or an entity or page range), add (module, chapter or page; a chapter gets a front page; a page may carry blocks), move, copy (page or chapter; new ids, -copy suffix), reorder (value: exactly the current ids in the new order).
61- Chapter numbers and /chapter-N paths are recomputed after every call. A chapter page's url_path is its last segment, e.g. \"loops\" for /chapter-2/loops; the front page's path is always /chapter-N. A hero section's label shows \"chapter N\" by itself while useDefaultTextForLabel is on and the label is unset or \"Chapter number...\"; leave it so, as a hand-set number goes stale when pages move.
62- url_path is unique in the course after the call; paths can be swapped in one call. Changed paths get redirects automatically; removed pages get none.
63- A new page that translates a page of another language version of the course takes pair_with: <that page's id>; it joins that page's page_language_group_id at publish, so language switching links the two. The source page must be published, and the group can't already have a page in this course.
64- dry_run: true returns an outline of the changed chapters and pages (counts, then one line each: old -> new path, title, position) and the redirects; read it against your plan, as detail: true adds the full diff.
65- Pages with exercises stay inside chapters. Settings (completion, certificates) are edited with patch_course_settings.";
66
67const PUBLISHING_AND_RISK: &str = "\
68Every write stages into your open changeset for the course; later calls build on it, and reads show it unless state is published. list_changesets shows it, discard_changeset drops it, publish_changeset publishes it.
69A change is risky only in live content (not draft, not test mode, not a hidden page, not a chapter that hasn't opened):
70- removing an exercise block whose exercise has submissions, or a page or chapter containing one
71- removing a visible page or an open chapter
72- moving a deadline earlier or adding one to an open chapter; moving an open chapter's opening time later
73- changing an exercise's own deadline so that the deadline that applies to it moves earlier, or it gets one, while its chapter is open
74- changing a completion policy or the module completion requirement after completions exist; turning chapter locking on or off
75publish_changeset refuses changesets with risky changes; the teacher publishes those on the review page. Size alone is never risky; moving exercises is never risky, even when the move changes which chapter deadline applies. Publishing fails if a staged page or entity changed live meanwhile: drop just those with discard_changeset and entities, read their published state again and redo your edits to them.
76A write fails with staged_state_changed when a parallel call changed what it read; read again and repeat it.
77After publishing, get_history lists changes and revert_change undoes one write call.";
78
79const PREVIEWS_AND_CHECKS: &str = "\
80get_preview_link returns read-only URLs, valid about 2 hours, no login needed, of staged state or, with nothing staged, of the published pages: without page or pages, one URL for all pages the changeset adds or changes (all course pages when nothing is staged), which the preview page lists and opens one at a time (?page=<id> picks one); page or pages gives a URL per page. Open it with your own browser tooling (playwright-cli) in a desktop or mobile viewport and check: render and console errors, LaTeX errors (.katex-error), broken links and images, and accessibility (axe). The preview shows each exercise's name, task instructions and review instructions, but not the exercise itself to answer, and has no chapter navigation; the changeset URL lists the changed pages and renders the Pages in chapter list on chapter front pages. Checks are expected before a changeset is published.";
81
82const CITATIONS_AND_GLOSSARY: &str = "\
83Citations are written in rich text as \\cite{key}, \\cite[postnote]{key} or \\cite[prenote][postnote]{key}, where key is a reference's citation_key. References hold one BibTeX entry each (fields citation_key, bibtex); the entry's key must equal citation_key and be unique in the course. The server checks only an entry's structure; pages render citations with a stricter parser, so keep to standard BibTeX and preview a page that cites a new entry.
84Glossary terms (fields term, definition) are highlighted automatically wherever the exact term appears, case-sensitively, as a whole word: a letter (ä and ö included), digit or _ at the term's edge never touches another one, so \"äly\" isn't highlighted in \"tekoäly\" and an inflected form like \"algoritmin\" isn't highlighted for \"algoritmi\". A tag counts as a word edge. Nothing is marked up in the content.
85Renaming a citation_key rewrites its citations in page content and exercise instructions. Removing a reference, or renaming or removing a term, changes no content; the result lists the pages and exercise instructions that use it.";
86
87const RICH_TEXT: &str = "\
88Rich-text attributes hold the HTML the editor stores, with inline formats only, in exactly the shapes the editor produces (see rich_text_formats). No block-level tags, scripts, event attributes, style attributes or inline images. Inline math is [latex]x^2[/latex], rendered with KaTeX like moocfi/latex blocks; inside it only &amp; is decoded. The editor's <math> format renders as browser MathML and looks different: use it only where the page already does. Write &amp;, &lt; and &gt; as entities. Matching in find_replace sees this markup: <em>for</em> loops doesn't match \"for loops\". The validator normalises harmless differences and rejects anything that would change meaning.";
89
90const EXERCISES: &str = "\
91An exercise block on a page holds only the exercise id (exercise_id in reads). get_exercise reads the exercise: name, tasks, review settings and its instructions; patch_exercise edits the name, the own deadline, each task's instructions (/tasks/<task id>) and the review instructions (/review_instructions) with page-content ops. Everything else (points, tries, task specs, review thresholds and questions) is read-only; the teacher edits it in the editor.
92- The effective deadline is the exercise's own, else its chapter's. Chapter deadlines are strongly preferred: own deadlines are easy to miss when dates change. Add one only when the teacher asks; deadline: null clears it. Times are RFC 3339 with an offset.
93- /tasks/<task id>/specs reads a task's private, public and model solution specs together; the private spec is what the editor saves and the only authoritative one, and the service derives the others from it, so they can lag behind a staged change until publish. Specs hold answer keys: never copy them into what students see.
94- Instructions take the blocks the editor allows there, which the validator checks; exercise blocks can't be nested in them.
95- Review instructions show only for exercises with peer or self reviews. Most exercises use the course default review config, so its instructions are shared: review_config edit_course_default edits them for every exercise using it. Prefer that. fork gives one exercise its own copy of the default settings and questions, for an exercise with special needs; use_course_default drops an exercise's own settings again, and the result lists what it drops.
96- Switching review settings is allowed after students have given reviews, but the result and the review page warn about it.
97- Name and instruction edits are never risky; a deadline change is risky as in publishing-and-risk.
98- find_replace and structural_replace include instructions by default (include_exercise_instructions) and find_replace exercise names with include_exercise_names; matches in the course default review instructions are listed once and change every exercise using it. Bulk tools never fork. Regexes (find_replace regex: true, structural_replace text_regex) use Rust regex syntax: no backreferences or lookaround; (?i) for case-insensitive.";
99
100/// The `guide` tool's result: the guide of each block type and topic asked for, or with neither
101/// asked for, the index of both. `schemas` may be `None` only when only topics are asked for.
102pub fn answer(
103    schemas: Option<&BlockSchemas>,
104    block_types: &[String],
105    topics: &[GuideTopic],
106) -> Result<Value, EditError> {
107    let mut answer = Map::new();
108    if let Some(schemas) = schemas {
109        if block_types.is_empty() && topics.is_empty() {
110            return Ok(index(schemas));
111        }
112        if !block_types.is_empty() {
113            answer = blocks_guide(schemas, block_types)?;
114        }
115    }
116    if !topics.is_empty() {
117        answer.insert("topics".into(), topics_guide(topics));
118    }
119    Ok(Value::Object(answer))
120}
121
122/// What the guide covers: its topics and the block types each page type allows.
123fn index(schemas: &BlockSchemas) -> Value {
124    json!({
125        "topics": GuideTopic::ALL,
126        "block_types_by_page_type": schemas.page_types,
127        "schema_version": schemas.version,
128        "usage": "Call guide with block_types and/or topics for details.",
129    })
130}
131
132/// The hand-written guide of each topic.
133fn topics_guide(topics: &[GuideTopic]) -> Value {
134    json!(
135        topics
136            .iter()
137            .map(|topic| json!({"topic": topic, "text": topic.text()}))
138            .collect::<Vec<_>>()
139    )
140}
141
142/// Each block type's schema, nesting rules and an example add op, with the rich-text formats.
143/// Errors on an unknown block type, listing similar ones.
144fn blocks_guide(
145    schemas: &BlockSchemas,
146    block_types: &[String],
147) -> Result<Map<String, Value>, EditError> {
148    let blocks = block_types
149        .iter()
150        .map(|name| block_guide(schemas, name))
151        .collect::<Result<Vec<_>, _>>()?;
152    Ok(Map::from_iter([
153        ("blocks".to_string(), Value::Array(blocks)),
154        (
155            "rich_text_formats".to_string(),
156            json!(schemas.rich_text_formats),
157        ),
158        ("schema_version".to_string(), json!(schemas.version)),
159    ]))
160}
161
162fn block_guide(schemas: &BlockSchemas, name: &str) -> Result<Value, EditError> {
163    let Some(schema) = schemas.blocks.get(name) else {
164        let suffix = name.rsplit('/').next().unwrap_or(name);
165        let similar: Vec<&String> = schemas
166            .blocks
167            .keys()
168            .filter(|k| k.contains(suffix))
169            .collect();
170        return Err(
171            EditError::new(EditErrorCode::NotFound, format!("no block type \"{name}\""))
172                .with_details(
173                    json!({"similar": similar, "all": schemas.blocks.keys().collect::<Vec<_>>()}),
174                ),
175        );
176    };
177    let page_types: Vec<&PageType> = schemas
178        .page_types
179        .iter()
180        .filter(|(_, names)| names.iter().any(|n| n == name))
181        .map(|(t, _)| t)
182        .collect();
183    let mut example_op =
184        json!({"op": "add", "path": "", "position": "end", "value": schema.example});
185    if schema
186        .allowed_parents
187        .as_ref()
188        .is_some_and(|p| !p.iter().any(|p| p == "root"))
189    {
190        example_op["path"] = json!("/<parent id>/innerBlocks");
191    }
192    Ok(json!({
193        "name": name,
194        "title": schema.title,
195        "description": schema.description,
196        "attributes": schema.attributes,
197        "allowed_parents": schema.allowed_parents,
198        "allowed_children": schema.allowed_children,
199        "template": schema.template,
200        "template_lock": schema.template_lock,
201        "page_types": page_types,
202        "enabled_by_course_setting": schemas.course_feature_gated_blocks.get(name),
203        "example_op": example_op,
204    }))
205}
206
207#[cfg(test)]
208mod tests {
209    use std::collections::BTreeMap;
210
211    use super::*;
212    use crate::validator::{BlockSchema, RichTextFormat};
213
214    fn schemas() -> BlockSchemas {
215        BlockSchemas {
216            version: "abc".into(),
217            page_types: BTreeMap::from([(
218                PageType::CoursePage,
219                vec!["core/paragraph".into(), "core/list-item".into()],
220            )]),
221            course_feature_gated_blocks: BTreeMap::new(),
222            max_request_bytes: 1_000_000,
223            rich_text_formats: vec![RichTextFormat {
224                name: "core/bold".into(),
225                tag: "strong".into(),
226                description: "Bold".into(),
227            }],
228            blocks: BTreeMap::from([
229                (
230                    "core/paragraph".into(),
231                    BlockSchema {
232                        title: "Paragraph".into(),
233                        description: String::new(),
234                        attributes: serde_json::from_value(
235                            json!({"content": {"type": "string", "x-rich-text": true}}),
236                        )
237                        .unwrap(),
238                        allowed_parents: None,
239                        allowed_children: Some(vec![]),
240                        template: None,
241                        template_lock: None,
242                        example: json!({"name": "core/paragraph", "attributes": {"content": "Example text."}, "innerBlocks": []}),
243                    },
244                ),
245                (
246                    "core/list-item".into(),
247                    BlockSchema {
248                        title: "List item".into(),
249                        description: String::new(),
250                        attributes: Map::new(),
251                        allowed_parents: Some(vec!["core/list".into()]),
252                        allowed_children: None,
253                        template: None,
254                        template_lock: None,
255                        example: json!({}),
256                    },
257                ),
258            ]),
259        }
260    }
261
262    #[test]
263    fn index_blocks_and_topics() {
264        let index = index(&schemas());
265        assert_eq!(index["topics"].as_array().unwrap().len(), 7);
266        assert_eq!(index["topics"][1], json!("course-structure"));
267        let guide = blocks_guide(
268            &schemas(),
269            &["core/paragraph".into(), "core/list-item".into()],
270        )
271        .unwrap();
272        assert_eq!(guide["blocks"][0]["example_op"]["path"], json!(""));
273        assert_eq!(
274            guide["blocks"][1]["example_op"]["path"],
275            json!("/<parent id>/innerBlocks")
276        );
277        assert_eq!(guide["blocks"][0]["page_types"], json!(["course-page"]));
278        let topics = topics_guide(&[GuideTopic::RichText]);
279        assert_eq!(topics[0]["topic"], json!("rich-text"));
280        assert!(topics[0]["text"].as_str().unwrap().contains("[latex]"));
281    }
282
283    #[test]
284    fn unknown_block_lists_similar() {
285        let error = blocks_guide(&schemas(), &["moocfi/paragraph".into()]).unwrap_err();
286        assert_eq!(error.details.unwrap()["similar"], json!(["core/paragraph"]));
287    }
288}