headless_lms_chatbot/chatbot_tools/custom_tools/
course_material_search.rs1use 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
86impl<'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
129fn 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 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 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 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}