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