1use crate::{
2 azure_chatbot::azure::tools::{AzureLLMFunctionToolDefinition, LLMToolType},
3 chatbot_tools::{
4 ChatbotTool, ChatbotToolDeclaration, ToolProperties, no_parameters,
5 tool_authorization::ToolRequirement,
6 },
7 prelude::*,
8 user_context::ChatbotTurnContext,
9};
10use headless_lms_models::chatbot_configurations::ToolCategory;
11use headless_lms_models::user_exercise_states::UserCourseProgress;
12use headless_lms_utils::cache::Cache;
13
14pub type CourseProgressTool = ToolProperties<CourseProgressState>;
15
16impl ChatbotToolDeclaration for CourseProgressTool {
17 const NAME: &'static str = "course_progress";
18
19 fn offer_requirements(_user_context: &ChatbotTurnContext) -> Vec<ToolRequirement> {
20 Vec::new()
21 }
22
23 const CATEGORY: ToolCategory = ToolCategory::CourseInfo;
24
25 fn get_tool_definition() -> AzureLLMFunctionToolDefinition {
26 AzureLLMFunctionToolDefinition {
27 tool_type: LLMToolType::Function,
28 name: Self::NAME.to_string(),
29 description: "Get the user's progress on this course, including information about exercises attempted, points gained, the passing criteria for the course and if the user meets the criteria.".to_string(),
30 parameters: no_parameters(),
31 strict: true
32 }
33 }
34}
35
36impl ChatbotTool for CourseProgressTool {
37 type Arguments = CourseProgressArguments;
38
39 fn call_requirements(
40 _arguments: &Self::Arguments,
41 _user_context: &ChatbotTurnContext,
42 ) -> Vec<ToolRequirement> {
43 Vec::new()
44 }
45
46 fn parse_arguments(_args_string: String) -> ChatbotResult<Self::Arguments> {
49 Ok(CourseProgressArguments {})
50 }
51
52 async fn from_db_and_arguments(
54 conn: &mut PgConnection,
55 _app_config: &ApplicationConfiguration,
56 _cache: &Cache,
57 _arguments: Self::Arguments,
58 user_context: &ChatbotTurnContext,
59 ) -> ChatbotResult<Self> {
60 let Some(user_id) = user_context.user_id else {
61 return Err(chatbot_err!(
62 ToolUseError,
63 "User id is missing.".to_string()
64 ));
65 };
66 let Some(course_id) = user_context.course_id else {
67 return Err(chatbot_err!(
68 ToolUseError,
69 "Course id is missing.".to_string()
70 ));
71 };
72 let Some(course_name) = &user_context.course_name else {
73 return Err(chatbot_err!(
74 ToolUseError,
75 "Course name is missing.".to_string()
76 ));
77 };
78 let user_progress = headless_lms_models::user_exercise_states::get_user_course_progress(
79 conn, course_id, user_id, true,
80 )
81 .await?;
82 Result::Ok(CourseProgressTool {
83 state: CourseProgressState {
84 course_name: course_name.clone(),
85 progress: user_progress,
86 },
87 })
88 }
89
90 fn output(&self) -> String {
92 let mut progress = self.state.progress.to_owned();
93 let course_name = &self.state.course_name;
94 let mut res = format!("The user is completing a course called {course_name}. ");
95
96 if progress.len() == 1 {
98 let module_progress = &progress[0];
99
100 res += "Their progress on this course is the following:";
101
102 res += &push_exercises_scores_progress(module_progress, "course");
103 } else {
104 progress.sort_by_key(|m| m.course_module_order_number);
107 let first_mod = progress.first();
108
109 let s = if let Some(module_progress) = first_mod {
111 let m_name = &module_progress.course_module_name;
112 format!(
113 "The course has one base module, and additional modules. The user's progress on the base course module called {m_name} is the following:"
114 ) + &push_exercises_scores_progress(module_progress, "module")
115 + "To pass the course, it's required to pass the base module. The following modules are additional to the course and to complete them, it's required to first complete the base module.\n"
116 } else {
117 "There is no progress information for this user on this course. ".to_string()
119 };
120 res += &s;
121
122 for module_progress in progress.iter().skip(1) {
125 let m_name = &module_progress.course_module_name;
126 res.push_str(&format!(
127 "The user's progress on the course module called {m_name} is the following:"
128 ));
129 res += &push_exercises_scores_progress(module_progress, "module");
130 }
131 }
132 res
133 }
134
135 fn output_description_instructions(&self) -> Option<String> {
136 if self.state.progress.len() > 1 {
137 Some(
138 "Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. If the user asks something like 'how to pass the course', describe the passing criteria and requirements of the base module. Encourage the user to ask further questions about other modules if needed.".to_string(),
139 )
140 } else {
141 Some(
142 "Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.".to_string(),
143 )
144 }
145 }
146}
147
148#[derive(Deserialize)]
149pub struct CourseProgressArguments {}
150
151pub struct CourseProgressState {
152 course_name: String,
153 progress: Vec<UserCourseProgress>,
154}
155
156fn push_exercises_scores_progress(
157 module_progress: &UserCourseProgress,
158 course_or_module: &str,
159) -> String {
160 let automatic_completion = module_progress.automatic_completion;
161 let requires_exam = module_progress.requires_exam;
162 let attempted_exercises = module_progress.attempted_exercises;
163 let total_exercises = module_progress.total_exercises;
164 let attempted_exercises_required = module_progress.attempted_exercises_required;
165 let score_given = module_progress.score_given;
166 let score_maximum = module_progress.score_maximum;
167 let score_required = module_progress.score_required;
168
169 let mut res = "".to_string();
170 if total_exercises.is_some() || score_maximum.is_some() {
171 if let (Some(a), Some(b)) = (total_exercises, score_maximum)
172 && a == 0
173 && b == 0
174 {
175 res += &format!(
176 " This {course_or_module} has no exercises and no points. It cannot be completed by doing exercises."
177 );
178 if requires_exam {
179 res += " Passing an exam is required for completion.";
180 } else {
181 res += &format!(
182 " The user should look for information about completing the {course_or_module} in the course material or contact the teacher.\n"
183 );
184 }
185 return res;
186 }
187 res += &format!(" On this {course_or_module}, there are available a total of ");
188
189 if let Some(a) = total_exercises {
190 res += &format!("{a} exercises");
191 }
192 if let Some(b) = score_maximum {
193 if total_exercises.is_some() {
194 res += " and ";
195 }
196 res += &format!("{b} exercise points");
197 }
198 res += ".";
199 }
200 if automatic_completion && score_required.is_none() && attempted_exercises_required.is_none() {
201 res += &format!(
202 " It's not required to attempt exercises or gain points to pass this {course_or_module}."
203 );
204 }
205
206 if requires_exam {
207 res += &format!(" To pass this {course_or_module}, it's required to complete an exam.");
208 }
209 if !automatic_completion {
210 res += &format!(
211 " This {course_or_module} is graded by a teacher and can't be automatically passed by completing exercises. The user should look for information about completing the {course_or_module} in the course material or contact the teacher."
212 );
213 }
214
215 if attempted_exercises_required.is_some() || score_required.is_some() {
216 if requires_exam {
217 res += " To be qualified to take the exam, it's required to ";
218 } else {
219 res += &format!(" To pass this {course_or_module}, it's required to ");
220 }
221
222 if let Some(a) = attempted_exercises_required {
223 res += &format!("attempt {a} exercises");
224 }
225 if let Some(b) = score_required {
226 if attempted_exercises_required.is_some() {
227 res += " and ";
228 }
229 res += &format!("gain {b} exercise points");
230 }
231 res += ".";
232 } else if requires_exam {
233 res += " The user can attempt the exam regardless of their progress on the course."
234 }
235
236 if let Some(b) = attempted_exercises {
237 res += &format!(" The user has attempted {b} exercises.");
238 } else {
239 res += " The user has not attempted any exercises.";
240 }
241 let attempted_exercises_n = attempted_exercises.unwrap_or(0);
242
243 let pass = if requires_exam {
244 "be qualified to take the exam".to_string()
245 } else {
246 format!("pass this {course_or_module}")
247 };
248
249 if let Some(c) = attempted_exercises_required {
250 let ex_left = c - attempted_exercises_n;
251 if ex_left <= 0 {
252 res += &format!(
253 " They meet the criteria to {pass} if they have also received enough points."
254 );
255 } else {
256 res += &format!(" To {pass}, they need to attempt {ex_left} more exercises.");
257 }
258 }
259
260 let score = (score_given * 10.0).floor() / 10.0;
262 res += &format!(" The user has gained {:.1} points.", score);
263 if let Some(e) = score_required {
264 let pts_left = e as f32 - score;
265 if pts_left <= 0 as f32 {
266 res += &format!(" The user has gained enough points to {pass}.")
267 } else {
268 res += &format!(
269 " To {pass}, the user needs to gain {:.1} more points.",
270 pts_left
271 )
272 }
273 }
274 res + "\n"
275}
276
277#[cfg(test)]
278mod tests {
279 use uuid::Uuid;
280
281 use super::*;
282
283 impl CourseProgressTool {
284 fn new_mock(course_name: String, progress: Vec<UserCourseProgress>) -> Self {
285 CourseProgressTool {
286 state: CourseProgressState {
287 course_name,
288 progress,
289 },
290 }
291 }
292 }
293
294 #[test]
295 fn test_course_progress_output_only_base_module() {
296 let progress = vec![UserCourseProgress {
297 course_module_id: Uuid::nil(),
298 course_module_name: "Example base module".to_string(),
299 course_module_order_number: 1,
300 score_given: 3.3,
301 score_required: Some(4),
302 score_maximum: Some(5),
303 total_exercises: Some(11),
304 attempted_exercises: Some(4),
305 attempted_exercises_required: Some(10),
306 automatic_completion: true,
307 requires_exam: false,
308 }];
309 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
310 let output = tool.get_tool_output();
311
312 let expected_output =
313"Result: [output]The user is completing a course called Advanced Chatbot Course. Their progress on this course is the following: On this course, there are available a total of 11 exercises and 5 exercise points. To pass this course, it's required to attempt 10 exercises and gain 4 exercise points. The user has attempted 4 exercises. To pass this course, they need to attempt 6 more exercises. The user has gained 3.3 points. To pass this course, the user needs to gain 0.7 more points.\n[/output]
314
315Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
316
317 assert_eq!(output, expected_output);
318 }
319
320 #[test]
321 fn test_course_progress_output_many_modules() {
322 let progress = vec![
323 UserCourseProgress {
324 course_module_id: Uuid::nil(),
325 course_module_name: "Second extra module".to_string(),
326 course_module_order_number: 3,
327 score_given: 0.0,
328 score_required: Some(4),
329 score_maximum: Some(5),
330 total_exercises: Some(6),
331 attempted_exercises: None,
332 attempted_exercises_required: Some(5),
333 automatic_completion: true,
334 requires_exam: false,
335 },
336 UserCourseProgress {
337 course_module_id: Uuid::nil(),
338 course_module_name: "Advanced Chatbot Course".to_string(),
339 course_module_order_number: 1,
340 score_given: 8.056,
341 score_required: Some(8),
342 score_maximum: Some(10),
343 total_exercises: Some(5),
344 attempted_exercises: Some(5),
345 attempted_exercises_required: Some(5),
346 automatic_completion: true,
347 requires_exam: false,
348 },
349 UserCourseProgress {
350 course_module_id: Uuid::nil(),
351 course_module_name: "First extra module".to_string(),
352 course_module_order_number: 2,
353 score_given: 3.94,
354 score_required: Some(5),
355 score_maximum: Some(6),
356 total_exercises: Some(6),
357 attempted_exercises: Some(4),
358 attempted_exercises_required: Some(5),
359 automatic_completion: true,
360 requires_exam: false,
361 },
362 UserCourseProgress {
363 course_module_id: Uuid::nil(),
364 course_module_name: "Chatbot advanced topics".to_string(),
365 course_module_order_number: 4,
366 score_given: 2.0,
367 score_required: None,
368 score_maximum: None,
369 total_exercises: Some(2),
370 attempted_exercises: Some(2),
371 attempted_exercises_required: None,
372 automatic_completion: true,
373 requires_exam: false,
374 },
375 ];
376
377 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
378 let output = tool.get_tool_output();
379
380 let expected_output =
381"Result: [output]The user is completing a course called Advanced Chatbot Course. The course has one base module, and additional modules. The user's progress on the base course module called Advanced Chatbot Course is the following: On this module, there are available a total of 5 exercises and 10 exercise points. To pass this module, it's required to attempt 5 exercises and gain 8 exercise points. The user has attempted 5 exercises. They meet the criteria to pass this module if they have also received enough points. The user has gained 8.0 points. The user has gained enough points to pass this module.
382To pass the course, it's required to pass the base module. The following modules are additional to the course and to complete them, it's required to first complete the base module.
383The user's progress on the course module called First extra module is the following: On this module, there are available a total of 6 exercises and 6 exercise points. To pass this module, it's required to attempt 5 exercises and gain 5 exercise points. The user has attempted 4 exercises. To pass this module, they need to attempt 1 more exercises. The user has gained 3.9 points. To pass this module, the user needs to gain 1.1 more points.
384The user's progress on the course module called Second extra module is the following: On this module, there are available a total of 6 exercises and 5 exercise points. To pass this module, it's required to attempt 5 exercises and gain 4 exercise points. The user has not attempted any exercises. To pass this module, they need to attempt 5 more exercises. The user has gained 0.0 points. To pass this module, the user needs to gain 4.0 more points.
385The user's progress on the course module called Chatbot advanced topics is the following: On this module, there are available a total of 2 exercises. It's not required to attempt exercises or gain points to pass this module. The user has attempted 2 exercises. The user has gained 2.0 points.\n[/output]
386
387Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. If the user asks something like 'how to pass the course', describe the passing criteria and requirements of the base module. Encourage the user to ask further questions about other modules if needed.[/instructions]".to_string();
388
389 assert_eq!(output, expected_output);
390 }
391
392 #[test]
393 fn test_course_progress_output_no_progress() {
394 let progress: Vec<UserCourseProgress> = vec![];
395
396 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
397 let output = tool.get_tool_output();
398
399 let expected_output =
400"Result: [output]The user is completing a course called Advanced Chatbot Course. There is no progress information for this user on this course. [/output]
401
402Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
403
404 assert_eq!(output, expected_output);
405 }
406
407 #[test]
408 fn test_course_progress_output_no_course_points_exercises() {
409 let progress = vec![UserCourseProgress {
410 course_module_id: Uuid::nil(),
411 course_module_name: "Example base module".to_string(),
412 course_module_order_number: 1,
413 score_given: 0.0,
414 score_required: None,
415 score_maximum: Some(0),
416 total_exercises: Some(0),
417 attempted_exercises: None,
418 attempted_exercises_required: None,
419 automatic_completion: true,
420 requires_exam: false,
421 }];
422 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
423 let output = tool.get_tool_output();
424
425 let expected_output =
426"Result: [output]The user is completing a course called Advanced Chatbot Course. Their progress on this course is the following: This course has no exercises and no points. It cannot be completed by doing exercises. The user should look for information about completing the course in the course material or contact the teacher.\n[/output]
427
428Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
429
430 assert_eq!(output, expected_output);
431 }
432
433 #[test]
434 fn test_course_progress_output_cant_be_completed() {
435 let progress = vec![UserCourseProgress {
436 course_module_id: Uuid::nil(),
437 course_module_name: "Example base module".to_string(),
438 course_module_order_number: 1,
439 score_given: 0.0,
440 score_required: Some(9),
441 score_maximum: Some(10),
442 total_exercises: Some(10),
443 attempted_exercises: None,
444 attempted_exercises_required: Some(10),
445 automatic_completion: false,
447 requires_exam: false,
448 }];
449 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
450 let output = tool.get_tool_output();
451
452 let expected_output =
453"Result: [output]The user is completing a course called Advanced Chatbot Course. Their progress on this course is the following: On this course, there are available a total of 10 exercises and 10 exercise points. This course is graded by a teacher and can't be automatically passed by completing exercises. The user should look for information about completing the course in the course material or contact the teacher. To pass this course, it's required to attempt 10 exercises and gain 9 exercise points. The user has not attempted any exercises. To pass this course, they need to attempt 10 more exercises. The user has gained 0.0 points. To pass this course, the user needs to gain 9.0 more points.\n[/output]
454
455Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
456
457 assert_eq!(output, expected_output);
458 }
459
460 #[test]
461 fn test_course_progress_output_exercises_required_none() {
462 let progress = vec![UserCourseProgress {
463 course_module_id: Uuid::nil(),
464 course_module_name: "Example base module".to_string(),
465 course_module_order_number: 1,
466 score_given: 0.0,
467 score_required: Some(9),
468 score_maximum: Some(10),
469 total_exercises: Some(10),
470 attempted_exercises: None,
471 attempted_exercises_required: None,
472 automatic_completion: true,
473 requires_exam: false,
474 }];
475 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
476 let output = tool.get_tool_output();
477
478 let expected_output =
479"Result: [output]The user is completing a course called Advanced Chatbot Course. Their progress on this course is the following: On this course, there are available a total of 10 exercises and 10 exercise points. To pass this course, it's required to gain 9 exercise points. The user has not attempted any exercises. The user has gained 0.0 points. To pass this course, the user needs to gain 9.0 more points.\n[/output]
480
481Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
482
483 assert_eq!(output, expected_output);
484 }
485
486 #[test]
487 fn test_course_progress_output_pts_required_none() {
488 let progress = vec![UserCourseProgress {
489 course_module_id: Uuid::nil(),
490 course_module_name: "Example base module".to_string(),
491 course_module_order_number: 1,
492 score_given: 0.0,
493 score_required: None,
494 score_maximum: Some(10),
495 total_exercises: Some(10),
496 attempted_exercises: None,
497 attempted_exercises_required: Some(10),
498 automatic_completion: true,
499 requires_exam: false,
500 }];
501 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
502 let output = tool.get_tool_output();
503
504 let expected_output =
505"Result: [output]The user is completing a course called Advanced Chatbot Course. Their progress on this course is the following: On this course, there are available a total of 10 exercises and 10 exercise points. To pass this course, it's required to attempt 10 exercises. The user has not attempted any exercises. To pass this course, they need to attempt 10 more exercises. The user has gained 0.0 points.\n[/output]
506
507Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
508
509 assert_eq!(output, expected_output);
510 }
511
512 #[test]
513 fn test_course_progress_output_exam_required() {
514 let progress = vec![UserCourseProgress {
515 course_module_id: Uuid::nil(),
516 course_module_name: "Example base module".to_string(),
517 course_module_order_number: 1,
518 score_given: 0.0780006,
519 score_required: Some(9),
520 score_maximum: Some(10),
521 total_exercises: Some(10),
522 attempted_exercises: None,
523 attempted_exercises_required: Some(10),
524 automatic_completion: true,
525 requires_exam: true,
526 }];
527 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
528 let output = tool.get_tool_output();
529
530 let expected_output =
531"Result: [output]The user is completing a course called Advanced Chatbot Course. Their progress on this course is the following: On this course, there are available a total of 10 exercises and 10 exercise points. To pass this course, it's required to complete an exam. To be qualified to take the exam, it's required to attempt 10 exercises and gain 9 exercise points. The user has not attempted any exercises. To be qualified to take the exam, they need to attempt 10 more exercises. The user has gained 0.0 points. To be qualified to take the exam, the user needs to gain 9.0 more points.\n[/output]
532
533Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
534
535 assert_eq!(output, expected_output);
536 }
537
538 #[test]
539 fn test_course_progress_output_exam_required_can_do_exam() {
540 let progress = vec![UserCourseProgress {
541 course_module_id: Uuid::nil(),
542 course_module_name: "Example base module".to_string(),
543 course_module_order_number: 1,
544 score_given: 9.00006,
545 score_required: Some(9),
546 score_maximum: Some(10),
547 total_exercises: Some(10),
548 attempted_exercises: Some(10),
549 attempted_exercises_required: Some(10),
550 automatic_completion: true,
551 requires_exam: true,
552 }];
553 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
554 let output = tool.get_tool_output();
555
556 let expected_output =
557"Result: [output]The user is completing a course called Advanced Chatbot Course. Their progress on this course is the following: On this course, there are available a total of 10 exercises and 10 exercise points. To pass this course, it's required to complete an exam. To be qualified to take the exam, it's required to attempt 10 exercises and gain 9 exercise points. The user has attempted 10 exercises. They meet the criteria to be qualified to take the exam if they have also received enough points. The user has gained 9.0 points. The user has gained enough points to be qualified to take the exam.\n[/output]
558
559Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
560
561 assert_eq!(output, expected_output);
562 }
563
564 #[test]
565 fn test_course_progress_output_exam_only_required() {
566 let progress = vec![UserCourseProgress {
567 course_module_id: Uuid::nil(),
568 course_module_name: "Example base module".to_string(),
569 course_module_order_number: 1,
570 score_given: 9.0,
571 score_required: None,
572 score_maximum: Some(10),
573 total_exercises: Some(10),
574 attempted_exercises: Some(10),
575 attempted_exercises_required: None,
576 automatic_completion: true,
577 requires_exam: true,
578 }];
579 let tool = CourseProgressTool::new_mock("Advanced Chatbot Course".to_string(), progress);
580 let output = tool.get_tool_output();
581
582 let expected_output =
583"Result: [output]The user is completing a course called Advanced Chatbot Course. Their progress on this course is the following: On this course, there are available a total of 10 exercises and 10 exercise points. It's not required to attempt exercises or gain points to pass this course. To pass this course, it's required to complete an exam. The user can attempt the exam regardless of their progress on the course. The user has attempted 10 exercises. The user has gained 9.0 points.\n[/output]
584
585Instructions for describing the output: [instructions]Describe this information in a short, clear way with no or minimal bullet points. Only give information that is relevant to the user's question. Encourage the user to ask further questions if needed.[/instructions]".to_string();
586
587 assert_eq!(output, expected_output);
588 }
589}