Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
find_course.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::{
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            // Any candidate id in the group works: the language-versions page lists the whole sibling set.
343            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}