Skip to main content

headless_lms_chatbot/
message_suggestion.rs

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/// Shape of the structured LLM output response, defined by the JSONSchema in
23/// [response_format]
24#[derive(serde::Deserialize)]
25struct ChatbotNextMessageSuggestionResponse {
26    suggestions: Vec<String>,
27}
28
29/// Names this feature's structured output to Azure. The test-mode mock Azure API picks its canned
30/// answer for this feature by this name.
31pub const RESPONSE_FORMAT_NAME: &str = "ChatbotNextMessageSuggestionResponse";
32
33/// The structured output format the suggestion LLM is asked to answer in. Must stay in
34/// sync with [ChatbotNextMessageSuggestionResponse].
35fn response_format() -> LLMRequestResponseFormatParam {
36    string_list_response_format(RESPONSE_FORMAT_NAME, "suggestions")
37}
38
39/// System prompt instructions for generating suggested next messages
40const 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
70/// User prompt instructions for generating suggested next messages
71pub const USER_PROMPT: &str = r#"Suggest exactly three messages that the user could send next."#;
72
73/// How much of the transcript the suggestion prompt carries. A suggestion only answers the recent
74/// thread, so a long conversation does not pay to send all of it.
75const RECENT_MESSAGES_IN_PROMPT: usize = 5;
76
77/// Calls an LLM and generates suggested messages for a chatbot conversation
78pub 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        // Taken in order rather than sampled: a random subset here varies a prompt prefix that is
89        // otherwise identical for every learner on the course, which costs a prompt cache hit and
90        // buys nothing, since the model is being shown examples rather than choosing between them.
91        + &(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
160/// Take ChatbotConversationMessages from a list until no more fit into the token budget.
161/// Transcribe the conversation's messages' content into a string.
162fn create_conversation_from_msgs(
163    conversation_messages: &[ChatbotConversationMessage],
164    mut used_tokens: i32,
165    token_budget: i32,
166) -> ChatbotResult<String> {
167    // todo empty messages?
168    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            // Only the roles [`create_msg_string`] renders. A developer page-context message would
174            // otherwise spend one of the [`RECENT_MESSAGES_IN_PROMPT`] slots, and its tokens,
175            // rendering nothing.
176            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    // calculate how many messages to include in the conversation context
190    let cutoff = conversation
191        .iter()
192        .enumerate()
193        // we want to take messages starting from the newest (=last)
194        .rev()
195        .map_while(|(idx, el)| {
196            match el.message.to_owned()  {
197                Message::Text(msg) => {
198                // add this message's tokens and extra 5 tokens for newline and
199                // tag to used_tokens
200                used_tokens += msg.used_tokens + 5;}
201                Message::ToolOutput(o) => {
202                // add the tokens needed for the tool info to used_tokens
203                let s = format!("Output:\n{}\n\n", o.output); // todo: which tool call was it
204                used_tokens += estimate_tokens(&s);
205                }
206                _ => {
207                    // if there is no message or tool output, skip this element.
208                    // putting in conv_len won't affect the truncation later as we take min.
209                    return Some(conv_len);
210                }
211            }
212
213            if used_tokens > token_budget {
214                return None;
215            }
216            // include this element's index as a potential cutoff point
217            Some(idx)
218        })
219        // select the minimum index i.e. oldest message to include
220        .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    // cut off messages older than order_n from the conversation to keep context short
227    let conversation = conversation[cutoff..]
228        .iter()
229        .map(create_msg_string)
230        .collect::<Vec<String>>()
231        .join("");
232    if conversation.trim().is_empty() {
233        // this happens only if the conversation contains only ChatbotConversationMessages
234        // that have no message property, which should never happen in practice
235        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(), //todo error?
255            }
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) // todo: get the tool name
266        }
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    /// A row keeps the argument text the model wrote as a JSON string, so rendering the stored value
281    /// itself escapes it a second time and the model is asked to suggest replies to a conversation
282    /// full of backslashes.
283    #[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    /// Nothing bounds how long a conversation runs, and the whole transcript would otherwise be
543    /// rendered into every suggestion request the learner triggers.
544    #[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    /// The message count is a floor on the cutoff, not a replacement for it, so a window of five
612    /// that does not fit still has to be trimmed rather than sent and rejected by the model.
613    #[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    /// A conversation carries developer messages holding the page context, and `create_msg_string`
634    /// renders that role as nothing. Counting one against the window would silently spend a slot,
635    /// and its tokens, on a message the model never sees.
636    #[test]
637    fn a_developer_message_does_not_spend_one_of_the_five_slots() {
638        // The developer message sits inside the last five so that counting it would push a
639        // rendered message out of the window.
640        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    /// The test-mode mock Azure API picks this feature's canned answer by the format name, so the
660    /// name is pinned even though the shape it wraps is shared.
661    #[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}