1use headless_lms_base::config::ApplicationConfiguration;
5use headless_lms_models::chatbot_configurations::ChatbotConfiguration;
6use headless_lms_models::chatbot_configurations_models::{ChatbotConfigurationModel, ModelType};
7use headless_lms_models::chatbot_conversation_message_messages::MessageRole;
8use headless_lms_models::chatbot_conversation_message_reasoning::ChatbotConversationMessageReasoning;
9use headless_lms_models::chatbot_conversation_messages::{ChatbotConversationMessage, Message};
10
11use super::azure::protocol::{
12 InputItem, LLMRequest, LLMRequestParams, LLMToolChoice, RequestTextOptions,
13};
14use super::azure::tools::AzureLLMToolDefinition;
15use super::client_tool_calls::abort::ToolCallAbortReason;
16use super::search_grounding::build_search_grounding_instruction;
17use crate::chatbot_error::ChatbotResult;
18use crate::chatbot_tools::provider_tools::azure_ai_search::{
19 self, get_azure_ai_search_tool_definition,
20};
21use crate::chatbot_tools::tool_category::EnabledToolCategories;
22use crate::chatbot_tools::{
23 get_client_chatbot_tool_definitions, get_permitted_chatbot_tool_definitions,
24};
25use crate::conversation_context::{
26 ChatbotPageContext, insert_page_context_message_if_changed, resolve_page_context,
27};
28use crate::llm_utils::{APIInputMessage, MessageContent, estimate_tokens, get_params_for_model};
29use crate::prelude::*;
30use crate::user_context::ChatbotTurnContext;
31
32fn replayable_input_messages(
41 messages: Vec<ChatbotConversationMessage>,
42) -> ChatbotResult<Vec<APIInputMessage>> {
43 let mut kept: Vec<ChatbotConversationMessage> = Vec::with_capacity(messages.len());
44 for message in messages.into_iter().rev() {
46 let keep = match &message.message {
47 Message::Reasoning(reasoning) => {
48 reasoning.encrypted_content.is_some()
49 && kept
50 .last()
51 .is_some_and(|next| may_follow_reasoning(reasoning, next))
52 }
53 _ => true,
54 };
55 if keep {
56 kept.push(message);
57 }
58 }
59 kept.into_iter()
60 .rev()
61 .map(APIInputMessage::try_from)
62 .collect()
63}
64
65pub(super) fn replayable_input_message(
72 message: ChatbotConversationMessage,
73) -> ChatbotResult<Option<APIInputMessage>> {
74 if let Message::Reasoning(reasoning) = &message.message
75 && reasoning.encrypted_content.is_none()
76 {
77 return Ok(None);
78 }
79 APIInputMessage::try_from(message).map(Some)
80}
81
82fn may_follow_reasoning(
88 reasoning: &ChatbotConversationMessageReasoning,
89 next: &ChatbotConversationMessage,
90) -> bool {
91 match &next.message {
92 Message::ToolCall(_) => true,
93 Message::Text(text) => text.message_role == MessageRole::Assistant,
94 Message::Reasoning(later) => later.response_id == reasoning.response_id,
95 Message::ToolOutput(_) => false,
96 }
97}
98
99fn conversation_prompt_cache_key(model_type: &ModelType, conversation_id: Uuid) -> Option<String> {
105 model_type
106 .is_azure_openai()
107 .then(|| conversation_id.to_string())
108}
109
110impl LLMRequest {
111 pub fn new(model: String, input: Vec<APIInputMessage>, params: LLMRequestParams) -> Self {
114 Self {
115 input,
116 model,
117 tools: Vec::new(),
118 tool_choice: None,
119 parallel_tool_calls: None,
120 max_output_tokens: None,
121 text: None,
122 prompt_cache_key: None,
123 params,
124 }
125 }
126
127 pub(super) async fn build_and_insert_incoming_user_message_to_db(
135 conn: &mut PgConnection,
136 chatbot_configuration_id: Uuid,
137 conversation_id: Uuid,
138 message: &str,
139 page_context: Option<ChatbotPageContext>,
140 user_context: &ChatbotTurnContext,
141 app_config: &ApplicationConfiguration,
142 ) -> ChatbotResult<Self> {
143 let configuration =
144 models::chatbot_configurations::get_by_id(conn, chatbot_configuration_id).await?;
145
146 let page = match page_context {
147 Some(page_context) => {
148 resolve_page_context(conn, page_context, configuration.course_id).await
149 }
150 None => None,
151 };
152
153 let mut tx = conn.begin().await?;
154
155 models::chatbot_conversation_messages::abort_pending_client_tool_calls(
156 &mut tx,
157 conversation_id,
158 ToolCallAbortReason::Replaced.model_output(),
159 )
160 .await?;
161
162 if let Some(page) = &page {
163 insert_page_context_message_if_changed(
166 &mut tx,
167 conversation_id,
168 page,
169 user_context.course_name.as_deref(),
170 )
171 .await?;
172 }
173
174 models::chatbot_conversation_messages::insert(
175 &mut tx,
176 ChatbotConversationMessage::text(
177 conversation_id,
178 MessageRole::User,
179 message.to_string(),
180 estimate_tokens(message),
181 None,
182 ),
183 )
184 .await?;
185
186 tx.commit().await?;
187
188 Self::build_from_conversation(
189 conn,
190 &configuration,
191 conversation_id,
192 user_context,
193 app_config,
194 )
195 .await
196 }
197
198 pub(super) async fn build_from_conversation(
206 conn: &mut PgConnection,
207 configuration: &ChatbotConfiguration,
208 conversation_id: Uuid,
209 user_context: &ChatbotTurnContext,
210 app_config: &ApplicationConfiguration,
211 ) -> ChatbotResult<Self> {
212 let inputs = TurnInputs::load(conn, configuration, conversation_id, user_context).await?;
213 Self::assemble(
214 configuration,
215 conversation_id,
216 inputs,
217 app_config,
218 &user_context.enabled_tool_categories,
219 )
220 }
221
222 fn assemble(
226 configuration: &ChatbotConfiguration,
227 conversation_id: Uuid,
228 inputs: TurnInputs,
229 app_config: &ApplicationConfiguration,
230 enabled_tool_categories: &EnabledToolCategories,
231 ) -> ChatbotResult<Self> {
232 let TurnInputs {
233 model,
234 messages,
235 mut tools,
236 } = inputs;
237
238 let offers_tools = !tools.is_empty();
239 let offers_search = configuration.use_azure_search
240 && enabled_tool_categories.contains(azure_ai_search::CATEGORY);
241
242 let mut system_prompt = configuration.prompt.clone();
243 system_prompt.push_str(
244 "All code you generate should be indented with 2 spaces, regardless of the language.\n",
245 );
246 if offers_search {
247 system_prompt.push_str(&build_search_grounding_instruction(enabled_tool_categories));
248 tools.push(AzureLLMToolDefinition::Search(
249 get_azure_ai_search_tool_definition(
250 app_config,
251 configuration.course_id.ok_or_else(|| {
252 chatbot_err!(Other, "Course id is missing from the chatbot configuration")
253 })?,
254 configuration.use_semantic_reranking,
255 )?,
256 ));
257 }
258
259 let tool_choice = if offers_tools || offers_search {
260 Some(LLMToolChoice::Auto)
261 } else {
262 None
263 };
264
265 let params = get_params_for_model(&model.model, &model.model_type, Some(configuration));
266 let prompt_cache_key = conversation_prompt_cache_key(&model.model_type, conversation_id);
267
268 let mut api_chat_messages = replayable_input_messages(messages)?;
269 api_chat_messages.insert(
270 0,
271 APIInputMessage {
272 message_type: InputItem::Message {
273 role: MessageRole::System,
274 content: MessageContent::Text(system_prompt),
275 },
276 },
277 );
278
279 Ok(Self {
280 input: api_chat_messages,
281 model: model.model,
282 max_output_tokens: Some(configuration.max_output_tokens),
283 tools,
284 tool_choice,
285 parallel_tool_calls: Some(true),
286 text: Some(RequestTextOptions {
287 verbosity: Some(configuration.verbosity),
288 format: None,
289 }),
290 prompt_cache_key,
291 params,
292 })
293 }
294}
295
296struct TurnInputs {
299 model: ChatbotConfigurationModel,
300 messages: Vec<ChatbotConversationMessage>,
301 tools: Vec<AzureLLMToolDefinition>,
302}
303
304impl TurnInputs {
305 async fn load(
306 conn: &mut PgConnection,
307 configuration: &ChatbotConfiguration,
308 conversation_id: Uuid,
309 user_context: &ChatbotTurnContext,
310 ) -> ChatbotResult<Self> {
311 let model = models::chatbot_configurations_models::get_by_chatbot_configuration_id(
312 conn,
313 configuration.id,
314 )
315 .await?;
316
317 let messages =
318 models::chatbot_conversation_messages::get_by_conversation_id(conn, conversation_id)
319 .await?;
320
321 let mut tools = get_permitted_chatbot_tool_definitions(conn, user_context).await?;
322 tools.extend(get_client_chatbot_tool_definitions(conn, user_context).await?);
323
324 Ok(Self {
325 model,
326 messages,
327 tools,
328 })
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use headless_lms_models::{
335 chatbot_conversation_message_tool_calls::ToolKind,
336 insert_data,
337 test_helper::{
338 Conn, chatbot_reasoning_message, chatbot_text_message, chatbot_tool_call_message,
339 chatbot_tool_output_message, insert_chatbot_conversation,
340 },
341 };
342
343 use super::*;
344 use crate::azure_chatbot::test_helpers::shape;
345
346 async fn replayed_items(
348 conn: &mut PgConnection,
349 build: impl Fn(Uuid) -> Vec<ChatbotConversationMessage>,
350 ) -> Vec<APIInputMessage> {
351 let (_configuration, conversation_id) = insert_chatbot_conversation(conn).await;
352 for message in build(conversation_id) {
353 models::chatbot_conversation_messages::insert(conn, message)
354 .await
355 .expect("the message is stored");
356 }
357
358 let stored =
359 models::chatbot_conversation_messages::get_by_conversation_id(conn, conversation_id)
360 .await
361 .expect("the conversation is read back");
362 replayable_input_messages(stored).expect("the messages convert")
363 }
364
365 async fn replayed_shape(
366 conn: &mut PgConnection,
367 build: impl Fn(Uuid) -> Vec<ChatbotConversationMessage>,
368 ) -> Vec<String> {
369 shape(&replayed_items(conn, build).await)
370 }
371
372 #[tokio::test]
377 async fn only_reasoning_that_carries_its_payload_is_replayed() {
378 insert_data!(:tx);
379
380 assert_eq!(
381 replayed_shape(tx.as_mut(), |id| vec![
382 chatbot_reasoning_message(id, "rs_replayable", "resp_1", Some("payload")),
383 chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
384 chatbot_tool_output_message(id, "call_1", ToolKind::Function, "resp_1"),
385 chatbot_reasoning_message(id, "rs_legacy", "resp_1", None),
386 chatbot_tool_call_message(id, "call_2", ToolKind::Function, "resp_1"),
387 chatbot_tool_output_message(id, "call_2", ToolKind::Function, "resp_1"),
388 ])
389 .await,
390 vec![
391 "reasoning:rs_replayable",
392 "call:call_1",
393 "output:call_1",
394 "call:call_2",
395 "output:call_2",
396 ]
397 );
398 }
399
400 #[tokio::test]
403 async fn a_replayed_reasoning_item_still_carries_its_payload() {
404 insert_data!(:tx);
405
406 let replayed = replayed_items(tx.as_mut(), |id| {
407 vec![
408 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("opaque-payload")),
409 chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
410 ]
411 })
412 .await;
413
414 let InputItem::Reasoning {
415 encrypted_content, ..
416 } = &replayed
417 .first()
418 .expect("the reasoning item is replayed")
419 .message_type
420 else {
421 panic!("the replayed item is a reasoning item");
422 };
423 assert_eq!(encrypted_content.as_deref(), Some("opaque-payload"));
424 }
425
426 #[tokio::test]
432 async fn reasoning_is_replayed_only_when_what_it_reasoned_about_follows_it() {
433 insert_data!(:tx);
434
435 assert_eq!(
436 replayed_shape(tx.as_mut(), |id| vec![
437 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
438 chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
439 ])
440 .await,
441 vec!["reasoning:rs_1", "call:call_1"],
442 );
443
444 assert_eq!(
445 replayed_shape(tx.as_mut(), |id| vec![
446 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
447 chatbot_text_message(id, MessageRole::Assistant, "Here you go.", Some("resp_1")),
448 ])
449 .await,
450 vec!["reasoning:rs_1", "message:Assistant"],
451 );
452
453 assert_eq!(
454 replayed_shape(tx.as_mut(), |id| vec![
455 chatbot_text_message(id, MessageRole::User, "How do loops work?", None),
456 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
457 ])
458 .await,
459 vec!["message:User"],
460 );
461
462 assert_eq!(
463 replayed_shape(tx.as_mut(), |id| vec![
464 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
465 chatbot_text_message(id, MessageRole::User, "How do loops work?", None),
466 ])
467 .await,
468 vec!["message:User"],
469 );
470
471 assert_eq!(
472 replayed_shape(tx.as_mut(), |id| vec![
473 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
474 chatbot_tool_output_message(id, "call_1", ToolKind::Function, "resp_1"),
475 ])
476 .await,
477 vec!["output:call_1"],
478 );
479
480 assert_eq!(
482 replayed_shape(tx.as_mut(), |id| vec![
483 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
484 chatbot_reasoning_message(id, "rs_legacy", "resp_1", None),
485 chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
486 ])
487 .await,
488 vec!["reasoning:rs_1", "call:call_1"],
489 );
490
491 assert_eq!(
493 replayed_shape(tx.as_mut(), |id| vec![
494 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
495 chatbot_reasoning_message(id, "rs_2", "resp_2", Some("payload")),
496 chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
497 ])
498 .await,
499 vec!["reasoning:rs_2", "call:call_1"],
500 );
501 }
502
503 #[tokio::test]
507 async fn a_run_of_reasoning_from_one_response_is_replayed_whole() {
508 insert_data!(:tx);
509
510 assert_eq!(
511 replayed_shape(tx.as_mut(), |id| vec![
512 chatbot_reasoning_message(id, "rs_1", "resp_1", Some("payload")),
513 chatbot_reasoning_message(id, "rs_2", "resp_1", Some("payload")),
514 chatbot_tool_call_message(id, "call_1", ToolKind::Function, "resp_1"),
515 chatbot_tool_output_message(id, "call_1", ToolKind::Function, "resp_1"),
516 chatbot_tool_call_message(id, "call_2", ToolKind::Function, "resp_1"),
517 chatbot_tool_output_message(id, "call_2", ToolKind::Function, "resp_1"),
518 ])
519 .await,
520 vec![
521 "reasoning:rs_1",
522 "reasoning:rs_2",
523 "call:call_1",
524 "output:call_1",
525 "call:call_2",
526 "output:call_2",
527 ],
528 );
529 }
530
531 #[test]
534 fn a_model_that_is_not_azure_openai_gets_no_prompt_cache_key() {
535 let conversation_id = Uuid::new_v4();
536
537 assert_eq!(
538 conversation_prompt_cache_key(&ModelType::Mistral, conversation_id),
539 None
540 );
541 assert!(
542 conversation_prompt_cache_key(&ModelType::GPTHardThinking, conversation_id).is_some()
543 );
544 }
545}