Skip to main content

headless_lms_models/
chatbot_conversation_messages_citations.rs

1use crate::prelude::*;
2use utoipa::ToSchema;
3
4#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
5pub struct ChatbotConversationMessageCitation {
6    pub id: Uuid,
7    pub created_at: DateTime<Utc>,
8    pub updated_at: DateTime<Utc>,
9    pub deleted_at: Option<DateTime<Utc>>,
10    pub conversation_message_id: Uuid,
11    pub conversation_id: Uuid,
12    pub course_material_chapter_number: Option<i32>,
13    pub title: String,
14    pub content: String,
15    pub document_url: String,
16    pub citation_number: i32,
17}
18
19impl Default for ChatbotConversationMessageCitation {
20    fn default() -> Self {
21        Self {
22            id: Uuid::nil(),
23            created_at: Default::default(),
24            updated_at: Default::default(),
25            deleted_at: None,
26            conversation_message_id: Uuid::nil(),
27            conversation_id: Uuid::nil(),
28            course_material_chapter_number: None,
29            title: Default::default(),
30            content: Default::default(),
31            document_url: Default::default(),
32            citation_number: Default::default(),
33        }
34    }
35}
36
37pub async fn insert(
38    conn: &mut PgConnection,
39    input: ChatbotConversationMessageCitation,
40) -> ModelResult<ChatbotConversationMessageCitation> {
41    let res = sqlx::query_as!(
42        ChatbotConversationMessageCitation,
43        r#"
44INSERT INTO chatbot_conversation_messages_citations (
45  conversation_message_id,
46  conversation_id,
47  course_material_chapter_number,
48  title,
49  content,
50  document_url,
51  citation_number)
52VALUES ($1, $2, $3, $4, $5, $6, $7)
53RETURNING *
54        "#,
55        input.conversation_message_id,
56        input.conversation_id,
57        input.course_material_chapter_number,
58        input.title,
59        input.content,
60        input.document_url,
61        input.citation_number
62    )
63    .fetch_one(conn)
64    .await?;
65    Ok(res)
66}
67
68/// Insert a batch of citation from the same conversation
69pub async fn insert_batch(
70    conn: &mut PgConnection,
71    input: Vec<ChatbotConversationMessageCitation>,
72    page_ids: Vec<Option<Uuid>>,
73) -> ModelResult<Vec<ChatbotConversationMessageCitation>> {
74    if input.is_empty() {
75        return Ok(vec![]);
76    }
77    let conv_id = input[0].conversation_id;
78    let cm_ids: Vec<Uuid> = input.iter().map(|x| x.conversation_message_id).collect();
79    let titles: Vec<String> = input.iter().map(|x| x.title.to_owned()).collect();
80    let contents: Vec<String> = input.iter().map(|x| x.content.to_owned()).collect();
81    let document_urls: Vec<String> = input.iter().map(|x| x.document_url.to_owned()).collect();
82    let citation_numbers: Vec<i32> = input.iter().map(|x| x.citation_number).collect();
83
84    let res = sqlx::query_as!(
85        ChatbotConversationMessageCitation,
86        r#"
87INSERT INTO chatbot_conversation_messages_citations (
88    conversation_id,
89    conversation_message_id,
90    title,
91    content,
92    document_url,
93    citation_number,
94    course_material_chapter_number
95  )
96SELECT $1,
97  input.cm_id,
98  input.title,
99  input.content,
100  input.document_url,
101  input.citation_number,
102  c.chapter_number
103FROM (
104    SELECT UNNEST($2::UUID []) cm_id,
105      UNNEST($3::TEXT []) title,
106      UNNEST($4::TEXT []) content,
107      UNNEST($5::TEXT []) document_url,
108      UNNEST($6::INTEGER []) citation_number,
109      UNNEST($7::UUID []) page_id
110  ) AS input
111  JOIN pages p ON p.id = input.page_id
112  LEFT JOIN chapters c ON p.chapter_id = c.id
113WHERE c.deleted_at IS NULL
114  AND p.deleted_at IS NULL
115RETURNING *
116        "#,
117        conv_id,
118        &cm_ids,
119        &titles,
120        &contents,
121        &document_urls,
122        &citation_numbers,
123        &page_ids as _,
124    )
125    .fetch_all(conn)
126    .await?;
127    Ok(res)
128}
129
130pub async fn get_by_message_id(
131    conn: &mut PgConnection,
132    message_id: Uuid,
133) -> ModelResult<Vec<ChatbotConversationMessageCitation>> {
134    let res = sqlx::query_as!(
135        ChatbotConversationMessageCitation,
136        r#"
137SELECT * FROM chatbot_conversation_messages_citations
138WHERE conversation_message_id = $1
139AND deleted_at IS NULL
140        "#,
141        message_id
142    )
143    .fetch_all(conn)
144    .await?;
145    Ok(res)
146}
147
148pub async fn get_by_conversation_id(
149    conn: &mut PgConnection,
150    conversation_id: Uuid,
151) -> ModelResult<Vec<ChatbotConversationMessageCitation>> {
152    let res = sqlx::query_as!(
153        ChatbotConversationMessageCitation,
154        r#"
155SELECT * FROM chatbot_conversation_messages_citations
156WHERE conversation_id = $1
157AND deleted_at IS NULL
158        "#,
159        conversation_id
160    )
161    .fetch_all(conn)
162    .await?;
163    Ok(res)
164}
165
166/// Points the citations of the conversation's newest turn at `conversation_message_id`, the
167/// message whose text cites them.
168///
169/// A citation is saved attached to the search tool output it was read from, a message the chatbot
170/// ui does not render, so a citation only becomes reachable once it is moved to the answer. The
171/// turn is taken to be every message after the newest message from the user: a turn can search in
172/// one round and answer in another, and the round that answers has a different Azure response id
173/// than the round that searched, both when a tool call was answered on the server and when the
174/// turn suspended and a later request resumed it.
175pub async fn attach_turn_citations_to_message(
176    conn: &mut PgConnection,
177    conversation_id: Uuid,
178    conversation_message_id: Uuid,
179) -> ModelResult<Vec<Uuid>> {
180    let res = sqlx::query_scalar!(
181        r#"
182UPDATE chatbot_conversation_messages_citations
183SET conversation_message_id = $1
184WHERE conversation_id = $2
185  AND deleted_at IS NULL
186  AND conversation_message_id IN (
187    SELECT message.id
188    FROM chatbot_conversation_messages message
189      JOIN chatbot_conversation_message_tool_outputs tool_output ON tool_output.chatbot_conversation_message_id = message.id
190    WHERE message.conversation_id = $2
191      AND message.deleted_at IS NULL
192      AND tool_output.deleted_at IS NULL
193      AND message.order_number > COALESCE(
194        (
195          SELECT MAX(user_message.order_number)
196          FROM chatbot_conversation_messages user_message
197            JOIN chatbot_conversation_message_messages text_message ON text_message.chatbot_conversation_message_id = user_message.id
198          WHERE user_message.conversation_id = $2
199            AND user_message.deleted_at IS NULL
200            AND text_message.deleted_at IS NULL
201            AND text_message.message_role = 'user'
202        ),
203        0
204      )
205  )
206RETURNING id
207        "#,
208        conversation_message_id,
209        conversation_id
210    )
211    .fetch_all(conn)
212    .await?;
213    Ok(res)
214}
215
216/// The highest citation number already used by a tool call in the turn still in progress, or
217/// `None` if the turn has cited nothing yet. A new search tool numbers its own hits starting
218/// after this rather than from zero, since a whole turn's citations end up on one message (see
219/// [`attach_turn_citations_to_message`]) and would otherwise collide with an earlier search's
220/// numbers. Scoped identically to that function's own subquery.
221pub async fn max_citation_number_in_turn(
222    conn: &mut PgConnection,
223    conversation_id: Uuid,
224) -> ModelResult<Option<i32>> {
225    let res = sqlx::query_scalar!(
226        r#"
227SELECT MAX(citation.citation_number)
228FROM chatbot_conversation_messages_citations citation
229  JOIN chatbot_conversation_messages message ON message.id = citation.conversation_message_id
230  JOIN chatbot_conversation_message_tool_outputs tool_output ON tool_output.chatbot_conversation_message_id = message.id
231WHERE citation.conversation_id = $1
232  AND citation.deleted_at IS NULL
233  AND message.deleted_at IS NULL
234  AND tool_output.deleted_at IS NULL
235  AND message.order_number > COALESCE(
236    (
237      SELECT MAX(user_message.order_number)
238      FROM chatbot_conversation_messages user_message
239        JOIN chatbot_conversation_message_messages text_message ON text_message.chatbot_conversation_message_id = user_message.id
240      WHERE user_message.conversation_id = $1
241        AND user_message.deleted_at IS NULL
242        AND text_message.deleted_at IS NULL
243        AND text_message.message_role = 'user'
244    ),
245    0
246  )
247        "#,
248        conversation_id
249    )
250    .fetch_one(conn)
251    .await?;
252    Ok(res)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::{
259        chatbot_conversation_message_messages::MessageRole,
260        chatbot_conversation_message_tool_calls::ToolKind,
261        chatbot_conversation_messages::{self, ChatbotConversationMessage},
262        test_helper::*,
263    };
264
265    async fn insert_text_message(
266        conn: &mut PgConnection,
267        conversation_id: Uuid,
268        role: MessageRole,
269        response_id: Option<&str>,
270    ) -> ChatbotConversationMessage {
271        chatbot_conversation_messages::insert(
272            conn,
273            chatbot_text_message(conversation_id, role, "text", response_id),
274        )
275        .await
276        .unwrap()
277    }
278
279    async fn insert_tool_call(
280        conn: &mut PgConnection,
281        conversation_id: Uuid,
282        tool_call_id: &str,
283        response_id: &str,
284    ) -> ChatbotConversationMessage {
285        chatbot_conversation_messages::insert(
286            conn,
287            chatbot_tool_call_message(
288                conversation_id,
289                tool_call_id,
290                ToolKind::ClientTool,
291                response_id,
292            ),
293        )
294        .await
295        .unwrap()
296    }
297
298    async fn insert_tool_output(
299        conn: &mut PgConnection,
300        conversation_id: Uuid,
301        tool_call_id: &str,
302        response_id: &str,
303    ) -> ChatbotConversationMessage {
304        chatbot_conversation_messages::insert(
305            conn,
306            chatbot_tool_output_message(
307                conversation_id,
308                tool_call_id,
309                ToolKind::ClientTool,
310                response_id,
311            ),
312        )
313        .await
314        .unwrap()
315    }
316
317    fn citation_ids(citations: &[ChatbotConversationMessageCitation]) -> Vec<Uuid> {
318        citations.iter().map(|citation| citation.id).collect()
319    }
320
321    async fn insert_citation(
322        conn: &mut PgConnection,
323        conversation_id: Uuid,
324        conversation_message_id: Uuid,
325    ) -> ChatbotConversationMessageCitation {
326        insert(
327            conn,
328            ChatbotConversationMessageCitation {
329                conversation_id,
330                conversation_message_id,
331                title: "A page".to_string(),
332                content: "Cited content".to_string(),
333                document_url: "https://example.com/page".to_string(),
334                ..Default::default()
335            },
336        )
337        .await
338        .unwrap()
339    }
340
341    /// A turn that suspends on a client tool call answers in a later request, under an Azure
342    /// response id of its own, so the search that produced the citations happened under a
343    /// different one. The learner is shown the citation markers of the answer either way, so a
344    /// citation left behind on the search output is a marker with nothing behind it.
345    #[tokio::test]
346    async fn citations_of_a_suspended_turn_reach_the_message_that_cites_them() {
347        insert_data!(:tx);
348        let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
349        insert_text_message(tx.as_mut(), conversation, MessageRole::User, None).await;
350        let search_output =
351            insert_tool_output(tx.as_mut(), conversation, "call_search", "resp_search").await;
352        let citation = insert_citation(tx.as_mut(), conversation, search_output.id).await;
353        insert_tool_call(tx.as_mut(), conversation, "call_question", "resp_question").await;
354        insert_tool_output(tx.as_mut(), conversation, "call_question", "resp_question").await;
355        let answer = insert_text_message(
356            tx.as_mut(),
357            conversation,
358            MessageRole::Assistant,
359            Some("resp_answer"),
360        )
361        .await;
362
363        attach_turn_citations_to_message(tx.as_mut(), conversation, answer.id)
364            .await
365            .unwrap();
366
367        let reachable = get_by_message_id(tx.as_mut(), answer.id).await.unwrap();
368        assert_eq!(citation_ids(&reachable), vec![citation.id]);
369    }
370
371    /// Citations of a turn that never answered belong to no message the learner reads, and moving
372    /// them to the next turn's answer would credit it with sources it never cited.
373    #[tokio::test]
374    async fn citations_of_an_earlier_turn_are_left_where_they_are() {
375        insert_data!(:tx);
376        let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
377        insert_text_message(tx.as_mut(), conversation, MessageRole::User, None).await;
378        let abandoned_search =
379            insert_tool_output(tx.as_mut(), conversation, "call_search", "resp_search").await;
380        let orphan = insert_citation(tx.as_mut(), conversation, abandoned_search.id).await;
381        insert_text_message(tx.as_mut(), conversation, MessageRole::User, None).await;
382        let answer = insert_text_message(
383            tx.as_mut(),
384            conversation,
385            MessageRole::Assistant,
386            Some("resp_answer"),
387        )
388        .await;
389
390        attach_turn_citations_to_message(tx.as_mut(), conversation, answer.id)
391            .await
392            .unwrap();
393
394        assert!(
395            get_by_message_id(tx.as_mut(), answer.id)
396                .await
397                .unwrap()
398                .is_empty()
399        );
400        let left = get_by_message_id(tx.as_mut(), abandoned_search.id)
401            .await
402            .unwrap();
403        assert_eq!(citation_ids(&left), vec![orphan.id]);
404    }
405
406    /// A citation belongs to one conversation, and nothing outside that conversation may claim it,
407    /// however the ids of a turn happen to repeat elsewhere.
408    #[tokio::test]
409    async fn citations_of_another_conversation_are_not_touched() {
410        insert_data!(:tx);
411        let (_configuration, conversation) = insert_chatbot_conversation(tx.as_mut()).await;
412        let (_other_configuration, other_conversation) =
413            insert_chatbot_conversation(tx.as_mut()).await;
414        insert_text_message(tx.as_mut(), other_conversation, MessageRole::User, None).await;
415        let other_search = insert_tool_output(
416            tx.as_mut(),
417            other_conversation,
418            "call_search",
419            "resp_search",
420        )
421        .await;
422        let other_citation =
423            insert_citation(tx.as_mut(), other_conversation, other_search.id).await;
424        insert_text_message(tx.as_mut(), conversation, MessageRole::User, None).await;
425        let answer = insert_text_message(
426            tx.as_mut(),
427            conversation,
428            MessageRole::Assistant,
429            Some("resp_answer"),
430        )
431        .await;
432
433        let moved = attach_turn_citations_to_message(tx.as_mut(), conversation, answer.id)
434            .await
435            .unwrap();
436
437        assert!(moved.is_empty());
438        let untouched = get_by_message_id(tx.as_mut(), other_search.id)
439            .await
440            .unwrap();
441        assert_eq!(citation_ids(&untouched), vec![other_citation.id]);
442    }
443}