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    pub conversation_title: Option<String>,
22}
23
24#[derive(Serialize, Deserialize, PartialEq, Clone, ToSchema)]
25
26/// Everything needed to display the chatbot to the user.
27pub struct ChatbotConversationInfo {
28    pub current_conversation: Option<ChatbotConversation>,
29    pub current_conversation_messages: Option<Vec<ChatbotConversationMessage>>,
30    pub current_conversation_message_citations: Option<Vec<ChatbotConversationMessageCitation>>,
31    pub chatbot_name: String,
32    pub course_name: Option<String>,
33    pub hide_citations: bool,
34    /// What to offer the learner as their next message. Absent when nothing should be offered: the
35    /// configuration has suggestions off, or the conversation is at a point where a suggestion does
36    /// not belong, such as a turn suspended on a question to the learner. Empty means suggestions
37    /// are wanted but none have been generated yet, which is what makes the endpoint generate them.
38    pub suggested_messages: Option<Vec<ChatbotConversationSuggestedMessage>>,
39}
40
41pub async fn insert(
42    conn: &mut PgConnection,
43    input: ChatbotConversation,
44) -> ModelResult<ChatbotConversation> {
45    let res = sqlx::query_as!(
46        ChatbotConversation,
47        r#"
48INSERT INTO chatbot_conversations (
49    course_id,
50    user_id,
51    anonymous_token,
52    chatbot_configuration_id,
53    conversation_title
54  )
55VALUES ($1, $2, $3, $4, $5)
56RETURNING *
57        "#,
58        input.course_id,
59        input.user_id,
60        input.anonymous_token,
61        input.chatbot_configuration_id,
62        input.conversation_title
63    )
64    .fetch_one(conn)
65    .await?;
66    Ok(res)
67}
68
69pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ChatbotConversation> {
70    let res = sqlx::query_as!(
71        ChatbotConversation,
72        r#"
73SELECT *
74FROM chatbot_conversations
75WHERE id = $1
76  AND deleted_at IS NULL
77        "#,
78        id
79    )
80    .fetch_one(conn)
81    .await?;
82    Ok(res)
83}
84
85/// Soft deletes a conversation, after which it accepts no further messages.
86///
87/// Deleting one that is already deleted is not an error, so a caller does not have to check first.
88/// See [crate::chatbot_configurations::delete] for removing the configuration a conversation
89/// belongs to.
90///
91/// Nothing in production deletes a conversation; this exists for tests.
92pub async fn delete(conn: &mut PgConnection, id: Uuid) -> ModelResult<()> {
93    sqlx::query!(
94        r#"
95UPDATE chatbot_conversations
96SET deleted_at = now()
97WHERE id = $1
98  AND deleted_at IS NULL
99        "#,
100        id
101    )
102    .execute(conn)
103    .await?;
104    Ok(())
105}
106
107pub async fn create_for_user_and_configuration(
108    conn: &mut PgConnection,
109    pkey_policy: PKeyPolicy<Uuid>,
110    user_id: Option<Uuid>,
111    anonymous_token: Option<String>,
112    chatbot_configuration_id: Uuid,
113) -> ModelResult<ChatbotConversation> {
114    let res = sqlx::query_as!(
115        ChatbotConversation,
116        r#"
117INSERT INTO chatbot_conversations (
118    id,
119    course_id,
120    user_id,
121    anonymous_token,
122    chatbot_configuration_id,
123    conversation_title
124  )
125SELECT $1,
126  chatbot_configurations.course_id,
127  $2,
128  $3,
129  chatbot_configurations.id,
130  NULL
131FROM chatbot_configurations
132WHERE chatbot_configurations.id = $4
133  AND chatbot_configurations.deleted_at IS NULL
134RETURNING *
135        "#,
136        pkey_policy.into_uuid(),
137        user_id,
138        anonymous_token,
139        chatbot_configuration_id,
140    )
141    .fetch_one(conn)
142    .await?;
143    Ok(res)
144}
145
146pub async fn get_latest_conversation_for_user(
147    conn: &mut PgConnection,
148    user_id: Option<Uuid>,
149    anonymous_token: Option<String>,
150    chatbot_configuration_id: Uuid,
151) -> ModelResult<ChatbotConversation> {
152    if let (Some(_user_id), Some(_anonymous_token)) = (&user_id, &anonymous_token) {
153        return Err(model_err!(
154            InvalidRequest,
155            "User ID and anonymous token cannot both be present".to_string()
156        ));
157    }
158    let res = sqlx::query_as!(
159        ChatbotConversation,
160        r#"
161SELECT *
162FROM chatbot_conversations
163WHERE (
164    user_id = $1
165    OR anonymous_token = $2
166  )
167  AND chatbot_configuration_id = $3
168  AND deleted_at IS NULL
169ORDER BY created_at DESC
170LIMIT 1
171        "#,
172        user_id,
173        anonymous_token,
174        chatbot_configuration_id
175    )
176    .fetch_one(conn)
177    .await?;
178    Ok(res)
179}
180
181pub async fn get_conversation_for_user(
182    conn: &mut PgConnection,
183    user_id: Option<Uuid>,
184    anonymous_token: Option<String>,
185    chatbot_configuration_id: Uuid,
186    conversation_id: Uuid,
187) -> ModelResult<ChatbotConversation> {
188    let res = sqlx::query_as!(
189        ChatbotConversation,
190        r#"
191SELECT *
192FROM chatbot_conversations
193WHERE (
194    user_id = $1
195    OR anonymous_token = $2
196  )
197  AND chatbot_configuration_id = $3
198  AND id = $4
199  AND deleted_at IS NULL
200        "#,
201        user_id,
202        anonymous_token,
203        chatbot_configuration_id,
204        conversation_id
205    )
206    .fetch_one(conn)
207    .await?;
208    Ok(res)
209}
210
211pub async fn get_all_conversations_for_user(
212    conn: &mut PgConnection,
213    user_id: Uuid,
214) -> ModelResult<Vec<ChatbotConversation>> {
215    let res = sqlx::query_as!(
216        ChatbotConversation,
217        r#"
218SELECT *
219FROM chatbot_conversations
220WHERE user_id = $1
221  AND deleted_at IS NULL
222ORDER BY created_at DESC
223        "#,
224        user_id,
225    )
226    .fetch_all(conn)
227    .await?;
228    Ok(res)
229}
230
231pub async fn update_conversation_title(
232    conn: &mut PgConnection,
233    conversation_id: Uuid,
234    conversation_title: String,
235) -> ModelResult<()> {
236    sqlx::query!(
237        r#"
238UPDATE chatbot_conversations
239SET conversation_title = $1
240WHERE id = $2
241  AND deleted_at IS NULL
242        "#,
243        conversation_title,
244        conversation_id
245    )
246    .execute(conn)
247    .await?;
248    Ok(())
249}
250
251/// Gets specific conversation or latest conversation for the user, if any. If conversation_id is not provided then latest conversation is given. Also inlcudes information about the chatbot so that the chatbot ui can be rendered using the information.
252pub async fn get_conversation_info(
253    tx: &mut PgConnection,
254    user_id: Option<Uuid>,
255    anonymous_token: Option<String>,
256    chatbot_configuration_id: Uuid,
257    conversation_id: Option<Uuid>,
258) -> ModelResult<ChatbotConversationInfo> {
259    let chatbot_configuration =
260        crate::chatbot_configurations::get_by_id(tx, chatbot_configuration_id).await?;
261    let course = if let Some(course_id) = chatbot_configuration.course_id {
262        Some(crate::courses::get_course(tx, course_id).await?)
263    } else {
264        None
265    };
266
267    let current_conversation = if let Some(conversation_id) = conversation_id {
268        get_conversation_for_user(
269            tx,
270            user_id,
271            anonymous_token,
272            chatbot_configuration_id,
273            conversation_id,
274        )
275        .await
276        .optional()?
277    } else {
278        get_latest_conversation_for_user(tx, user_id, anonymous_token, chatbot_configuration_id)
279            .await
280            .optional()?
281    };
282
283    let conversation_id = current_conversation.as_ref().map(|c| c.id);
284
285    let current_conversation_messages = OptionFuture::from(conversation_id.map(|id| {
286        crate::chatbot_conversation_messages::get_by_conversation_id_for_display(tx, id)
287    }))
288    .await
289    .transpose()?;
290
291    let current_conversation_message_citations =
292        OptionFuture::from(conversation_id.map(|id| {
293            crate::chatbot_conversation_messages_citations::get_by_conversation_id(tx, id)
294        }))
295        .await
296        .transpose()?;
297
298    let suggested_messages = if chatbot_configuration.suggest_next_messages
299        && let Some(ccm) = &current_conversation_messages
300        // A suspended turn is waiting for the learner to answer the chatbot's own question, which
301        // is not a moment to suggest asking something else.
302        && !crate::chatbot_conversation_messages::turn_is_suspended(ccm)
303        && let Some(last_ccm) = ccm.last()
304    {
305        let sm = crate::chatbot_conversation_suggested_messages::get_by_conversation_message_id(
306            tx,
307            last_ccm.id.to_owned(),
308        )
309        .await?;
310        // return an empty vec if there are not yet any suggested messages
311        Some(sm)
312    } else {
313        None
314    };
315
316    Ok(ChatbotConversationInfo {
317        current_conversation,
318        current_conversation_messages,
319        current_conversation_message_citations,
320        suggested_messages,
321        // Don't want to expose everything from the chatbot configuration to the user because it contains private information like the prompt.
322        chatbot_name: chatbot_configuration.chatbot_name,
323        course_name: course.map(|course| course.name),
324        hide_citations: chatbot_configuration.hide_citations,
325    })
326}