headless_lms_server/controllers/course_material/
chatbot.rs1use actix_web::http::header::ContentType;
2use chrono::Utc;
3
4use headless_lms_chatbot::azure_chatbot::{ChatbotUserContext, send_chat_request_and_parse_stream};
5use headless_lms_chatbot::llm_utils::estimate_tokens;
6use headless_lms_models::application_task_default_language_models::ApplicationTask;
7use headless_lms_models::chatbot_conversation_message_messages::{
8 ChatbotConversationMessageMessage, MessageRole,
9};
10use headless_lms_models::chatbot_conversation_messages::Message;
11use headless_lms_models::chatbot_conversations::{
12 self, ChatbotConversation, ChatbotConversationInfo,
13};
14use headless_lms_models::{chatbot_configurations, courses};
15use rand::seq::IndexedRandom;
16use utoipa::OpenApi;
17
18use crate::{domain::authorization::authorize_access_to_course_material, prelude::*};
19
20#[derive(OpenApi)]
21#[openapi(paths(
22 get_default_chatbot_configuration_for_course,
23 send_message,
24 new_conversation,
25 current_conversation_info
26))]
27pub(crate) struct CourseMaterialChatbotApiDoc;
28
29#[utoipa::path(
35 get,
36 path = "/default-for-course/{course_id}",
37 operation_id = "getDefaultChatbotConfigurationForCourse",
38 tag = "course-material-chatbot",
39 params(
40 ("course_id" = Uuid, Path, description = "Course id")
41 ),
42 responses(
43 (status = 200, description = "Default chatbot configuration id", body = Option<Uuid>)
44 )
45)]
46#[instrument(skip(pool))]
47async fn get_default_chatbot_configuration_for_course(
48 pool: web::Data<PgPool>,
49 course_id: web::Path<Uuid>,
50) -> ControllerResult<web::Json<Option<Uuid>>> {
51 let token = skip_authorize();
52
53 let mut conn = pool.acquire().await?;
54 let chatbot_configurations =
55 models::chatbot_configurations::get_for_course(&mut conn, *course_id).await?;
56
57 let res = chatbot_configurations
58 .into_iter()
59 .filter(|c| c.enabled_to_students)
60 .find(|c| c.default_chatbot)
61 .map(|c| c.id);
62
63 token.authorized_ok(web::Json(res))
64}
65
66#[utoipa::path(
72 post,
73 path = "/{chatbot_configuration_id}/conversations/{conversation_id}/send-message",
74 operation_id = "sendChatbotMessage",
75 tag = "course-material-chatbot",
76 params(
77 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id"),
78 ("conversation_id" = Uuid, Path, description = "Conversation id")
79 ),
80 request_body(
81 content = String,
82 content_type = "application/json"
83 ),
84 responses(
85 (status = 200, description = "Chatbot response stream", body = String)
86 )
87)]
88#[instrument(skip(pool, app_conf))]
89async fn send_message(
90 pool: web::Data<PgPool>,
91 params: web::Path<(Uuid, Uuid)>,
92 user: AuthUser,
93 app_conf: web::Data<ApplicationConfiguration>,
94 payload: web::Json<String>,
95) -> ControllerResult<HttpResponse> {
96 let message = payload.into_inner();
97 let chatbot_configuration_id = params.0;
98 let conversation_id = params.1;
99 let mut conn = pool.acquire().await?;
100 let chatbot_configuration =
101 chatbot_configurations::get_by_id(&mut conn, chatbot_configuration_id).await?;
102 let token = authorize_access_to_course_material(
103 &mut conn,
104 Some(user.id),
105 chatbot_configuration.course_id,
106 )
107 .await?;
108 let conversation = chatbot_conversations::get_by_id(&mut conn, conversation_id).await?;
109 if conversation.user_id != user.id
110 || conversation.chatbot_configuration_id != chatbot_configuration_id
111 || conversation.course_id != chatbot_configuration.course_id
112 {
113 return Err(controller_err!(
114 Forbidden,
115 "Conversation does not belong to the authenticated user and chatbot configuration"
116 .to_string()
117 ));
118 }
119
120 let course_name = courses::get_course(&mut conn, chatbot_configuration.course_id)
121 .await?
122 .name;
123 let chatbot_user = ChatbotUserContext {
124 user_id: user.id.to_owned(),
125 course_id: chatbot_configuration.course_id,
126 course_name,
127 };
128 let mut tx = conn.begin().await?;
129
130 let response_stream = send_chat_request_and_parse_stream(
131 &mut tx,
132 pool.get_ref().clone(),
134 &app_conf,
135 chatbot_configuration_id,
136 conversation_id,
137 &message,
138 chatbot_user,
139 )
140 .await?;
141
142 tx.commit().await?;
143
144 token.authorized_ok(
145 HttpResponse::Ok()
146 .content_type(ContentType::json())
147 .streaming(response_stream),
148 )
149}
150
151#[utoipa::path(
157 post,
158 path = "/{chatbot_configuration_id}/conversations/new",
159 operation_id = "newChatbotConversation",
160 tag = "course-material-chatbot",
161 params(
162 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id")
163 ),
164 responses(
165 (status = 200, description = "Created chatbot conversation", body = ChatbotConversation)
166 )
167)]
168#[instrument(skip(pool))]
169async fn new_conversation(
170 pool: web::Data<PgPool>,
171 user: AuthUser,
172 params: web::Path<Uuid>,
173) -> ControllerResult<web::Json<ChatbotConversation>> {
174 let mut conn = pool.acquire().await?;
175
176 let configuration = models::chatbot_configurations::get_by_id(&mut conn, *params).await?;
177 let token =
178 authorize_access_to_course_material(&mut conn, Some(user.id), configuration.course_id)
179 .await?;
180
181 let conversation = models::chatbot_conversations::create_for_user_and_configuration(
182 &mut conn,
183 PKeyPolicy::Generate,
184 user.id,
185 configuration.id,
186 )
187 .await?;
188
189 let _first_message =
190 models::chatbot_conversation_messages::insert_for_conversation_user_and_configuration(
191 &mut conn,
192 models::chatbot_conversation_messages::ChatbotConversationMessage {
193 id: Uuid::new_v4(),
194 created_at: Utc::now(),
195 updated_at: Utc::now(),
196 deleted_at: None,
197 conversation_id: conversation.id,
198 order_number: 0,
199 message: Message::Text(ChatbotConversationMessageMessage {
200 text: configuration.initial_message.clone(),
201 message_role: MessageRole::Assistant,
202 message_is_complete: true,
203 used_tokens: estimate_tokens(&configuration.initial_message),
204 response_id: Some("initial-message".to_string()),
205 ..Default::default()
206 }),
207 },
208 user.id,
209 configuration.id,
210 )
211 .await?;
212
213 token.authorized_ok(web::Json(conversation))
214}
215
216#[utoipa::path(
222 get,
223 path = "/{chatbot_configuration_id}/conversations/current",
224 operation_id = "getChatbotCurrentConversationInfo",
225 tag = "course-material-chatbot",
226 params(
227 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id")
228 ),
229 responses(
230 (
231 status = 200,
232 description = "Current chatbot conversation info",
233 body = ChatbotConversationInfo
234 )
235 )
236)]
237#[instrument(skip(pool, app_conf))]
238async fn current_conversation_info(
239 pool: web::Data<PgPool>,
240 user: AuthUser,
241 app_conf: web::Data<ApplicationConfiguration>,
242 params: web::Path<Uuid>,
243) -> ControllerResult<web::Json<ChatbotConversationInfo>> {
244 let mut conn = pool.acquire().await?;
245 let chatbot_configuration =
246 models::chatbot_configurations::get_by_id(&mut conn, *params).await?;
247 let token = authorize_access_to_course_material(
248 &mut conn,
249 Some(user.id),
250 chatbot_configuration.course_id,
251 )
252 .await?;
253 let res = chatbot_conversations::get_current_conversation_info(
254 &mut conn,
255 user.id,
256 chatbot_configuration.id,
257 )
258 .await?;
259
260 if chatbot_configuration.suggest_next_messages
261 && let Some(suggested_messages) = &res.suggested_messages
263 && suggested_messages.is_empty()
264 && let Some(current_conversation_messages) = &res.current_conversation_messages
265 && let Some(last_message) = current_conversation_messages.last()
266 {
267 let initial_suggested_messages = if last_message.order_number == 1 {
268 let initial_suggested_messages = chatbot_configuration
270 .initial_suggested_messages
271 .unwrap_or(vec![]);
272 if initial_suggested_messages.len() > 3 {
274 let mut rng = rand::rng();
275 initial_suggested_messages
276 .sample(&mut rng, 3)
277 .cloned()
278 .collect()
279 } else {
280 initial_suggested_messages
281 }
282 } else {
283 let course_description =
285 models::courses::get_course(&mut conn, chatbot_configuration.course_id)
286 .await?
287 .description;
288 let message_suggest_llm =
289 models::application_task_default_language_models::get_for_task(
290 &mut conn,
291 ApplicationTask::MessageSuggestion,
292 )
293 .await?;
294 headless_lms_chatbot::message_suggestion::generate_suggested_messages(
295 &app_conf,
296 message_suggest_llm,
297 current_conversation_messages,
298 chatbot_configuration.initial_suggested_messages,
299 &res.course_name,
300 course_description,
301 )
302 .await?
303 };
304
305 if !initial_suggested_messages.is_empty() {
306 headless_lms_models::chatbot_conversation_suggested_messages::insert_batch(
307 &mut conn,
308 &last_message.id,
309 initial_suggested_messages,
310 )
311 .await?;
312 }
313
314 let res = chatbot_conversations::get_current_conversation_info(
315 &mut conn,
316 user.id,
317 chatbot_configuration.id,
318 )
319 .await?;
320 return token.authorized_ok(web::Json(res));
321 }
322
323 token.authorized_ok(web::Json(res))
324}
325
326pub fn _add_routes(cfg: &mut ServiceConfig) {
334 cfg.route(
335 "/{chatbot_configuration_id}/conversations/{conversation_id}/send-message",
336 web::post().to(send_message),
337 )
338 .route(
339 "/{chatbot_configuration_id}/conversations/current",
340 web::get().to(current_conversation_info),
341 )
342 .route(
343 "/{chatbot_configuration_id}/conversations/new",
344 web::post().to(new_conversation),
345 )
346 .route(
347 "/default-for-course/{course_id}",
348 web::get().to(get_default_chatbot_configuration_for_course),
349 );
350}