headless_lms_chatbot/
message_suggestion.rs

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/// Shape of the structured LLM output response, defined by the JSONSchema in
23/// generate_suggested_messages
24#[derive(serde::Deserialize)]
25struct ChatbotNextMessageSuggestionResponse {
26    suggestions: Vec<String>,
27}
28
29/// System prompt instructions for generating suggested next messages
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.
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
60/// User prompt instructions for generating suggested next messages
61pub const USER_PROMPT: &str = r#"Suggest exactly three messages that the user could send next."#;
62
63/// Calls an LLM and generates suggested messages for a chatbot conversation
64pub 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 there are initial suggested messages, then include <=5 of them as examples
75        + &(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
165/// Take ChatbotConversationMessages from a list until no more fit into the token budget.
166/// Transcribe the conversation's messages' content into a string.
167fn 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    // calculate how many messages to include in the conversation context
177    let cutoff = conversation_messages
178        .iter()
179        .enumerate()
180        // we want to take messages starting from the newest (=last)
181        .rev()
182        .map_while(|(idx, el)| {
183            if el.message.is_some() {
184                // add this message's tokens and extra 5 tokens for newline and
185                // tag to used_tokens
186                used_tokens += el.used_tokens + 5;
187            } else if let Some(output) = &el.tool_output {
188                // add the tokens needed for the tool info to used_tokens
189                let s = format!("{}:\n{}\n\n", output.tool_name, output.tool_output);
190                used_tokens += estimate_tokens(&s);
191            } else {
192                // if there is no message or tool output, skip this element.
193                // putting in conv_len won't affect the truncation later as we take min.
194                return Some(conv_len);
195            }
196            if used_tokens > token_budget {
197                return None;
198            }
199            // include this element's index as a potential cutoff point
200            Some(idx)
201        })
202        // select the minimum index i.e. oldest message to include
203        .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    // cut off messages older than order_n from the conversation to keep context short
210    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        // this happens only if the conversation contains only ChatbotConversationMessages
217        // that have no message property, which should never happen in practice
218        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}