Skip to main content

headless_lms_server/controllers/course_material/
chatbot.rs

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