1use std::collections::HashMap;
2
3use crate::{
4 azure_chatbot::{
5 ArrayItem, ArrayProperty, JSONSchema, JSONType, LLMRequest, LLMRequestParams,
6 LLMRequestResponseFormatParam, NonThinkingParams, Schema, ThinkingParams,
7 },
8 content_cleaner::calculate_safe_token_limit,
9 llm_utils::{
10 APIMessage, APIMessageKind, APIMessageText, estimate_tokens, make_blocking_llm_request,
11 parse_text_completion,
12 },
13 prelude::{ChatbotError, ChatbotErrorType, ChatbotResult},
14};
15use headless_lms_base::config::ApplicationConfiguration;
16use headless_lms_base::error::backend_error::BackendError;
17use headless_lms_models::{
18 application_task_default_language_models::TaskLMSpec,
19 chatbot_conversation_messages::{ChatbotConversationMessage, MessageRole},
20};
21use rand::seq::IndexedRandom;
22
23#[derive(serde::Deserialize)]
26struct ChatbotNextMessageSuggestionResponse {
27 suggestions: Vec<String>,
28}
29
30const SYSTEM_PROMPT: &str = r#"You are given a conversation between a helpful teaching assistant chatbot and a student. Your task is to analyze the conversation and suggest what messages the user could send to the teaching assistant chatbot next to best support the user's learning.
32
33When generating suggestions:
34- Base them strictly on the content and tone of the conversation so far
35- Think of questions that will help deepen the user's understanding, aid in learning, clear misconceptions and motivate the user to think and reason about the subject at hand
36- Avoid introducing unrelated topics or information not motivated by the conversation
37- Maintain a supportive, respectful, and clear tone
38- Keep the suggested messages short and concise
39- The teaching assistant's messages are marked to have been said by 'assistant'.
40
41Steps:
421. Analyze the student's current level of understanding, confusion, and engagement.
432. Identify any misconceptions, gaps, or opportunities for deeper learning.
443. Propose next messages that:
45 - Advance understanding
46 - Encourage active thinking
47 - Are appropriate in difficulty and tone
48 - Are appropriate in the context of the conversation so far
49 - Are relevant to the topic of the conversation, context and course
50
51Constraints:
52- Do not continue the conversation yourself.
53- Role-play a student who wants to learn.
54- Only output the suggested messages, nothing else.
55- Suggest exactly 3 alternate next user messages.
56- Be brief, concise and clear. Use as few words and sentences as possible.
57- Generate messages that fit some of the categories 'clarification', 'elaboration', 'placing in context', 'how to learn this', and 'practical use'
58
59"#;
60
61pub const USER_PROMPT: &str = r#"Suggest exactly three messages that the user could send next."#;
63
64pub async fn generate_suggested_messages(
66 app_config: &ApplicationConfiguration,
67 task_lm: TaskLMSpec,
68 conversation_messages: &[ChatbotConversationMessage],
69 initial_suggested_messages: Option<Vec<String>>,
70 course_name: &str,
71 course_desc: Option<String>,
72) -> ChatbotResult<Vec<String>> {
73 let prompt = SYSTEM_PROMPT.to_owned()
74 + &format!("The course is: {}\n\n", course_name)
75 + &(if let Some(ism) = initial_suggested_messages {
77 let mut rng = rand::rng();
78 let examples = ism
79 .sample(&mut rng, 5)
80 .cloned()
81 .collect::<Vec<String>>()
82 .join(" ");
83 format!("Example suggested messages: {}\n\n", examples)} else {"".to_string()})
84 + &(if let Some(c_d) = course_desc {format!("Description for course: {}\n\n", c_d)} else {"".to_string()})
85 + "The conversation so far:\n";
86
87 let used_tokens = estimate_tokens(&prompt) + estimate_tokens(USER_PROMPT);
88 let token_budget =
89 calculate_safe_token_limit(task_lm.context_size, task_lm.context_utilization);
90
91 let conversation =
92 &create_conversation_from_msgs(conversation_messages, used_tokens, token_budget)?;
93
94 let system_prompt = APIMessage {
95 role: MessageRole::System,
96 fields: APIMessageKind::Text(APIMessageText {
97 content: prompt + conversation,
98 }),
99 };
100
101 let user_prompt = APIMessage {
102 role: MessageRole::User,
103 fields: APIMessageKind::Text(APIMessageText {
104 content: USER_PROMPT.to_string(),
105 }),
106 };
107
108 let params = if task_lm.thinking {
109 LLMRequestParams::Thinking(ThinkingParams {
110 max_completion_tokens: Some(7000),
111 verbosity: None,
112 reasoning_effort: None,
113 tools: vec![],
114 tool_choice: None,
115 })
116 } else {
117 LLMRequestParams::NonThinking(NonThinkingParams {
118 max_tokens: Some(4000),
119 temperature: None,
120 top_p: None,
121 frequency_penalty: None,
122 presence_penalty: None,
123 })
124 };
125
126 let chat_request = LLMRequest {
127 messages: vec![system_prompt, user_prompt],
128 data_sources: vec![],
129 params,
130 response_format: Some(LLMRequestResponseFormatParam {
131 format_type: JSONType::JsonSchema,
132 json_schema: JSONSchema {
133 name: "ChatbotNextMessageSuggestionResponse".to_string(),
134 strict: true,
135 schema: Schema {
136 type_field: JSONType::Object,
137 properties: HashMap::from([(
138 "suggestions".to_string(),
139 ArrayProperty {
140 type_field: JSONType::Array,
141 items: ArrayItem {
142 type_field: JSONType::String,
143 },
144 },
145 )]),
146 required: Vec::from(["suggestions".to_string()]),
147 additional_properties: false,
148 },
149 },
150 }),
151 stop: None,
152 };
153
154 let completion = make_blocking_llm_request(chat_request, app_config, &task_lm).await?;
155
156 let completion_content: &String = &parse_text_completion(completion)?;
157 let suggestions: ChatbotNextMessageSuggestionResponse =
158 serde_json::from_str(completion_content).map_err(|_| {
159 ChatbotError::new(
160 ChatbotErrorType::ChatbotMessageSuggestError,
161 "The message suggestion LLM returned an incorrectly formatted response."
162 .to_string(),
163 None,
164 )
165 })?;
166
167 Ok(suggestions.suggestions)
168}
169
170fn create_conversation_from_msgs(
173 conversation_messages: &[ChatbotConversationMessage],
174 mut used_tokens: i32,
175 token_budget: i32,
176) -> ChatbotResult<String> {
177 conversation_messages
178 .to_vec()
179 .sort_by_key(|el| el.order_number);
180 let conv_len = conversation_messages.len();
181 let cutoff = conversation_messages
183 .iter()
184 .enumerate()
185 .rev()
187 .map_while(|(idx, el)| {
188 if el.message.is_some() {
189 used_tokens += el.used_tokens + 5;
192 } else if let Some(output) = &el.tool_output {
193 let s = format!("{}:\n{}\n\n", output.tool_name, output.tool_output);
195 used_tokens += estimate_tokens(&s);
196 } else {
197 return Some(conv_len);
200 }
201 if used_tokens > token_budget {
202 return None;
203 }
204 Some(idx)
206 })
207 .min()
209 .ok_or(ChatbotError::new(
210 ChatbotErrorType::ChatbotMessageSuggestError,
211 "Failed to create context for message suggestion LLM, there were no conversation messages or none of them fit into the context.",
212 None,
213 ))?;
214 let conversation = conversation_messages[cutoff..]
216 .iter()
217 .map(create_msg_string)
218 .collect::<Vec<String>>()
219 .join("");
220 if conversation.trim().is_empty() {
221 return Err(ChatbotError::new(
224 ChatbotErrorType::ChatbotMessageSuggestError,
225 "Failed to create context for message suggestion LLM, there were no conversation messages or no content in any messages. There should be some messages before messages are suggested.",
226 None,
227 ));
228 };
229
230 Ok(conversation)
231}
232
233fn create_msg_string(m: &ChatbotConversationMessage) -> String {
234 match m.message_role {
235 MessageRole::User => {
236 if let Some(message) = &m.message {
237 format!("Student:\n{message}\n\n")
238 } else {
239 "".to_string()
240 }
241 }
242 MessageRole::Assistant => {
243 if let Some(message) = &m.message {
244 format!("Assistant:\n{message}\n\n")
245 } else {
246 "".to_string()
247 }
248 }
249 MessageRole::Tool => {
250 if let Some(output) = &m.tool_output {
251 format!("Tool {}: {}\n\n", output.tool_name, output.tool_output)
252 } else {
253 "".to_string()
254 }
255 }
256 MessageRole::System => "".to_string(),
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 fn get_msgs() -> Vec<ChatbotConversationMessage> {
265 vec![ChatbotConversationMessage {
266 order_number: 0,
267 message_role: MessageRole::System,
268 message: Some("You are a helpful assistant.".to_string()),
269 used_tokens: 6,
270 ..Default::default()
271 },
272 ChatbotConversationMessage {
273 order_number: 0,
274 message_role: MessageRole::Assistant,
275 message: Some("Hello! What can I help you with?".to_string()),
276 used_tokens: 9,
277 ..Default::default()
278 },
279 ChatbotConversationMessage {
280 order_number: 1,
281 message_role: MessageRole::User,
282 message: Some("Hi, I’m stuck on solving quadratic equations. I don’t really get when to factor and when to use the quadratic formula.".to_string()),
283 used_tokens: 26,
284 ..Default::default()
285 },
286 ChatbotConversationMessage {
287 order_number: 2,
288 message_role: MessageRole::Assistant,
289 message: Some("No worries — that’s a super common question 😊
290Let’s start simple: do you remember the standard form of a quadratic equation?".to_string()),
291 used_tokens: 27,
292 ..Default::default()
293 },
294 ChatbotConversationMessage {
295 order_number: 3,
296 message_role: MessageRole::User,
297 message: Some("Is it something like ax² + bx + c = 0?".to_string()),
298 used_tokens: 14,
299 ..Default::default()
300 },
301 ChatbotConversationMessage {
302 order_number: 4,
303 message_role: MessageRole::Assistant,
304 message: Some("Exactly! Great start.
305Now, factoring works best when the numbers are “nice” — meaning you can easily find two numbers that multiply to ac and add to b.
306
307The quadratic formula works every time, but it can be more work.
308
309Let’s try this one:
310x² + 5x + 6 = 0
311
312Would you like to try factoring it?".to_string()),
313 used_tokens: 79,
314 ..Default::default()
315 },
316 ChatbotConversationMessage {
317 order_number: 5,
318 message_role: MessageRole::User,
319 message: Some("Okay… I need two numbers that multiply to 6 and add to 5. So 2 and 3?".to_string()),
320 used_tokens: 24,
321 ..Default::default()
322 },
323 ChatbotConversationMessage {
324 order_number: 6,
325 message_role: MessageRole::Assistant,
326 message: Some("Perfect! 🎯
327
328So we rewrite it as:
329(x + 2)(x + 3) = 0
330
331Now what do we do?".to_string()),
332 used_tokens: 56,
333 ..Default::default()
334 },
335 ChatbotConversationMessage {
336 order_number: 7,
337 message_role: MessageRole::User,
338 message: Some("Set each part equal to zero?
339x + 2 = 0 and x + 3 = 0?".to_string()),
340 used_tokens: 23,
341 ..Default::default()
342 },
343 ChatbotConversationMessage {
344 order_number: 8,
345 message_role: MessageRole::Assistant,
346 message: Some("Yes!
347
348So your solutions are:
349x = -2
350x = -3
351
352See how clean that was? That’s when factoring is ideal.
353
354Now let’s try one that’s harder:
3552x² + 3x + 1 = 0
356
357Do you want to try factoring again, or use the quadratic formula?".to_string()),
358 used_tokens: 67,
359 ..Default::default()
360 },
361 ChatbotConversationMessage {
362 order_number: 9,
363 message_role: MessageRole::User,
364 message: Some("Factoring looks harder with the 2 in front… maybe quadratic formula?".to_string()),
365 used_tokens: 15,
366 ..Default::default()
367 },
368 ChatbotConversationMessage {
369 order_number: 10,
370 message_role: MessageRole::Assistant,
371 message: Some("Good instinct. When a ≠ 1, the quadratic formula is often safer.
372
373The formula is:
374
375x = (-b ± √(b² - 4ac)) / 2a
376
377For this equation:
378a = 2
379b = 3
380c = 1
381
382First step: calculate the discriminant (b² - 4ac). Want to try that?".to_string()),
383 used_tokens: 80,
384 ..Default::default()
385 },
386 ChatbotConversationMessage {
387 order_number: 11,
388 message_role: MessageRole::User,
389 message: Some("Okay
3903² - 4(2)(1)
391= 9 - 8
392= 1".to_string()),
393 used_tokens: 22,
394 ..Default::default()
395 },
396 ChatbotConversationMessage {
397 order_number: 12,
398 message_role: MessageRole::Assistant,
399 message: Some("Excellent!
400
401Now plug it into the formula:
402
403x = (-3 ± √1) / 4
404
405Since √1 = 1, what are the two answers?".to_string()),
406 used_tokens: 34,
407 ..Default::default()
408 },
409 ChatbotConversationMessage {
410 order_number: 13,
411 message_role: MessageRole::User,
412 message: Some("(-3 + 1)/4 = -2/4 = -1/2
413(-3 - 1)/4 = -4/4 = -1".to_string()),
414 used_tokens: 33,
415 ..Default::default()
416 },
417 ChatbotConversationMessage {
418 order_number: 14,
419 message_role: MessageRole::Assistant,
420 message: Some("YES 👏
421
422So your solutions are:
423x = -1/2
424x = -1
425
426You handled that really well.
427
428Quick check:
429When would you choose factoring over the quadratic formula?".to_string()),
430 used_tokens: 40,
431 ..Default::default()
432 },
433 ChatbotConversationMessage {
434 order_number: 15,
435 message_role: MessageRole::User,
436 message: Some("If the numbers are simple and easy to factor. Otherwise use the formula since it always works.".to_string()),
437 used_tokens: 19,
438 ..Default::default()
439 },
440 ChatbotConversationMessage {
441 order_number: 16,
442 message_role: MessageRole::Assistant,
443 message: Some("Exactly right. You’ve got it!
444
445Want to try a challenge problem next time with completing the square?".to_string()),
446 used_tokens: 21,
447 ..Default::default()
448 },
449 ]
450 }
451
452 #[test]
453 fn create_context_it_fits() {
454 let used_tokens = 2000;
455 let token_budget = 3000;
456 let conv_msgs = get_msgs();
457
458 let convo = create_conversation_from_msgs(&conv_msgs, used_tokens, token_budget)
459 .expect("Creating conversation string failed!");
460
461 let expected_string = r#"Assistant:
462Hello! What can I help you with?
463
464Student:
465Hi, I’m stuck on solving quadratic equations. I don’t really get when to factor and when to use the quadratic formula.
466
467Assistant:
468No worries — that’s a super common question 😊
469Let’s start simple: do you remember the standard form of a quadratic equation?
470
471Student:
472Is it something like ax² + bx + c = 0?
473
474Assistant:
475Exactly! Great start.
476Now, factoring works best when the numbers are “nice” — meaning you can easily find two numbers that multiply to ac and add to b.
477
478The quadratic formula works every time, but it can be more work.
479
480Let’s try this one:
481x² + 5x + 6 = 0
482
483Would you like to try factoring it?
484
485Student:
486Okay… I need two numbers that multiply to 6 and add to 5. So 2 and 3?
487
488Assistant:
489Perfect! 🎯
490
491So we rewrite it as:
492(x + 2)(x + 3) = 0
493
494Now what do we do?
495
496Student:
497Set each part equal to zero?
498x + 2 = 0 and x + 3 = 0?
499
500Assistant:
501Yes!
502
503So your solutions are:
504x = -2
505x = -3
506
507See how clean that was? That’s when factoring is ideal.
508
509Now let’s try one that’s harder:
5102x² + 3x + 1 = 0
511
512Do you want to try factoring again, or use the quadratic formula?
513
514Student:
515Factoring looks harder with the 2 in front… maybe quadratic formula?
516
517Assistant:
518Good instinct. When a ≠ 1, the quadratic formula is often safer.
519
520The formula is:
521
522x = (-b ± √(b² - 4ac)) / 2a
523
524For this equation:
525a = 2
526b = 3
527c = 1
528
529First step: calculate the discriminant (b² - 4ac). Want to try that?
530
531Student:
532Okay
5333² - 4(2)(1)
534= 9 - 8
535= 1
536
537Assistant:
538Excellent!
539
540Now plug it into the formula:
541
542x = (-3 ± √1) / 4
543
544Since √1 = 1, what are the two answers?
545
546Student:
547(-3 + 1)/4 = -2/4 = -1/2
548(-3 - 1)/4 = -4/4 = -1
549
550Assistant:
551YES 👏
552
553So your solutions are:
554x = -1/2
555x = -1
556
557You handled that really well.
558
559Quick check:
560When would you choose factoring over the quadratic formula?
561
562Student:
563If the numbers are simple and easy to factor. Otherwise use the formula since it always works.
564
565Assistant:
566Exactly right. You’ve got it!
567
568Want to try a challenge problem next time with completing the square?
569
570"#.to_string();
571
572 assert_eq!(convo, expected_string);
573 }
574
575 #[test]
576 fn create_context_it_wont_fit() {
577 let used_tokens = 2000;
578 let token_budget = 2300;
579 let conv_msgs = get_msgs();
580
581 let convo = create_conversation_from_msgs(&conv_msgs, used_tokens, token_budget)
582 .expect("Creating conversation string failed!");
583
584 let expected_string = r#"Assistant:
585Good instinct. When a ≠ 1, the quadratic formula is often safer.
586
587The formula is:
588
589x = (-b ± √(b² - 4ac)) / 2a
590
591For this equation:
592a = 2
593b = 3
594c = 1
595
596First step: calculate the discriminant (b² - 4ac). Want to try that?
597
598Student:
599Okay
6003² - 4(2)(1)
601= 9 - 8
602= 1
603
604Assistant:
605Excellent!
606
607Now plug it into the formula:
608
609x = (-3 ± √1) / 4
610
611Since √1 = 1, what are the two answers?
612
613Student:
614(-3 + 1)/4 = -2/4 = -1/2
615(-3 - 1)/4 = -4/4 = -1
616
617Assistant:
618YES 👏
619
620So your solutions are:
621x = -1/2
622x = -1
623
624You handled that really well.
625
626Quick check:
627When would you choose factoring over the quadratic formula?
628
629Student:
630If the numbers are simple and easy to factor. Otherwise use the formula since it always works.
631
632Assistant:
633Exactly right. You’ve got it!
634
635Want to try a challenge problem next time with completing the square?
636
637"#
638 .to_string();
639 assert_eq!(convo, expected_string);
640 }
641}