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 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/**
39GET `/api/v0/course-material/course-modules/chatbot/default-for-course/:course-id`
40
41Returns the default chatbot configuration id for a course if the default chatbot is enabled to students.
42*/
43#[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    /// What the learner wrote.
78    pub message: String,
79    /// The course material page the learner has open, when they are on one.
80    pub page_context: Option<ChatbotPageContext>,
81}
82
83/**
84POST `/api/v0/course-material/chatbot/:chatbot_configuration_id/conversations/:conversation_id/send-message`
85
86Sends a new chat message to the chatbot.
87*/
88#[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// Neither the payload nor the request is recorded: the payload carries the learner's message, and
108// `HttpRequest`'s Debug is a multi-line dump of every header.
109#[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        // An Arc, cheap to clone.
140        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
157/// Checks that the caller may use this chatbot and that the conversation is theirs, and collects
158/// the context a turn of it needs.
159///
160/// Every endpoint that continues a conversation has to go through here: without the ownership
161/// comparison a caller could send messages into, or answer tool calls of, someone else's
162/// conversation by guessing its id.
163async 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    /// The call being answered, as its `tool_call_id` arrived in the `ToolCall` stream event.
212    pub tool_call_id: String,
213    /// The tool the caller believes `tool_call_id` belongs to, checked against the call's
214    /// recorded name so a client answering the wrong bubble fails clearly instead of being
215    /// silently accepted as whatever tool the call actually was.
216    pub tool_name: ClientToolName,
217    pub answer: ClientToolAnswer,
218}
219
220/**
221POST `/api/v0/course-material/chatbot/:chatbot_configuration_id/conversations/:conversation_id/tool-response`
222
223Answers a tool call the chatbot suspended its turn on, which resumes the turn once nothing else
224is outstanding. Responds with the same stream `send-message` does, carrying either the resumed
225turn or a lone `Suspended` event when the turn is still waiting for another answer.
226*/
227#[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// Neither the payload nor the request is recorded: the answer can carry what the learner wrote,
247// and `HttpRequest`'s Debug prints every header, including the anonymous chatbot bearer token that
248// `handle_anonymous_token` reads.
249#[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        // An Arc, cheap to clone.
293        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/**
311POST `/api/v0/course-material/course-modules/chatbot/:chatbot_configuration_id/conversations/new`
312
313Sends a new chat message to the chatbot.
314*/
315#[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/**
374POST `/api/v0/course-material/course-modules/chatbot/:chatbot_configuration_id/conversations/current`
375
376Returns the current conversation for the user.
377*/
378#[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    // A None means no suggestion belongs here at all, which includes a turn suspended on a question
420    // to the learner, so the generation below is skipped for those without a check of its own.
421    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            // for the first message, get initial_suggested_messages
430            let initial_suggested_messages = chatbot_configuration
431                .initial_suggested_messages
432                .unwrap_or(vec![]);
433            // take 3 random elements
434            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            // for other messages, generate suggested messages
445            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
492/**
493Add a route for each controller in this module.
494
495The name starts with an underline in order to appear before other functions in the module documentation.
496
497We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
498*/
499pub 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}