1use headless_lms_utils::version::Version;
5use serde::Serialize;
6
7use crate::page_readiness::PlaceholderText;
8use crate::protocol::context::ClientKind;
9use crate::validator::BlockSchemas;
10
11pub const CURRENT_SKILL_VERSION: Version = Version(0, 0, 0);
13pub const MINIMUM_SKILL_VERSION: Version = Version(0, 0, 0);
15pub const SKILL_SETUP_URL: &str =
17 "https://github.com/rage/course-material-agent-skills/blob/main/SETUP.md";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum HintKind {
23 FormatTip,
24 State,
25 Consequence,
26 SkillVersion,
27 VisualCheck,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
32pub struct Hint {
33 pub kind: HintKind,
34 pub text: String,
35}
36
37impl Hint {
38 fn new(kind: HintKind, text: impl Into<String>) -> Self {
39 Self {
40 kind,
41 text: text.into(),
42 }
43 }
44}
45
46pub const SKILL_DECLINED: &str = "declined";
48
49#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
51pub enum SkillRefusal {
52 #[error("{0}")]
54 Outdated(String),
55 #[error("{0}")]
57 Missing(String),
58}
59
60pub fn skill_version(reported: Option<&str>) -> Result<Option<Hint>, SkillRefusal> {
64 check_skill_version(reported, CURRENT_SKILL_VERSION, MINIMUM_SKILL_VERSION)
65}
66
67pub fn skill_missing() -> SkillRefusal {
70 SkillRefusal::Missing(format!(
72 "Nothing was done: no skill_version was sent, and this server can't be used safely without the course-material skill. \
73Ask the teacher whether it is fine to install the skill, and wait for their answer before continuing. \
74Tell them what it is: the course-material skill (https://github.com/rage/course-material-agent-skills) gives the agent the editing workflow, \
75the safety rules (what needs the teacher's approval, checking pages before publishing) and the formats of course content. \
76If the teacher agrees, install it: clone that repository into the user-level skills folder as course-material \
77(Claude Code: ~/.claude/skills/course-material, or skills/course-material in $CLAUDE_CONFIG_DIR if it is set; Codex: ~/.agents/skills/course-material), \
78read SKILL.md in it, then continue with the teacher's task, sending skill_version from the skill's VERSION file on every call. \
79The rest of the setup ({SKILL_SETUP_URL}) isn't needed for the task and can wait until it is done. \
80If the teacher says no, send skill_version \"{SKILL_DECLINED}\" on every call from then on; the tools then work without the skill."
81 ))
82}
83
84fn check_skill_version(
85 reported: Option<&str>,
86 current: Version,
87 minimum: Version,
88) -> Result<Option<Hint>, SkillRefusal> {
89 let Some(reported) = reported else {
90 return Ok(Some(Hint::new(
91 HintKind::SkillVersion,
92 format!(
93 "No skill_version was sent, so course tools are refused until the course-material skill is installed or the teacher declines it. The skill holds the workflow, the safety rules and the content formats; setup: {SKILL_SETUP_URL}"
94 ),
95 )));
96 };
97 if reported == SKILL_DECLINED {
98 return Ok(Some(Hint::new(
99 HintKind::SkillVersion,
100 "The teacher declined the course-material skill, so its workflow, safety rules and content formats aren't in use; guide has the block formats.",
101 )));
102 }
103 let Some(reported_version) = Version::parse(reported) else {
104 return Ok(Some(Hint::new(
105 HintKind::SkillVersion,
106 format!(
107 "skill_version \"{reported}\" isn't a version number; the current skill is {current}."
108 ),
109 )));
110 };
111 if reported_version < minimum {
112 return Err(SkillRefusal::Outdated(format!(
113 "Skill version {reported} is older than {minimum}, the oldest this server works with (current {current}). Update the skill as its update section describes, re-read SKILL.md, then repeat the call."
114 )));
115 }
116 if reported_version < current {
117 return Ok(Some(Hint::new(
118 HintKind::SkillVersion,
119 format!(
120 "Skill version {reported}; current {current}; see the skill's update section. An outdated skill can produce corrupted content."
121 ),
122 )));
123 }
124 Ok(None)
125}
126
127pub fn staged(changeset_id: uuid::Uuid, is_draft: bool) -> Hint {
130 let text = if is_draft {
131 format!(
132 "The changes are staged in changeset {changeset_id}. The course is a draft, so students see neither them nor the published course until the teacher takes the course out of draft."
133 )
134 } else {
135 format!(
136 "The changes are staged in changeset {changeset_id}; students don't see them until it is published."
137 )
138 };
139 Hint::new(HintKind::State, text)
140}
141
142pub fn needs_teacher_review(review_url: &str, reason_count: usize) -> Hint {
144 Hint::new(
145 HintKind::State,
146 format!(
147 "{reason_count} staged change(s) are risky, so only the teacher can publish this changeset, on the review page: {review_url}"
148 ),
149 )
150}
151
152pub fn published(changeset_id: uuid::Uuid, is_draft: bool) -> Hint {
154 let text = if is_draft {
155 format!(
156 "Changeset {changeset_id} is published to the draft course; students see the course once the teacher takes it out of draft."
157 )
158 } else {
159 format!("Changeset {changeset_id} is published; students see the changes now.")
160 };
161 Hint::new(HintKind::State, text)
162}
163
164pub fn rendering_changes(page_count: usize, client: ClientKind) -> Hint {
166 let tooling = match client {
167 ClientKind::ClaudeCode | ClientKind::Codex => "playwright-cli and its skill",
168 ClientKind::Other => "a browser tool such as playwright-cli",
169 };
170 Hint::new(
171 HintKind::VisualCheck,
172 format!(
173 "{page_count} page(s) in the changeset have rendering changes; preview them before publishing. get_preview_link gives one preview URL for the whole changeset to open with {tooling}."
174 ),
175 )
176}
177
178pub fn published_rendering_changes(page_count: usize) -> Hint {
180 Hint::new(
181 HintKind::VisualCheck,
182 format!(
183 "{page_count} published page(s) have rendering changes; any not previewed before publishing can be checked on the course site."
184 ),
185 )
186}
187
188pub fn template_placeholders(page_id: uuid::Uuid, placeholders: &[PlaceholderText]) -> Hint {
190 let blocks: Vec<String> = placeholders
191 .iter()
192 .map(|p| {
193 format!(
194 "{} {} {} \"{}\"",
195 p.block_name, p.block, p.attribute, p.text
196 )
197 })
198 .collect();
199 Hint::new(
200 HintKind::State,
201 format!(
202 "Page {page_id} still holds placeholder text from the default page template ({}); publish_changeset refuses it until the text is replaced or those blocks are removed.",
203 blocks.join("; ")
204 ),
205 )
206}
207
208pub fn redirects_created(count: usize) -> Hint {
210 Hint::new(
211 HintKind::Consequence,
212 format!(
213 "{count} page path(s) changed; redirects from the old paths are created automatically when the changeset is published."
214 ),
215 )
216}
217
218pub fn links_to_old_paths(count: usize) -> Hint {
220 Hint::new(
221 HintKind::Consequence,
222 format!(
223 "{count} link(s) in course content point at old paths; they keep working through the redirects."
224 ),
225 )
226}
227
228pub fn links_to_reused_paths(count: usize) -> Hint {
230 Hint::new(
231 HintKind::Consequence,
232 format!(
233 "{count} link(s) in course content point at old paths that other pages now have, so they will open those pages (now_opens_page in links_to_old_paths). Change them to the new paths if they should still open the moved pages."
234 ),
235 )
236}
237
238pub fn exercises_copied(count: usize) -> Hint {
240 Hint::new(
241 HintKind::Consequence,
242 format!("{count} copied exercise(s) are new exercises with no submissions."),
243 )
244}
245
246pub fn blocks_moved_between_pages(count: usize) -> Hint {
248 Hint::new(
249 HintKind::Consequence,
250 format!(
251 "{count} block(s) moved to another page keep their ids; feedback and proposed edits on them follow."
252 ),
253 )
254}
255
256pub fn review_config_switched_with_reviews(review_count: i64) -> Hint {
258 Hint::new(
259 HintKind::Consequence,
260 format!(
261 "Students have given {review_count} review(s) on this exercise. After the switch students no longer see those reviews and they no longer match the review questions; the teacher sees this warning on the review page."
262 ),
263 )
264}
265
266pub fn answered_task_instructions_edited() -> Hint {
268 Hint::new(
269 HintKind::Consequence,
270 "Students have already answered this exercise. Before staging the change, tell the teacher what it means for those earlier answers. Answer options and answer keys are in the task spec, which only the teacher can change, in the exercise editor; a changed question can leave them wrong.",
271 )
272}
273
274pub fn submissions_affected(count: usize) -> Hint {
276 Hint::new(
277 HintKind::Consequence,
278 format!("{count} removed exercise(s) have student submissions."),
279 )
280}
281
282pub fn format_tips<'a>(
285 block_names: impl IntoIterator<Item = &'a str>,
286 schemas: &BlockSchemas,
287) -> Vec<Hint> {
288 block_names
289 .into_iter()
290 .filter_map(|name| {
291 let schema = schemas.blocks.get(name)?;
292 let rich_text: Vec<&str> = schema
293 .attributes
294 .iter()
295 .filter(|(_, s)| {
296 s.get("x-rich-text").and_then(serde_json::Value::as_bool) == Some(true)
297 })
298 .map(|(attribute, _)| attribute.as_str())
299 .collect();
300 let mut facts = Vec::new();
301 if !rich_text.is_empty() {
302 facts.push(format!("rich text in {}", rich_text.join(", ")));
303 }
304 if let Some(children) = &schema.allowed_children {
305 if children.is_empty() {
306 facts.push("no inner blocks".to_string());
307 } else {
308 facts.push(format!("inner blocks: {}", children.join(", ")));
309 }
310 }
311 if schema
312 .allowed_parents
313 .as_ref()
314 .is_some_and(|p| p.iter().any(|p| p == "root"))
315 {
316 facts.push("top level only".to_string());
317 }
318 (!facts.is_empty()).then(|| {
319 Hint::new(
320 HintKind::FormatTip,
321 format!("{name}: {}.", facts.join("; ")),
322 )
323 })
324 })
325 .collect()
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn skill_versions() {
334 let missing = check_skill_version(None, Version(1, 6, 0), Version(1, 2, 0))
335 .unwrap()
336 .unwrap();
337 assert!(missing.text.contains("No skill_version was sent"));
338 let outdated = check_skill_version(Some("1.4.2"), Version(1, 6, 0), Version(1, 2, 0))
339 .unwrap()
340 .unwrap();
341 assert_eq!(
342 outdated.text,
343 "Skill version 1.4.2; current 1.6.0; see the skill's update section. An outdated skill can produce corrupted content."
344 );
345 assert!(
346 check_skill_version(Some("1.6.0"), Version(1, 6, 0), Version(1, 2, 0))
347 .unwrap()
348 .is_none()
349 );
350 assert!(
351 check_skill_version(Some("1.7.0"), Version(1, 6, 0), Version(1, 2, 0))
352 .unwrap()
353 .is_none()
354 );
355 assert!(
356 check_skill_version(Some("2.0"), Version(1, 6, 0), Version(1, 2, 0))
357 .unwrap()
358 .is_none()
359 );
360 let too_old =
361 check_skill_version(Some("1.1.9"), Version(1, 6, 0), Version(1, 2, 0)).unwrap_err();
362 assert!(too_old.to_string().contains("update section"));
363 let declined =
364 check_skill_version(Some(SKILL_DECLINED), Version(1, 6, 0), Version(1, 2, 0))
365 .unwrap()
366 .unwrap();
367 assert!(declined.text.contains("declined"));
368 for not_a_version in ["banana", "1.3.0-rc1", "1.x", "1.2.3.4"] {
369 let hint = check_skill_version(Some(not_a_version), Version(1, 6, 0), Version(1, 2, 0))
370 .unwrap()
371 .unwrap();
372 assert!(
373 hint.text.contains("isn't a version number"),
374 "{not_a_version}"
375 );
376 }
377 assert!(
378 skill_version(Some(&CURRENT_SKILL_VERSION.to_string()))
379 .unwrap()
380 .is_none()
381 );
382 }
383
384 #[test]
385 fn hints_state_facts_not_commands() {
386 let hints = [
387 rendering_changes(2, ClientKind::Codex),
388 redirects_created(3),
389 needs_teacher_review("https://x/review", 1),
390 skill_version(None).unwrap().unwrap(),
391 skill_version(Some(SKILL_DECLINED)).unwrap().unwrap(),
392 ];
393 for hint in hints {
394 let lower = hint.text.to_lowercase();
395 assert!(
396 !lower.contains("git pull") && !lower.starts_with("run "),
397 "{}",
398 hint.text
399 );
400 }
401 }
402}