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
24pub struct UpdateCheatingStatusTool;
27
28#[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}