headless_lms_mcp/staging/
report.rs1use chrono::{DateTime, Utc};
4use headless_lms_models::library::agent_changesets::{
5 FieldChange, StageCallResult, course_state::CourseState,
6};
7use serde::Serialize;
8use serde_json::Value;
9use uuid::Uuid;
10
11use super::validation::BlockFinding;
12use crate::diff::BlockChange;
13use crate::explanations::ExplainedEntity;
14use crate::hints::{self, Hint};
15use crate::page_readiness;
16use crate::protocol::context::ClientKind;
17use crate::state::McpState;
18use crate::tools::ToolResult;
19
20pub struct WriteReport {
22 pub(super) summary: StagingSummary,
23 pub hints: Vec<Hint>,
24 pub after: CourseState,
26}
27
28impl WriteReport {
29 pub fn into_result(self, extra: &impl Serialize) -> ToolResult {
31 #[derive(Serialize)]
32 struct Fields<'a, E> {
33 #[serde(flatten)]
34 extra: &'a E,
35 #[serde(flatten)]
36 summary: &'a StagingSummary,
37 }
38 ToolResult::success(Fields {
39 extra,
40 summary: &self.summary,
41 })
42 .with_hints(self.hints)
43 }
44}
45
46#[derive(Serialize)]
47#[serde(untagged)]
48pub(super) enum StagingSummary {
49 Nothing(NothingStaged),
50 DryRun(DryRunSummary),
51 BriefDryRun(BriefDryRunSummary),
52 Staged(StagedSummary),
53}
54
55#[derive(Serialize)]
56pub(super) struct NothingStaged {
57 pub(super) staged: [Value; 0],
58 pub(super) message: &'static str,
59 #[serde(skip_serializing_if = "Vec::is_empty")]
60 pub(super) unchanged_block_warnings: Vec<BlockFinding>,
61}
62
63#[derive(Serialize)]
65pub(super) struct DryRunEntity {
66 #[serde(flatten)]
67 pub(super) entity: ExplainedEntity,
68 #[serde(skip_serializing_if = "Vec::is_empty")]
69 pub(super) field_changes: Vec<FieldChange>,
70}
71
72#[derive(Serialize)]
73pub(super) struct DryRunSummary {
74 pub(super) dry_run: bool,
75 pub(super) would_stage: Vec<DryRunEntity>,
77 #[serde(skip_serializing_if = "Vec::is_empty")]
78 pub(super) block_changes: Vec<BlockChange>,
79 #[serde(skip_serializing_if = "Vec::is_empty")]
80 pub(super) unchanged_block_warnings: Vec<BlockFinding>,
81}
82
83#[derive(Serialize)]
85pub(super) struct BriefDryRunSummary {
86 pub(super) dry_run: bool,
87 pub(super) would_stage_count: usize,
88 pub(super) block_change_count: usize,
89 #[serde(skip_serializing_if = "Vec::is_empty")]
91 pub(super) risky_entities: Vec<ExplainedEntity>,
92 #[serde(skip_serializing_if = "Vec::is_empty")]
93 pub(super) unchanged_block_warnings: Vec<BlockFinding>,
94}
95
96#[derive(Serialize)]
97pub(super) struct StagedSummary {
98 pub(super) changeset: Uuid,
99 pub(super) review_url: String,
101 pub(super) changeset_revision: i32,
102 pub(super) call: Uuid,
103 pub(super) staged: Vec<ExplainedEntity>,
104 pub(super) changeset_expires_at: DateTime<Utc>,
105 #[serde(skip_serializing_if = "Vec::is_empty")]
106 pub(super) unchanged_block_warnings: Vec<BlockFinding>,
107}
108
109pub(super) async fn write_hints(
112 state: &McpState,
113 staged: &StageCallResult,
114 is_draft: bool,
115 removed_exercises_with_submissions: usize,
116 new_block_names: &[&str],
117) -> Vec<Hint> {
118 let mut hints = vec![hints::staged(staged.changeset.id, is_draft)];
119 if staged.risk_reason_count > 0 {
120 let review_url = state.review_url(staged.changeset.id);
121 hints.push(hints::needs_teacher_review(
122 &review_url,
123 staged.risk_reason_count,
124 ));
125 }
126 if removed_exercises_with_submissions > 0 {
127 hints.push(hints::submissions_affected(
128 removed_exercises_with_submissions,
129 ));
130 }
131 if !new_block_names.is_empty()
132 && let Ok(schemas) = state.block_schemas().await
133 {
134 hints.extend(hints::format_tips(
135 new_block_names.iter().copied(),
136 &schemas,
137 ));
138 }
139 hints
140}
141
142pub(super) fn page_hints(
145 client: ClientKind,
146 block_changes: &[BlockChange],
147 live: &CourseState,
148 before: &CourseState,
149 after: &CourseState,
150 written_pages: &[Uuid],
151) -> Vec<Hint> {
152 let mut hints = Vec::new();
153 if !block_changes.is_empty()
155 && page_readiness::pages_with_content_changes(before, live)
156 .next()
157 .is_none()
158 {
159 let changed = page_readiness::pages_with_content_changes(after, live).count();
160 if changed > 0 {
161 hints.push(hints::rendering_changes(changed, client));
162 }
163 }
164 for page in written_pages.iter().filter_map(|id| after.page(*id)) {
165 let placeholders = page_readiness::placeholder_texts(&page.content);
166 if !placeholders.is_empty() {
167 hints.push(hints::template_placeholders(page.id, &placeholders));
168 }
169 }
170 hints
171}