Skip to main content

headless_lms_mcp/
explanations.rs

1//! What the typed risk reasons and publish blockers of models mean, in words for agents.
2
3use headless_lms_models::{
4    agent_changeset_entities::{AgentChangesetEntityOperation, ChangesetEntitySummary, RiskReason},
5    library::{agent_changesets::PublishBlocker, entity_snapshots::EntityRef},
6};
7use serde::Serialize;
8
9/// A risk reason with what it means and what publishing it does to students.
10#[derive(Debug, Clone, PartialEq, Serialize)]
11pub struct ExplainedRiskReason {
12    #[serde(flatten)]
13    pub reason: RiskReason,
14    pub message: String,
15    pub consequence: &'static str,
16}
17
18impl From<RiskReason> for ExplainedRiskReason {
19    fn from(reason: RiskReason) -> Self {
20        let (message, consequence) = match &reason {
21            RiskReason::ExerciseWithSubmissionsRemoved { exercise_id } => (
22                format!("removes exercise {exercise_id}, which has submissions"),
23                "students' answers and points for this exercise stop counting",
24            ),
25            RiskReason::PageWithSubmissionsRemoved => (
26                "removes the page, which has exercises with submissions".to_string(),
27                "students' answers and points on this page stop counting",
28            ),
29            RiskReason::ChapterWithSubmissionsRemoved => (
30                "removes the chapter, which has exercises with submissions".to_string(),
31                "students' answers and points in this chapter stop counting",
32            ),
33            RiskReason::VisiblePageRemoved => (
34                "removes the page, which students can see".to_string(),
35                "the page disappears for students and its address gets no redirect",
36            ),
37            RiskReason::OpenChapterRemoved => (
38                "removes the chapter, which is open to students".to_string(),
39                "the chapter and its pages disappear for students, without redirects",
40            ),
41            RiskReason::DeadlineMovedEarlier { before, after } => (
42                format!("moves the deadline of the open chapter earlier, from {before} to {after}"),
43                "students get less time; answers after the new deadline no longer count",
44            ),
45            RiskReason::DeadlineAddedToOpenChapter { deadline } => (
46                format!("adds the deadline {deadline} to the open chapter"),
47                "answers after the deadline no longer count",
48            ),
49            RiskReason::ExerciseDeadlineMovedEarlier {
50                exercise_id,
51                before,
52                after,
53            } => (
54                format!(
55                    "moves the deadline of open exercise {exercise_id} earlier, from {before} to {after}"
56                ),
57                "students get less time; answers after the new deadline no longer count",
58            ),
59            RiskReason::ExerciseDeadlineAdded {
60                exercise_id,
61                deadline,
62            } => (
63                format!("adds the deadline {deadline} to open exercise {exercise_id}"),
64                "answers after the deadline no longer count",
65            ),
66            RiskReason::OpeningMovedLater { opens_at } => (
67                format!("moves the opening time of the open chapter later, to {opens_at}"),
68                "students lose access to the chapter until it opens again",
69            ),
70            RiskReason::CompletionPolicyChangedAfterCompletions => (
71                "changes the completion policy of a module that students have already completed"
72                    .to_string(),
73                "who completes the module changes from now on, while existing completions stay",
74            ),
75            RiskReason::CompletionRequirementChangedAfterCompletions => (
76                "changes how many module completions the base module requires after completions exist"
77                    .to_string(),
78                "students who have completed modules may gain or lose the course completion",
79            ),
80            RiskReason::ChapterLockingToggled { is_enabled } => (
81                format!(
82                    "turns chapter locking {}",
83                    if *is_enabled { "on" } else { "off" }
84                ),
85                "students' access to later chapters changes immediately",
86            ),
87        };
88        Self {
89            reason,
90            message,
91            consequence,
92        }
93    }
94}
95
96/// A staged entity as agents read it: a [`ChangesetEntitySummary`] with its risk reasons
97/// explained.
98#[derive(Debug, Clone, PartialEq, Serialize)]
99pub struct ExplainedEntity {
100    #[serde(flatten)]
101    pub entity: EntityRef,
102    /// Whether the page is its chapter's front page; write results set it, `list_changesets` doesn't.
103    #[serde(skip_serializing_if = "std::ops::Not::not")]
104    pub is_chapter_front_page: bool,
105    pub operation: AgentChangesetEntityOperation,
106    #[serde(skip_serializing_if = "Vec::is_empty")]
107    pub risk_reasons: Vec<ExplainedRiskReason>,
108}
109
110impl From<ChangesetEntitySummary> for ExplainedEntity {
111    fn from(summary: ChangesetEntitySummary) -> Self {
112        Self {
113            entity: summary.entity,
114            is_chapter_front_page: false,
115            operation: summary.operation,
116            risk_reasons: summary.risk_reasons.into_iter().map(Into::into).collect(),
117        }
118    }
119}
120
121/// A publish blocker with what it means.
122#[derive(Debug, Clone, PartialEq, Serialize)]
123pub struct ExplainedBlocker {
124    #[serde(flatten)]
125    pub blocker: PublishBlocker,
126    pub message: String,
127}
128
129fn named(entity: &EntityRef) -> String {
130    format!("\"{}\" ({})", entity.label, entity.entity_id)
131}
132
133impl From<PublishBlocker> for ExplainedBlocker {
134    fn from(blocker: PublishBlocker) -> Self {
135        let message = match &blocker {
136            PublishBlocker::PageInMissingChapter { page, chapter_id } => format!(
137                "page {} would be in chapter {chapter_id}, which wouldn't exist",
138                named(page)
139            ),
140            PublishBlocker::DuplicatePath { url_path, pages } => {
141                format!("{} pages would have the url_path {url_path}", pages.len())
142            }
143            PublishBlocker::DuplicatePageOrderNumber {
144                chapter,
145                order_number,
146                pages,
147            } => format!(
148                "{} pages would share the order number {order_number} {}",
149                pages.len(),
150                match chapter {
151                    Some(chapter) => format!("in chapter {}", named(chapter)),
152                    None => "among the top-level pages".to_string(),
153                }
154            ),
155            PublishBlocker::ChapterInMissingModule { chapter, module_id } => format!(
156                "chapter {} would be in module {module_id}, which wouldn't exist",
157                named(chapter)
158            ),
159            PublishBlocker::MissingFrontPage {
160                chapter,
161                front_page_id,
162            } => format!(
163                "chapter {} would have front page {front_page_id}, which wouldn't exist",
164                named(chapter)
165            ),
166            PublishBlocker::FrontPageInOtherChapter {
167                chapter,
168                front_page,
169            } => format!(
170                "chapter {} would have front page {}, which would be in another chapter",
171                named(chapter),
172                named(front_page)
173            ),
174            PublishBlocker::FrontPageOffPath {
175                chapter,
176                front_page,
177                url_path,
178                expected_url_path,
179            } => format!(
180                "chapter {} would have front page {} at {url_path} instead of {expected_url_path}",
181                named(chapter),
182                named(front_page)
183            ),
184            PublishBlocker::DuplicateChapterNumber {
185                chapter_number,
186                chapters,
187            } => format!(
188                "{} chapters would have the number {chapter_number}",
189                chapters.len()
190            ),
191            PublishBlocker::DuplicateModuleOrderNumber {
192                order_number,
193                modules,
194            } => format!(
195                "{} modules would have the order number {order_number}",
196                modules.len()
197            ),
198        };
199        Self { blocker, message }
200    }
201}
202
203/// The blockers, each explained.
204pub fn explain_blockers(blockers: Vec<PublishBlocker>) -> Vec<ExplainedBlocker> {
205    blockers.into_iter().map(Into::into).collect()
206}