headless_lms_chatbot/chatbot_tools/custom_tools/
course_finder.rs1use 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}