Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
course_structure.rs

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