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 rand::seq::IndexedRandom;
17use utoipa::{OpenApi, ToSchema};
18
19use crate::{
20 domain::{
21 authentication::handle_anonymous_token,
22 authorization::{AuthorizationToken, authorize_access_to_chatbot},
23 },
24 prelude::*,
25};
26use rand::distr::{Alphanumeric, SampleString};
27
28#[derive(OpenApi)]
29#[openapi(paths(
30 get_default_chatbot_configuration_for_course,
31 send_message,
32 tool_response,
33 new_conversation,
34 current_conversation_info
35))]
36pub(crate) struct CourseMaterialChatbotApiDoc;
37
38#[utoipa::path(
44 get,
45 path = "/default-for-course/{course_id}",
46 operation_id = "getDefaultChatbotConfigurationForCourse",
47 tag = "course-material-chatbot",
48 params(
49 ("course_id" = Uuid, Path, description = "Course id")
50 ),
51 responses(
52 (status = 200, description = "Default chatbot configuration id", body = Option<Uuid>)
53 )
54)]
55#[instrument(skip(pool))]
56async fn get_default_chatbot_configuration_for_course(
57 pool: web::Data<PgPool>,
58 course_id: web::Path<Uuid>,
59) -> ControllerResult<web::Json<Option<Uuid>>> {
60 let token = skip_authorize();
61
62 let mut conn = pool.acquire().await?;
63 let chatbot_configurations =
64 models::chatbot_configurations::get_for_course(&mut conn, *course_id).await?;
65
66 let res = chatbot_configurations
67 .into_iter()
68 .filter(|c| c.enabled_to_students)
69 .find(|c| c.default_chatbot)
70 .map(|c| c.id);
71
72 token.authorized_ok(web::Json(res))
73}
74
75#[derive(Debug, Deserialize, Serialize, ToSchema)]
76pub struct SendChatbotMessage {
77 pub message: String,
79 pub page_context: Option<ChatbotPageContext>,
81}
82
83#[utoipa::path(
89 post,
90 path = "/{chatbot_configuration_id}/conversations/{conversation_id}/send-message",
91 operation_id = "sendChatbotMessage",
92 tag = "course-material-chatbot",
93 params(
94 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id"),
95 ("conversation_id" = Uuid, Path, description = "Conversation id")
96 ),
97 request_body = SendChatbotMessage,
98 responses(
99 (
100 status = 200,
101 description = "Chatbot response stream",
102 body = ChatbotChatStreamEvent,
103 content_type = "application/x-ndjson"
104 )
105 )
106)]
107#[instrument(
110 skip(pool, app_conf, payload, req),
111 fields(has_page_context = payload.page_context.is_some())
112)]
113async fn send_message(
114 pool: web::Data<PgPool>,
115 params: web::Path<(Uuid, Uuid)>,
116 user: Option<AuthUser>,
117 app_conf: web::Data<ApplicationConfiguration>,
118 payload: web::Json<SendChatbotMessage>,
119 req: HttpRequest,
120) -> ControllerResult<HttpResponse> {
121 let SendChatbotMessage {
122 message,
123 page_context,
124 } = payload.into_inner();
125 let chatbot_configuration_id = params.0;
126 let conversation_id = params.1;
127 let mut conn = pool.acquire().await?;
128
129 let (token, chatbot_user) = authorize_access_to_conversation(
130 &mut conn,
131 chatbot_configuration_id,
132 conversation_id,
133 user,
134 req,
135 )
136 .await?;
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 page_context,
146 chatbot_user,
147 )
148 .await?;
149
150 token.authorized_ok(
151 HttpResponse::Ok()
152 .content_type("application/x-ndjson")
153 .streaming(response_stream),
154 )
155}
156
157async fn authorize_access_to_conversation(
164 conn: &mut PgConnection,
165 chatbot_configuration_id: Uuid,
166 conversation_id: Uuid,
167 user: Option<AuthUser>,
168 req: HttpRequest,
169) -> Result<(AuthorizationToken, ChatbotTurnContext), ControllerError> {
170 let chatbot_configuration =
171 chatbot_configurations::get_by_id(conn, chatbot_configuration_id).await?;
172
173 let token =
174 authorize_access_to_chatbot(conn, user.map(|u| u.id), &chatbot_configuration).await?;
175
176 let conversation = chatbot_conversations::get_by_id(conn, conversation_id).await?;
177
178 let anonymous_token = handle_anonymous_token(&req, user);
179
180 if conversation.user_id != user.map(|u| u.id)
181 || conversation.chatbot_configuration_id != chatbot_configuration_id
182 || conversation.course_id != chatbot_configuration.course_id
183 || conversation.anonymous_token != anonymous_token
184 {
185 return Err(controller_err!(
186 Forbidden,
187 "Conversation does not belong to the authenticated user and chatbot configuration"
188 .to_string()
189 ));
190 }
191
192 let course_name = if let Some(course_id) = chatbot_configuration.course_id {
193 Some(courses::get_course(conn, course_id).await?.name)
194 } else {
195 None
196 };
197
198 let chatbot_user = ChatbotTurnContext::new(
199 user.map(|u| u.id),
200 chatbot_configuration.course_id,
201 course_name,
202 conversation_id,
203 &chatbot_configuration,
204 );
205
206 Ok((token, chatbot_user))
207}
208
209#[derive(Debug, Deserialize, Serialize, ToSchema)]
210pub struct ChatbotToolResponse {
211 pub tool_call_id: String,
213 pub tool_name: ClientToolName,
217 pub answer: ClientToolAnswer,
218}
219
220#[utoipa::path(
228 post,
229 path = "/{chatbot_configuration_id}/conversations/{conversation_id}/tool-response",
230 operation_id = "sendChatbotToolResponse",
231 tag = "course-material-chatbot",
232 params(
233 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id"),
234 ("conversation_id" = Uuid, Path, description = "Conversation id")
235 ),
236 request_body = ChatbotToolResponse,
237 responses(
238 (
239 status = 200,
240 description = "Chatbot response stream",
241 body = ChatbotChatStreamEvent,
242 content_type = "application/x-ndjson"
243 )
244 )
245)]
246#[instrument(skip(pool, app_conf, payload, req))]
250async fn tool_response(
251 pool: web::Data<PgPool>,
252 params: web::Path<(Uuid, Uuid)>,
253 user: Option<AuthUser>,
254 app_conf: web::Data<ApplicationConfiguration>,
255 payload: web::Json<ChatbotToolResponse>,
256 req: HttpRequest,
257) -> ControllerResult<HttpResponse> {
258 let ChatbotToolResponse {
259 tool_call_id,
260 tool_name,
261 answer,
262 } = payload.into_inner();
263 let chatbot_configuration_id = params.0;
264 let conversation_id = params.1;
265 let mut conn = pool.acquire().await?;
266
267 let (token, chatbot_user) = authorize_access_to_conversation(
268 &mut conn,
269 chatbot_configuration_id,
270 conversation_id,
271 user,
272 req,
273 )
274 .await?;
275
276 let recorded_call =
277 chatbot_conversation_message_tool_calls::get_by_conversation_and_tool_call_id(
278 &mut conn,
279 conversation_id,
280 &tool_call_id,
281 )
282 .await?;
283 if recorded_call.is_none_or(|call| call.tool_name != tool_name.as_str()) {
284 return Err(ControllerError::new(
285 ControllerErrorType::BadRequest,
286 "tool_name does not match the tool call being answered".to_string(),
287 None,
288 ));
289 }
290
291 let response_stream = answer_tool_call_and_resume_stream(
292 pool.get_ref().clone(),
294 &app_conf,
295 chatbot_configuration_id,
296 conversation_id,
297 &tool_call_id,
298 &answer,
299 chatbot_user,
300 )
301 .await?;
302
303 token.authorized_ok(
304 HttpResponse::Ok()
305 .content_type("application/x-ndjson")
306 .streaming(response_stream),
307 )
308}
309
310#[utoipa::path(
316 post,
317 path = "/{chatbot_configuration_id}/conversations/new",
318 operation_id = "newChatbotConversation",
319 tag = "course-material-chatbot",
320 params(
321 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id")
322 ),
323 responses(
324 (status = 200, description = "Created chatbot conversation", body = ChatbotConversation)
325 )
326)]
327#[instrument(skip(pool))]
328async fn new_conversation(
329 pool: web::Data<PgPool>,
330 user: Option<AuthUser>,
331 params: web::Path<Uuid>,
332) -> ControllerResult<web::Json<ChatbotConversation>> {
333 let mut conn = pool.acquire().await?;
334
335 let configuration = models::chatbot_configurations::get_by_id(&mut conn, *params).await?;
336
337 let token = authorize_access_to_chatbot(&mut conn, user.map(|u| u.id), &configuration).await?;
338
339 let anonymous_token = if let Some(_user) = user {
340 None
341 } else {
342 Some(Alphanumeric.sample_string(&mut rand::rng(), 128))
343 };
344
345 let conversation = models::chatbot_conversations::create_for_user_and_configuration(
346 &mut conn,
347 PKeyPolicy::Generate,
348 user.map(|u| u.id),
349 anonymous_token.as_ref().map(|a| a.to_owned()),
350 configuration.id,
351 )
352 .await?;
353
354 let _first_message =
355 models::chatbot_conversation_messages::insert_for_conversation_user_and_configuration(
356 &mut conn,
357 models::chatbot_conversation_messages::ChatbotConversationMessage::text(
358 conversation.id,
359 MessageRole::Assistant,
360 configuration.initial_message.clone(),
361 estimate_tokens(&configuration.initial_message),
362 Some("initial-message".to_string()),
363 ),
364 user.map(|u| u.id),
365 anonymous_token,
366 configuration.id,
367 )
368 .await?;
369
370 token.authorized_ok(web::Json(conversation))
371}
372
373#[utoipa::path(
379 get,
380 path = "/{chatbot_configuration_id}/conversations/current",
381 operation_id = "getChatbotCurrentConversationInfo",
382 tag = "course-material-chatbot",
383 params(
384 ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id")
385 ),
386 responses(
387 (
388 status = 200,
389 description = "Current chatbot conversation info",
390 body = ChatbotConversationInfo
391 )
392 )
393)]
394#[instrument(skip(pool, app_conf, req))]
395async fn current_conversation_info(
396 pool: web::Data<PgPool>,
397 user: Option<AuthUser>,
398 app_conf: web::Data<ApplicationConfiguration>,
399 params: web::Path<Uuid>,
400 req: HttpRequest,
401) -> ControllerResult<web::Json<ChatbotConversationInfo>> {
402 let mut conn = pool.acquire().await?;
403 let chatbot_configuration =
404 models::chatbot_configurations::get_by_id(&mut conn, *params).await?;
405
406 let token =
407 authorize_access_to_chatbot(&mut conn, user.map(|u| u.id), &chatbot_configuration).await?;
408
409 let anonymous_token = handle_anonymous_token(&req, user);
410
411 let res = chatbot_conversations::get_current_conversation_info(
412 &mut conn,
413 user.map(|u| u.id),
414 anonymous_token.as_ref().map(|a| a.to_owned()),
415 chatbot_configuration.id,
416 )
417 .await?;
418
419 if chatbot_configuration.suggest_next_messages
422 && let Some(suggested_messages) = &res.suggested_messages
423 && suggested_messages.is_empty()
424 && let Some(current_conversation_messages) = &res.current_conversation_messages
425 && let Some(last_message) = current_conversation_messages.last()
426 && let Some(course_name) = &res.course_name
427 {
428 let initial_suggested_messages = if last_message.order_number == 1 {
429 let initial_suggested_messages = chatbot_configuration
431 .initial_suggested_messages
432 .unwrap_or(vec![]);
433 if initial_suggested_messages.len() > 3 {
435 let mut rng = rand::rng();
436 initial_suggested_messages
437 .sample(&mut rng, 3)
438 .cloned()
439 .collect()
440 } else {
441 initial_suggested_messages
442 }
443 } else {
444 let course_description = if let Some(course_id) = chatbot_configuration.course_id {
446 models::courses::get_course(&mut conn, course_id)
447 .await?
448 .description
449 } else {
450 None
451 };
452 let message_suggest_llm =
453 models::application_task_default_language_models::get_for_task(
454 &mut conn,
455 ApplicationTask::MessageSuggestion,
456 )
457 .await?;
458
459 headless_lms_chatbot::message_suggestion::generate_suggested_messages(
460 &app_conf,
461 message_suggest_llm,
462 current_conversation_messages,
463 chatbot_configuration.initial_suggested_messages,
464 Some(course_name.to_owned()),
465 course_description,
466 )
467 .await?
468 };
469
470 if !initial_suggested_messages.is_empty() {
471 headless_lms_models::chatbot_conversation_suggested_messages::insert_batch(
472 &mut conn,
473 &last_message.id,
474 initial_suggested_messages,
475 )
476 .await?;
477 }
478
479 let res = chatbot_conversations::get_current_conversation_info(
480 &mut conn,
481 user.map(|u| u.id),
482 anonymous_token,
483 chatbot_configuration.id,
484 )
485 .await?;
486 return token.authorized_ok(web::Json(res));
487 }
488
489 token.authorized_ok(web::Json(res))
490}
491
492pub fn _add_routes(cfg: &mut ServiceConfig) {
500 cfg.route(
501 "/{chatbot_configuration_id}/conversations/{conversation_id}/send-message",
502 web::post().to(send_message),
503 )
504 .route(
505 "/{chatbot_configuration_id}/conversations/{conversation_id}/tool-response",
506 web::post().to(tool_response),
507 )
508 .route(
509 "/{chatbot_configuration_id}/conversations/current",
510 web::get().to(current_conversation_info),
511 )
512 .route(
513 "/{chatbot_configuration_id}/conversations/new",
514 web::post().to(new_conversation),
515 )
516 .route(
517 "/default-for-course/{course_id}",
518 web::get().to(get_default_chatbot_configuration_for_course),
519 );
520}