headless_lms_chatbot/chatbot_tools/custom_tools/
course_structure.rs1use headless_lms_utils::cache::Cache;
2use indexmap::IndexMap;
3
4use headless_lms_models::chatbot_configurations::ToolCategory;
5use headless_lms_models::pages;
6use headless_lms_utils::{
7 document_schema_processor::get_learning_objectives,
8 json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType},
9};
10
11use crate::{
12 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
13 chatbot_tools::{
14 ChatbotTool, ChatbotToolDeclaration, ToolProperties,
15 argument_parsing::deserialize_to_optional_uuid_and_errors_to_none,
16 course_scope::{
17 COURSE_ID_ARGUMENT_DESCRIPTION, material_requirements, resolve_course_scope,
18 },
19 output_limits::CappedList,
20 tool_authorization::ToolRequirement,
21 },
22 prelude::*,
23 user_context::ChatbotTurnContext,
24};
25
26pub type CourseStructureTool = ToolProperties<CourseStructureState>;
27
28pub struct CourseStructureState {
29 structure: CourseStructure,
30 course_id_from_argument: bool,
34}
35
36const MAX_PAGE_GROUPS: usize = 100;
40const MAX_PAGES_PER_GROUP: usize = 200;
41
42#[derive(Serialize, Deserialize, Debug)]
43#[serde(rename_all = "snake_case")]
44pub enum PageType {
45 CourseFrontPage,
46 TopLevelPage,
47 ChapterFrontPage,
48 GenericPage,
49}
50
51impl PageType {
52 fn determine(
54 order_number: i32,
55 chapter_number: Option<i32>,
56 module_number: Option<i32>,
57 ) -> Self {
58 if chapter_number.is_none() && module_number.is_none() && order_number == 0 {
59 PageType::CourseFrontPage
60 } else if chapter_number.is_none() && module_number.is_none() && order_number != 0 {
61 PageType::TopLevelPage
62 } else if chapter_number.is_some() && order_number == 0 {
63 PageType::ChapterFrontPage
64 } else {
65 PageType::GenericPage
66 }
67 }
68}
69
70#[derive(Serialize)]
71struct CourseStructure {
72 page_groups: CappedList<PageGroup>,
73}
74
75#[derive(Serialize)]
80struct PageGroup {
81 #[serde(skip_serializing_if = "Option::is_none")]
82 module_name: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 chapter_number: Option<i32>,
85 #[serde(skip_serializing_if = "Option::is_none")]
86 chapter_title: Option<String>,
87 pages: CappedList<PageDocumentInfo>,
88}
89
90#[derive(PartialEq, Eq, Hash)]
92struct PageGroupKey {
93 module_name: Option<String>,
94 chapter_number: Option<i32>,
95 chapter_title: Option<String>,
96}
97
98#[derive(Serialize, Deserialize, Debug)]
99pub struct PageDocumentInfo {
100 pub page_id: Uuid,
101 pub url_path: String,
102 pub page_title: String,
103 pub page_type: PageType,
104 #[serde(skip_serializing_if = "Option::is_none")]
105 pub learning_objectives: Option<String>,
106}
107
108#[derive(Deserialize)]
109pub struct CourseStructureArguments {
110 #[serde(deserialize_with = "deserialize_to_optional_uuid_and_errors_to_none")]
111 course_id: Option<Uuid>,
112}
113
114impl ChatbotToolDeclaration for CourseStructureTool {
115 const NAME: &'static str = "course_structure";
116
117 fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
118 material_requirements(user_context.course_id)
119 }
120
121 const CATEGORY: ToolCategory = ToolCategory::CourseInfo;
122
123 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
124 AzureLLMFunctionToolDefinition {
125 tool_type: LLMToolType::Function,
126 name: Self::NAME.to_string(),
127 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(),
128 parameters: Schema::strict_object(
129 IndexMap::from([(
130 "course_id".to_string(),
131 SchemaPropertyType::Item(JsonItem {
132 type_field: JSONType::String,
133 description: Some(COURSE_ID_ARGUMENT_DESCRIPTION.to_string()),
134 }),
135 )]),
136 None,
137 ),
138 strict: true,
139 }
140 }
141}
142
143impl ChatbotTool for CourseStructureTool {
144 type Arguments = CourseStructureArguments;
145
146 fn call_requirements(
147 arguments: &Self::Arguments,
148 user_context: &ChatbotTurnContext,
149 ) -> Vec<ToolRequirement> {
150 material_requirements(resolve_course_scope(user_context, arguments.course_id).ok())
151 }
152
153 fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
156 if args_string.trim().is_empty() {
157 return Ok(CourseStructureArguments { course_id: None });
158 }
159 serde_json::from_str(&args_string).map_err(|e| {
160 chatbot_err!(
161 InvalidToolArguments,
162 format!("Couldn't parse tool arguments. Arguments: {args_string}"),
163 e
164 )
165 })
166 }
167
168 async fn from_db_and_arguments(
169 conn: &mut PgConnection,
170 _app_config: &ApplicationConfiguration,
171 _cache: &Cache,
172 arguments: Self::Arguments,
173 user_context: &ChatbotTurnContext,
174 ) -> ChatbotResult<Self>
175 where
176 Self: Sized,
177 {
178 let course_id_from_argument = arguments.course_id.is_some();
179 let course_id = resolve_course_scope(user_context, arguments.course_id)?;
180
181 let mut pages_info = pages::get_page_info_special_for_course(conn, course_id).await?;
182 pages_info.sort_by_key(|x| {
183 x.module_number.map(|x| x + 1).unwrap_or(0) * 100
187 + x.chapter_number.map(|x| x + 1).unwrap_or(0) * 10
188 + x.order_number
189 });
190
191 let mut grouped: IndexMap<PageGroupKey, Vec<PageDocumentInfo>> = IndexMap::new();
192 for page in pages_info {
193 let key = PageGroupKey {
194 module_name: page.module_name.clone(),
195 chapter_number: page.chapter_number,
196 chapter_title: page.chapter_title.clone(),
197 };
198 let learning_objectives = page
199 .blocks_cloned()
200 .ok()
201 .and_then(|blocks| get_learning_objectives(&blocks));
202 grouped.entry(key).or_default().push(PageDocumentInfo {
203 page_id: page.page_id,
204 url_path: page.url_path,
205 page_title: page.page_title,
206 page_type: PageType::determine(
207 page.order_number,
208 page.chapter_number,
209 page.module_number,
210 ),
211 learning_objectives,
212 });
213 }
214
215 let page_groups = grouped
216 .into_iter()
217 .map(|(key, pages)| PageGroup {
218 module_name: key.module_name,
219 chapter_number: key.chapter_number,
220 chapter_title: key.chapter_title,
221 pages: CappedList::new(pages, MAX_PAGES_PER_GROUP),
222 })
223 .collect();
224
225 Ok(CourseStructureTool {
226 state: CourseStructureState {
227 structure: CourseStructure {
228 page_groups: CappedList::new(page_groups, MAX_PAGE_GROUPS),
229 },
230 course_id_from_argument,
231 },
232 })
233 }
234
235 fn output(&self) -> String {
236 serde_json::to_string(&self.state.structure).unwrap_or("Not found.".to_string())
237 }
238
239 fn output_description_instructions(&self) -> Option<String> {
240 let closing = if self.state.course_id_from_argument {
241 "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."
242 } else {
243 "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."
244 };
245 let mut notes = vec![format!(
246 "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}"
247 )];
248 if self.state.structure.page_groups.is_truncated()
249 || self
250 .state
251 .structure
252 .page_groups
253 .iter()
254 .any(|group| group.pages.is_truncated())
255 {
256 notes.push(
257 "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."
258 .to_string(),
259 );
260 }
261 Some(notes.join(" "))
262 }
263}