Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
find_user.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::user_details::EmailVerificationMethod;
9use headless_lms_models::{user_details, user_details::UserDetail, users};
10use headless_lms_utils::json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType};
11
12use crate::{
13    azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
14    chatbot_tools::{
15        ChatbotTool, ChatbotToolDeclaration, ToolProperties, argument_parsing::parse_required_uuid,
16        tool_authorization::ToolRequirement,
17    },
18    prelude::*,
19    user_context::ChatbotTurnContext,
20};
21
22const MAX_CANDIDATES: usize = 10;
23const MIN_FUZZY_QUERY_LENGTH: usize = 3;
24
25pub type FindUserTool = ToolProperties<FindUserState>;
26
27pub struct FindUserState {
28    matched_as: &'static str,
29    candidates: Vec<UserCandidateOutput>,
30    base_url: String,
31    query: String,
32}
33
34#[derive(Clone, Serialize)]
35struct UserCandidateOutput {
36    user_id: Uuid,
37    email: String,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    first_name: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    last_name: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    upstream_id: Option<i32>,
44    created_at: DateTime<Utc>,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    email_verified_at: Option<DateTime<Utc>>,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    email_verified_method: Option<EmailVerificationMethod>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    deleted_at: Option<DateTime<Utc>>,
51}
52
53#[derive(Serialize)]
54struct FindUserOutput {
55    matched_as: &'static str,
56    candidates: Vec<UserCandidateOutput>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    note: Option<String>,
59}
60
61enum FindUserKind {
62    Email,
63    Name,
64    UserId,
65    UpstreamId,
66    Auto,
67}
68
69pub struct FindUserArguments {
70    query: String,
71    kind: FindUserKind,
72}
73
74#[derive(Deserialize)]
75struct RawFindUserArguments {
76    query: String,
77    kind: String,
78}
79
80/// Manual, not derived: `kind` needs validation `#[derive(Deserialize)]` can't express, and this
81/// is what [ChatbotTool::Arguments]'s `DeserializeOwned` bound is satisfied by (`parse_arguments`
82/// below is overridden and never calls it, but the bound still has to hold).
83impl<'de> serde::Deserialize<'de> for FindUserArguments {
84    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
85    where
86        D: serde::Deserializer<'de>,
87    {
88        let raw = RawFindUserArguments::deserialize(deserializer)?;
89        build_arguments(raw).map_err(serde::de::Error::custom)
90    }
91}
92
93fn build_arguments(raw: RawFindUserArguments) -> ChatbotResult<FindUserArguments> {
94    let query = raw.query.trim().to_string();
95    if query.is_empty() {
96        return Err(chatbot_err!(
97            InvalidToolArguments,
98            "query must not be empty.".to_string()
99        ));
100    }
101
102    let kind = match raw.kind.as_str() {
103        "email" => FindUserKind::Email,
104        "name" => FindUserKind::Name,
105        "user_id" => FindUserKind::UserId,
106        "upstream_id" => FindUserKind::UpstreamId,
107        "auto" => FindUserKind::Auto,
108        other => {
109            return Err(chatbot_err!(
110                InvalidToolArguments,
111                format!(
112                    "Unknown kind '{other}'. Valid values: email, name, user_id, upstream_id, auto."
113                )
114            ));
115        }
116    };
117
118    if matches!(kind, FindUserKind::Email | FindUserKind::Name)
119        && query.chars().count() < MIN_FUZZY_QUERY_LENGTH
120    {
121        return Err(chatbot_err!(
122            InvalidToolArguments,
123            format!(
124                "query must be at least {MIN_FUZZY_QUERY_LENGTH} characters long for email or name search."
125            )
126        ));
127    }
128
129    Ok(FindUserArguments { query, kind })
130}
131
132impl ChatbotToolDeclaration for FindUserTool {
133    const NAME: &'static str = "find_user";
134
135    fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
136        vec![ToolRequirement::global(Action::ViewUserProgressOrDetails)]
137    }
138
139    const CATEGORY: ToolCategory = ToolCategory::AdminSupportAccounts;
140
141    fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
142        AzureLLMFunctionToolDefinition {
143            tool_type: LLMToolType::Function,
144            name: Self::NAME.to_string(),
145            description: "Find a user by email, name, user id, or upstream id, to identify who a support request is about before looking up or changing anything for them.".to_string(),
146            parameters: Schema::strict_object(
147                IndexMap::from([
148                    (
149                        "query".to_string(),
150                        SchemaPropertyType::Item(JsonItem {
151                            type_field: JSONType::String,
152                            description: Some("The value to search for: an email address, a name, a user_id (UUID), or an upstream_id (integer), depending on kind.".to_string()),
153                        }),
154                    ),
155                    (
156                        "kind".to_string(),
157                        SchemaPropertyType::Item(JsonItem {
158                            type_field: JSONType::String,
159                            description: Some("One of: email, name, user_id, upstream_id, auto. Use auto when unsure.".to_string()),
160                        }),
161                    ),
162                ]),
163                None,
164            ),
165            strict: true,
166        }
167    }
168}
169
170impl ChatbotTool for FindUserTool {
171    type Arguments = FindUserArguments;
172
173    fn call_requirements(
174        _arguments: &Self::Arguments,
175        _user_context: &ChatbotTurnContext,
176    ) -> Vec<ToolRequirement> {
177        vec![ToolRequirement::global(Action::ViewUserProgressOrDetails)]
178    }
179
180    fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
181        let raw: RawFindUserArguments = serde_json::from_str(&args_string).map_err(|e| {
182            chatbot_err!(
183                InvalidToolArguments,
184                format!("Couldn't parse tool arguments. Arguments: {args_string}"),
185                e
186            )
187        })?;
188        build_arguments(raw)
189    }
190
191    async fn from_db_and_arguments(
192        conn: &mut PgConnection,
193        app_config: &ApplicationConfiguration,
194        _cache: &Cache,
195        arguments: Self::Arguments,
196        _user_context: &ChatbotTurnContext,
197    ) -> ChatbotResult<Self> {
198        let base_url = app_config.base_url.trim_end_matches('/').to_string();
199        let query = arguments.query.clone();
200        let (matched_as, details) = match arguments.kind {
201            FindUserKind::UserId => ("user_id", find_by_user_id(conn, &arguments.query).await?),
202            FindUserKind::UpstreamId => (
203                "upstream_id",
204                find_by_upstream_id(conn, &arguments.query).await?,
205            ),
206            FindUserKind::Email => (
207                "email",
208                user_details::search_for_user_details_by_email(conn, &arguments.query).await?,
209            ),
210            FindUserKind::Name => (
211                "name",
212                user_details::search_for_user_details_fuzzy_match(conn, &arguments.query).await?,
213            ),
214            FindUserKind::Auto => find_auto(conn, &arguments.query).await?,
215        };
216
217        let details: Vec<UserDetail> = details.into_iter().take(MAX_CANDIDATES).collect();
218        let user_ids: Vec<Uuid> = details.iter().map(|d| d.user_id).collect();
219        let users_by_id: std::collections::HashMap<Uuid, users::User> =
220            users::get_by_ids(conn, &user_ids)
221                .await?
222                .into_iter()
223                .map(|u| (u.id, u))
224                .collect();
225
226        let mut candidates = Vec::new();
227        for detail in details {
228            let user = users_by_id.get(&detail.user_id);
229            candidates.push(UserCandidateOutput {
230                user_id: detail.user_id,
231                email: detail.email,
232                first_name: detail.first_name,
233                last_name: detail.last_name,
234                upstream_id: user.and_then(|u| u.upstream_id),
235                created_at: detail.created_at,
236                email_verified_at: detail.email_verified_at,
237                email_verified_method: detail.email_verified_method,
238                deleted_at: user.and_then(|u| u.deleted_at),
239            });
240        }
241
242        Ok(FindUserTool {
243            state: FindUserState {
244                matched_as,
245                candidates,
246                base_url,
247                query,
248            },
249        })
250    }
251
252    fn output(&self) -> String {
253        let note = self.state.candidates.is_empty().then(|| {
254            "No candidates found. Try a different kind (email, name, user_id, upstream_id, auto) or check the query for typos."
255                .to_string()
256        });
257
258        let result = FindUserOutput {
259            matched_as: self.state.matched_as,
260            candidates: self.state.candidates.clone(),
261            note,
262        };
263
264        serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
265    }
266
267    fn output_description_instructions(&self) -> Option<String> {
268        let mut notes = vec![
269            "If exactly one candidate matches, proceed with its user_id. If several match, list them to the admin (email, name, created date) and ask which one is meant before doing anything else. Never guess between candidates. Mention when the matched email differs from what the admin typed (likely a typo).".to_string(),
270        ];
271
272        if self.state.matched_as == "email" {
273            notes.push("kind \"email\" is a fuzzy (trigram) match, not an exact lookup: verify the returned email character-by-character against what the admin typed before using a candidate.".to_string());
274        }
275
276        if self.state.matched_as == "name" {
277            notes.push("matched_as \"name\" means an email or ID search found nothing and only the name search matched — this is a weak match; a hit on a common name could be any user with that name.".to_string());
278        }
279
280        if self.state.candidates.len() >= MAX_CANDIDATES {
281            notes.push(format!(
282                "The candidate list is capped at {MAX_CANDIDATES} and truncation is not signalled beyond this note: if this many came back, ask the admin to narrow the query rather than assuming this is the complete set."
283            ));
284        }
285
286        let has_upstream_id = self
287            .state
288            .candidates
289            .iter()
290            .any(|c| c.upstream_id.is_some());
291        let missing_upstream_id = self
292            .state
293            .candidates
294            .iter()
295            .any(|c| c.upstream_id.is_none());
296        if has_upstream_id && missing_upstream_id {
297            notes.push(format!(
298                "upstream_id is the TMC/mooc.fi account id; some candidates have it and some don't, which is the classic duplicate-account shape (one TMC account, one local-only account) — check with the admin before picking one. The search-users page has no upstream_id column, so this can only be confirmed on each candidate's own page ({base_url}/manage/users/<user_id>).",
299                base_url = self.state.base_url
300            ));
301        }
302
303        if self
304            .state
305            .candidates
306            .iter()
307            .any(|c| c.email_verified_method.is_some())
308        {
309            notes.push("email_verified_at absent means the address was never proven and is auto-cleared on every email change, so it being absent right after an address correction is expected, not suspicious. email_verified_method strength ranges from real proof (EmailedCode, TmcConfirmed) through an inference (PasswordResetBackfill) down to AdminAsserted, which is only a human's assertion and may have been set by a support admin rather than the user.".to_string());
310        }
311
312        if !self.state.candidates.is_empty() {
313            let search_url =
314                url::Url::parse(&format!("{}/manage/search-users", self.state.base_url))
315                    .map(|mut u| {
316                        u.query_pairs_mut().append_pair("search", &self.state.query);
317                        u.to_string()
318                    })
319                    .unwrap_or_else(|_| self.state.base_url.clone());
320            notes.push(format!(
321                "{search_url} runs the same three searches this tool wraps — open it and compare its row set to the candidates listed here. Each candidate's own page, {base_url}/manage/users/<user_id>, is where to confirm the email before acting on that account id.",
322                base_url = self.state.base_url
323            ));
324        }
325
326        Some(notes.join(" "))
327    }
328}
329
330/// Looks up one user by `user_id`, rejecting a query that is not a valid UUID.
331async fn find_by_user_id(conn: &mut PgConnection, query: &str) -> ChatbotResult<Vec<UserDetail>> {
332    let user_id = parse_required_uuid("user_id", query)?;
333    Ok(user_details::get_user_details_by_user_id(conn, user_id)
334        .await
335        .optional()?
336        .into_iter()
337        .collect())
338}
339
340/// Looks up one user by `upstream_id`, rejecting a query that is not an integer.
341async fn find_by_upstream_id(
342    conn: &mut PgConnection,
343    query: &str,
344) -> ChatbotResult<Vec<UserDetail>> {
345    let upstream_id = query.parse::<i32>().map_err(|e| {
346        chatbot_err!(
347            InvalidToolArguments,
348            format!("'{query}' is not a valid upstream_id (integer)."),
349            e
350        )
351    })?;
352    let Some(user) = users::find_by_upstream_id(conn, upstream_id).await? else {
353        return Ok(Vec::new());
354    };
355    Ok(user_details::get_user_details_by_user_id(conn, user.id)
356        .await
357        .optional()?
358        .into_iter()
359        .collect())
360}
361
362/// Tries interpretations of `query` in order — UUID, upstream id, email, name — and returns the
363/// first one that yields at least one candidate. Falls back to a (possibly empty) name search.
364async fn find_auto(
365    conn: &mut PgConnection,
366    query: &str,
367) -> ChatbotResult<(&'static str, Vec<UserDetail>)> {
368    if Uuid::from_str(query).is_ok() {
369        let details = find_by_user_id(conn, query).await?;
370        if !details.is_empty() {
371            return Ok(("user_id", details));
372        }
373    }
374
375    if query.parse::<i32>().is_ok() {
376        let details = find_by_upstream_id(conn, query).await?;
377        if !details.is_empty() {
378            return Ok(("upstream_id", details));
379        }
380    }
381
382    if query.contains('@') {
383        let details = user_details::search_for_user_details_by_email(conn, query).await?;
384        if !details.is_empty() {
385            return Ok(("email", details));
386        }
387    }
388
389    if query.chars().count() < MIN_FUZZY_QUERY_LENGTH {
390        return Err(chatbot_err!(
391            InvalidToolArguments,
392            format!(
393                "query must be at least {MIN_FUZZY_QUERY_LENGTH} characters long for email or name search."
394            )
395        ));
396    }
397    let details = user_details::search_for_user_details_fuzzy_match(conn, query).await?;
398    Ok(("name", details))
399}