headless_lms_mcp/handlers/
bulk.rs1use 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
23pub struct FindReplace;
25pub 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
45fn 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
72async 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
103fn 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
117async 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}