Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
course_finder.rs

1use std::collections::HashMap;
2
3use indexmap::IndexMap;
4use serde::Deserializer;
5
6use crate::{
7    azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
8    chatbot_tools::{
9        ChatbotTool, ChatbotToolDeclaration, ToolProperties, tool_authorization::ToolRequirement,
10    },
11    prelude::*,
12    user_context::ChatbotTurnContext,
13};
14use headless_lms_models::chatbot_configurations::ToolCategory;
15use headless_lms_models::{
16    course_audiences::get_course_ids_by_audience_vectors,
17    course_prerequisites::get_course_ids_by_prerequisite_vectors,
18    courses::{self, Course, get_by_description_vectors},
19};
20use headless_lms_utils::{
21    azure_embedding::create_embeddings,
22    json_schema_types::{Schema, string_array_property},
23};
24
25#[derive(Debug)]
26pub struct CourseFinderState {
27    courses: Vec<CourseOccurrences>,
28}
29
30#[derive(Deserialize, Clone, Debug)]
31pub struct CourseFinderArguments {
32    #[serde(deserialize_with = "empty_vec_as_none")]
33    description: Option<Vec<String>>,
34    #[serde(deserialize_with = "empty_vec_as_none")]
35    prerequisites: Option<Vec<String>>,
36    #[serde(deserialize_with = "empty_vec_as_none")]
37    audiences: Option<Vec<String>>,
38}
39#[derive(Serialize, Deserialize, Clone, Debug)]
40pub struct CourseOccurrences {
41    course: Course,
42    occurrences: usize,
43}
44
45pub type CourseFinderTool = ToolProperties<CourseFinderState>;
46
47impl ChatbotTool for CourseFinderTool {
48    type Arguments = CourseFinderArguments;
49
50    fn call_requirements(
51        _arguments: &Self::Arguments,
52        _user_context: &ChatbotTurnContext,
53    ) -> Vec<ToolRequirement> {
54        Vec::new()
55    }
56
57    async fn from_db_and_arguments(
58        conn: &mut PgConnection,
59        app_config: &ApplicationConfiguration,
60        arguments: Self::Arguments,
61        _user_context: &ChatbotTurnContext,
62    ) -> ChatbotResult<Self> {
63        let audience_courses = if let Some(audiences) = &arguments.audiences {
64            let audience_embeddings = create_embeddings(app_config, audiences.clone())
65                .await?
66                .to_owned();
67
68            get_course_ids_by_audience_vectors(conn, audience_embeddings, audiences.clone()).await?
69        } else {
70            vec![]
71        };
72
73        let prerequisite_courses = if let Some(prerequisites) = &arguments.prerequisites {
74            let prerequisite_embeddings = create_embeddings(app_config, prerequisites.clone())
75                .await?
76                .to_owned();
77
78            get_course_ids_by_prerequisite_vectors(
79                conn,
80                prerequisite_embeddings,
81                prerequisites.clone(),
82            )
83            .await?
84        } else {
85            vec![]
86        };
87
88        let description_courses = if let Some(description) = &arguments.description {
89            let description_embeddings = create_embeddings(app_config, description.clone())
90                .await?
91                .to_owned();
92
93            get_by_description_vectors(conn, description_embeddings, description.clone()).await?
94        } else {
95            vec![]
96        };
97
98        let course_ids = [description_courses, audience_courses, prerequisite_courses].concat();
99
100        let mut counts: HashMap<Uuid, usize> = HashMap::new();
101
102        for id in &course_ids {
103            *counts.entry(*id).or_insert(0) += 1;
104        }
105
106        let courses = courses::get_by_ids(conn, &course_ids).await?;
107
108        let mut course_occurrences: Vec<CourseOccurrences> = courses
109            .into_iter()
110            .map(|course| CourseOccurrences {
111                occurrences: counts[&course.id],
112                course,
113            })
114            .collect();
115
116        course_occurrences.sort_by_key(|b| std::cmp::Reverse(b.occurrences));
117
118        Ok(CourseFinderTool {
119            state: CourseFinderState {
120                courses: course_occurrences,
121            },
122        })
123    }
124
125    fn output(&self) -> String {
126        serde_json::to_string(&self.state.courses)
127            .unwrap_or_else(|_| "No courses found".to_string())
128    }
129
130    fn output_description_instructions(&self) -> Option<String> {
131        Some("Do not return the whole JSON of the courses to the user. 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())
132    }
133}
134
135impl ChatbotToolDeclaration for CourseFinderTool {
136    const NAME: &'static str = "course_finder";
137
138    fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
139        Vec::new()
140    }
141
142    const CATEGORY: ToolCategory = ToolCategory::CourseCatalog;
143
144    fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
145        AzureLLMFunctionToolDefinition {
146            tool_type: LLMToolType::Function,
147            name: Self::NAME.to_string(),
148            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(),
149            parameters: Schema::strict_object(
150                IndexMap::from([
151                    (
152                        "description".to_string(),
153                        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.")),
154                    ),
155                    (
156                        "prerequisites".to_string(),
157                        string_array_property(Some("List of keywords of preliminary knowledge possessed to be suitable for a course.")),
158                    ),
159                    (
160                        "audiences".to_string(),
161                        string_array_property(Some("List of keywords of audience types that a course is suitable for.")),
162                    ),
163                ]),
164                None,
165            ),
166            strict: true,
167        }
168    }
169}
170
171fn empty_vec_as_none<'de, D>(deserializer: D) -> Result<Option<Vec<String>>, D::Error>
172where
173    D: Deserializer<'de>,
174{
175    let opt = Option::<Vec<String>>::deserialize(deserializer)?;
176
177    Ok(opt.and_then(|vec| {
178        let vec: Vec<String> = vec
179            .into_iter()
180            .map(|s| s.trim().to_owned())
181            .filter(|s| !s.is_empty())
182            .collect();
183
184        if vec.is_empty() { None } else { Some(vec) }
185    }))
186}