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