Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
find_user.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::user_details::EmailVerificationMethod;
8use headless_lms_models::{user_details, user_details::UserDetail, users};
9use headless_lms_utils::json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType};
10
11use crate::{
12    azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
13    chatbot_tools::{
14        ChatbotTool, ChatbotToolDeclaration, ToolProperties, argument_parsing::parse_required_uuid,
15        tool_authorization::ToolRequirement,
16    },
17    prelude::*,
18    user_context::ChatbotTurnContext,
19};
20
21const MAX_CANDIDATES: usize = 10;
22const MIN_FUZZY_QUERY_LENGTH: usize = 3;
23
24pub type FindUserTool = ToolProperties<FindUserState>;
25
26pub struct FindUserState {
27    matched_as: &'static str,
28    candidates: Vec<UserCandidateOutput>,
29    base_url: String,
30    query: String,
31}
32
33#[derive(Clone, Serialize)]
34struct UserCandidateOutput {
35    user_id: Uuid,
36    email: String,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    first_name: Option<String>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    last_name: Option<String>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    upstream_id: Option<i32>,
43    created_at: DateTime<Utc>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    email_verified_at: Option<DateTime<Utc>>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    email_verified_method: Option<EmailVerificationMethod>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    deleted_at: Option<DateTime<Utc>>,
50}
51
52#[derive(Serialize)]
53struct FindUserOutput {
54    matched_as: &'static str,
55    candidates: Vec<UserCandidateOutput>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    note: Option<String>,
58}
59
60enum FindUserKind {
61    Email,
62    Name,
63    UserId,
64    UpstreamId,
65    Auto,
66}
67
68pub struct FindUserArguments {
69    query: String,
70    kind: FindUserKind,
71}
72
73#[derive(Deserialize)]
74struct RawFindUserArguments {
75    query: String,
76    kind: String,
77}
78
79/// Manual, not derived: `kind` needs validation `#[derive(Deserialize)]` can't express, and this
80/// is what [ChatbotTool::Arguments]'s `DeserializeOwned` bound is satisfied by (`parse_arguments`
81/// below is overridden and never calls it, but the bound still has to hold).
82impl<'de> serde::Deserialize<'de> for FindUserArguments {
83    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
84    where
85        D: serde::Deserializer<'de>,
86    {
87        let raw = RawFindUserArguments::deserialize(deserializer)?;
88        build_arguments(raw).map_err(serde::de::Error::custom)
89    }
90}
91
92fn build_arguments(raw: RawFindUserArguments) -> ChatbotResult<FindUserArguments> {
93    let query = raw.query.trim().to_string();
94    if query.is_empty() {
95        return Err(chatbot_err!(
96            InvalidToolArguments,
97            "query must not be empty.".to_string()
98        ));
99    }
100
101    let kind = match raw.kind.as_str() {
102        "email" => FindUserKind::Email,
103        "name" => FindUserKind::Name,
104        "user_id" => FindUserKind::UserId,
105        "upstream_id" => FindUserKind::UpstreamId,
106        "auto" => FindUserKind::Auto,
107        other => {
108            return Err(chatbot_err!(
109                InvalidToolArguments,
110                format!(
111                    "Unknown kind '{other}'. Valid values: email, name, user_id, upstream_id, auto."
112                )
113            ));
114        }
115    };
116
117    if matches!(kind, FindUserKind::Email | FindUserKind::Name)
118        && query.chars().count() < MIN_FUZZY_QUERY_LENGTH
119    {
120        return Err(chatbot_err!(
121            InvalidToolArguments,
122            format!(
123                "query must be at least {MIN_FUZZY_QUERY_LENGTH} characters long for email or name search."
124            )
125        ));
126    }
127
128    Ok(FindUserArguments { query, kind })
129}
130
131impl ChatbotToolDeclaration for FindUserTool {
132    const NAME: &'static str = "find_user";
133
134    fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
135        vec![ToolRequirement::global(Action::ViewUserProgressOrDetails)]
136    }
137
138    const CATEGORY: ToolCategory = ToolCategory::AdminSupportAccounts;
139
140    fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
141        AzureLLMFunctionToolDefinition {
142            tool_type: LLMToolType::Function,
143            name: Self::NAME.to_string(),
144            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(),
145            parameters: Schema::strict_object(
146                IndexMap::from([
147                    (
148                        "query".to_string(),
149                        SchemaPropertyType::Item(JsonItem {
150                            type_field: JSONType::String,
151                            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()),
152                        }),
153                    ),
154                    (
155                        "kind".to_string(),
156                        SchemaPropertyType::Item(JsonItem {
157                            type_field: JSONType::String,
158                            description: Some("One of: email, name, user_id, upstream_id, auto. Use auto when unsure.".to_string()),
159                        }),
160                    ),
161                ]),
162                None,
163            ),
164            strict: true,
165        }
166    }
167}
168
169impl ChatbotTool for FindUserTool {
170    type Arguments = FindUserArguments;
171
172    fn call_requirements(
173        _arguments: &Self::Arguments,
174        _user_context: &ChatbotTurnContext,
175    ) -> Vec<ToolRequirement> {
176        vec![ToolRequirement::global(Action::ViewUserProgressOrDetails)]
177    }
178
179    fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
180        let raw: RawFindUserArguments = serde_json::from_str(&args_string).map_err(|e| {
181            chatbot_err!(
182                InvalidToolArguments,
183                format!("Couldn't parse tool arguments. Arguments: {args_string}"),
184                e
185            )
186        })?;
187        build_arguments(raw)
188    }
189
190    async fn from_db_and_arguments(
191        conn: &mut PgConnection,
192        app_config: &ApplicationConfiguration,
193        arguments: Self::Arguments,
194        _user_context: &ChatbotTurnContext,
195    ) -> ChatbotResult<Self> {
196        let base_url = app_config.base_url.trim_end_matches('/').to_string();
197        let query = arguments.query.clone();
198        let (matched_as, details) = match arguments.kind {
199            FindUserKind::UserId => ("user_id", find_by_user_id(conn, &arguments.query).await?),
200            FindUserKind::UpstreamId => (
201                "upstream_id",
202                find_by_upstream_id(conn, &arguments.query).await?,
203            ),
204            FindUserKind::Email => (
205                "email",
206                user_details::search_for_user_details_by_email(conn, &arguments.query).await?,
207            ),
208            FindUserKind::Name => (
209                "name",
210                user_details::search_for_user_details_fuzzy_match(conn, &arguments.query).await?,
211            ),
212            FindUserKind::Auto => find_auto(conn, &arguments.query).await?,
213        };
214
215        let details: Vec<UserDetail> = details.into_iter().take(MAX_CANDIDATES).collect();
216        let user_ids: Vec<Uuid> = details.iter().map(|d| d.user_id).collect();
217        let users_by_id: std::collections::HashMap<Uuid, users::User> =
218            users::get_by_ids(conn, &user_ids)
219                .await?
220                .into_iter()
221                .map(|u| (u.id, u))
222                .collect();
223
224        let mut candidates = Vec::new();
225        for detail in details {
226            let user = users_by_id.get(&detail.user_id);
227            candidates.push(UserCandidateOutput {
228                user_id: detail.user_id,
229                email: detail.email,
230                first_name: detail.first_name,
231                last_name: detail.last_name,
232                upstream_id: user.and_then(|u| u.upstream_id),
233                created_at: detail.created_at,
234                email_verified_at: detail.email_verified_at,
235                email_verified_method: detail.email_verified_method,
236                deleted_at: user.and_then(|u| u.deleted_at),
237            });
238        }
239
240        Ok(FindUserTool {
241            state: FindUserState {
242                matched_as,
243                candidates,
244                base_url,
245                query,
246            },
247        })
248    }
249
250    fn output(&self) -> String {
251        let note = self.state.candidates.is_empty().then(|| {
252            "No candidates found. Try a different kind (email, name, user_id, upstream_id, auto) or check the query for typos."
253                .to_string()
254        });
255
256        let result = FindUserOutput {
257            matched_as: self.state.matched_as,
258            candidates: self.state.candidates.clone(),
259            note,
260        };
261
262        serde_json::to_string_pretty(&result).unwrap_or_else(|_| "{}".to_string())
263    }
264
265    fn output_description_instructions(&self) -> Option<String> {
266        let mut notes = vec![
267            "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(),
268        ];
269
270        if self.state.matched_as == "email" {
271            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());
272        }
273
274        if self.state.matched_as == "name" {
275            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());
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 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.",
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, email, name — and returns the
361/// first one that yields at least one candidate. Falls back to a (possibly empty) name 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    if query.contains('@') {
381        let details = user_details::search_for_user_details_by_email(conn, query).await?;
382        if !details.is_empty() {
383            return Ok(("email", details));
384        }
385    }
386
387    if query.chars().count() < MIN_FUZZY_QUERY_LENGTH {
388        return Err(chatbot_err!(
389            InvalidToolArguments,
390            format!(
391                "query must be at least {MIN_FUZZY_QUERY_LENGTH} characters long for email or name search."
392            )
393        ));
394    }
395    let details = user_details::search_for_user_details_fuzzy_match(conn, query).await?;
396    Ok(("name", details))
397}