headless_lms_chatbot/
conversation_context.rs1use serde::{Deserialize, Serialize};
4use tracing::warn;
5use utoipa::ToSchema;
6
7use headless_lms_models::{
8 chatbot_conversation_message_messages::MessageRole,
9 chatbot_conversation_messages::ChatbotConversationMessage, pages::PageChatbotContext,
10};
11
12use crate::{llm_utils::estimate_tokens, prelude::*};
13
14const PAGE_CONTEXT_RESPONSE_ID: &str = "page-context";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
26pub struct ChatbotPageContext {
27 pub page_id: Uuid,
28}
29
30pub async fn resolve_page_context(
39 conn: &mut PgConnection,
40 page_context: ChatbotPageContext,
41 course_id: Option<Uuid>,
42) -> Option<PageChatbotContext> {
43 let Ok(page) = models::pages::get_page_chatbot_context(conn, page_context.page_id).await else {
44 warn!(
45 page_id = %page_context.page_id,
46 "Ignoring chatbot page context for a page that could not be read"
47 );
48 return None;
49 };
50 if course_id.is_none() || page.course_id != course_id {
54 warn!(
55 page_id = %page.id,
56 "Ignoring chatbot page context for a page outside the chatbot's course"
57 );
58 return None;
59 }
60 if page.hidden || page.deleted_at.is_some() {
64 warn!(
65 page_id = %page.id,
66 "Ignoring chatbot page context for a page that is not published"
67 );
68 return None;
69 }
70
71 Some(page)
72}
73
74pub async fn insert_page_context_message_if_changed(
84 conn: &mut PgConnection,
85 conversation_id: Uuid,
86 page: &PageChatbotContext,
87 course_name: Option<&str>,
88) -> ChatbotResult<()> {
89 let text = page_context_text(&page.title, page.chapter_name.as_deref(), course_name);
90 let latest = models::chatbot_conversation_messages::get_latest_developer_message_text(
91 conn,
92 conversation_id,
93 )
94 .await?;
95 if latest.as_deref() == Some(text.as_str()) {
96 return Ok(());
97 }
98
99 models::chatbot_conversation_messages::insert(
100 conn,
101 page_context_message(conversation_id, text),
102 )
103 .await?;
104 Ok(())
105}
106
107fn page_context_text(
110 page_title: &str,
111 chapter_name: Option<&str>,
112 course_name: Option<&str>,
113) -> String {
114 let mut location = format!("the page \"{page_title}\"");
115 if let Some(chapter_name) = chapter_name {
116 location.push_str(&format!(" in the chapter \"{chapter_name}\""));
117 }
118 if let Some(course_name) = course_name {
119 location.push_str(&format!(" of the course \"{course_name}\""));
120 }
121 format!(
122 "The learner is reading {location}. Take that as what they are asking about when their message does not say."
123 )
124}
125
126fn page_context_message(conversation_id: Uuid, text: String) -> ChatbotConversationMessage {
127 let used_tokens = estimate_tokens(&text);
128 ChatbotConversationMessage::text(
129 conversation_id,
130 MessageRole::Developer,
131 text,
132 used_tokens,
133 Some(PAGE_CONTEXT_RESPONSE_ID.to_string()),
134 )
135}
136
137#[cfg(test)]
138mod tests {
139 use headless_lms_models::chatbot_conversation_messages::Message;
140
141 use crate::{
142 azure_chatbot::azure::protocol::InputItem,
143 llm_utils::{APIInputMessage, APIOutputMessage},
144 };
145
146 use super::*;
147
148 #[test]
149 fn page_context_text_omits_what_is_unknown() {
150 let full = page_context_text("Loops", Some("Basics"), Some("Programming"));
151 assert!(full.contains(
152 "the page \"Loops\" in the chapter \"Basics\" of the course \"Programming\""
153 ));
154
155 let page_only = page_context_text("Loops", None, None);
156 assert!(page_only.contains("the page \"Loops\""));
157 assert!(!page_only.contains("chapter"));
158 assert!(!page_only.contains("course"));
159 }
160
161 #[test]
162 fn page_context_message_is_a_developer_message_that_satisfies_the_check_constraint() {
163 let message = page_context_message(Uuid::new_v4(), "Reading a page".to_string());
164 let Message::Text(text) = message.message else {
165 panic!("expected a text message");
166 };
167 assert_eq!(text.message_role, MessageRole::Developer);
168 assert_eq!(text.response_id.as_deref(), Some(PAGE_CONTEXT_RESPONSE_ID));
169 }
170
171 #[test]
174 fn a_stored_page_context_converts_back_into_a_developer_message() {
175 let message = page_context_message(Uuid::new_v4(), "Reading a page".to_string());
176
177 let input = APIInputMessage::try_from(message.clone()).expect("input conversion");
178 let InputItem::Message { role, content } = input.message_type else {
179 panic!("expected a message input item");
180 };
181 assert_eq!(role, MessageRole::Developer);
182 assert_eq!(content.get_content_text(), "Reading a page");
183
184 let output = APIOutputMessage::try_from(message).expect("output conversion");
185 let InputItem::Message { role, .. } = APIInputMessage::from(output).message_type else {
186 panic!("expected a message input item");
187 };
188 assert_eq!(role, MessageRole::Developer);
189 }
190}