headless_lms_chatbot/chatbot_tools/custom_tools/
course_structure.rs1use indexmap::IndexMap;
2
3use headless_lms_models::chatbot_configurations::ToolCategory;
4use headless_lms_models::pages;
5use headless_lms_utils::{
6 document_schema_processor::get_learning_objectives,
7 json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType},
8};
9
10use crate::{
11 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
12 chatbot_tools::{
13 ChatbotTool, ChatbotToolDeclaration, ToolProperties,
14 argument_parsing::deserialize_to_optional_uuid_and_errors_to_none,
15 course_scope::{
16 COURSE_ID_ARGUMENT_DESCRIPTION, material_requirements, resolve_course_scope,
17 },
18 output_limits::CappedList,
19 tool_authorization::ToolRequirement,
20 },
21 prelude::*,
22 user_context::ChatbotTurnContext,
23};
24
25pub type CourseStructureTool = ToolProperties<CourseStructureState>;
26
27pub struct CourseStructureState {
28 structure: CourseStructure,
29 course_id_from_argument: bool,
33}
34
35const MAX_PAGE_GROUPS: usize = 100;
39const MAX_PAGES_PER_GROUP: usize = 200;
40
41#[derive(Serialize, Deserialize, Debug)]
42#[serde(rename_all = "snake_case")]
43pub enum PageType {
44 CourseFrontPage,
45 TopLevelPage,
46 ChapterFrontPage,
47 GenericPage,
48}
49
50impl PageType {
51 fn determine(
53 order_number: i32,
54 chapter_number: Option<i32>,
55 module_number: Option<i32>,
56 ) -> Self {
57 if chapter_number.is_none() && module_number.is_none() && order_number == 0 {
58 PageType::CourseFrontPage
59 } else if chapter_number.is_none() && module_number.is_none() && order_number != 0 {
60 PageType::TopLevelPage
61 } else if chapter_number.is_some() && order_number == 0 {
62 PageType::ChapterFrontPage
63 } else {
64 PageType::GenericPage
65 }
66 }
67}
68
69#[derive(Serialize)]
70struct CourseStructure {
71 page_groups: CappedList<PageGroup>,
72}
73
74#[derive(Serialize)]
79struct PageGroup {
80 #[serde(skip_serializing_if = "Option::is_none")]
81 module_name: Option<String>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 chapter_number: Option<i32>,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 chapter_title: Option<String>,
86 pages: CappedList<PageDocumentInfo>,
87}
88
89#[derive(PartialEq, Eq, Hash)]
91struct PageGroupKey {
92 module_name: Option<String>,
93 chapter_number: Option<i32>,
94 chapter_title: Option<String>,
95}
96
97#[derive(Serialize, Deserialize, Debug)]
98pub struct PageDocumentInfo {
99 pub page_id: Uuid,
100 pub url_path: String,
101 pub page_title: String,
102 pub page_type: PageType,
103 #[serde(skip_serializing_if = "Option::is_none")]
104 pub learning_objectives: Option<String>,
105}
106
107#[derive(Deserialize)]
108pub struct CourseStructureArguments {
109 #[serde(deserialize_with = "deserialize_to_optional_uuid_and_errors_to_none")]
110 course_id: Option<Uuid>,
111}
112
113impl ChatbotToolDeclaration for CourseStructureTool {
114 const NAME: &'static str = "course_structure";
115
116 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
117 material_requirements(user_context.course_id)
118 }
119
120 const CATEGORY: ToolCategory = ToolCategory::CourseInfo;
121
122 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
123 AzureLLMFunctionToolDefinition {
124 tool_type: LLMToolType::Function,
125 name: Self::NAME.to_string(),
126 description: "Get the course structure as the course's pages in order, grouped by the module and chapter they belong to. Each page is listed with its title and its learning objectives, if any. Information about the course pages' content can be found with the document_lookup tool.".to_string(),
127 parameters: Schema::strict_object(
128 IndexMap::from([(
129 "course_id".to_string(),
130 SchemaPropertyType::Item(JsonItem {
131 type_field: JSONType::String,
132 description: Some(COURSE_ID_ARGUMENT_DESCRIPTION.to_string()),
133 }),
134 )]),
135 None,
136 ),
137 strict: true,
138 }
139 }
140}
141
142impl ChatbotTool for CourseStructureTool {
143 type Arguments = CourseStructureArguments;
144
145 fn call_requirements(
146 arguments: &Self::Arguments,
147 user_context: &ChatbotTurnContext,
148 ) -> Vec<ToolRequirement> {
149 material_requirements(resolve_course_scope(user_context, arguments.course_id).ok())
150 }
151
152 fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
155 if args_string.trim().is_empty() {
156 return Ok(CourseStructureArguments { course_id: None });
157 }
158 serde_json::from_str(&args_string).map_err(|e| {
159 chatbot_err!(
160 InvalidToolArguments,
161 format!("Couldn't parse tool arguments. Arguments: {args_string}"),
162 e
163 )
164 })
165 }
166
167 async fn from_db_and_arguments(
168 conn: &mut PgConnection,
169 _app_config: &ApplicationConfiguration,
170 arguments: Self::Arguments,
171 user_context: &ChatbotTurnContext,
172 ) -> ChatbotResult<Self>
173 where
174 Self: Sized,
175 {
176 let course_id_from_argument = arguments.course_id.is_some();
177 let course_id = resolve_course_scope(user_context, arguments.course_id)?;
178
179 let mut pages_info = pages::get_page_info_special_for_course(conn, course_id).await?;
180 pages_info.sort_by_key(|x| {
181 x.module_number.map(|x| x + 1).unwrap_or(0) * 100
185 + x.chapter_number.map(|x| x + 1).unwrap_or(0) * 10
186 + x.order_number
187 });
188
189 let mut grouped: IndexMap<PageGroupKey, Vec<PageDocumentInfo>> = IndexMap::new();
190 for page in pages_info {
191 let key = PageGroupKey {
192 module_name: page.module_name.clone(),
193 chapter_number: page.chapter_number,
194 chapter_title: page.chapter_title.clone(),
195 };
196 let learning_objectives = page
197 .blocks_cloned()
198 .ok()
199 .and_then(|blocks| get_learning_objectives(&blocks));
200 grouped.entry(key).or_default().push(PageDocumentInfo {
201 page_id: page.page_id,
202 url_path: page.url_path,
203 page_title: page.page_title,
204 page_type: PageType::determine(
205 page.order_number,
206 page.chapter_number,
207 page.module_number,
208 ),
209 learning_objectives,
210 });
211 }
212
213 let page_groups = grouped
214 .into_iter()
215 .map(|(key, pages)| PageGroup {
216 module_name: key.module_name,
217 chapter_number: key.chapter_number,
218 chapter_title: key.chapter_title,
219 pages: CappedList::new(pages, MAX_PAGES_PER_GROUP),
220 })
221 .collect();
222
223 Ok(CourseStructureTool {
224 state: CourseStructureState {
225 structure: CourseStructure {
226 page_groups: CappedList::new(page_groups, MAX_PAGE_GROUPS),
227 },
228 course_id_from_argument,
229 },
230 })
231 }
232
233 fn output(&self) -> String {
234 serde_json::to_string(&self.state.structure).unwrap_or("Not found.".to_string())
235 }
236
237 fn output_description_instructions(&self) -> Option<String> {
238 let closing = if self.state.course_id_from_argument {
239 "This is a course the admin is looking up on behalf of a user, not the one this chat is running on. Look up a listed page's content with document_lookup using its page_id, or search the course's pages with course_material_search."
240 } else {
241 "The user has access to the course structure, so you shouldn't give it to them: they know it already. You can give an overview if asked. Look up a listed page's content with document_lookup using its page_id, or search the course's pages with course_material_search."
242 };
243 let mut notes = vec![format!(
244 "Pages are grouped by the module and chapter they belong to, so a page's place in the course is on its group rather than on the page. Use the course structure to find out more about the course and answer the user's questions. The learning objectives listed on the course front page or top level pages are objectives for the whole course. Learning objectives listed on a chapter front page encompass the whole chapter, and objectives listed on a generic page are for the page only. {closing}"
245 )];
246 if self.state.structure.page_groups.is_truncated()
247 || self
248 .state
249 .structure
250 .page_groups
251 .iter()
252 .any(|group| group.pages.is_truncated())
253 {
254 notes.push(
255 "A truncated marker means this course has more pages than fit in one result, so do not answer questions about how many pages or chapters it has from this."
256 .to_string(),
257 );
258 }
259 Some(notes.join(" "))
260 }
261}