Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
course_structure.rs

1use 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    /// Whether `course_id` was resolved from the argument (a support admin reading a course they
30    /// are not on) rather than from the chatbot's own context, which decides how the closing
31    /// instructions are worded.
32    course_id_from_argument: bool,
33}
34
35/// The most page groups a course reports and the most pages one group lists. Both are far above
36/// any real course; they exist so a pathological page count degrades into a legible partial list
37/// rather than into the mid-value cut the output-wide backstop would make of it.
38const 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    /// Determine page type based on page's position in course structure
52    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/// The pages of one chapter, or the pages of a module that sit outside any chapter.
75///
76/// Grouped rather than flat because the module and chapter a page belongs to are otherwise
77/// repeated on every page of them, which on a large course is most of the output.
78#[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/// What a group's pages are keyed by. Not serialized: the same values are on the group itself.
90#[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    /// A model that treats this tool as parameterless sends an empty argument string, which is
153    /// not valid JSON, so that keeps working alongside `course_id`.
154    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            // map module number 0 to 1 so that pages without a module
182            // are ordered first. same for chapters.
183            // order by module first, then chapter, then page number.
184            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}