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