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