Skip to main content

headless_lms_chatbot/chatbot_tools/action_tools/
update_cheating_status.rs

1use headless_lms_authorization::Action;
2use indexmap::IndexMap;
3
4use headless_lms_models::chatbot_configurations::ToolCategory;
5use headless_lms_models::{
6    courses, suspected_cheaters, suspected_cheaters::SuspectedCheaterStatus, user_details, users,
7};
8use headless_lms_utils::json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType};
9
10use crate::{
11    azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
12    chatbot_tools::{
13        ChatbotToolDeclaration,
14        action_tools::{
15            ActionAuditFields, ConfirmableActionTool, ExecutedAction, verify_display_field,
16        },
17        argument_parsing::parse_required_uuid,
18        tool_authorization::{ToolAuthorization, ToolRequirement},
19    },
20    prelude::*,
21    user_context::ChatbotTurnContext,
22};
23
24/// Confirms or dismisses a `Flagged` suspected-cheater row. Only a `Flagged` row is actionable:
25/// `ConfirmedCheating`/`Dismissed` are terminal states a support admin cannot re-decide here.
26pub struct UpdateCheatingStatusTool;
27
28/// Wire values for `decision`, internal to this tool: never crosses the API boundary as-is.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum CheatingDecision {
31    Confirm,
32    Dismiss,
33}
34
35pub struct UpdateCheatingStatusArguments {
36    user_id: Uuid,
37    course_id: Uuid,
38    user_email: String,
39    course_name: String,
40    decision: CheatingDecision,
41}
42
43#[derive(Deserialize)]
44struct RawArguments {
45    user_id: String,
46    course_id: String,
47    user_email: String,
48    course_name: String,
49    decision: String,
50}
51
52impl ChatbotToolDeclaration for UpdateCheatingStatusTool {
53    const NAME: &'static str = "update_cheating_status";
54
55    fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
56        vec![ToolRequirement::on_turn(Action::Teach, user_context)]
57    }
58
59    const CATEGORY: ToolCategory = ToolCategory::AdminSupportAcademicIntegrity;
60
61    fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
62        AzureLLMFunctionToolDefinition {
63            tool_type: LLMToolType::Function,
64            name: Self::NAME.to_string(),
65            description: "Confirm or dismiss a flagged suspected-cheating case for a user in a course. Suspends for the admin's confirmation before anything changes. Only applies to a case that is currently flagged (awaiting review); it refuses if the case was already confirmed or dismissed.".to_string(),
66            parameters: Schema::strict_object(
67                IndexMap::from([
68                    (
69                        "user_id".to_string(),
70                        SchemaPropertyType::Item(JsonItem {
71                            type_field: JSONType::String,
72                            description: Some("The user's id, as returned by find_user.".to_string()),
73                        }),
74                    ),
75                    (
76                        "course_id".to_string(),
77                        SchemaPropertyType::Item(JsonItem {
78                            type_field: JSONType::String,
79                            description: Some("The course's id, as returned by find_course.".to_string()),
80                        }),
81                    ),
82                    (
83                        "user_email".to_string(),
84                        SchemaPropertyType::Item(JsonItem {
85                            type_field: JSONType::String,
86                            description: Some("The user's current email, exactly as find_user returned it. Shown to the admin and checked against the account before anything changes.".to_string()),
87                        }),
88                    ),
89                    (
90                        "course_name".to_string(),
91                        SchemaPropertyType::Item(JsonItem {
92                            type_field: JSONType::String,
93                            description: Some("The course's name, exactly as find_course returned it. Shown to the admin and checked against the course before anything changes.".to_string()),
94                        }),
95                    ),
96                    (
97                        "decision".to_string(),
98                        SchemaPropertyType::Item(JsonItem {
99                            type_field: JSONType::String,
100                            description: Some("Either 'confirm' (the student cheated; their completions in the course are failed) or 'dismiss' (the flag was a false alarm; the case is closed with no penalty). Only a case that is still flagged can be decided.".to_string()),
101                        }),
102                    ),
103                ]),
104                None,
105            ),
106            strict: true,
107        }
108    }
109}
110
111impl ConfirmableActionTool for UpdateCheatingStatusTool {
112    type Arguments = UpdateCheatingStatusArguments;
113    type Facts = ();
114
115    fn call_requirements(
116        arguments: &Self::Arguments,
117        _user_context: &ChatbotTurnContext,
118    ) -> Vec<ToolRequirement> {
119        vec![ToolRequirement::on_course(
120            Action::Teach,
121            arguments.course_id,
122        )]
123    }
124
125    fn parse_arguments(arguments: &str) -> ChatbotResult<Self::Arguments> {
126        let raw: RawArguments = serde_json::from_str(arguments).map_err(|e| {
127            chatbot_err!(
128                InvalidToolArguments,
129                format!("Couldn't parse tool arguments. Arguments: {arguments}"),
130                e
131            )
132        })?;
133
134        let user_id = parse_required_uuid("user_id", &raw.user_id)?;
135        let course_id = parse_required_uuid("course_id", &raw.course_id)?;
136
137        let user_email = raw.user_email.trim().to_string();
138        if user_email.is_empty() {
139            return Err(chatbot_err!(
140                InvalidToolArguments,
141                "user_email must not be empty.".to_string()
142            ));
143        }
144        let course_name = raw.course_name.trim().to_string();
145        if course_name.is_empty() {
146            return Err(chatbot_err!(
147                InvalidToolArguments,
148                "course_name must not be empty.".to_string()
149            ));
150        }
151
152        let decision = match raw.decision.as_str() {
153            "confirm" => CheatingDecision::Confirm,
154            "dismiss" => CheatingDecision::Dismiss,
155            other => {
156                return Err(chatbot_err!(
157                    InvalidToolArguments,
158                    format!("'{other}' is not a valid decision. Valid values: confirm, dismiss.")
159                ));
160            }
161        };
162
163        Ok(UpdateCheatingStatusArguments {
164            user_id,
165            course_id,
166            user_email,
167            course_name,
168            decision,
169        })
170    }
171
172    async fn execute(
173        conn: &mut PgConnection,
174        _app_config: &ApplicationConfiguration,
175        arguments: &Self::Arguments,
176        _authorization: &ToolAuthorization<Self>,
177    ) -> ChatbotResult<(ExecutedAction, Self::Facts)> {
178        let user = users::get_active_by_id(conn, arguments.user_id)
179            .await
180            .map_err(|e| {
181                chatbot_err!(
182                    InvalidToolArguments,
183                    "The user no longer exists. Re-run find_user.".to_string(),
184                    e
185                )
186            })?;
187        let user_detail = user_details::get_user_details_by_user_id(conn, user.id)
188            .await
189            .optional()?
190            .ok_or_else(|| {
191                chatbot_err!(
192                    InvalidToolArguments,
193                    "The user no longer exists. Re-run find_user.".to_string()
194                )
195            })?;
196        verify_display_field(
197            "email",
198            &user_detail.email,
199            &arguments.user_email,
200            "find_user",
201        )?;
202
203        let course = courses::get_course(conn, arguments.course_id)
204            .await
205            .optional()?
206            .ok_or_else(|| {
207                chatbot_err!(
208                    InvalidToolArguments,
209                    "The course no longer exists. Re-run find_course.".to_string()
210                )
211            })?;
212        verify_display_field(
213            "course_name",
214            &course.name,
215            &arguments.course_name,
216            "find_course",
217        )?;
218
219        let cheater = suspected_cheaters::get_by_user_id_and_course_id(
220            conn,
221            arguments.user_id,
222            arguments.course_id,
223        )
224        .await
225        .optional()?
226        .ok_or_else(|| {
227            chatbot_err!(
228                InvalidToolArguments,
229                "There is no suspected-cheating record for this user in this course -- no flag was ever raised. This is a substantive answer, not a failed lookup.".to_string()
230            )
231        })?;
232
233        if cheater.status != SuspectedCheaterStatus::Flagged {
234            let status_name = match cheater.status {
235                SuspectedCheaterStatus::Flagged => unreachable!("checked above"),
236                SuspectedCheaterStatus::ConfirmedCheating => "already confirmed-cheating",
237                SuspectedCheaterStatus::Dismissed => "already dismissed",
238            };
239            return Err(chatbot_err!(
240                InvalidToolArguments,
241                format!(
242                    "This case is {status_name} -- a terminal state, nothing to decide, and not retryable with the other decision either."
243                )
244            ));
245        }
246
247        let (verb, consequence) = match arguments.decision {
248            CheatingDecision::Confirm => {
249                suspected_cheaters::confirm_cheater_by_user_id_and_course_id(
250                    conn,
251                    arguments.user_id,
252                    arguments.course_id,
253                )
254                .await?;
255                (
256                    "confirmed",
257                    "Their completions in this course have been failed.",
258                )
259            }
260            CheatingDecision::Dismiss => {
261                suspected_cheaters::dismiss_by_user_id_and_course_id(
262                    conn,
263                    arguments.user_id,
264                    arguments.course_id,
265                )
266                .await?;
267                (
268                    "dismissed",
269                    "Their completion, grade and certificate are visible to them again.",
270                )
271            }
272        };
273
274        Ok((
275            ExecutedAction {
276                output: format!(
277                    "The cheating flag for {} in {} was {verb}. {consequence}",
278                    arguments.user_email, arguments.course_name
279                ),
280                client_payload: None,
281                audit: ActionAuditFields {
282                    target_user_id: Some(arguments.user_id),
283                    course_id: Some(arguments.course_id),
284                    summary: format!(
285                        "Cheating flag {verb} for {} in {}",
286                        arguments.user_email, arguments.course_name
287                    ),
288                },
289            },
290            (),
291        ))
292    }
293
294    fn output_description_instructions(
295        arguments: &Self::Arguments,
296        _facts: Option<&()>,
297        app_config: &ApplicationConfiguration,
298    ) -> Option<String> {
299        let base_url = app_config.base_url.trim_end_matches('/');
300        let tab = match arguments.decision {
301            CheatingDecision::Confirm => "confirmed",
302            CheatingDecision::Dismiss => "dismissed",
303        };
304
305        let mut notes = vec![
306            "Never put the suspicion into words meant for the student, and do not describe how the flag was raised -- it is an automatic system heuristic, not evidence of anything. Point at user_overview's cheating_flags entry for this case before recommending a decision, and never explain the flagging mechanism itself, to the student or in any text a student could see.".to_string(),
307            format!(
308                "Before deciding, the admin can review the case at {base_url}/manage/courses/{}/other/cheaters/suspected -- that table is keyed by user_id only, with no name or email column, so match it by UUID rather than the email shown elsewhere in the conversation.",
309                arguments.course_id
310            ),
311        ];
312
313        match arguments.decision {
314            CheatingDecision::Confirm => notes.push(format!("Confirming fails every module completion the user has in this course (not just the one that triggered the flag) and moves the case to a terminal state this tool cannot undo -- the row now shows at {base_url}/manage/courses/{}/other/cheaters/{tab}.", arguments.course_id)),
315            CheatingDecision::Dismiss => notes.push(format!("After a dismissal, the student-visible effect is simply that their completion, grade and certificate become available again -- describe it that way, not as a cheating suspicion being cleared. The row now shows at {base_url}/manage/courses/{}/other/cheaters/{tab}.", arguments.course_id)),
316        }
317
318        notes.push(format!(
319            "{base_url}/manage/users/{} shows this user's cheating-flag status across every course, for cross-course context.",
320            arguments.user_id
321        ));
322
323        Some(notes.join(" "))
324    }
325}