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 "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
330async 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
340async 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
362async 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}