Skip to main content

headless_lms_mcp/staging/
report.rs

1//! What a write call reports back: the staging summary and the hints that follow it.
2
3use 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
20/// What a write call staged, or would stage on a dry run.
21pub struct WriteReport {
22    pub(super) summary: StagingSummary,
23    pub hints: Vec<Hint>,
24    /// The state after the call, for `read_back`.
25    pub after: CourseState,
26}
27
28impl WriteReport {
29    /// The tool result: the fields of `extra`, a struct, and the staging summary.
30    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/// An entity a dry run would stage, with what the call changes of it.
64#[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    /// What a real run would list as `staged`.
76    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/// A dry run without the per-entity and per-block detail, for calls that report their own outline.
84#[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    /// The would-be staged entities that need a teacher's review.
90    #[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    /// Where the teacher reviews and publishes the changeset.
100    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
109/// What the agent should know after staging: where to review, what a human must check and what
110/// the block types `new_block_names` look like.
111pub(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
142/// What the agent should know about the pages after a call changed `block_changes`: the first
143/// rendering changes in the changeset, and placeholder text left in `written_pages`.
144pub(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    // publish_changeset repeats the hint with the final count.
154    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}