1use headless_lms_authorization::Action;
2use std::str::FromStr;
3
4use indexmap::IndexMap;
5
6use headless_lms_models::chatbot_configurations::ToolCategory;
7use headless_lms_models::{
8 course_instance_enrollments::get_course_enrollments_info_for_user,
9 courses,
10 email_deliveries::{EmailSendStatus, get_recent_deliveries_for_user},
11 email_templates::EmailTemplateType,
12 roles::{UserRole, get_roles},
13 suspected_cheaters::{SuspectedCheaterStatus, get_suspected_cheater_info_for_user},
14 user_details::{self, EmailVerificationMethod},
15 users,
16};
17use headless_lms_utils::json_schema_types::{
18 JSONType, JsonItem, Schema, SchemaPropertyType, string_array_property,
19};
20
21use crate::{
22 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
23 chatbot_tools::{
24 ChatbotTool, ChatbotToolDeclaration, ToolProperties, output_limits::CappedList,
25 tool_authorization::ToolRequirement,
26 },
27 prelude::*,
28 user_context::ChatbotTurnContext,
29};
30
31const EMAIL_DELIVERY_LIMIT: i64 = 20;
32
33pub type UserOverviewTool = ToolProperties<UserOverviewState>;
34
35pub struct UserOverviewState {
36 facets: IndexMap<String, UserOverviewFacetValue>,
37 base_url: String,
38 user_id: Uuid,
39}
40
41#[derive(Serialize)]
42#[serde(untagged)]
43enum UserOverviewFacetValue {
44 Profile(ProfileFacet),
45 Roles(CappedList<RoleFacet>),
46 Enrollments(CappedList<EnrollmentFacet>),
47 CheatingFlags(CappedList<CheatingFlagFacet>),
48 EmailDeliveries(Vec<EmailDeliveryFacet>),
49}
50
51const MAX_USER_OVERVIEW_ROWS: usize = 200;
54
55#[derive(Serialize)]
56struct ProfileFacet {
57 user_id: Uuid,
58 email: String,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 first_name: Option<String>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 last_name: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 country: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 email_communication_consent: Option<bool>,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 email_verified_at: Option<DateTime<Utc>>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 email_verified_method: Option<EmailVerificationMethod>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 upstream_id: Option<i32>,
73 #[serde(skip_serializing_if = "Option::is_none")]
74 email_domain: Option<String>,
75 created_at: DateTime<Utc>,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 deleted_at: Option<DateTime<Utc>>,
78}
79
80#[derive(Serialize)]
83struct RoleFacet {
84 role: UserRole,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 course_id: Option<Uuid>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 course_instance_id: Option<Uuid>,
89 #[serde(skip_serializing_if = "Option::is_none")]
90 exam_id: Option<Uuid>,
91}
92
93#[derive(Serialize)]
94struct EnrollmentFacet {
95 course_id: Uuid,
96 course_name: String,
97 instance_name: String,
98 first_enrolled_at: DateTime<Utc>,
99 is_current: bool,
100 completed_modules_count: usize,
101 completions_needing_review_count: i32,
102}
103
104#[derive(Serialize)]
105struct CheatingFlagFacet {
106 course_id: Uuid,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 course_name: Option<String>,
109 status: SuspectedCheaterStatus,
110 total_points: i32,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 total_duration_seconds: Option<i32>,
113 threshold_seconds: i32,
114 created_at: DateTime<Utc>,
115}
116
117#[derive(Serialize)]
118struct EmailDeliveryFacet {
119 email_template_type: EmailTemplateType,
120 created_at: DateTime<Utc>,
121 status: EmailSendStatus,
122 retry_count: i32,
123 #[serde(skip_serializing_if = "Option::is_none")]
124 last_attempt_at: Option<DateTime<Utc>>,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 failure_code: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 failure_is_transient: Option<bool>,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
132enum UserOverviewFacet {
133 Profile,
134 Roles,
135 Enrollments,
136 CheatingFlags,
137 EmailDeliveries,
138}
139
140impl UserOverviewFacet {
141 fn wire_name(self) -> &'static str {
142 match self {
143 Self::Profile => "profile",
144 Self::Roles => "roles",
145 Self::Enrollments => "enrollments",
146 Self::CheatingFlags => "cheating_flags",
147 Self::EmailDeliveries => "email_deliveries",
148 }
149 }
150
151 fn from_wire_name(s: &str) -> Option<Self> {
152 match s {
153 "profile" => Some(Self::Profile),
154 "roles" => Some(Self::Roles),
155 "enrollments" => Some(Self::Enrollments),
156 "cheating_flags" => Some(Self::CheatingFlags),
157 "email_deliveries" => Some(Self::EmailDeliveries),
158 _ => None,
159 }
160 }
161}
162
163pub struct UserOverviewArguments {
164 user_id: Uuid,
165 facets: Vec<UserOverviewFacet>,
166}
167
168impl<'de> serde::Deserialize<'de> for UserOverviewArguments {
169 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
170 where
171 D: serde::Deserializer<'de>,
172 {
173 #[derive(Deserialize)]
174 struct Raw {
175 user_id: String,
176 facets: Vec<String>,
177 }
178 let raw = Raw::deserialize(deserializer)?;
179 let user_id = Uuid::from_str(&raw.user_id).map_err(serde::de::Error::custom)?;
180
181 let mut facets = Vec::new();
182 for wire_name in &raw.facets {
183 let facet = UserOverviewFacet::from_wire_name(wire_name).ok_or_else(|| {
184 serde::de::Error::custom(format!(
185 "Unknown facet '{wire_name}'. Valid facets: profile, roles, enrollments, cheating_flags, email_deliveries."
186 ))
187 })?;
188 if !facets.contains(&facet) {
189 facets.push(facet);
190 }
191 }
192 if facets.is_empty() {
193 return Err(serde::de::Error::custom(
194 "At least one facet must be requested.",
195 ));
196 }
197
198 Ok(UserOverviewArguments { user_id, facets })
199 }
200}
201
202impl ChatbotToolDeclaration for UserOverviewTool {
203 const NAME: &'static str = "user_overview";
204
205 fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
206 vec![ToolRequirement::global(Action::ViewUserProgressOrDetails)]
207 }
208
209 const CATEGORY: ToolCategory = ToolCategory::AdminSupportAccounts;
210
211 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
212 AzureLLMFunctionToolDefinition {
213 tool_type: LLMToolType::Function,
214 name: Self::NAME.to_string(),
215 description: "Get an overview of a user's account for support purposes: profile details, roles, course enrollments, cheating flags, and recent email deliveries. Requires global admin.".to_string(),
216 parameters: Schema::strict_object(
217 IndexMap::from([
218 (
219 "user_id".to_string(),
220 SchemaPropertyType::Item(JsonItem {
221 type_field: JSONType::String,
222 description: Some("The id of the user to look up.".to_string()),
223 }),
224 ),
225 (
226 "facets".to_string(),
227 string_array_property(Some(
228 "Which parts of the user's overview to fetch. Valid values: 'profile', 'roles', 'enrollments', 'cheating_flags', 'email_deliveries'. At least one is required.",
229 )),
230 ),
231 ]),
232 None,
233 ),
234 strict: true,
235 }
236 }
237}
238
239impl ChatbotTool for UserOverviewTool {
240 type Arguments = UserOverviewArguments;
241
242 fn call_requirements(
243 arguments: &Self::Arguments,
244 _user_context: &ChatbotTurnContext,
245 ) -> Vec<ToolRequirement> {
246 vec![ToolRequirement::on_user(
247 Action::ViewUserProgressOrDetails,
248 arguments.user_id,
249 )]
250 }
251
252 async fn from_db_and_arguments(
253 conn: &mut PgConnection,
254 app_config: &ApplicationConfiguration,
255 arguments: Self::Arguments,
256 _user_context: &ChatbotTurnContext,
257 ) -> ChatbotResult<Self> {
258 let base_url = app_config.base_url.trim_end_matches('/').to_string();
259 let user_id = arguments.user_id;
260 let user_detail = user_details::get_user_details_by_user_id(conn, user_id)
261 .await
262 .map_err(|e| {
263 chatbot_err!(ToolUseError, format!("No user found with id {user_id}."), e)
264 })?;
265
266 let mut facets = IndexMap::new();
267 for facet in &arguments.facets {
268 let value = match facet {
269 UserOverviewFacet::Profile => {
270 let user = users::get_by_id(conn, user_id).await?;
271 UserOverviewFacetValue::Profile(ProfileFacet {
272 user_id: user_detail.user_id,
273 email: user_detail.email.clone(),
274 first_name: user_detail.first_name.clone(),
275 last_name: user_detail.last_name.clone(),
276 country: user_detail.country.clone(),
277 email_communication_consent: user_detail.email_communication_consent,
278 email_verified_at: user_detail.email_verified_at,
279 email_verified_method: user_detail.email_verified_method,
280 upstream_id: user.upstream_id,
281 email_domain: user.email_domain,
282 created_at: user.created_at,
283 deleted_at: user.deleted_at,
284 })
285 }
286 UserOverviewFacet::Roles => {
287 let roles = get_roles(conn, user_id).await?;
288 UserOverviewFacetValue::Roles(CappedList::new(
289 roles
290 .into_iter()
291 .filter(|role| !role.is_global && role.organization_id.is_none())
292 .map(|role| RoleFacet {
293 role: role.role,
294 course_id: role.course_id,
295 course_instance_id: role.course_instance_id,
296 exam_id: role.exam_id,
297 })
298 .collect(),
299 MAX_USER_OVERVIEW_ROWS,
300 ))
301 }
302 UserOverviewFacet::Enrollments => {
303 let enrollments = get_course_enrollments_info_for_user(conn, user_id).await?;
304 UserOverviewFacetValue::Enrollments(CappedList::new(
305 enrollments
306 .course_enrollments
307 .into_iter()
308 .map(|enrollment| {
309 let instance_name = enrollment
310 .course_instances
311 .iter()
312 .map(|instance| {
313 instance
314 .name
315 .clone()
316 .unwrap_or_else(|| "Default".to_string())
317 })
318 .collect::<Vec<_>>()
319 .join(", ");
320 EnrollmentFacet {
321 course_id: enrollment.course_id,
322 course_name: enrollment.course.name,
323 instance_name,
324 first_enrolled_at: enrollment.first_enrolled_at,
325 is_current: enrollment.is_current,
326 completed_modules_count: enrollment
327 .course_module_completions
328 .len(),
329 completions_needing_review_count: enrollment
330 .course_module_completions_needing_review,
331 }
332 })
333 .collect(),
334 MAX_USER_OVERVIEW_ROWS,
335 ))
336 }
337 UserOverviewFacet::CheatingFlags => {
338 let flags = get_suspected_cheater_info_for_user(conn, user_id).await?;
339 let course_ids: Vec<Uuid> = flags.iter().map(|f| f.course_id).collect();
340 let course_names: std::collections::HashMap<Uuid, String> =
341 courses::get_by_ids(conn, &course_ids)
342 .await?
343 .into_iter()
344 .map(|c| (c.id, c.name))
345 .collect();
346 let mut rows = Vec::with_capacity(flags.len());
347 for flag in flags {
348 let course_name = course_names.get(&flag.course_id).cloned();
349 rows.push(CheatingFlagFacet {
350 course_id: flag.course_id,
351 course_name,
352 status: flag.status,
353 total_points: flag.total_points,
354 total_duration_seconds: flag.total_duration_seconds,
355 threshold_seconds: flag.threshold_seconds,
356 created_at: flag.first_flagged_at,
357 });
358 }
359 UserOverviewFacetValue::CheatingFlags(CappedList::new(
360 rows,
361 MAX_USER_OVERVIEW_ROWS,
362 ))
363 }
364 UserOverviewFacet::EmailDeliveries => {
365 let deliveries =
366 get_recent_deliveries_for_user(conn, user_id, EMAIL_DELIVERY_LIMIT).await?;
367 UserOverviewFacetValue::EmailDeliveries(
368 deliveries
369 .into_iter()
370 .map(|delivery| EmailDeliveryFacet {
371 email_template_type: delivery.email_template_type,
372 created_at: delivery.created_at,
373 status: delivery.status,
374 retry_count: delivery.retry_count,
375 last_attempt_at: delivery.last_attempt_at,
376 failure_code: delivery.failure_code,
377 failure_is_transient: delivery.failure_is_transient,
378 })
379 .collect(),
380 )
381 }
382 };
383 facets.insert(facet.wire_name().to_string(), value);
384 }
385
386 Ok(UserOverviewTool {
387 state: UserOverviewState {
388 facets,
389 base_url,
390 user_id,
391 },
392 })
393 }
394
395 fn output(&self) -> String {
396 serde_json::to_string_pretty(&self.state.facets)
397 .unwrap_or_else(|_| "Failed to serialize user overview.".to_string())
398 }
399
400 fn output_description_instructions(&self) -> Option<String> {
401 let mut notes: Vec<String> = Vec::new();
402 let base_url = &self.state.base_url;
403 let user_id = self.state.user_id;
404
405 let has_profile = self
406 .state
407 .facets
408 .contains_key(UserOverviewFacet::Profile.wire_name());
409 let has_enrollments = self
410 .state
411 .facets
412 .contains_key(UserOverviewFacet::Enrollments.wire_name());
413 if has_profile || has_enrollments {
414 notes.push(format!(
415 "{base_url}/manage/users/{user_id} shows this user's enrollment list and profile \
416 fields for cross-checking."
417 ));
418 }
419
420 if let Some(UserOverviewFacetValue::CheatingFlags(flags)) = self
421 .state
422 .facets
423 .get(UserOverviewFacet::CheatingFlags.wire_name())
424 && !flags.is_empty()
425 {
426 let tab = |status: &SuspectedCheaterStatus| match status {
427 SuspectedCheaterStatus::Flagged => "suspected",
428 SuspectedCheaterStatus::ConfirmedCheating => "confirmed",
429 SuspectedCheaterStatus::Dismissed => "dismissed",
430 };
431 let course_links = flags
432 .iter()
433 .map(|f| {
434 format!(
435 "{base_url}/manage/courses/{}/other/cheaters/{}",
436 f.course_id,
437 tab(&f.status)
438 )
439 })
440 .collect::<Vec<_>>()
441 .join(", ");
442 notes.push(format!(
443 "cheating_flags rows are not current suspicions: the list includes resolved \
444 cases. Only status 'Flagged' is awaiting review and actionable, and only via \
445 update_cheating_status (read that tool's own description before recommending \
446 any action); 'ConfirmedCheating' and 'Dismissed' are terminal, closed cases. A \
447 flag reflects a system heuristic, not evidence of misconduct \u{2014} never \
448 quote, imply, or hint at a cheating suspicion in any text meant for the \
449 student. created_at is when the flag was first raised, not the latest status \
450 change. Verify at {base_url}/manage/users/{user_id}, and per flag at: \
451 {course_links}. That per-course table is keyed by user_id only, with no name \
452 or email column, so match on the UUID."
453 ));
454 }
455
456 if let Some(UserOverviewFacetValue::Enrollments(enrollments)) = self
457 .state
458 .facets
459 .get(UserOverviewFacet::Enrollments.wire_name())
460 && !enrollments.is_empty()
461 {
462 let course_links = enrollments
463 .iter()
464 .map(|e| {
465 format!(
466 "{base_url}/manage/courses/{}/user-status-summary/{user_id}",
467 e.course_id
468 )
469 })
470 .collect::<Vec<_>>()
471 .join(", ");
472 notes.push(format!(
473 "enrollments.completed_modules_count counts completion rows, including failed \
474 and under-review ones, and can double-count a module completed twice for a \
475 grade improvement \u{2014} it is not the number of modules passed. \
476 completions_needing_review_count > 0 is internal and explains a missing or \
477 delayed certificate; never surface it to the student. is_current reflects which \
478 language version of the course the user last selected, not whether they are \
479 currently enrolled \u{2014} is_current: false can still be an active enrollment. \
480 instance_name 'Default' is the course's unnamed default instance, not missing \
481 data; multiple names mean multiple enrollments in the same course. Enrollments \
482 in deleted courses are silently omitted, so their absence here does not mean \
483 the student was never enrolled. Per enrollment, the deeper per-course view is \
484 at: {course_links} \u{2014} its \"X of Y modules\" figure is derived the same \
485 way as completed_modules_count, so it is not an independent check of the \
486 caveat above."
487 ));
488 }
489
490 if let Some(UserOverviewFacetValue::Roles(roles)) =
491 self.state.facets.get(UserOverviewFacet::Roles.wire_name())
492 && !roles.is_empty()
493 {
494 let role_links = roles
495 .iter()
496 .map(|role| {
497 if let Some(course_instance_id) = role.course_instance_id {
498 format!(
499 "{base_url}/manage/course-instances/{course_instance_id}/permissions"
500 )
501 } else if let Some(course_id) = role.course_id {
502 format!("{base_url}/manage/courses/{course_id}/permissions")
503 } else if let Some(exam_id) = role.exam_id {
504 format!("{base_url}/manage/exams/{exam_id}/permissions")
505 } else {
506 format!("{base_url}/manage/permissions")
507 }
508 })
509 .collect::<Vec<_>>()
510 .join(", ");
511 notes.push(format!(
512 "roles only lists course-, course-instance-, or exam-scoped roles for this \
513 user \u{2014} organization-wide and platform-wide roles are left out. UserRole \
514 values are distinct capabilities, not a seniority hierarchy. A role on the \
515 course in question means this person is staff there, not a student. Verify \
516 each row's scope at: {role_links} (built from that row's own scope id) \u{2014} \
517 the user page's role badges carry no scope attribution, so scope can only be \
518 confirmed on these pages."
519 ));
520 }
521
522 if let Some(UserOverviewFacetValue::EmailDeliveries(deliveries)) = self
523 .state
524 .facets
525 .get(UserOverviewFacet::EmailDeliveries.wire_name())
526 && !deliveries.is_empty()
527 {
528 notes.push(
529 "email_deliveries.status: 'Sent' means handed to the mail relay, not confirmed \
530 delivered; 'Queued' has not been handed over yet; 'Retrying' has failed at \
531 least once but is still within the 3-day retry window; 'SendFailed' means \
532 retries have stopped. Distinguish a recurring failure_code (delivery keeps \
533 failing) from a send with no failure recorded (probably just landed in spam). \
534 failure_is_transient absent means nothing has failed, not that severity is \
535 unknown; last_attempt_at absent means no attempt has been made. This is only \
536 the 20 most recent deliveries addressed to this account, not its full history."
537 .to_string(),
538 );
539 }
540
541 if notes.is_empty() {
542 None
543 } else {
544 Some(notes.join(" "))
545 }
546 }
547}