Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
course_finder.rs

1use headless_lms_utils::cache::Cache;
2use std::collections::HashMap;
3
4use indexmap::IndexMap;
5use serde::Deserializer;
6
7use crate::{
8    azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
9    chatbot_tools::{
10        ChatbotTool, ChatbotToolDeclaration, ToolProperties, tool_authorization::ToolRequirement,
11    },
12    prelude::*,
13    user_context::ChatbotTurnContext,
14};
15use headless_lms_models::{
16    chatbot_configurations::ToolCategory,
17    organizations::{self, DatabaseOrganization},
18};
19use headless_lms_models::{
20    course_audiences::get_course_ids_by_audience_vectors,
21    course_prerequisites::get_course_ids_by_prerequisite_vectors,
22    courses::{self, Course, get_by_description_vectors},
23    external_courses::{ExternalCourseOutput, get_external_courses_by_embeddings},
24};
25use headless_lms_utils::{
26    azure_embedding::create_embeddings,
27    course_url::build_course_url,
28    json_schema_types::{Schema, string_array_property},
29};
30
31#[derive(Debug, Serialize)]
32pub struct CourseFinderState {
33    courses: Vec<CourseOccurrences>,
34    external_courses: Vec<ExternalCourseOutput>,
35}
36
37#[derive(Deserialize, Clone, Debug)]
38pub struct CourseFinderArguments {
39    #[serde(deserialize_with = "empty_vec_as_none")]
40    description: Option<Vec<String>>,
41    #[serde(deserialize_with = "empty_vec_as_none")]
42    prerequisites: Option<Vec<String>>,
43    #[serde(deserialize_with = "empty_vec_as_none")]
44    audiences: Option<Vec<String>>,
45}
46#[derive(Serialize, Deserialize, Clone, Debug)]
47pub struct CourseOccurrences {
48    course: Course,
49    occurrences: usize,
50    course_url: String,
51}
52
53pub type CourseFinderTool = ToolProperties<CourseFinderState>;
54
55impl ChatbotTool for CourseFinderTool {
56    type Arguments = CourseFinderArguments;
57
58    fn call_requirements(
59        _arguments: &Self::Arguments,
60        _user_context: &ChatbotTurnContext,
61    ) -> Vec<ToolRequirement> {
62        Vec::new()
63    }
64
65    async fn from_db_and_arguments(
66        conn: &mut PgConnection,
67        app_config: &ApplicationConfiguration,
68        _cache: &Cache,
69        arguments: Self::Arguments,
70        _user_context: &ChatbotTurnContext,
71    ) -> ChatbotResult<Self> {
72        let audience_courses = if let Some(audiences) = &arguments.audiences {
73            let audience_embeddings = create_embeddings(app_config, audiences.clone())
74                .await?
75                .to_owned();
76
77            get_course_ids_by_audience_vectors(conn, audience_embeddings, audiences.clone()).await?
78        } else {
79            vec![]
80        };
81
82        let prerequisite_courses = if let Some(prerequisites) = &arguments.prerequisites {
83            let prerequisite_embeddings = create_embeddings(app_config, prerequisites.clone())
84                .await?
85                .to_owned();
86
87            get_course_ids_by_prerequisite_vectors(
88                conn,
89                prerequisite_embeddings,
90                prerequisites.clone(),
91            )
92            .await?
93        } else {
94            vec![]
95        };
96
97        let (description_courses, external_courses) =
98            if let Some(description) = &arguments.description {
99                let description_embeddings = create_embeddings(app_config, description.clone())
100                    .await?
101                    .to_owned();
102
103                let external_courses = get_external_courses_by_embeddings(
104                    conn,
105                    description.clone(),
106                    description_embeddings.clone(),
107                )
108                .await?;
109
110                let courses =
111                    get_by_description_vectors(conn, description_embeddings, description.clone())
112                        .await?;
113                (courses, external_courses)
114            } else {
115                (vec![], vec![])
116            };
117
118        let course_ids = [description_courses, audience_courses, prerequisite_courses].concat();
119
120        let mut counts: HashMap<Uuid, usize> = HashMap::new();
121
122        for id in &course_ids {
123            *counts.entry(*id).or_insert(0) += 1;
124        }
125
126        let courses = courses::get_by_ids(conn, &course_ids).await?;
127
128        let organization_ids: Vec<Uuid> = courses
129            .iter()
130            .map(|course| course.organization_id)
131            .collect();
132
133        let organizations = organizations::get_by_ids(conn, &organization_ids).await?;
134
135        let organization_by_id: HashMap<Uuid, &DatabaseOrganization> = organizations
136            .iter()
137            .map(|organization| (organization.id, organization))
138            .collect();
139
140        let mut course_occurrences: Vec<CourseOccurrences> = courses
141            .into_iter()
142            .filter_map(|course| {
143                if course.is_draft
144                    || course.is_test_mode
145                    || course.is_unlisted
146                    || course.is_closed()
147                {
148                    return None;
149                }
150                let organization = organization_by_id.get(&course.organization_id)?;
151
152                Some(CourseOccurrences {
153                    occurrences: counts[&course.id],
154                    course_url: build_course_url(
155                        &app_config.base_url,
156                        &organization.slug,
157                        &course.slug,
158                    ),
159                    course,
160                })
161            })
162            .collect();
163
164        course_occurrences.sort_by_key(|b| std::cmp::Reverse(b.occurrences));
165
166        Ok(CourseFinderTool {
167            state: CourseFinderState {
168                courses: course_occurrences,
169                external_courses,
170            },
171        })
172    }
173
174    fn output(&self) -> String {
175        serde_json::to_string(&self.state).unwrap_or_else(|_| "No courses found".to_string())
176    }
177
178    fn output_description_instructions(&self) -> Option<String> {
179        Some("Do not return the whole JSON of the courses to the user. Courses under the 'courses'key are what you should prioritize. If in addition to those courses there is some external course that matches the user query especially well, you can recommend that as well. If you recommend an external course, state that an external course is not in courses.mooc.fi. If the course is checked to be on the old platform (on_old_platform is true), say that it is on the older version of our platform so not all information may be found on this site. If it is not on the old platform, just say it is hosted on an external platform and we don't have any additional information on it. Use the url provided to link the external course. Present the most suitable courses based on the user query. Use the course names and course descriptions to give a list and a very brief and summarized description of each course to the user. If there are duplicate courses ignore them. You can also mention why the course could be suitable to the user based on their request.".to_string())
180    }
181}
182
183impl ChatbotToolDeclaration for CourseFinderTool {
184    const NAME: &'static str = "course_finder";
185
186    fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
187        Vec::new()
188    }
189
190    const CATEGORY: ToolCategory = ToolCategory::CourseCatalog;
191
192    fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
193        AzureLLMFunctionToolDefinition {
194            tool_type: LLMToolType::Function,
195            name: Self::NAME.to_string(),
196            description: "Find suitable courses for the user if they want to find available courses for their conditions. The arguments should be created based on the terms with which the user wants to filter the courses. The needed arguments should therefore be parsed from the user message. The arguments are arrays of keywords for the parameters the user is using to search the courses. At least one of the three arguments is required. Match on any single argument will find a course so it is safe to provide all types of arguments when suitable. This tool is useful to find any courses if the user wants recommendations for courses they can take.".to_string(),
197            parameters: Schema::strict_object(
198                IndexMap::from([
199                    (
200                        "description".to_string(),
201                        string_array_property(Some("List of keywords used to search course descriptions based on if the user tries to find courses based on what they contain or teach.")),
202                    ),
203                    (
204                        "prerequisites".to_string(),
205                        string_array_property(Some("List of keywords of preliminary knowledge possessed to be suitable for a course.")),
206                    ),
207                    (
208                        "audiences".to_string(),
209                        string_array_property(Some("List of keywords of audience types that a course is suitable for.")),
210                    ),
211                ]),
212                None,
213            ),
214            strict: true,
215        }
216    }
217}
218
219fn empty_vec_as_none<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
220where
221    D: Deserializer<'de>,
222{
223    let opt = Option::<Vec<String>>::deserialize(deserializer)?;
224
225    Ok(opt.and_then(|vec| {
226        let vec: Vec<String> = vec
227            .into_iter()
228            .map(|s| s.trim().to_owned())
229            .filter(|s| !s.is_empty())
230            .collect();
231
232        if vec.is_empty() { None } else { Some(vec) }
233    }))
234}