1use crate::{
2 azure_chatbot::azure::protocol::{
3 InputItem, LLMRequestParams, LLMRequestResponseFormatParam, NonThinkingParams,
4 ThinkingParams,
5 },
6 chatbot_error::chatbot_err,
7 content_cleaner::calculate_safe_token_limit,
8 llm_utils::{
9 APIInputMessage, MessageContent, estimate_tokens, model_is_thinking,
10 request_structured_json, string_list_response_format,
11 },
12 prelude::{ChatbotError, ChatbotErrorType, ChatbotResult},
13};
14use headless_lms_base::config::ApplicationConfiguration;
15use headless_lms_base::error::backend_error::BackendError;
16use headless_lms_models::{
17 application_task_default_language_models::TaskLMSpec,
18 chatbot_conversation_message_messages::MessageRole,
19 chatbot_conversation_messages::{ChatbotConversationMessage, Message},
20};
21
22#[derive(serde::Deserialize)]
25struct ChatbotNextMessageSuggestionResponse {
26 suggestions: Vec<String>,
27}
28
29pub const RESPONSE_FORMAT_NAME: &str = "ChatbotNextMessageSuggestionResponse";
32
33fn response_format() -> LLMRequestResponseFormatParam {
36 string_list_response_format(RESPONSE_FORMAT_NAME, "suggestions")
37}
38
39const 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.
41
42When generating suggestions:
43- Base them strictly on the content and tone of the conversation so far
44- 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
45- Avoid introducing unrelated topics or information not motivated by the conversation
46- Maintain a supportive, respectful, and clear tone
47- Keep the suggested messages short and concise
48- The teaching assistant's messages are marked to have been said by 'assistant'.
49
50Steps:
511. Analyze the student's current level of understanding, confusion, and engagement.
522. Identify any misconceptions, gaps, or opportunities for deeper learning.
533. Propose next messages that:
54 - Advance understanding
55 - Encourage active thinking
56 - Are appropriate in difficulty and tone
57 - Are appropriate in the context of the conversation so far
58 - Are relevant to the topic of the conversation, context and course
59
60Constraints:
61- Do not continue the conversation yourself.
62- Role-play a student who wants to learn.
63- Only output the suggested messages, nothing else.
64- Suggest exactly 3 alternate next user messages.
65- Be brief, concise and clear. Use as few words and sentences as possible.
66- Generate messages that fit some of the categories 'clarification', 'elaboration', 'placing in context', 'how to learn this', and 'practical use'
67
68"#;
69
70pub const USER_PROMPT: &str = r#"Suggest exactly three messages that the user could send next."#;
72
73const RECENT_MESSAGES_IN_PROMPT: usize = 5;
76
77pub async fn generate_suggested_messages(
79 app_config: &ApplicationConfiguration,
80 task_lm: TaskLMSpec,
81 conversation_messages: &[ChatbotConversationMessage],
82 initial_suggested_messages: Option<Vec<String>>,
83 course_name: Option<String>,
84 course_desc: Option<String>,
85) -> ChatbotResult<Vec<String>> {
86 let prompt = SYSTEM_PROMPT.to_owned()
87 + &course_name.map_or("".to_string(), |c| format!("The course is: {}\n\n", c))
88 + &(if let Some(ism) = initial_suggested_messages {
92 let examples = ism
93 .iter()
94 .take(5)
95 .cloned()
96 .collect::<Vec<String>>()
97 .join(" ");
98 format!("Example suggested messages: {}\n\n", examples)} else {"".to_string()})
99 + &(if let Some(c_d) = course_desc {format!("Description for course: {}\n\n", c_d)} else {"".to_string()})
100 + "The conversation so far:\n";
101
102 let used_tokens = estimate_tokens(&prompt) + estimate_tokens(USER_PROMPT);
103 let token_budget =
104 calculate_safe_token_limit(task_lm.context_size, task_lm.context_utilization);
105
106 let conversation =
107 &create_conversation_from_msgs(conversation_messages, used_tokens, token_budget)?;
108
109 let system_prompt = APIInputMessage {
110 message_type: InputItem::Message {
111 role: MessageRole::System,
112 content: MessageContent::Text(prompt + conversation),
113 },
114 };
115
116 let user_prompt = APIInputMessage {
117 message_type: InputItem::Message {
118 role: MessageRole::User,
119 content: MessageContent::Text(USER_PROMPT.to_string()),
120 },
121 };
122
123 let (params, max_output_tokens) = if model_is_thinking(task_lm.model_type) {
124 (
125 LLMRequestParams::GPTThinking(ThinkingParams { reasoning: None }),
126 Some(7000),
127 )
128 } else {
129 (
130 LLMRequestParams::GPTNonThinking(NonThinkingParams {
131 temperature: None,
132 top_p: None,
133 frequency_penalty: None,
134 presence_penalty: None,
135 }),
136 Some(4000),
137 )
138 };
139
140 let suggestions: ChatbotNextMessageSuggestionResponse = request_structured_json(
141 vec![system_prompt, user_prompt],
142 task_lm.model.to_owned(),
143 params,
144 max_output_tokens,
145 response_format(),
146 app_config,
147 || {
148 chatbot_err!(
149 ChatbotMessageSuggestError,
150 "The message suggestion LLM returned an incorrectly formatted response."
151 .to_string()
152 )
153 },
154 )
155 .await?;
156
157 Ok(suggestions.suggestions)
158}
159
160fn create_conversation_from_msgs(
163 conversation_messages: &[ChatbotConversationMessage],
164 mut used_tokens: i32,
165 token_budget: i32,
166) -> ChatbotResult<String> {
167 let mut sorted_conversation_messages = conversation_messages.to_vec();
169 sorted_conversation_messages.sort_by_key(|el| el.order_number);
170 let conversation: Vec<ChatbotConversationMessage> = sorted_conversation_messages
171 .iter()
172 .filter_map(|el| match &el.message {
173 Message::Text(text)
177 if matches!(
178 text.message_role,
179 MessageRole::Assistant | MessageRole::User
180 ) =>
181 {
182 Some(el.to_owned())
183 }
184 _ => None,
185 })
186 .collect();
187
188 let conv_len = conversation.len();
189 let cutoff = conversation
191 .iter()
192 .enumerate()
193 .rev()
195 .map_while(|(idx, el)| {
196 match el.message.to_owned() {
197 Message::Text(msg) => {
198 used_tokens += msg.used_tokens + 5;}
201 Message::ToolOutput(o) => {
202 let s = format!("Output:\n{}\n\n", o.output); used_tokens += estimate_tokens(&s);
205 }
206 _ => {
207 return Some(conv_len);
210 }
211 }
212
213 if used_tokens > token_budget {
214 return None;
215 }
216 Some(idx)
218 })
219 .min()
221 .ok_or(chatbot_err!(
222 ChatbotMessageSuggestError,
223 "Failed to create context for message suggestion LLM, there were no conversation messages or none of them fit into the context."
224 ))?;
225 let cutoff = cutoff.max(conv_len.saturating_sub(RECENT_MESSAGES_IN_PROMPT));
226 let conversation = conversation[cutoff..]
228 .iter()
229 .map(create_msg_string)
230 .collect::<Vec<String>>()
231 .join("");
232 if conversation.trim().is_empty() {
233 return Err(chatbot_err!(
236 ChatbotMessageSuggestError,
237 "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."
238 ));
239 };
240
241 Ok(conversation)
242}
243
244fn create_msg_string(m: &ChatbotConversationMessage) -> String {
245 match m.message.to_owned() {
246 Message::Text(text_message) => {
247 match text_message.message_role {
248 MessageRole::Assistant => {
249 format!("Assistant:\n{}\n\n", text_message.text)
250 }
251 MessageRole::User => {
252 format!("Student:\n{}\n\n", text_message.text)
253 }
254 _ => "".to_string(), }
256 }
257 Message::ToolCall(tool_call) => {
258 format!(
259 "Tool call: Name: {} Arguments: {}\n\n",
260 tool_call.tool_name,
261 tool_call.arguments_json()
262 )
263 }
264 Message::ToolOutput(tool_output) => {
265 format!("Tool output: {}\n\n", tool_output.output) }
267 Message::Reasoning(..) => "".to_string(),
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use headless_lms_models::chatbot_conversation_message_messages::ChatbotConversationMessageMessage;
274 use headless_lms_models::chatbot_conversation_message_tool_calls::ChatbotConversationMessageToolCall;
275 use headless_lms_models::test_helper::chatbot_text_message;
276 use uuid::Uuid;
277
278 use super::*;
279
280 #[test]
284 fn a_tool_call_is_described_with_the_argument_text_the_model_wrote() {
285 let call = ChatbotConversationMessage {
286 message: Message::ToolCall(ChatbotConversationMessageToolCall {
287 tool_name: "course_progress".to_string(),
288 tool_arguments: serde_json::Value::String(r#"{"query":"loops"}"#.to_string()),
289 ..Default::default()
290 }),
291 ..Default::default()
292 };
293
294 assert_eq!(
295 create_msg_string(&call),
296 "Tool call: Name: course_progress Arguments: {\"query\":\"loops\"}\n\n"
297 );
298 }
299
300 fn get_msgs() -> Vec<ChatbotConversationMessage> {
301 vec![ChatbotConversationMessage {
302 order_number: 0,
303 message: Message::Text(ChatbotConversationMessageMessage {
304 message_role: MessageRole::System,
305 text: "You are a helpful assistant.".to_string(),
306 used_tokens: 6,
307 ..Default::default()
308 }),
309 ..Default::default()
310 },
311 ChatbotConversationMessage {
312 order_number: 0,
313 message: Message::Text(ChatbotConversationMessageMessage {
314 message_role: MessageRole::Assistant,
315 text: "Hello! What can I help you with?".to_string(),
316 used_tokens: 9,
317 ..Default::default()}),
318 ..Default::default()
319 },
320 ChatbotConversationMessage {
321 order_number: 1,
322 message: Message::Text(ChatbotConversationMessageMessage {
323 message_role: MessageRole::User,
324
325 text: "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(),
326 used_tokens: 26,
327 ..Default::default()}),
328 ..Default::default()
329 },
330 ChatbotConversationMessage {
331 order_number: 2,
332 message: Message::Text(ChatbotConversationMessageMessage {
333
334 message_role: MessageRole::Assistant,
335 text: "No worries — that’s a super common question 😊
336Let’s start simple: do you remember the standard form of a quadratic equation?".to_string(),
337 used_tokens: 27,
338 ..Default::default()}),
339 ..Default::default()
340 },
341 ChatbotConversationMessage {
342 order_number: 3,
343 message: Message::Text(ChatbotConversationMessageMessage {
344
345 message_role: MessageRole::User,
346 text: "Is it something like ax² + bx + c = 0?".to_string(),
347 used_tokens: 14,
348 ..Default::default()}),
349 ..Default::default()
350 },
351 ChatbotConversationMessage {
352 order_number: 4,
353 message: Message::Text(ChatbotConversationMessageMessage {
354
355 message_role: MessageRole::Assistant,
356 text: "Exactly! Great start.
357Now, factoring works best when the numbers are “nice” — meaning you can easily find two numbers that multiply to ac and add to b.
358
359The quadratic formula works every time, but it can be more work.
360
361Let’s try this one:
362x² + 5x + 6 = 0
363
364Would you like to try factoring it?".to_string(),
365 used_tokens: 79,
366 ..Default::default()}),
367 ..Default::default()
368 },
369 ChatbotConversationMessage {
370 order_number: 5,
371 message: Message::Text(ChatbotConversationMessageMessage {
372
373 message_role: MessageRole::User,
374 text: "Okay… I need two numbers that multiply to 6 and add to 5. So 2 and 3?".to_string(),
375 used_tokens: 24,
376 ..Default::default()}),
377 ..Default::default()
378 },
379 ChatbotConversationMessage {
380 order_number: 6,
381 message: Message::Text(ChatbotConversationMessageMessage {
382
383 message_role: MessageRole::Assistant,
384 text: "Perfect! 🎯
385
386So we rewrite it as:
387(x + 2)(x + 3) = 0
388
389Now what do we do?".to_string(),
390 used_tokens: 56,
391 ..Default::default()}),
392 ..Default::default()
393 },
394 ChatbotConversationMessage {
395 order_number: 7,
396 message: Message::Text(ChatbotConversationMessageMessage {
397
398 message_role: MessageRole::User,
399 text: "Set each part equal to zero?
400x + 2 = 0 and x + 3 = 0?".to_string(),
401 used_tokens: 23,
402 ..Default::default()}),
403 ..Default::default()
404 },
405 ChatbotConversationMessage {
406 order_number: 8,
407 message: Message::Text(ChatbotConversationMessageMessage {
408
409 message_role: MessageRole::Assistant,
410 text: "Yes!
411
412So your solutions are:
413x = -2
414x = -3
415
416See how clean that was? That’s when factoring is ideal.
417
418Now let’s try one that’s harder:
4192x² + 3x + 1 = 0
420
421Do you want to try factoring again, or use the quadratic formula?".to_string(),
422 used_tokens: 67,
423 ..Default::default()}),
424 ..Default::default()
425 },
426 ChatbotConversationMessage {
427 order_number: 9,
428 message: Message::Text(ChatbotConversationMessageMessage {
429
430 message_role: MessageRole::User,
431 text: "Factoring looks harder with the 2 in front… maybe quadratic formula?".to_string(),
432 used_tokens: 15,
433 ..Default::default()}),
434 ..Default::default()
435 },
436 ChatbotConversationMessage {
437 order_number: 10,
438 message: Message::Text(ChatbotConversationMessageMessage {
439
440 message_role: MessageRole::Assistant,
441 text: "Good instinct. When a ≠ 1, the quadratic formula is often safer.
442
443The formula is:
444
445x = (-b ± √(b² - 4ac)) / 2a
446
447For this equation:
448a = 2
449b = 3
450c = 1
451
452First step: calculate the discriminant (b² - 4ac). Want to try that?".to_string(),
453 used_tokens: 80,
454 ..Default::default()}),
455 ..Default::default()
456 },
457 ChatbotConversationMessage {
458 order_number: 11,
459 message: Message::Text(ChatbotConversationMessageMessage {
460
461 message_role: MessageRole::User,
462 text: "Okay
4633² - 4(2)(1)
464= 9 - 8
465= 1".to_string(),
466 used_tokens: 22,
467 ..Default::default()}),
468 ..Default::default()
469
470 },
471 ChatbotConversationMessage {
472 order_number: 12,
473 message: Message::Text(ChatbotConversationMessageMessage {
474
475 message_role: MessageRole::Assistant,
476 text: "Excellent!
477
478Now plug it into the formula:
479
480x = (-3 ± √1) / 4
481
482Since √1 = 1, what are the two answers?".to_string(),
483 used_tokens: 34,
484 ..Default::default()}),
485 ..Default::default()
486 },
487 ChatbotConversationMessage {
488 order_number: 13,
489 message: Message::Text(ChatbotConversationMessageMessage {
490
491 message_role: MessageRole::User,
492 text: "(-3 + 1)/4 = -2/4 = -1/2
493(-3 - 1)/4 = -4/4 = -1".to_string(),
494 used_tokens: 33,
495 ..Default::default()}),
496 ..Default::default()
497 },
498 ChatbotConversationMessage {
499 order_number: 14,
500 message: Message::Text(ChatbotConversationMessageMessage {
501
502 message_role: MessageRole::Assistant,
503 text: "YES 👏
504
505So your solutions are:
506x = -1/2
507x = -1
508
509You handled that really well.
510
511Quick check:
512When would you choose factoring over the quadratic formula?".to_string(),
513 used_tokens: 40,
514 ..Default::default()}),
515 ..Default::default()
516 },
517 ChatbotConversationMessage {
518 order_number: 15,
519 message: Message::Text(ChatbotConversationMessageMessage {
520
521 message_role: MessageRole::User,
522 text: "If the numbers are simple and easy to factor. Otherwise use the formula since it always works.".to_string(),
523 used_tokens: 19,
524 ..Default::default()}),
525 ..Default::default()
526 },
527 ChatbotConversationMessage {
528 order_number: 16,
529 message: Message::Text(ChatbotConversationMessageMessage {
530
531 message_role: MessageRole::Assistant,
532 text: "Exactly right. You’ve got it!
533
534Want to try a challenge problem next time with completing the square?".to_string(),
535 used_tokens: 21,
536 ..Default::default()}),
537 ..Default::default()
538 },
539 ]
540 }
541
542 #[test]
545 fn a_long_conversation_is_rendered_from_its_last_five_messages_only() {
546 let convo = create_conversation_from_msgs(&get_msgs(), 0, 100_000)
547 .expect("Creating conversation string failed!");
548
549 let expected_string = r#"Assistant:
550Excellent!
551
552Now plug it into the formula:
553
554x = (-3 ± √1) / 4
555
556Since √1 = 1, what are the two answers?
557
558Student:
559(-3 + 1)/4 = -2/4 = -1/2
560(-3 - 1)/4 = -4/4 = -1
561
562Assistant:
563YES 👏
564
565So your solutions are:
566x = -1/2
567x = -1
568
569You handled that really well.
570
571Quick check:
572When would you choose factoring over the quadratic formula?
573
574Student:
575If the numbers are simple and easy to factor. Otherwise use the formula since it always works.
576
577Assistant:
578Exactly right. You’ve got it!
579
580Want to try a challenge problem next time with completing the square?
581
582"#
583 .to_string();
584
585 assert_eq!(convo, expected_string);
586 }
587
588 fn text_msg(
589 order_number: i32,
590 message_role: MessageRole,
591 text: &str,
592 used_tokens: i32,
593 ) -> ChatbotConversationMessage {
594 let mut message = chatbot_text_message(Uuid::nil(), message_role, text, None);
595 message.order_number = order_number;
596 if let Message::Text(text) = &mut message.message {
597 text.used_tokens = used_tokens;
598 }
599 message
600 }
601
602 fn msgs_with_one_long_answer() -> Vec<ChatbotConversationMessage> {
603 vec![
604 text_msg(0, MessageRole::Assistant, "Here is the derivation.", 60),
605 text_msg(1, MessageRole::User, "Why does the loop never end?", 20),
606 text_msg(2, MessageRole::Assistant, "Check the condition.", 20),
607 text_msg(3, MessageRole::User, "The counter never decrements.", 20),
608 ]
609 }
610
611 #[test]
614 fn the_token_budget_still_trims_inside_the_five_message_window() {
615 let convo = create_conversation_from_msgs(&msgs_with_one_long_answer(), 0, 80)
616 .expect("Creating conversation string failed!");
617
618 let expected_string = r#"Student:
619Why does the loop never end?
620
621Assistant:
622Check the condition.
623
624Student:
625The counter never decrements.
626
627"#
628 .to_string();
629
630 assert_eq!(convo, expected_string);
631 }
632
633 #[test]
637 fn a_developer_message_does_not_spend_one_of_the_five_slots() {
638 let messages = vec![
641 text_msg(0, MessageRole::User, "First question", 10),
642 text_msg(1, MessageRole::Assistant, "one", 10),
643 text_msg(2, MessageRole::Developer, "Page context", 500),
644 text_msg(3, MessageRole::Assistant, "two", 10),
645 text_msg(4, MessageRole::Assistant, "three", 10),
646 text_msg(5, MessageRole::Assistant, "four", 10),
647 text_msg(6, MessageRole::Assistant, "five", 10),
648 ];
649
650 let convo = create_conversation_from_msgs(&messages, 0, 100_000)
651 .expect("Creating conversation string failed!");
652
653 assert_eq!(
654 convo,
655 "Assistant:\none\n\nAssistant:\ntwo\n\nAssistant:\nthree\n\nAssistant:\nfour\n\nAssistant:\nfive\n\n"
656 );
657 }
658
659 #[test]
662 fn the_response_format_is_named_after_this_feature() {
663 let serialized =
664 serde_json::to_value(response_format()).expect("The response format serializes");
665 assert_eq!(
666 serialized["name"],
667 serde_json::json!("ChatbotNextMessageSuggestionResponse")
668 );
669 }
670}