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
79impl<'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
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 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}