headless_lms_chatbot/chatbot_tools/custom_tools/
find_course.rs1use headless_lms_authorization::Action;
2use std::str::FromStr;
3
4use indexmap::IndexMap;
5
6use headless_lms_models::chatbot_configurations::ToolCategory;
7use headless_lms_models::{
8 course_instances::{self, CourseInstance},
9 courses::{self, Course},
10 organizations,
11};
12use headless_lms_utils::json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType};
13
14use crate::{
15 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
16 chatbot_tools::{
17 ChatbotTool, ChatbotToolDeclaration, ToolProperties, tool_authorization::ToolRequirement,
18 },
19 prelude::*,
20 user_context::ChatbotTurnContext,
21};
22
23pub type FindCourseTool = ToolProperties<FindCourseState>;
24
25pub struct FindCourseState {
26 candidates: Vec<CourseCandidate>,
27 base_url: String,
28}
29
30struct CourseCandidate {
31 course: Course,
32 instances: Vec<CourseInstance>,
33 organization_name: String,
34}
35
36#[derive(Serialize)]
37struct CourseCandidateOutput {
38 course_id: Uuid,
39 name: String,
40 slug: String,
41 language_code: String,
42 organization_name: String,
43 is_draft: bool,
44 is_test_mode: bool,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 closed_at: Option<chrono::DateTime<chrono::Utc>>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 closed_additional_message: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 closed_course_successor_id: Option<Uuid>,
51 instances: Vec<CourseInstanceOutput>,
52}
53
54#[derive(Serialize)]
55struct CourseInstanceOutput {
56 course_instance_id: Uuid,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 name: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 starts_at: Option<chrono::DateTime<chrono::Utc>>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 ends_at: Option<chrono::DateTime<chrono::Utc>>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 support_email: Option<String>,
65}
66
67impl From<&CourseInstance> for CourseInstanceOutput {
68 fn from(instance: &CourseInstance) -> Self {
69 Self {
70 course_instance_id: instance.id,
71 name: instance.name.clone(),
72 starts_at: instance.starts_at,
73 ends_at: instance.ends_at,
74 support_email: instance.support_email.clone(),
75 }
76 }
77}
78
79impl From<&CourseCandidate> for CourseCandidateOutput {
80 fn from(candidate: &CourseCandidate) -> Self {
81 let course = &candidate.course;
82 Self {
83 course_id: course.id,
84 name: course.name.clone(),
85 slug: course.slug.clone(),
86 language_code: course.language_code.clone(),
87 organization_name: candidate.organization_name.clone(),
88 is_draft: course.is_draft,
89 is_test_mode: course.is_test_mode,
90 closed_at: course.closed_at,
91 closed_additional_message: course.closed_additional_message.clone(),
92 closed_course_successor_id: course.closed_course_successor_id,
93 instances: candidate
94 .instances
95 .iter()
96 .map(CourseInstanceOutput::from)
97 .collect(),
98 }
99 }
100}
101
102#[derive(Deserialize)]
103pub struct FindCourseArguments {
104 query: String,
105}
106
107const MAX_CANDIDATES: i64 = 5;
108
109impl ChatbotToolDeclaration for FindCourseTool {
110 const NAME: &'static str = "find_course";
111
112 fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
113 vec![ToolRequirement::global(Action::Administrate)]
114 }
115
116 const CATEGORY: ToolCategory = ToolCategory::AdminSupportCourses;
117
118 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
119 AzureLLMFunctionToolDefinition {
120 tool_type: LLMToolType::Function,
121 name: Self::NAME.to_string(),
122 description: "Find a course by UUID, exact slug, or (part of) its name. Use this to resolve which course an admin means before calling course- or user-scoped tools.".to_string(),
123 parameters: Schema::strict_object(
124 IndexMap::from([(
125 "query".to_string(),
126 SchemaPropertyType::Item(JsonItem {
127 type_field: JSONType::String,
128 description: Some(
129 "Course UUID, exact slug, or (part of) the course name.".to_string(),
130 ),
131 }),
132 )]),
133 None,
134 ),
135 strict: true,
136 }
137 }
138}
139
140impl ChatbotTool for FindCourseTool {
141 type Arguments = FindCourseArguments;
142
143 fn call_requirements(
144 _arguments: &Self::Arguments,
145 _user_context: &ChatbotTurnContext,
146 ) -> Vec<ToolRequirement> {
147 vec![ToolRequirement::global(Action::Administrate)]
148 }
149
150 fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
151 let mut arguments: Self::Arguments = serde_json::from_str(&args_string).map_err(|e| {
152 chatbot_err!(
153 InvalidToolArguments,
154 format!("Couldn't parse tool arguments. Arguments: {args_string}"),
155 e
156 )
157 })?;
158 arguments.query = arguments.query.trim().to_string();
159 if arguments.query.is_empty() {
160 return Err(chatbot_err!(
161 InvalidToolArguments,
162 "query must not be empty.".to_string()
163 ));
164 }
165 Ok(arguments)
166 }
167
168 async fn from_db_and_arguments(
169 conn: &mut PgConnection,
170 app_config: &ApplicationConfiguration,
171 arguments: Self::Arguments,
172 _user_context: &ChatbotTurnContext,
173 ) -> ChatbotResult<Self> {
174 let base_url = app_config.base_url.trim_end_matches('/').to_string();
175 let query = arguments.query;
176
177 let courses = if let Ok(course_id) = Uuid::from_str(&query) {
178 match courses::get_course(conn, course_id).await.optional()? {
179 Some(course) => vec![course],
180 None => {
181 courses::search_courses_by_slug_or_name(conn, &query, MAX_CANDIDATES).await?
182 }
183 }
184 } else {
185 courses::search_courses_by_slug_or_name(conn, &query, MAX_CANDIDATES).await?
186 };
187
188 let organization_ids: Vec<Uuid> = courses.iter().map(|c| c.organization_id).collect();
189 let organization_names: std::collections::HashMap<Uuid, String> =
190 organizations::get_by_ids(conn, &organization_ids)
191 .await?
192 .into_iter()
193 .map(|org| (org.id, org.name))
194 .collect();
195
196 let mut candidates = Vec::with_capacity(courses.len());
197 for course in courses {
198 let instances =
199 course_instances::get_course_instances_for_course(conn, course.id).await?;
200 let organization_name = organization_names
201 .get(&course.organization_id)
202 .cloned()
203 .unwrap_or_default();
204 candidates.push(CourseCandidate {
205 course,
206 instances,
207 organization_name,
208 });
209 }
210
211 Ok(FindCourseTool {
212 state: FindCourseState {
213 candidates,
214 base_url,
215 },
216 })
217 }
218
219 fn output(&self) -> String {
220 let candidates: Vec<CourseCandidateOutput> = self
221 .state
222 .candidates
223 .iter()
224 .map(CourseCandidateOutput::from)
225 .collect();
226 serde_json::to_string_pretty(&candidates).unwrap_or_else(|_| "No courses found".to_string())
227 }
228
229 fn output_description_instructions(&self) -> Option<String> {
230 let candidates = &self.state.candidates;
231 let base_url = &self.state.base_url;
232 let mut notes = vec![
233 "If several courses match (e.g. language versions of the same course — compare \
234 language_code), ask the admin which one before proceeding. The instance contact \
235 emails shown here may be stale; the course_configuration tool's staff facet is the \
236 fresher source."
237 .to_string(),
238 ];
239
240 if !candidates.is_empty() {
241 let overview_links = candidates
242 .iter()
243 .map(|c| {
244 format!(
245 "{} ({}): {base_url}/manage/courses/{}/overview",
246 c.course.name, c.course.language_code, c.course.id
247 )
248 })
249 .collect::<Vec<_>>()
250 .join(", ");
251 notes.push(format!(
252 "Course overview pages, to confirm you and the admin are looking at the same \
253 course: {overview_links}."
254 ));
255 }
256
257 if candidates.is_empty() {
258 notes.push(
259 "No courses matched. This means either nothing matched the query, or the \
260 course was deleted (deleted courses are excluded from this search)."
261 .to_string(),
262 );
263 }
264
265 if candidates.len() == MAX_CANDIDATES as usize {
266 notes.push(format!(
267 "Results are capped at {MAX_CANDIDATES} and ordered exact slug match > name \
268 substring > fuzzy match; there may be more matching courses that were \
269 silently truncated from this list."
270 ));
271 }
272
273 if candidates
274 .iter()
275 .any(|c| c.course.is_test_mode || c.course.is_draft)
276 {
277 notes.push(
278 "Some results have is_test_mode or is_draft set. A test-mode course is a \
279 staff testing copy and a draft course is unpublished — neither is the course \
280 a student is asking about."
281 .to_string(),
282 );
283 }
284
285 if candidates.iter().any(|c| c.course.closed_at.is_some()) {
286 let now = chrono::Utc::now();
287 let mut closed_at_note = String::from(
288 "closed_at is a scheduled closing timestamp: absent means the course was \
289 never scheduled to close, a future value means it's still open, and only a \
290 past value means it's actually closed.",
291 );
292 if candidates.iter().any(|c| {
293 c.course.closed_at.is_some_and(|t| t <= now)
294 && c.course.closed_course_successor_id.is_none()
295 }) {
296 closed_at_note.push_str(
297 " A closed course with no closed_course_successor_id has nowhere \
298 configured to send the student.",
299 );
300 }
301 if candidates
302 .iter()
303 .any(|c| c.course.closed_course_successor_id.is_some())
304 {
305 closed_at_note.push_str(
306 " closed_course_successor_id is a course id, not a name — call \
307 find_course again to identify it.",
308 );
309 }
310 notes.push(closed_at_note);
311 }
312
313 if candidates
314 .iter()
315 .flat_map(|c| &c.instances)
316 .any(|i| i.starts_at.is_none() || i.ends_at.is_none())
317 {
318 notes.push(
319 "Some instances are missing starts_at or ends_at. The platform itself is \
320 inconsistent about whether such an instance counts as started, so report the \
321 absence rather than asserting whether the instance is running."
322 .to_string(),
323 );
324 }
325
326 let mut name_to_candidates: std::collections::HashMap<&str, Vec<&CourseCandidate>> =
327 std::collections::HashMap::new();
328 for candidate in candidates {
329 name_to_candidates
330 .entry(candidate.course.name.as_str())
331 .or_default()
332 .push(candidate);
333 }
334 if let Some(ambiguous) = name_to_candidates.values().find(|group| {
335 group
336 .iter()
337 .map(|c| c.course.language_code.as_str())
338 .collect::<std::collections::HashSet<_>>()
339 .len()
340 >= 2
341 }) {
342 let representative_id = ambiguous[0].course.id;
344 notes.push(format!(
345 "Some results share a name but differ in language_code — these are separate \
346 course rows, and a student's progress lives in exactly one of them. \
347 {base_url}/manage/courses/{representative_id}/language-versions lists the \
348 whole sibling set side by side."
349 ));
350 }
351
352 Some(notes.join(" "))
353 }
354}