Skip to main content

headless_lms_chatbot/chatbot_tools/action_tools/
reset_exercises.rs

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
25/// Resets a user's progress on selected exercises (or the whole course) after admin
26/// confirmation. Confirmed answers run [Self::execute].
27pub 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
121/// What [ResetExercisesTool::execute] found out while performing the reset, beyond what the
122/// arguments already say.
123pub 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        // A wrong or stale id/name pair refuses the action instead of resetting the wrong
257        // exercise: mistargeting must never survive as a silent no-op or wrong mutation.
258        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        // Writes exercise_reset_logs itself; the generated execute_action_tool arm additionally
306        // writes chatbot_action_logs, so this reset is audited in both places on purpose.
307        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}