Skip to main content

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