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