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                let exact =
208                    user_details::search_for_user_details_exact(conn, &arguments.query).await?;
209                if exact.is_empty() {
210                    ("fuzzy", find_by_fuzzy_match(conn, &arguments.query).await?)
211                } else {
212                    ("email", exact)
213                }
214            }
215            FindUserKind::Name => ("fuzzy", find_by_fuzzy_match(conn, &arguments.query).await?),
216            FindUserKind::Auto => find_auto(conn, &arguments.query).await?,
217        };
218
219        let details: Vec<UserDetail> = details.into_iter().take(MAX_CANDIDATES).collect();
220        let user_ids: Vec<Uuid> = details.iter().map(|d| d.user_id).collect();
221        let users_by_id: std::collections::HashMap<Uuid, users::User> =
222            users::get_by_ids(conn, &user_ids)
223                .await?
224                .into_iter()
225                .map(|u| (u.id, u))
226                .collect();
227
228        let mut candidates = Vec::new();
229        for detail in details {
230            let user = users_by_id.get(&detail.user_id);
231            candidates.push(UserCandidateOutput {
232                user_id: detail.user_id,
233                email: detail.email,
234                first_name: detail.first_name,
235                last_name: detail.last_name,
236                upstream_id: user.and_then(|u| u.upstream_id),
237                created_at: detail.created_at,
238                email_verified_at: detail.email_verified_at,
239                email_verified_method: detail.email_verified_method,
240                deleted_at: user.and_then(|u| u.deleted_at),
241            });
242        }
243
244        Ok(FindUserTool {
245            state: FindUserState {
246                matched_as,
247                candidates,
248                base_url,
249                query,
250            },
251        })
252    }
253
254    fn output(&self) -> String {
255        let note = self.state.candidates.is_empty().then(|| {
256            "No candidates found. Try a different kind (email, name, user_id, upstream_id, auto) or check the query for typos."
257                .to_string()
258        });
259
260        let result = FindUserOutput {
261            matched_as: self.state.matched_as,
262            candidates: self.state.candidates.clone(),
263            note,
264        };
265
266        serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
267    }
268
269    fn output_description_instructions(&self) -> Option<String> {
270        let mut notes = vec![
271            "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(),
272        ];
273
274        if self.state.matched_as == "fuzzy" {
275            notes.push("matched_as \"fuzzy\" means no id or email matched exactly and these are typo-tolerant (trigram) matches on the name or the part of the email before the @ — a weak match: a hit on a common name could be any user with that name, so verify the returned email character-by-character against what the admin typed before using a candidate.".to_string());
276        }
277
278        if self.state.candidates.len() >= MAX_CANDIDATES {
279            notes.push(format!(
280                "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."
281            ));
282        }
283
284        let has_upstream_id = self
285            .state
286            .candidates
287            .iter()
288            .any(|c| c.upstream_id.is_some());
289        let missing_upstream_id = self
290            .state
291            .candidates
292            .iter()
293            .any(|c| c.upstream_id.is_none());
294        if has_upstream_id && missing_upstream_id {
295            notes.push(format!(
296                "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>).",
297                base_url = self.state.base_url
298            ));
299        }
300
301        if self
302            .state
303            .candidates
304            .iter()
305            .any(|c| c.email_verified_method.is_some())
306        {
307            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());
308        }
309
310        if !self.state.candidates.is_empty() {
311            let search_url =
312                url::Url::parse(&format!("{}/manage/search-users", self.state.base_url))
313                    .map(|mut u| {
314                        u.query_pairs_mut().append_pair("search", &self.state.query);
315                        u.to_string()
316                    })
317                    .unwrap_or_else(|_| self.state.base_url.clone());
318            notes.push(format!(
319                "{search_url} runs the same 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.",
320                base_url = self.state.base_url
321            ));
322        }
323
324        Some(notes.join(" "))
325    }
326}
327
328/// Looks up one user by `user_id`, rejecting a query that is not a valid UUID.
329async fn find_by_user_id(conn: &mut PgConnection, query: &str) -> ChatbotResult<Vec<UserDetail>> {
330    let user_id = parse_required_uuid("user_id", query)?;
331    Ok(user_details::get_user_details_by_user_id(conn, user_id)
332        .await
333        .optional()?
334        .into_iter()
335        .collect())
336}
337
338/// Looks up one user by `upstream_id`, rejecting a query that is not an integer.
339async fn find_by_upstream_id(
340    conn: &mut PgConnection,
341    query: &str,
342) -> ChatbotResult<Vec<UserDetail>> {
343    let upstream_id = query.parse::<i32>().map_err(|e| {
344        chatbot_err!(
345            InvalidToolArguments,
346            format!("'{query}' is not a valid upstream_id (integer)."),
347            e
348        )
349    })?;
350    let Some(user) = users::find_by_upstream_id(conn, upstream_id).await? else {
351        return Ok(Vec::new());
352    };
353    Ok(user_details::get_user_details_by_user_id(conn, user.id)
354        .await
355        .optional()?
356        .into_iter()
357        .collect())
358}
359
360/// Tries interpretations of `query` in order — UUID, upstream id, exact email — and returns the
361/// first one that yields at least one candidate. Falls back to a (possibly empty) fuzzy search.
362async fn find_auto(
363    conn: &mut PgConnection,
364    query: &str,
365) -> ChatbotResult<(&'static str, Vec<UserDetail>)> {
366    if Uuid::from_str(query).is_ok() {
367        let details = find_by_user_id(conn, query).await?;
368        if !details.is_empty() {
369            return Ok(("user_id", details));
370        }
371    }
372
373    if query.parse::<i32>().is_ok() {
374        let details = find_by_upstream_id(conn, query).await?;
375        if !details.is_empty() {
376            return Ok(("upstream_id", details));
377        }
378    }
379
380    let details = user_details::search_for_user_details_exact(conn, query).await?;
381    if !details.is_empty() {
382        return Ok(("email", details));
383    }
384
385    if query.chars().count() < MIN_FUZZY_QUERY_LENGTH {
386        return Err(chatbot_err!(
387            InvalidToolArguments,
388            format!(
389                "query must be at least {MIN_FUZZY_QUERY_LENGTH} characters long for email or name search."
390            )
391        ));
392    }
393    Ok(("fuzzy", find_by_fuzzy_match(conn, query).await?))
394}
395
396/// Users whose name or email local part resembles `query`, best first.
397async fn find_by_fuzzy_match(
398    conn: &mut PgConnection,
399    query: &str,
400) -> ChatbotResult<Vec<UserDetail>> {
401    Ok(user_details::search_for_user_details_fuzzy(conn, query)
402        .await?
403        .users)
404}