1use headless_lms_authorization::Action;
2use indexmap::IndexMap;
3
4use headless_lms_models::chatbot_configurations::ToolCategory;
5use headless_lms_models::{courses, exercises, user_details, users};
6use headless_lms_utils::json_schema_types::{
7 JSONType, JsonItem, Schema, SchemaPropertyType, string_array_property,
8};
9
10use crate::{
11 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
12 chatbot_tools::{
13 ChatbotToolDeclaration,
14 action_tools::{
15 ActionAuditFields, ConfirmableActionTool, ExecutedAction, display_field_matches,
16 verify_display_field,
17 },
18 argument_parsing::parse_required_uuid,
19 tool_authorization::{ToolAuthorization, ToolRequirement},
20 },
21 prelude::*,
22 user_context::ChatbotTurnContext,
23};
24
25pub struct ResetExercisesTool;
28
29pub struct ResetExercisesArguments {
30 pub user_id: Uuid,
31 pub course_id: Uuid,
32 pub user_email: String,
33 pub course_name: String,
34 pub exercise_ids: Vec<Uuid>,
35 pub exercise_names: Vec<String>,
36 pub reason: String,
37}
38
39#[derive(Deserialize)]
40struct RawArguments {
41 user_id: String,
42 course_id: String,
43 user_email: String,
44 course_name: String,
45 exercise_ids: Vec<String>,
46 exercise_names: Vec<String>,
47 reason: String,
48}
49
50impl ChatbotToolDeclaration for ResetExercisesTool {
51 const NAME: &'static str = "reset_exercises";
52
53 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
54 vec![ToolRequirement::on_turn(Action::Teach, user_context)]
55 }
56
57 const CATEGORY: ToolCategory = ToolCategory::AdminSupportLearningProgress;
58
59 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
60 AzureLLMFunctionToolDefinition {
61 tool_type: LLMToolType::Function,
62 name: Self::NAME.to_string(),
63 description: "Resets a user's progress on selected exercises in a course, after the admin confirms in the chat UI. Deletes the user's submissions, gradings, exercise states and peer-review queue entries for those exercises so they can be resubmitted. Requires global admin.".to_string(),
64 parameters: Schema::strict_object(
65 IndexMap::from([
66 (
67 "user_id".to_string(),
68 SchemaPropertyType::Item(JsonItem {
69 type_field: JSONType::String,
70 description: Some("UUID of the user whose exercises will be reset.".to_string()),
71 }),
72 ),
73 (
74 "course_id".to_string(),
75 SchemaPropertyType::Item(JsonItem {
76 type_field: JSONType::String,
77 description: Some("UUID of the course the exercises belong to.".to_string()),
78 }),
79 ),
80 (
81 "user_email".to_string(),
82 SchemaPropertyType::Item(JsonItem {
83 type_field: JSONType::String,
84 description: Some("The user's current email, exactly as an earlier tool (e.g. find_user) returned it. Shown to the admin and checked against the account before resetting.".to_string()),
85 }),
86 ),
87 (
88 "course_name".to_string(),
89 SchemaPropertyType::Item(JsonItem {
90 type_field: JSONType::String,
91 description: Some("The course's current name, exactly as an earlier tool (e.g. find_course or course_configuration) returned it. Shown to the admin and checked against the course before resetting.".to_string()),
92 }),
93 ),
94 (
95 "exercise_ids".to_string(),
96 string_array_property(Some(
97 "UUIDs of the exercises to reset. Pass an empty array to reset every exercise in the course.",
98 )),
99 ),
100 (
101 "exercise_names".to_string(),
102 string_array_property(Some(
103 "Human-readable names matching exercise_ids one-to-one, shown to the admin in the confirmation. Pass an empty array when exercise_ids is empty.",
104 )),
105 ),
106 (
107 "reason".to_string(),
108 SchemaPropertyType::Item(JsonItem {
109 type_field: JSONType::String,
110 description: Some("Why, e.g. the support ticket reference. Recorded in the reset log.".to_string()),
111 }),
112 ),
113 ]),
114 None,
115 ),
116 strict: true,
117 }
118 }
119}
120
121pub struct ResetExercisesFacts {
124 requested_count: usize,
125 actual_reset_count: usize,
126}
127
128impl ConfirmableActionTool for ResetExercisesTool {
129 type Arguments = ResetExercisesArguments;
130 type Facts = ResetExercisesFacts;
131
132 fn call_requirements(
133 arguments: &Self::Arguments,
134 _user_context: &ChatbotTurnContext,
135 ) -> Vec<ToolRequirement> {
136 vec![ToolRequirement::on_course(
137 Action::Teach,
138 arguments.course_id,
139 )]
140 }
141
142 fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments> {
143 let raw: RawArguments = serde_json::from_str(arguments).map_err(|e| {
144 chatbot_err!(
145 InvalidToolArguments,
146 format!("Couldn't parse tool arguments. Arguments: {arguments}"),
147 e
148 )
149 })?;
150
151 let user_id = parse_required_uuid("user_id", &raw.user_id)?;
152 let course_id = parse_required_uuid("course_id", &raw.course_id)?;
153
154 if raw.exercise_names.len() != raw.exercise_ids.len() {
155 return Err(chatbot_err!(
156 InvalidToolArguments,
157 "exercise_names must have the same length as exercise_ids.".to_string()
158 ));
159 }
160
161 let mut exercise_ids = Vec::with_capacity(raw.exercise_ids.len());
162 for id in &raw.exercise_ids {
163 exercise_ids.push(parse_required_uuid("exercise id", id)?);
164 }
165 for name in &raw.exercise_names {
166 if name.trim().is_empty() {
167 return Err(chatbot_err!(
168 InvalidToolArguments,
169 "exercise_names entries must not be empty.".to_string()
170 ));
171 }
172 }
173
174 let reason = raw.reason.trim().to_string();
175 if reason.is_empty() {
176 return Err(chatbot_err!(
177 InvalidToolArguments,
178 "reason must not be empty.".to_string()
179 ));
180 }
181 let user_email = raw.user_email.trim().to_string();
182 if user_email.is_empty() {
183 return Err(chatbot_err!(
184 InvalidToolArguments,
185 "user_email must not be empty.".to_string()
186 ));
187 }
188 let course_name = raw.course_name.trim().to_string();
189 if course_name.is_empty() {
190 return Err(chatbot_err!(
191 InvalidToolArguments,
192 "course_name must not be empty.".to_string()
193 ));
194 }
195
196 Ok(ResetExercisesArguments {
197 user_id,
198 course_id,
199 user_email,
200 course_name,
201 exercise_ids,
202 exercise_names: raw.exercise_names,
203 reason,
204 })
205 }
206
207 async fn execute(
208 conn: &mut PgConnection,
209 _app_config: &ApplicationConfiguration,
210 arguments: &Self::Arguments,
211 authorization: &ToolAuthorization<Self>,
212 ) -> ChatbotResult<(ExecutedAction, Self::Facts)> {
213 let user = users::get_active_by_id(conn, arguments.user_id)
214 .await
215 .map_err(|e| {
216 chatbot_err!(
217 ToolUseError,
218 format!("No active user found with id {}.", arguments.user_id),
219 e
220 )
221 })?;
222 let user_detail = user_details::get_user_details_by_user_id(conn, user.id)
223 .await
224 .map_err(|e| {
225 chatbot_err!(
226 ToolUseError,
227 format!("No user details found for user {}.", user.id),
228 e
229 )
230 })?;
231 verify_display_field(
232 "user_email",
233 &user_detail.email,
234 &arguments.user_email,
235 "find_user",
236 )?;
237
238 let course = courses::get_course(conn, arguments.course_id)
239 .await
240 .map_err(|e| {
241 chatbot_err!(
242 ToolUseError,
243 format!("No course found with id {}.", arguments.course_id),
244 e
245 )
246 })?;
247 verify_display_field(
248 "course_name",
249 &course.name,
250 &arguments.course_name,
251 "find_course",
252 )?;
253
254 let course_exercises = exercises::get_exercises_by_course_id(conn, course.id).await?;
255
256 let requested_count = if arguments.exercise_ids.is_empty() {
259 course_exercises.len()
260 } else {
261 arguments.exercise_ids.len()
262 };
263 let (exercise_ids, exercise_label) = if arguments.exercise_ids.is_empty() {
264 (
265 course_exercises.iter().map(|e| e.id).collect::<Vec<_>>(),
266 "all exercises in the course".to_string(),
267 )
268 } else {
269 for (id, name) in arguments.exercise_ids.iter().zip(&arguments.exercise_names) {
270 match course_exercises.iter().find(|e| &e.id == id) {
271 Some(exercise) if display_field_matches(&exercise.name, name) => {}
272 Some(exercise) => {
273 return Err(chatbot_err!(
274 ToolUseError,
275 format!(
276 "Exercise {id} is named '{}' in the course, not '{name}'. Re-run course_structure to get current names before retrying.",
277 exercise.name
278 )
279 ));
280 }
281 None => {
282 return Err(chatbot_err!(
283 ToolUseError,
284 format!("Exercise {id} does not belong to course {}.", course.id)
285 ));
286 }
287 }
288 }
289 (
290 arguments.exercise_ids.clone(),
291 arguments.exercise_names.join(", "),
292 )
293 };
294
295 let pairs = exercises::collect_user_ids_and_exercise_ids_for_reset(
296 conn,
297 &[user.id],
298 &exercise_ids,
299 None,
300 false,
301 false,
302 )
303 .await?;
304
305 let reset_results = exercises::reset_exercises_for_selected_users(
308 conn,
309 &pairs,
310 Some(authorization.acting_user_id()),
311 course.id,
312 Some(format!("reset-by-support-chatbot: {}", arguments.reason)),
313 )
314 .await?;
315 let reset_count: usize = reset_results.iter().map(|(_, exs)| exs.len()).sum();
316
317 Ok((
318 ExecutedAction {
319 output: format!(
320 "{reset_count} exercises were reset for {} in {}: {exercise_label}. The user can now resubmit them.",
321 user_detail.email, course.name
322 ),
323 client_payload: None,
324 audit: ActionAuditFields {
325 target_user_id: Some(user.id),
326 course_id: Some(course.id),
327 summary: format!(
328 "Reset {reset_count} exercises for {} in {} (reason: {})",
329 user_detail.email, course.name, arguments.reason
330 ),
331 },
332 },
333 ResetExercisesFacts {
334 requested_count,
335 actual_reset_count: reset_count,
336 },
337 ))
338 }
339
340 fn output_description_instructions(
341 arguments: &Self::Arguments,
342 facts: Option<&Self::Facts>,
343 app_config: &ApplicationConfiguration,
344 ) -> Option<String> {
345 let base_url = app_config.base_url.trim_end_matches('/');
346 let user_status_summary_url = format!(
347 "{base_url}/manage/courses/{}/user-status-summary/{}",
348 arguments.course_id, arguments.user_id
349 );
350 let user_page_url = format!("{base_url}/manage/users/{}", arguments.user_id);
351
352 let mut notes = vec![
353 "Confirm to the admin what was reset and remind them the user's previous submissions and points for those exercises are gone.".to_string(),
354 format!(
355 "This does not revoke module completions, grades or already-generated certificates -- the module can still show completed while points read 0, which is visible at {user_status_summary_url} (points back to 0 and submissions cleared for the reset exercises, module completions unchanged)."
356 ),
357 "Peer-review queue entries for the reset exercises were deleted (received reviews must be earned again) and the affected chapters were unlocked.".to_string(),
358 format!(
359 "The manual reset tool page shows nothing after a successful reset (it just clears its selection and shows a toast), so use {user_status_summary_url} to confirm the reset landed and {user_page_url} to see the reset log entry and the exact reason text the student sees."
360 ),
361 ];
362
363 if let Some(facts) = facts
364 && facts.actual_reset_count < facts.requested_count
365 {
366 notes.push(format!(
367 "Only {} of the {} requested exercises had a previous submission to reset -- a lower count, including 0, is normal and not a failure to retry; check {user_status_summary_url} to see which exercises had nothing to reset.",
368 facts.actual_reset_count, facts.requested_count
369 ));
370 }
371
372 if !arguments.reason.trim().is_empty() {
373 notes.push("The reason text is shown to the student verbatim in the course material as the reset notice until they resubmit -- write it in student-safe wording and tell the admin what it will say.".to_string());
374 }
375
376 Some(notes.join(" "))
377 }
378}