Skip to main content

headless_lms_server/controllers/course_material/
chatbot.rs

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/**
48GET `/api/v0/course-material/course-modules/chatbot/default-for-course/:course-id`
49
50Returns the default chatbot configuration id for a course if the default chatbot is enabled to students.
51*/
52#[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    /// What the learner wrote.
87    pub message: String,
88    /// The course material page the learner has open, when they are on one.
89    pub page_context: Option<ChatbotPageContext>,
90}
91
92/**
93POST `/api/v0/course-material/chatbot/:chatbot_configuration_id/conversations/:conversation_id/send-message`
94
95Sends a new chat message to the chatbot.
96*/
97#[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// Neither the payload nor the request is recorded: the payload carries the learner's message, and
117// `HttpRequest`'s Debug is a multi-line dump of every header.
118#[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        // An Arc, cheap to clone.
150        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
168/// Checks that the caller may use this chatbot and that the conversation is theirs, and collects
169/// the context a turn of it needs.
170///
171/// Every endpoint that continues a conversation has to go through here: without the ownership
172/// comparison a caller could send messages into, or answer tool calls of, someone else's
173/// conversation by guessing its id.
174async 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    /// The call being answered, as its `tool_call_id` arrived in the `ToolCall` stream event.
223    pub tool_call_id: String,
224    /// The tool the caller believes `tool_call_id` belongs to, checked against the call's
225    /// recorded name so a client answering the wrong bubble fails clearly instead of being
226    /// silently accepted as whatever tool the call actually was.
227    pub tool_name: ClientToolName,
228    pub answer: ClientToolAnswer,
229}
230
231/**
232POST `/api/v0/course-material/chatbot/:chatbot_configuration_id/conversations/:conversation_id/tool-response`
233
234Answers a tool call the chatbot suspended its turn on, which resumes the turn once nothing else
235is outstanding. Responds with the same stream `send-message` does, carrying either the resumed
236turn or a lone `Suspended` event when the turn is still waiting for another answer.
237*/
238#[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// Neither the payload nor the request is recorded: the answer can carry what the learner wrote,
258// and `HttpRequest`'s Debug prints every header, including the anonymous chatbot bearer token that
259// `handle_anonymous_token` reads.
260#[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        // An Arc, cheap to clone.
305        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/**
324POST `/api/v0/course-material/course-modules/chatbot/:chatbot_configuration_id/conversations/new`
325
326Sends a new chat message to the chatbot.
327*/
328#[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/**
387GET `/api/v0/course-material/chatbot/conversations/all`
388
389Returns all conversations that a user has.
390*/
391#[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/**
413GET `/api/v0/course-material/chatbot/:chatbot_configuration_id/conversations`
414
415Returns a chatbot conversation for the user. If conversation id is not provided as a query parameter then latest conversation is returned.
416*/
417#[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    // A None means no suggestion belongs here at all, which includes a turn suspended on a question
464    // to the learner, so the generation below is skipped for those without a check of its own.
465    if chatbot_configuration.suggest_next_messages
466        // suggested_messages is None if suggest_next_messages=false
467        && 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            // for the first message, get initial_suggested_messages
475            let initial_suggested_messages = chatbot_configuration
476                .initial_suggested_messages
477                .unwrap_or(vec![]);
478            // take 3 random elements
479            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            // for other messages, generate suggested messages
490            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/**
538GET `/api/v0/course-material/chatbot/:chatbot_configuration_id/conversations/current/id`
539
540Returns current chatbot conversation id.
541*/
542#[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/**
584PUT `/api/v0/course-material/chatbot/:chatbot_configuration_id/conversations/:conversation_id/update-title`
585
586Updates the title of a chatbot conversation.
587*/
588#[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
634/**
635Add a route for each controller in this module.
636
637The name starts with an underline in order to appear before other functions in the module documentation.
638
639We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
640*/
641pub 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}