Skip to main content

headless_lms_chatbot/
conversation_context.rs

1//! What a chatbot request says about the course material page the learner has open.
2
3use 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
14/// Stands in for the Azure response id of a page context message, which we write ourselves and
15/// which therefore has no Azure response behind it. The `not_null_for_llm_generated_messages`
16/// check constraint demands one for every message that is not from the user; the initial
17/// assistant message of a conversation stands in for it the same way.
18const PAGE_CONTEXT_RESPONSE_ID: &str = "page-context";
19
20/// What the learner has open when they send a message.
21///
22/// Only the page id is accepted: everything the model reads is looked up from the database.
23/// The context becomes a developer message, which the model weighs above the learner's own
24/// words, so a client that could write its text could give itself instructions.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
26pub struct ChatbotPageContext {
27    pub page_id: Uuid,
28}
29
30/// The page the learner claims to have open, or `None` when nothing about it may be shown to the
31/// model.
32///
33/// Read before the conversation is locked, since it depends on nothing the lock protects. The
34/// context is advisory, so a page id that leads nowhere is dropped rather than failing the
35/// message it came with: the id is the client's, and a learner whose page was deleted under them
36/// would otherwise be unable to write to the chatbot at all. `course_id` is the course of the
37/// chatbot configuration; a configuration with no course accepts no page context at all.
38pub 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    // Both sides are optional, and a bare `!=` would let a course-less configuration through for
51    // every course-less page: by DB constraint those are exactly the anonymously reachable
52    // configurations on one side and every exam page on the other.
53    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    // `get_page_chatbot_context` reads a page whatever its state, and the id reaches us as the
61    // client's claim to be on it, so a title nobody is meant to see yet would otherwise reach the
62    // model.
63    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
74/// Records what the learner is looking at as a developer message in the conversation, so that
75/// it survives a history rebuild instead of being prepended in memory on every request.
76///
77/// Writes nothing when the conversation's newest page context already says the same thing.
78/// `course_name` is the name of the course the chatbot belongs to.
79///
80/// Must be called inside the transaction that took the conversation lock: whether this context
81/// repeats the newest one is decided from the conversation, so two requests reading it before
82/// either writes would both conclude it changed and both write.
83pub 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
107/// Describes the learner's place in the course to the model. Parts that could not be resolved
108/// are left out rather than named as unknown.
109fn 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    /// A stored page context has to survive both directions of the history rebuild, otherwise
172    /// every later message of the conversation fails to convert.
173    #[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}