Skip to main content

headless_lms_chatbot/chatbot_tools/custom_tools/
course_material_search.rs

1use headless_lms_authorization::Action;
2use std::str::FromStr;
3
4use indexmap::IndexMap;
5
6use headless_lms_models::chatbot_configurations::ToolCategory;
7use headless_lms_models::{
8    chatbot_conversation_messages_citations, courses, organizations,
9    pages::{self, PageSearchResult, SearchRequest},
10};
11use headless_lms_utils::{
12    json_schema_types::{JSONType, JsonItem, Schema, SchemaPropertyType},
13    strings::truncate_utf8_at_boundary,
14};
15
16use crate::{
17    azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
18    chatbot_tools::{
19        ChatbotTool, ChatbotToolDeclaration, ToolCitation, ToolProperties,
20        course_scope::resolve_course_scope, tool_authorization::ToolRequirement,
21    },
22    prelude::*,
23    user_context::ChatbotTurnContext,
24};
25
26pub type CourseMaterialSearchTool = ToolProperties<CourseMaterialSearchState>;
27
28struct SearchHit {
29    page_id: Uuid,
30    title: String,
31    chapter_name: Option<String>,
32    url_path: String,
33    rank: Option<f32>,
34    snippet: Option<String>,
35    citation_number: i32,
36}
37
38#[derive(Serialize)]
39struct OutputCourse<'a> {
40    id: Uuid,
41    name: &'a str,
42    slug: &'a str,
43}
44
45#[derive(Serialize)]
46struct OutputResult<'a> {
47    page_id: Uuid,
48    title: &'a str,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    chapter_name: Option<&'a str>,
51    url_path: &'a str,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    rank: Option<f32>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    snippet: Option<&'a str>,
56    citation_number: i32,
57}
58
59#[derive(Serialize)]
60struct Output<'a> {
61    course: OutputCourse<'a>,
62    results: Vec<OutputResult<'a>>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    note: Option<&'static str>,
65}
66
67pub struct CourseMaterialSearchState {
68    course_id: Uuid,
69    course_name: String,
70    course_slug: String,
71    hits: Vec<SearchHit>,
72    document_url_prefix: String,
73}
74
75#[derive(Deserialize)]
76struct RawArguments {
77    course_id: String,
78    query: String,
79}
80
81pub struct CourseMaterialSearchArguments {
82    course_id: Uuid,
83    query: String,
84}
85
86/// Manual, not derived: `course_id`/`query` need validation `#[derive(Deserialize)]` can't
87/// express, and this is what [ChatbotTool::Arguments]'s `DeserializeOwned` bound is satisfied by
88/// (`parse_arguments` below is overridden and never calls it, but the bound still has to hold).
89impl<'de> Deserialize<'de> for CourseMaterialSearchArguments {
90    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
91    where
92        D: serde::Deserializer<'de>,
93    {
94        let raw = RawArguments::deserialize(deserializer)?;
95        build_arguments(raw).map_err(serde::de::Error::custom)
96    }
97}
98
99fn build_arguments(raw: RawArguments) -> ChatbotResult<CourseMaterialSearchArguments> {
100    let course_id = Uuid::from_str(&raw.course_id).map_err(|e| {
101        chatbot_err!(
102            InvalidToolArguments,
103            format!("'{}' is not a valid course_id.", raw.course_id),
104            e
105        )
106    })?;
107    let query = raw.query.trim().to_string();
108    if query.is_empty() {
109        return Err(chatbot_err!(
110            InvalidToolArguments,
111            "query must not be empty.".to_string()
112        ));
113    }
114    if query.chars().count() > MAX_QUERY_LENGTH {
115        return Err(chatbot_err!(
116            InvalidToolArguments,
117            format!(
118                "query is too long ({} characters); keep it under {MAX_QUERY_LENGTH} characters, closer to a few keywords than a paragraph.",
119                query.chars().count()
120            )
121        ));
122    }
123    Ok(CourseMaterialSearchArguments { course_id, query })
124}
125
126const MAX_RESULTS: usize = 10;
127const MAX_QUERY_LENGTH: usize = 200;
128
129/// Marks from `ts_headline` on the raw match, useless once handed to the model.
130fn strip_headline_marks(headline: Option<String>) -> Option<String> {
131    headline.map(|s| s.replace("<b>", "").replace("</b>", ""))
132}
133
134impl ChatbotToolDeclaration for CourseMaterialSearchTool {
135    const NAME: &'static str = "course_material_search";
136
137    fn offer_requirements(user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
138        vec![ToolRequirement::on_turn(
139            Action::ViewInternalCourseStructure,
140            user_context,
141        )]
142    }
143
144    const CATEGORY: ToolCategory = ToolCategory::CourseMaterial;
145
146    fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
147        AzureLLMFunctionToolDefinition {
148            tool_type: LLMToolType::Function,
149            name: Self::NAME.to_string(),
150            description: "Search a course's own pages by keyword, the same full-text search that backs the course material search dialog. Returns the pages that matched, each with a short snippet. Use this before document_lookup to find which page has what you need.".to_string(),
151            parameters: Schema::strict_object(
152                IndexMap::from([
153                    (
154                        "course_id".to_string(),
155                        SchemaPropertyType::Item(JsonItem {
156                            type_field: JSONType::String,
157                            description: Some(
158                                "The course to search. Resolve it with find_course first."
159                                    .to_string(),
160                            ),
161                        }),
162                    ),
163                    (
164                        "query".to_string(),
165                        SchemaPropertyType::Item(JsonItem {
166                            type_field: JSONType::String,
167                            description: Some(
168                                "What to look for, in the course's own language and wording. This is a keyword search, not a semantic one: prefer the words the material would use, and try a different phrasing if nothing is found."
169                                    .to_string(),
170                            ),
171                        }),
172                    ),
173                ]),
174                None,
175            ),
176            strict: true,
177        }
178    }
179}
180
181impl ChatbotTool for CourseMaterialSearchTool {
182    type Arguments = CourseMaterialSearchArguments;
183
184    fn call_requirements(
185        arguments: &Self::Arguments,
186        _user_context: &ChatbotTurnContext,
187    ) -> Vec<ToolRequirement> {
188        vec![ToolRequirement::on_course(
189            Action::ViewInternalCourseStructure,
190            arguments.course_id,
191        )]
192    }
193
194    fn parse_arguments(args_string: String) -> ChatbotResult<Self::Arguments> {
195        let raw: RawArguments = serde_json::from_str(&args_string).map_err(|e| {
196            chatbot_err!(
197                InvalidToolArguments,
198                format!("Couldn't parse tool arguments. Arguments: {args_string}"),
199                e
200            )
201        })?;
202        build_arguments(raw)
203    }
204
205    async fn from_db_and_arguments(
206        conn: &mut PgConnection,
207        app_config: &ApplicationConfiguration,
208        arguments: Self::Arguments,
209        user_context: &ChatbotTurnContext,
210    ) -> ChatbotResult<Self> {
211        let course_id = resolve_course_scope(user_context, Some(arguments.course_id))?;
212        let course = courses::get_course(conn, course_id).await.map_err(|e| {
213            chatbot_err!(
214                InvalidToolArguments,
215                format!("No course found with id {course_id}."),
216                e
217            )
218        })?;
219        let organization = organizations::get_organization(conn, course.organization_id).await?;
220
221        let search_request = SearchRequest {
222            query: arguments.query,
223        };
224        let phrase_results =
225            pages::get_page_search_results_for_phrase(conn, course_id, &search_request).await?;
226        let word_results =
227            pages::get_page_search_results_for_words(conn, course_id, &search_request).await?;
228
229        let mut merged: Vec<PageSearchResult> = phrase_results;
230        let already_present: std::collections::HashSet<Uuid> =
231            merged.iter().map(|r| r.id).collect();
232        merged.extend(
233            word_results
234                .into_iter()
235                .filter(|r| !already_present.contains(&r.id)),
236        );
237        merged.truncate(MAX_RESULTS);
238
239        // A whole turn's citations end up on one message, so numbering has to continue past
240        // whatever an earlier search already used in this turn rather than restart at zero.
241        let starting_number = if let Some(conversation_id) = user_context.conversation_id {
242            chatbot_conversation_messages_citations::max_citation_number_in_turn(
243                conn,
244                conversation_id,
245            )
246            .await?
247            .unwrap_or(0)
248        } else {
249            0
250        };
251
252        let ids_missing_headline: Vec<Uuid> = merged
253            .iter()
254            .filter(|r| r.title_headline.is_none())
255            .map(|r| r.id)
256            .collect();
257        let fallback_titles = pages::get_titles_by_ids(conn, &ids_missing_headline).await?;
258
259        let mut hits = Vec::with_capacity(merged.len());
260        for (i, result) in merged.into_iter().enumerate() {
261            let title = match strip_headline_marks(result.title_headline) {
262                Some(title) => title,
263                // No headline (the query didn't match the title itself): fall back to the
264                // page's plain title rather than showing the model an empty string.
265                None => fallback_titles.get(&result.id).cloned().unwrap_or_default(),
266            };
267            hits.push(SearchHit {
268                page_id: result.id,
269                title,
270                chapter_name: result.chapter_name,
271                url_path: result.url_path,
272                rank: result.rank,
273                snippet: strip_headline_marks(result.content_headline),
274                citation_number: starting_number + 1 + i as i32,
275            });
276        }
277
278        Ok(CourseMaterialSearchTool {
279            state: CourseMaterialSearchState {
280                course_id,
281                course_name: course.name,
282                course_slug: course.slug,
283                hits,
284                document_url_prefix: format!(
285                    "{}/org/{}/courses",
286                    app_config.base_url.trim_end_matches('/'),
287                    organization.slug
288                ),
289            },
290        })
291    }
292
293    fn output(&self) -> String {
294        let course = OutputCourse {
295            id: self.state.course_id,
296            name: &self.state.course_name,
297            slug: &self.state.course_slug,
298        };
299        let results: Vec<OutputResult> = self
300            .state
301            .hits
302            .iter()
303            .map(|hit| OutputResult {
304                page_id: hit.page_id,
305                title: &hit.title,
306                chapter_name: hit.chapter_name.as_deref(),
307                url_path: &hit.url_path,
308                rank: hit.rank,
309                snippet: hit.snippet.as_deref(),
310                citation_number: hit.citation_number,
311            })
312            .collect();
313
314        let note = results.is_empty().then_some(
315            "No page matched. Try different wording, or list the course's pages with course_structure.",
316        );
317        let output = Output {
318            course,
319            results,
320            note,
321        };
322        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "No results.".to_string())
323    }
324
325    fn output_description_instructions(&self) -> Option<String> {
326        Some("Quote the material verbatim and name the page title. Cite a page by writing 【0:N†source】 immediately after the sentence that uses it, where N is that result's citation_number; the admin sees those as clickable links to the page. Fetch the whole page with document_lookup (course_id plus the result's page_id) only when the snippet was not clearly enough. If nothing matched, say so plainly instead of guessing.".to_string())
327    }
328
329    /// Column widths in `chatbot_conversation_messages_citations` are `VARCHAR(255)`; truncate to
330    /// fit the way `to_chatbot_conversation_message_citation` does for the Azure path.
331    fn citations(&self) -> Vec<ToolCitation> {
332        self.state
333            .hits
334            .iter()
335            .map(|hit| {
336                let title = truncate_utf8_at_boundary(&hit.title, 255).to_string();
337                let snippet = hit
338                    .snippet
339                    .as_deref()
340                    .map(|s| truncate_utf8_at_boundary(s, 255).to_string())
341                    .unwrap_or_default();
342                let document_url = truncate_utf8_at_boundary(
343                    &format!("{}{}", self.state.document_url_prefix, hit.url_path),
344                    255,
345                )
346                .to_string();
347                ToolCitation {
348                    page_id: hit.page_id,
349                    title,
350                    snippet,
351                    document_url,
352                    citation_number: hit.citation_number,
353                }
354            })
355            .collect()
356    }
357}