1use headless_lms_chatbot::azure_chatbot::events::ChatbotChatStreamEvent;
2use headless_lms_chatbot::azure_chatbot::turn::{
3 answer_tool_call_and_resume_stream, send_chat_request_and_parse_stream,
4};
5use headless_lms_chatbot::chatbot_tools::{ClientToolAnswer, ClientToolName};
6use headless_lms_chatbot::conversation_context::ChatbotPageContext;
7use headless_lms_chatbot::llm_utils::estimate_tokens;
8use headless_lms_chatbot::user_context::ChatbotTurnContext;
9use headless_lms_models::application_task_default_language_models::ApplicationTask;
10use headless_lms_models::chatbot_conversation_message_messages::MessageRole;
11use headless_lms_models::chatbot_conversation_message_tool_calls;
12use headless_lms_models::chatbot_conversations::{
13 self, ChatbotConversation, ChatbotConversationInfo,
14};
15use headless_lms_models::{chatbot_configurations, courses};
16use headless_lms_utils::cache::Cache;
17use rand::seq::IndexedRandom;
18use utoipa::{OpenApi, ToSchema};
19
20use crate::{
21 domain::{
22 authentication::handle_anonymous_token,
23 authorization::{AuthorizationToken, authorize_access_to_chatbot},
24 },
25 prelude::*,
26};
27use rand::distr::{Alphanumeric, SampleString};
28
29#[derive(OpenApi)]
30#[openapi(paths(
31 get_default_chatbot_configuration_for_course,
32 send_message,
33 tool_response,
34 new_conversation,
35 conversation_info,
36 current_conversation_id,
37 all_user_conversations,
38 update_title,
39))]
40pub(crate) struct CourseMaterialChatbotApiDoc;
41
42#[derive(Deserialize, Debug)]
43pub struct ConversationQuery {
44 pub conversation_id: Option<Uuid>,
45}
46
47#[utoipa::path(
53 get,
54 path = "/default-for-course/{course_id}",
55 operation_id = "getDefaultChatbotConfigurationForCourse",
56 tag = "course-material-chatbot",
57 params(
58 ("course_id" = Uuid, Path, description = "Course id")
59 ),
60 responses(
61 (status = 200, description = "Default chatbot configuration id", body = Option<Uuid>)
62 )
63)]
64#[instrument(skip(pool))]
65async fn get_default_chatbot_configuration_for_course(
66 pool: web::Data<PgPool>,
67 course_id: web::Path<Uuid>,
68) -> ControllerResult<web::Json<Option<Uuid>>> {
69 let token = skip_authorize();
70
71 let mut conn = pool.acquire().await?;
72 let chatbot_configurations =
73 models::chatbot_configurations::get_for_course(&mut conn, *course_id).await?;
74
75 let res = chatbot_configurations
76 .into_iter()
77 .filter(|c| c.enabled_to_students)
78 .find(|c| c.default_chatbot)
79 .map(|c| c.id);
80
81 token.authorized_ok(web::Json(res))
82}
83
84#[derive(Debug, Deserialize, Serialize, ToSchema)]
85pub struct SendChatbotMessage {
86 pub message: String,
88 pub page_context: Option<ChatbotPageContext>,
90}
91
92#[utoipa::path(
98 post,
99 path = "/{chatbot_configuration_id}/conversations/{conversation_id}/send-message",
100 operation_id = "sendChatbotMessage",
101 tag = "course-material-chatbot",
102 params(
103 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id"),
104 ("conversation_id" = Uuid, Path, description = "Conversation id")
105 ),
106 request_body = SendChatbotMessage,
107 responses(
108 (
109 status = 200,
110 description = "Chatbot response stream",
111 body = ChatbotChatStreamEvent,
112 content_type = "application/x-ndjson"
113 )
114 )
115)]
116#[instrument(
119 skip(pool, cache, app_conf, payload, req),
120 fields(has_page_context = payload.page_context.is_some())
121)]
122async fn send_message(
123 pool: web::Data<PgPool>,
124 cache: web::Data<Cache>,
125 params: web::Path<(Uuid, Uuid)>,
126 user: Option<AuthUser>,
127 app_conf: web::Data<ApplicationConfiguration>,
128 payload: web::Json<SendChatbotMessage>,
129 req: HttpRequest,
130) -> ControllerResult<HttpResponse> {
131 let SendChatbotMessage {
132 message,
133 page_context,
134 } = payload.into_inner();
135 let chatbot_configuration_id = params.0;
136 let conversation_id = params.1;
137 let mut conn = pool.acquire().await?;
138
139 let (token, chatbot_user) = authorize_access_to_conversation(
140 &mut conn,
141 chatbot_configuration_id,
142 conversation_id,
143 user,
144 req,
145 )
146 .await?;
147
148 let response_stream = send_chat_request_and_parse_stream(
149 pool.get_ref().clone(),
151 &app_conf,
152 cache.get_ref().clone(),
153 chatbot_configuration_id,
154 conversation_id,
155 &message,
156 page_context,
157 chatbot_user,
158 )
159 .await?;
160
161 token.authorized_ok(
162 HttpResponse::Ok()
163 .content_type("application/x-ndjson")
164 .streaming(response_stream),
165 )
166}
167
168async fn authorize_access_to_conversation(
175 conn: &mut PgConnection,
176 chatbot_configuration_id: Uuid,
177 conversation_id: Uuid,
178 user: Option<AuthUser>,
179 req: HttpRequest,
180) -> Result<(AuthorizationToken, ChatbotTurnContext), ControllerError> {
181 let chatbot_configuration =
182 chatbot_configurations::get_by_id(conn, chatbot_configuration_id).await?;
183
184 let token =
185 authorize_access_to_chatbot(conn, user.map(|u| u.id), &chatbot_configuration).await?;
186
187 let conversation = chatbot_conversations::get_by_id(conn, conversation_id).await?;
188
189 let anonymous_token = handle_anonymous_token(&req, user);
190
191 if conversation.user_id != user.map(|u| u.id)
192 || conversation.chatbot_configuration_id != chatbot_configuration_id
193 || conversation.course_id != chatbot_configuration.course_id
194 || conversation.anonymous_token != anonymous_token
195 {
196 return Err(controller_err!(
197 Forbidden,
198 "Conversation does not belong to the authenticated user and chatbot configuration"
199 .to_string()
200 ));
201 }
202
203 let course_name = if let Some(course_id) = chatbot_configuration.course_id {
204 Some(courses::get_course(conn, course_id).await?.name)
205 } else {
206 None
207 };
208
209 let chatbot_user = ChatbotTurnContext::new(
210 user.map(|u| u.id),
211 chatbot_configuration.course_id,
212 course_name,
213 conversation_id,
214 &chatbot_configuration,
215 );
216
217 Ok((token, chatbot_user))
218}
219
220#[derive(Debug, Deserialize, Serialize, ToSchema)]
221pub struct ChatbotToolResponse {
222 pub tool_call_id: String,
224 pub tool_name: ClientToolName,
228 pub answer: ClientToolAnswer,
229}
230
231#[utoipa::path(
239 post,
240 path = "/{chatbot_configuration_id}/conversations/{conversation_id}/tool-response",
241 operation_id = "sendChatbotToolResponse",
242 tag = "course-material-chatbot",
243 params(
244 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id"),
245 ("conversation_id" = Uuid, Path, description = "Conversation id")
246 ),
247 request_body = ChatbotToolResponse,
248 responses(
249 (
250 status = 200,
251 description = "Chatbot response stream",
252 body = ChatbotChatStreamEvent,
253 content_type = "application/x-ndjson"
254 )
255 )
256)]
257#[instrument(skip(pool, cache, app_conf, payload, req))]
261async fn tool_response(
262 pool: web::Data<PgPool>,
263 cache: web::Data<Cache>,
264 params: web::Path<(Uuid, Uuid)>,
265 user: Option<AuthUser>,
266 app_conf: web::Data<ApplicationConfiguration>,
267 payload: web::Json<ChatbotToolResponse>,
268 req: HttpRequest,
269) -> ControllerResult<HttpResponse> {
270 let ChatbotToolResponse {
271 tool_call_id,
272 tool_name,
273 answer,
274 } = payload.into_inner();
275 let chatbot_configuration_id = params.0;
276 let conversation_id = params.1;
277 let mut conn = pool.acquire().await?;
278
279 let (token, chatbot_user) = authorize_access_to_conversation(
280 &mut conn,
281 chatbot_configuration_id,
282 conversation_id,
283 user,
284 req,
285 )
286 .await?;
287
288 let recorded_call =
289 chatbot_conversation_message_tool_calls::get_by_conversation_and_tool_call_id(
290 &mut conn,
291 conversation_id,
292 &tool_call_id,
293 )
294 .await?;
295 if recorded_call.is_none_or(|call| call.tool_name != tool_name.as_str()) {
296 return Err(ControllerError::new(
297 ControllerErrorType::BadRequest,
298 "tool_name does not match the tool call being answered".to_string(),
299 None,
300 ));
301 }
302
303 let response_stream = answer_tool_call_and_resume_stream(
304 pool.get_ref().clone(),
306 &app_conf,
307 cache.get_ref().clone(),
308 chatbot_configuration_id,
309 conversation_id,
310 &tool_call_id,
311 &answer,
312 chatbot_user,
313 )
314 .await?;
315
316 token.authorized_ok(
317 HttpResponse::Ok()
318 .content_type("application/x-ndjson")
319 .streaming(response_stream),
320 )
321}
322
323#[utoipa::path(
329 post,
330 path = "/{chatbot_configuration_id}/conversations/new",
331 operation_id = "newChatbotConversation",
332 tag = "course-material-chatbot",
333 params(
334 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id")
335 ),
336 responses(
337 (status = 200, description = "Created chatbot conversation", body = ChatbotConversation)
338 )
339)]
340#[instrument(skip(pool))]
341async fn new_conversation(
342 pool: web::Data<PgPool>,
343 user: Option<AuthUser>,
344 params: web::Path<Uuid>,
345) -> ControllerResult<web::Json<ChatbotConversation>> {
346 let mut conn = pool.acquire().await?;
347
348 let configuration = models::chatbot_configurations::get_by_id(&mut conn, *params).await?;
349
350 let token = authorize_access_to_chatbot(&mut conn, user.map(|u| u.id), &configuration).await?;
351
352 let anonymous_token = if let Some(_user) = user {
353 None
354 } else {
355 Some(Alphanumeric.sample_string(&mut rand::rng(), 128))
356 };
357
358 let conversation = models::chatbot_conversations::create_for_user_and_configuration(
359 &mut conn,
360 PKeyPolicy::Generate,
361 user.map(|u| u.id),
362 anonymous_token.as_ref().map(|a| a.to_owned()),
363 configuration.id,
364 )
365 .await?;
366
367 let _first_message =
368 models::chatbot_conversation_messages::insert_for_conversation_user_and_configuration(
369 &mut conn,
370 models::chatbot_conversation_messages::ChatbotConversationMessage::text(
371 conversation.id,
372 MessageRole::Assistant,
373 configuration.initial_message.clone(),
374 estimate_tokens(&configuration.initial_message),
375 Some("initial-message".to_string()),
376 ),
377 user.map(|u| u.id),
378 anonymous_token,
379 configuration.id,
380 )
381 .await?;
382
383 token.authorized_ok(web::Json(conversation))
384}
385
386#[utoipa::path(
392 get,
393 path = "/conversations/all",
394 operation_id = "AllUserConversations",
395 tag = "course-material-chatbot",
396 responses(
397 (status = 200, description = "All chatbot conversations for user", body = Vec<ChatbotConversation>)
398 )
399)]
400#[instrument(skip(pool))]
401async fn all_user_conversations(
402 pool: web::Data<PgPool>,
403 user: AuthUser,
404) -> ControllerResult<web::Json<Vec<ChatbotConversation>>> {
405 let mut conn = pool.acquire().await?;
406 let token = authorize(&mut conn, Act::View, Some(user.id), Res::GlobalPermissions).await?;
407
408 let res = chatbot_conversations::get_all_conversations_for_user(&mut conn, user.id).await?;
409 token.authorized_ok(web::Json(res))
410}
411
412#[utoipa::path(
418 get,
419 path = "/{chatbot_configuration_id}/conversations",
420 operation_id = "getConversationInfo",
421 tag = "course-material-chatbot",
422 params(
423 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id"),
424 ("conversation_id" = Option<Uuid>, Query, description = "Conversation id")
425 ),
426 responses(
427 (
428 status = 200,
429 description = "Selected chatbot conversation info",
430 body = ChatbotConversationInfo
431 )
432 )
433)]
434#[instrument(skip(pool, app_conf))]
435
436async fn conversation_info(
437 pool: web::Data<PgPool>,
438 user: Option<AuthUser>,
439 app_conf: web::Data<ApplicationConfiguration>,
440 params: web::Path<Uuid>,
441 query: web::Query<ConversationQuery>,
442 req: HttpRequest,
443) -> ControllerResult<web::Json<ChatbotConversationInfo>> {
444 let mut conn = pool.acquire().await?;
445 let conversation_id = query.conversation_id;
446 let chatbot_configuration =
447 models::chatbot_configurations::get_by_id(&mut conn, *params).await?;
448
449 let token =
450 authorize_access_to_chatbot(&mut conn, user.map(|u| u.id), &chatbot_configuration).await?;
451
452 let anonymous_token = handle_anonymous_token(&req, user);
453
454 let res = chatbot_conversations::get_conversation_info(
455 &mut conn,
456 user.map(|u| u.id),
457 anonymous_token.as_ref().map(|a| a.to_owned()),
458 chatbot_configuration.id,
459 conversation_id,
460 )
461 .await?;
462
463 if chatbot_configuration.suggest_next_messages
466 && let Some(suggested_messages) = &res.suggested_messages
468 && suggested_messages.is_empty()
469 && let Some(current_conversation_messages) = &res.current_conversation_messages
470 && let Some(last_message) = current_conversation_messages.last()
471 && let Some(course_name) = &res.course_name
472 {
473 let initial_suggested_messages = if last_message.order_number == 1 {
474 let initial_suggested_messages = chatbot_configuration
476 .initial_suggested_messages
477 .unwrap_or(vec![]);
478 if initial_suggested_messages.len() > 3 {
480 let mut rng = rand::rng();
481 initial_suggested_messages
482 .sample(&mut rng, 3)
483 .cloned()
484 .collect()
485 } else {
486 initial_suggested_messages
487 }
488 } else {
489 let course_description = if let Some(course_id) = chatbot_configuration.course_id {
491 models::courses::get_course(&mut conn, course_id)
492 .await?
493 .description
494 } else {
495 None
496 };
497 let message_suggest_llm =
498 models::application_task_default_language_models::get_for_task(
499 &mut conn,
500 ApplicationTask::MessageSuggestion,
501 )
502 .await?;
503
504 headless_lms_chatbot::message_suggestion::generate_suggested_messages(
505 &app_conf,
506 message_suggest_llm,
507 current_conversation_messages,
508 chatbot_configuration.initial_suggested_messages,
509 Some(course_name.to_owned()),
510 course_description,
511 )
512 .await?
513 };
514
515 if !initial_suggested_messages.is_empty() {
516 headless_lms_models::chatbot_conversation_suggested_messages::insert_batch(
517 &mut conn,
518 &last_message.id,
519 initial_suggested_messages,
520 )
521 .await?;
522 }
523 let res = chatbot_conversations::get_conversation_info(
524 &mut conn,
525 user.map(|u| u.id),
526 anonymous_token,
527 chatbot_configuration.id,
528 conversation_id,
529 )
530 .await?;
531 return token.authorized_ok(web::Json(res));
532 }
533
534 token.authorized_ok(web::Json(res))
535}
536
537#[utoipa::path(
543 get,
544 path = "/{chatbot_configuration_id}/conversations/current/id",
545 operation_id = "getCurrentConversationId",
546 tag = "course-material-chatbot",
547 params(
548 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id")
549 ),
550 responses(
551 (status = 200, description = "Current conversation ID", body = Option<Uuid>)
552 )
553)]
554#[instrument(skip(pool))]
555async fn current_conversation_id(
556 pool: web::Data<PgPool>,
557 user: Option<AuthUser>,
558 params: web::Path<Uuid>,
559 req: HttpRequest,
560) -> ControllerResult<web::Json<Option<Uuid>>> {
561 let mut conn = pool.acquire().await?;
562 let chatbot_configuration =
563 models::chatbot_configurations::get_by_id(&mut conn, *params).await?;
564
565 let token =
566 authorize_access_to_chatbot(&mut conn, user.map(|u| u.id), &chatbot_configuration).await?;
567
568 let anonymous_token = handle_anonymous_token(&req, user);
569
570 let current_conversation = chatbot_conversations::get_latest_conversation_for_user(
571 &mut conn,
572 user.map(|u| u.id),
573 anonymous_token,
574 chatbot_configuration.id,
575 )
576 .await
577 .optional()?;
578
579 let current_conversation_id = current_conversation.map(|c| c.id);
580 token.authorized_ok(web::Json(current_conversation_id))
581}
582
583#[utoipa::path(
589 put,
590 path = "/{chatbot_configuration_id}/conversations/{conversation_id}/update-title",
591 operation_id = "updateTitle",
592 tag = "course-material-chatbot",
593 params(
594 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id"),
595 ("conversation_id" = Uuid, Path, description = "Conversation id")
596 ),
597 request_body(content = String, content_type = "application/json"),
598 responses(
599 (status = 200, description = "Conversation title updated")
600 )
601)]
602#[instrument(skip(pool))]
603async fn update_title(
604 pool: web::Data<PgPool>,
605 user: Option<AuthUser>,
606 payload: web::Json<String>,
607 params: web::Path<(Uuid, Uuid)>,
608 req: HttpRequest,
609) -> ControllerResult<web::Json<()>> {
610 let mut conn = pool.acquire().await?;
611
612 let chatbot_configuration_id = params.0;
613 let conversation_id = params.1;
614
615 let (token, _chatbot_user) = authorize_access_to_conversation(
616 &mut conn,
617 chatbot_configuration_id,
618 conversation_id,
619 user,
620 req,
621 )
622 .await?;
623
624 chatbot_conversations::update_conversation_title(
625 &mut conn,
626 conversation_id,
627 payload.into_inner(),
628 )
629 .await?;
630
631 token.authorized_ok(web::Json(()))
632}
633
634pub fn _add_routes(cfg: &mut ServiceConfig) {
642 cfg.route(
643 "/{chatbot_configuration_id}/conversations/{conversation_id}/send-message",
644 web::post().to(send_message),
645 )
646 .route(
647 "/{chatbot_configuration_id}/conversations/{conversation_id}/tool-response",
648 web::post().to(tool_response),
649 )
650 .route(
651 "/{chatbot_configuration_id}/conversations/new",
652 web::post().to(new_conversation),
653 )
654 .route(
655 "/default-for-course/{course_id}",
656 web::get().to(get_default_chatbot_configuration_for_course),
657 )
658 .route(
659 "/{chatbot_configuration_id}/conversations",
660 web::get().to(conversation_info),
661 )
662 .route(
663 "/{chatbot_configuration_id}/conversations/current/id",
664 web::get().to(current_conversation_id),
665 )
666 .route("/conversations/all", web::get().to(all_user_conversations))
667 .route(
668 "/{chatbot_configuration_id}/conversations/{conversation_id}/update-title",
669 web::put().to(update_title),
670 );
671}