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