headless_lms_chatbot/
citations.rs1use std::path::PathBuf;
2
3use secrecy::SecretString;
4
5use crate::{llm_utils::build_llm_headers, prelude::*};
6
7use headless_lms_models::chatbot_conversation_messages_citations::{
8 self, ChatbotConversationMessageCitation,
9};
10use headless_lms_utils::strings::truncate_utf8_at_boundary;
11use headless_lms_utils::url_encoding::url_decode;
12use reqwest::Response;
13use serde::{Deserialize, Serialize};
14use tracing::{error, instrument, trace};
15use url::Url;
16
17#[derive(Serialize, Deserialize, Debug, Clone)]
18pub struct CourseMaterialDocument {
19 pub chunk_id: String,
20 pub chunk: String,
21 pub title: String,
22 pub url: String,
23 pub filepath: String,
24}
25
26pub struct DocumentProperties {
27 pub page_id: Uuid,
28}
29
30pub fn parse_document_filepath(filepath: &str) -> ChatbotResult<DocumentProperties> {
33 let mut page_path = PathBuf::from(filepath);
34 page_path.set_extension("");
35 let page_id_str = page_path.file_name().ok_or(chatbot_err!(
36 ToolUseError,
37 "Failed to parse document filepath"
38 ))?;
39 let page_id = Uuid::parse_str(page_id_str.to_string_lossy().as_ref()).map_err(|_| {
40 chatbot_err!(
41 ToolUseError,
42 format!("Failed to parse document page id: {:?}", page_id_str)
43 )
44 })?;
45
46 Ok(DocumentProperties { page_id })
47}
48
49impl CourseMaterialDocument {
50 pub fn to_chatbot_conversation_message_citation(
53 &self,
54 conversation_message_id: Uuid,
55 conversation_id: Uuid,
56 citation_number: i32,
57 ) -> ChatbotResult<(ChatbotConversationMessageCitation, Option<Uuid>)> {
58 let content = if self.chunk.len() < 255 {
60 self.chunk.clone()
61 } else {
62 truncate_utf8_at_boundary(&self.chunk, 255).to_string()
63 };
64
65 let decoded_title = url_decode(&self.title)?;
69 let decoded_url = url_decode(&self.url)?;
70
71 let page_id = parse_document_filepath(&self.filepath)
73 .ok()
74 .map(|x| x.page_id);
75 Ok((
76 ChatbotConversationMessageCitation {
77 conversation_message_id,
78 conversation_id,
79 title: decoded_title,
80 content,
81 document_url: decoded_url,
82 citation_number,
83 ..Default::default()
84 },
85 page_id,
86 ))
87 }
88}
89
90pub async fn chatbot_cited_documents_to_citations(
93 conn: &mut PgConnection,
94 test_chatbot: bool,
95 mut document_urls: Vec<Url>,
96 api_key: &SecretString,
97 conversation_message_id: Uuid,
98 conversation_id: Uuid,
99) -> ChatbotResult<Vec<ChatbotConversationMessageCitation>> {
100 let mut documents: Vec<(CourseMaterialDocument, i32)> = vec![];
101 for (idx, url) in document_urls.iter_mut().enumerate() {
102 let document = get_course_material_document(url, api_key).await?;
103 let citation_number = idx as i32;
104 documents.push((document, citation_number));
105 }
106 let res = save_documents(
107 conn,
108 test_chatbot,
109 documents,
110 conversation_message_id,
111 conversation_id,
112 )
113 .await?;
114
115 Ok(res)
116}
117
118async fn get_course_material_document(
120 endpoint: &mut Url,
121 api_key: &SecretString,
122) -> ChatbotResult<CourseMaterialDocument> {
123 endpoint.set_query(Some(
124 "api-version=2024-07-01&$select=chunk_id,parent_id,chunk,title,url,filepath,course_id",
125 ));
126 let headers = build_llm_headers(api_key)?;
127
128 let response = REQWEST_CLIENT
129 .get(endpoint.clone())
130 .headers(headers)
131 .send()
132 .await?;
133
134 process_course_material_document_response(response).await
135}
136
137#[instrument(skip(response), fields(status = %response.status()))]
138async fn process_course_material_document_response(
139 response: Response,
140) -> ChatbotResult<CourseMaterialDocument> {
141 if !response.status().is_success() {
142 let status = response.status();
143 let error_text = response.text().await?;
144 error!(
145 status = %status,
146 error = %error_text,
147 "Error fetching document from search index."
148 );
149 return Err(chatbot_err!(
150 FailedAzureResponse,
151 format!(
152 "Error fetching document from search index: Status: {}. Error: {}",
153 status, error_text
154 )
155 ));
156 }
157
158 trace!("Processing successful LLM response");
159 let document: CourseMaterialDocument = response.json().await?;
161
162 Ok(document)
163}
164
165async fn save_documents(
167 conn: &mut PgConnection,
168 test_chatbot: bool,
169 documents_with_citation_numbers: Vec<(CourseMaterialDocument, i32)>,
170 conversation_message_id: Uuid,
171 conversation_id: Uuid,
172) -> ChatbotResult<Vec<ChatbotConversationMessageCitation>> {
173 let (citations, page_ids): (Vec<ChatbotConversationMessageCitation>, Vec<Option<Uuid>>) =
174 documents_with_citation_numbers
175 .iter()
176 .map(|(d, citation_number)| {
177 d.to_chatbot_conversation_message_citation(
178 conversation_message_id,
179 conversation_id,
180 citation_number.to_owned(),
181 )
182 })
183 .collect::<ChatbotResult<Vec<(ChatbotConversationMessageCitation, Option<Uuid>)>>>()?
184 .into_iter()
185 .unzip();
186 if test_chatbot {
187 return save_documents_mock(conn, citations).await;
188 };
189 let res =
190 chatbot_conversation_messages_citations::insert_batch(conn, citations, page_ids).await?;
191
192 Ok(res)
193}
194
195async fn save_documents_mock(
196 conn: &mut PgConnection,
197 citations: Vec<ChatbotConversationMessageCitation>,
198) -> ChatbotResult<Vec<ChatbotConversationMessageCitation>> {
199 let mut res = vec![];
200 for input in citations {
201 let a = chatbot_conversation_messages_citations::insert(conn, input).await?;
202 res.push(a)
203 }
204 Ok(res)
205}