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
80impl<'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
328async 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
338async 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
360async 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
396async 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}