Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
user_overview.rs

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