Skip to main content

headless_lms_models/
chatbot_conversations.rs

1use futures::future::OptionFuture;
2use utoipa::ToSchema;
3
4use crate::{
5    chatbot_conversation_messages::ChatbotConversationMessage,
6    chatbot_conversation_messages_citations::ChatbotConversationMessageCitation,
7    chatbot_conversation_suggested_messages::ChatbotConversationSuggestedMessage, prelude::*,
8};
9
10#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
11
12pub struct ChatbotConversation {
13    pub id: Uuid,
14    pub anonymous_token: Option<String>,
15    pub created_at: DateTime<Utc>,
16    pub updated_at: DateTime<Utc>,
17    pub deleted_at: Option<DateTime<Utc>>,
18    pub course_id: Option<Uuid>,
19    pub user_id: Option<Uuid>,
20    pub chatbot_configuration_id: Uuid,
21}
22
23#[derive(Serialize, Deserialize, PartialEq, Clone, ToSchema)]
24
25/// Everything needed to display the chatbot to the user.
26pub struct ChatbotConversationInfo {
27    pub current_conversation: Option<ChatbotConversation>,
28    pub current_conversation_messages: Option<Vec<ChatbotConversationMessage>>,
29    pub current_conversation_message_citations: Option<Vec<ChatbotConversationMessageCitation>>,
30    pub chatbot_name: String,
31    pub course_name: Option<String>,
32    pub hide_citations: bool,
33    /// What to offer the learner as their next message. Absent when nothing should be offered: the
34    /// configuration has suggestions off, or the conversation is at a point where a suggestion does
35    /// not belong, such as a turn suspended on a question to the learner. Empty means suggestions
36    /// are wanted but none have been generated yet, which is what makes the endpoint generate them.
37    pub suggested_messages: Option<Vec<ChatbotConversationSuggestedMessage>>,
38}
39
40pub async fn insert(
41    conn: &mut PgConnection,
42    input: ChatbotConversation,
43) -> ModelResult<ChatbotConversation> {
44    let res = sqlx::query_as!(
45        ChatbotConversation,
46        r#"
47INSERT INTO chatbot_conversations (
48    course_id,
49    user_id,
50    anonymous_token,
51    chatbot_configuration_id
52  )
53VALUES ($1, $2, $3, $4)
54RETURNING *
55        "#,
56        input.course_id,
57        input.user_id,
58        input.anonymous_token,
59        input.chatbot_configuration_id
60    )
61    .fetch_one(conn)
62    .await?;
63    Ok(res)
64}
65
66pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ChatbotConversation> {
67    let res = sqlx::query_as!(
68        ChatbotConversation,
69        r#"
70SELECT *
71FROM chatbot_conversations
72WHERE id = $1
73  AND deleted_at IS NULL
74        "#,
75        id
76    )
77    .fetch_one(conn)
78    .await?;
79    Ok(res)
80}
81
82/// Soft deletes a conversation, after which it accepts no further messages.
83///
84/// Deleting one that is already deleted is not an error, so a caller does not have to check first.
85/// See [crate::chatbot_configurations::delete] for removing the configuration a conversation
86/// belongs to.
87///
88/// Nothing in production deletes a conversation; this exists for tests.
89pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
90    sqlx::query!(
91        r#"
92UPDATE chatbot_conversations
93SET deleted_at = now()
94WHERE id = $1
95  AND deleted_at IS NULL
96        "#,
97        id
98    )
99    .execute(conn)
100    .await?;
101    Ok(())
102}
103
104pub async fn create_for_user_and_configuration(
105    conn: &mut PgConnection,
106    pkey_policy: PKeyPolicy<Uuid>,
107    user_id: Option<Uuid>,
108    anonymous_token: Option<String>,
109    chatbot_configuration_id: Uuid,
110) -> ModelResult<ChatbotConversation> {
111    let res = sqlx::query_as!(
112        ChatbotConversation,
113        r#"
114INSERT INTO chatbot_conversations (
115    id,
116    course_id,
117    user_id,
118    anonymous_token,
119    chatbot_configuration_id
120  )
121SELECT $1,
122  chatbot_configurations.course_id,
123  $2,
124  $3,
125  chatbot_configurations.id
126FROM chatbot_configurations
127WHERE chatbot_configurations.id = $4
128  AND chatbot_configurations.deleted_at IS NULL
129RETURNING *
130        "#,
131        pkey_policy.into_uuid(),
132        user_id,
133        anonymous_token,
134        chatbot_configuration_id
135    )
136    .fetch_one(conn)
137    .await?;
138    Ok(res)
139}
140
141pub async fn get_latest_conversation_for_user(
142    conn: &mut PgConnection,
143    user_id: Option<Uuid>,
144    anonymous_token: Option<String>,
145    chatbot_configuration_id: Uuid,
146) -> ModelResult<ChatbotConversation> {
147    if let (Some(_user_id), Some(_anonymous_token)) = (&user_id, &anonymous_token) {
148        return Err(model_err!(
149            InvalidRequest,
150            "User ID and anonymous token cannot both be present".to_string()
151        ));
152    }
153    let res = sqlx::query_as!(
154        ChatbotConversation,
155        r#"
156SELECT *
157FROM chatbot_conversations
158WHERE (
159    user_id = $1
160    OR anonymous_token = $2
161  )
162  AND chatbot_configuration_id = $3
163  AND deleted_at IS NULL
164ORDER BY created_at DESC
165LIMIT 1
166        "#,
167        user_id,
168        anonymous_token,
169        chatbot_configuration_id
170    )
171    .fetch_one(conn)
172    .await?;
173    Ok(res)
174}
175
176/// Gets the current conversation for the user, if any. Also inlcudes information about the chatbot so that the chatbot ui can be rendered using the information.
177pub async fn get_current_conversation_info(
178    tx: &mut PgConnection,
179    user_id: Option<Uuid>,
180    anonymous_token: Option<String>,
181    chatbot_configuration_id: Uuid,
182) -> ModelResult<ChatbotConversationInfo> {
183    let chatbot_configuration =
184        crate::chatbot_configurations::get_by_id(tx, chatbot_configuration_id).await?;
185    let course = if let Some(course_id) = chatbot_configuration.course_id {
186        Some(crate::courses::get_course(tx, course_id).await?)
187    } else {
188        None
189    };
190
191    let current_conversation =
192        get_latest_conversation_for_user(tx, user_id, anonymous_token, chatbot_configuration_id)
193            .await
194            .optional()?;
195    let current_conversation_id = current_conversation.as_ref().map(|c| c.id);
196    // the messages are sorted by response_order_number
197    let current_conversation_messages = OptionFuture::from(current_conversation_id.map(|id| {
198        crate::chatbot_conversation_messages::get_by_conversation_id_for_display(tx, id)
199    }))
200    .await
201    .transpose()?;
202
203    let current_conversation_message_citations =
204        OptionFuture::from(current_conversation_id.map(|id| {
205            crate::chatbot_conversation_messages_citations::get_by_conversation_id(tx, id)
206        }))
207        .await
208        .transpose()?;
209
210    let suggested_messages = if chatbot_configuration.suggest_next_messages
211        && let Some(ccm) = &current_conversation_messages
212        // A suspended turn is waiting for the learner to answer the chatbot's own question, which
213        // is not a moment to suggest asking something else.
214        && !crate::chatbot_conversation_messages::turn_is_suspended(ccm)
215        && let Some(last_ccm) = ccm.last()
216    {
217        let sm = crate::chatbot_conversation_suggested_messages::get_by_conversation_message_id(
218            tx,
219            last_ccm.id.to_owned(),
220        )
221        .await?;
222        // return an empty vec if there are not yet any suggested messages
223        Some(sm)
224    } else {
225        None
226    };
227
228    Ok(ChatbotConversationInfo {
229        current_conversation,
230        current_conversation_messages,
231        current_conversation_message_citations,
232        suggested_messages,
233        // Don't want to expose everything from the chatbot configuration to the user because it contains private information like the prompt.
234        chatbot_name: chatbot_configuration.chatbot_name,
235        course_name: course.map(|course| course.name),
236        hide_citations: chatbot_configuration.hide_citations,
237    })
238}