Skip to main content

headless_lms_mcp/handlers/
bulk.rs

1//! Course-wide text and block replacement.
2
3use async_trait::async_trait;
4use serde::Serialize;
5use serde_json::json;
6use uuid::Uuid;
7
8use crate::access::{connection, open_course};
9use crate::engine::find_replace::{FindReplaceRequest, InflectedVariant, TextMatch, find_replace};
10use crate::engine::structural_replace::structural_replace;
11use crate::engine::{BlockMatch, Document, DocumentRef, PageRef};
12use crate::ids::RandomIds;
13use crate::instruction_lists::{self, ExerciseTextParts};
14use crate::snapshots;
15use crate::staging::{self, WriteMode, WriteRequest};
16use crate::state::McpState;
17use crate::tools::args::{ContentScope, FindReplaceArgs, IdArgument, StructuralReplaceArgs};
18use crate::tools::{
19    McpScope, RequestContext, ToolError, ToolHandler, ToolOutcome, ToolResult, off_worker,
20};
21use headless_lms_models::library::agent_changesets::course_state::{CourseState, CourseView};
22
23/// Handler of the `find_replace` tool.
24pub struct FindReplace;
25/// Handler of the `structural_replace` tool.
26pub struct StructuralReplace;
27
28#[derive(Serialize)]
29struct FindReplaceSummary<'a> {
30    total: usize,
31    matches: &'a [TextMatch],
32    #[serde(
33        rename = "inflected_variants_not_changed",
34        skip_serializing_if = "<[_]>::is_empty"
35    )]
36    inflected_variants: &'a [InflectedVariant],
37}
38
39#[derive(Serialize)]
40struct StructuralReplaceSummary<'a> {
41    total: usize,
42    matches: &'a [BlockMatch],
43}
44
45/// The pages of `state` a scope covers: one page, one chapter's pages or the whole course.
46fn pages_in_scope(
47    state: &CourseState,
48    scope: Option<ContentScope>,
49) -> Result<Vec<PageRef<'_>>, ToolError> {
50    let course_id = state.course_id();
51    if let Some(page_id) = scope.and_then(|s| s.page).map(IdArgument::id) {
52        let page = state.page(page_id).ok_or_else(|| ToolError::NotFound {
53            message: format!("course {course_id} has no page {page_id}"),
54        })?;
55        return Ok(vec![snapshots::page_ref(page)]);
56    }
57    if let Some(chapter_id) = scope.and_then(|s| s.chapter).map(IdArgument::id) {
58        if !state.chapters.iter().any(|c| c.id == chapter_id) {
59            return Err(ToolError::NotFound {
60                message: format!("course {course_id} has no chapter {chapter_id}"),
61            });
62        }
63        return Ok(state
64            .pages_in_order(Some(chapter_id))
65            .into_iter()
66            .map(snapshots::page_ref)
67            .collect());
68    }
69    Ok(snapshots::page_refs(state))
70}
71
72/// The course default review instructions are in scope for the whole course, or when an exercise
73/// in scope shows them.
74async fn texts_in_scope(
75    state: &McpState,
76    view: &mut CourseView,
77    scope: Option<ContentScope>,
78    with_instructions: bool,
79    with_names: bool,
80) -> Result<(Vec<Uuid>, Vec<Document>), ToolError> {
81    let page_ids: Vec<Uuid> = pages_in_scope(&view.staged, scope)?
82        .iter()
83        .map(|page| page.page_id)
84        .collect();
85    if !with_instructions && !with_names {
86        return Ok((page_ids, Vec::new()));
87    }
88    view.load_exercises_on_pages(&mut *connection(state).await?, &page_ids)
89        .await?;
90    let is_whole_course = scope.is_none_or(|s| s.page.is_none() && s.chapter.is_none());
91    let texts = instruction_lists::text_documents(
92        &view.staged,
93        &page_ids,
94        ExerciseTextParts {
95            instructions: with_instructions,
96            course_default: is_whole_course,
97            names: with_names,
98        },
99    );
100    Ok((page_ids, texts))
101}
102
103/// What the engine reads: the pages, then the exercise texts.
104fn engine_documents<'a>(
105    staged: &'a CourseState,
106    page_ids: &[Uuid],
107    texts: &'a [Document],
108) -> Vec<DocumentRef<'a>> {
109    page_ids
110        .iter()
111        .filter_map(|id| staged.page(*id))
112        .map(|page| DocumentRef::from(snapshots::page_ref(page)))
113        .chain(texts.iter().map(Document::as_document_ref))
114        .collect()
115}
116
117/// Stages what a bulk edit changed as one call; when it changed nothing, the result is `summary`
118/// alone.
119async fn stage_changed(
120    state: &McpState,
121    ctx: &RequestContext,
122    view: &CourseView,
123    changed: Vec<Document>,
124    request: WriteRequest,
125    summary: &impl Serialize,
126) -> Result<ToolOutcome, ToolError> {
127    if changed.is_empty() {
128        return Ok(ToolResult::success(summary).into());
129    }
130    let mut after = view.staged.clone();
131    instruction_lists::apply_documents(&mut after, changed)?;
132    let report = staging::commit(state, ctx, view, after, request).await?;
133    Ok(report.into_result(summary).into())
134}
135
136#[async_trait(?Send)]
137impl ToolHandler<McpState> for FindReplace {
138    type Args = FindReplaceArgs;
139
140    async fn call(
141        &self,
142        state: &McpState,
143        ctx: &RequestContext,
144        args: FindReplaceArgs,
145    ) -> Result<ToolOutcome, ToolError> {
146        if args.replace.is_some() {
147            ctx.require_scope(McpScope::MaterialStage)?;
148        }
149        let mut view = open_course(state, ctx, args.course.id()).await?;
150        let audit = json!(&args);
151        let (page_ids, texts) = texts_in_scope(
152            state,
153            &mut view,
154            args.scope,
155            args.include_exercise_instructions.unwrap_or(true),
156            args.include_exercise_names.unwrap_or(false),
157        )
158        .await?;
159        let request = FindReplaceRequest {
160            find: args.find,
161            replace: args.replace,
162            is_regex: args.regex,
163            attributes: args.attributes,
164            expected_count: args.expected_count.map(|c| c as usize),
165        };
166        let staged = view.staged.clone();
167        let result = off_worker(move || {
168            find_replace(&engine_documents(&staged, &page_ids, &texts), &request)
169        })
170        .await??;
171        let summary = FindReplaceSummary {
172            total: result.total(),
173            matches: &result.matches,
174            inflected_variants: &result.inflected_variants,
175        };
176        let request = WriteRequest::new(
177            audit,
178            format!(
179                "replaced {} match(es) in {} page(s) or exercise text(s)",
180                summary.total,
181                result.changed_pages.len()
182            ),
183            WriteMode::from_dry_run(args.dry_run),
184        );
185        stage_changed(state, ctx, &view, result.changed_pages, request, &summary).await
186    }
187}
188
189#[async_trait(?Send)]
190impl ToolHandler<McpState> for StructuralReplace {
191    type Args = StructuralReplaceArgs;
192
193    async fn call(
194        &self,
195        state: &McpState,
196        ctx: &RequestContext,
197        args: StructuralReplaceArgs,
198    ) -> Result<ToolOutcome, ToolError> {
199        let mut view = open_course(state, ctx, args.course.id()).await?;
200        let audit = json!(&args);
201        let StructuralReplaceArgs {
202            scope,
203            pattern,
204            action,
205            expected_count,
206            include_exercise_instructions,
207            dry_run,
208            ..
209        } = args;
210        let (page_ids, texts) = texts_in_scope(
211            state,
212            &mut view,
213            scope,
214            include_exercise_instructions.unwrap_or(true),
215            false,
216        )
217        .await?;
218        let staged = view.staged.clone();
219        let result = off_worker(move || {
220            structural_replace(
221                &engine_documents(&staged, &page_ids, &texts),
222                &pattern,
223                &action,
224                expected_count.map(|c| c as usize),
225                &RandomIds,
226            )
227        })
228        .await??;
229        let summary = StructuralReplaceSummary {
230            total: result.matches.len(),
231            matches: &result.matches,
232        };
233        let request = WriteRequest::new(
234            audit,
235            format!(
236                "{} block(s) in {} page(s) or exercise text(s)",
237                summary.total,
238                result.changed_pages.len()
239            ),
240            WriteMode::from_dry_run(dry_run),
241        );
242        stage_changed(state, ctx, &view, result.changed_pages, request, &summary).await
243    }
244}