Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
user_overview.rs

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
52/// The most rows a per-user list reports. One row per course the account has ever touched, which
53/// stays small for a student and does not for a staff account or a long-lived test account.
54const 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/// Only course-, course-instance-, or exam-scoped roles: global and organization-scoped roles
82/// are filtered out before this is built, so those fields would never be anything but absent.
83#[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}