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
68pub 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
166pub 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
216pub 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 #[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 #[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 #[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}