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::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 mut tx: sqlx::Transaction<Postgres> = conn.begin().await?;
98    let course_id = chatbot_configurations::get_by_id(&mut tx, chatbot_configuration_id)
99        .await?
100        .course_id;
101    let course_name = courses::get_course(&mut tx, course_id).await?.name;
102    let chatbot_user = ChatbotUserContext {
103        user_id: user.id.to_owned(),
104        course_id,
105        course_name,
106    };
107    let token = skip_authorize();
108
109    let response_stream = send_chat_request_and_parse_stream(
110        &mut tx,
111        // An Arc, cheap to clone.
112        pool.get_ref().clone(),
113        &app_conf,
114        chatbot_configuration_id,
115        conversation_id,
116        &message,
117        chatbot_user,
118    )
119    .await?;
120
121    tx.commit().await?;
122
123    token.authorized_ok(
124        HttpResponse::Ok()
125            .content_type(ContentType::json())
126            .streaming(response_stream),
127    )
128}
129
130/**
131POST `/api/v0/course-material/course-modules/chatbot/:chatbot_configuration_id/conversations/new`
132
133Sends a new chat message to the chatbot.
134*/
135#[utoipa::path(
136    post,
137    path = "/{chatbot_configuration_id}/conversations/new",
138    operation_id = "newChatbotConversation",
139    tag = "course-material-chatbot",
140    params(
141        ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id")
142    ),
143    responses(
144        (status = 200, description = "Created chatbot conversation", body = ChatbotConversation)
145    )
146)]
147#[instrument(skip(pool))]
148async fn new_conversation(
149    pool: web::Data<PgPool>,
150    user: AuthUser,
151    params: web::Path<Uuid>,
152) -> ControllerResult<web::Json<ChatbotConversation>> {
153    let token = skip_authorize();
154
155    let mut conn = pool.acquire().await?;
156    let mut tx = conn.begin().await?;
157
158    let configuration = models::chatbot_configurations::get_by_id(&mut tx, *params).await?;
159
160    let conversation = models::chatbot_conversations::insert(
161        &mut tx,
162        ChatbotConversation {
163            id: Uuid::new_v4(),
164            created_at: Utc::now(),
165            updated_at: Utc::now(),
166            deleted_at: None,
167            course_id: configuration.course_id,
168            user_id: user.id,
169            chatbot_configuration_id: configuration.id,
170        },
171    )
172    .await?;
173
174    let _first_message = models::chatbot_conversation_messages::insert(
175        &mut tx,
176        models::chatbot_conversation_messages::ChatbotConversationMessage {
177            id: Uuid::new_v4(),
178            created_at: Utc::now(),
179            updated_at: Utc::now(),
180            deleted_at: None,
181            conversation_id: conversation.id,
182            message: Some(configuration.initial_message.clone()),
183            message_role: MessageRole::Assistant,
184            message_is_complete: true,
185            used_tokens: estimate_tokens(&configuration.initial_message),
186            order_number: 0,
187            tool_output: None,
188            tool_call_fields: vec![],
189        },
190    )
191    .await?;
192
193    tx.commit().await?;
194
195    token.authorized_ok(web::Json(conversation))
196}
197
198/**
199POST `/api/v0/course-material/course-modules/chatbot/:chatbot_configuration_id/conversations/current`
200
201Returns the current conversation for the user.
202*/
203#[utoipa::path(
204    get,
205    path = "/{chatbot_configuration_id}/conversations/current",
206    operation_id = "getChatbotCurrentConversationInfo",
207    tag = "course-material-chatbot",
208    params(
209        ("chatbot_configuration_id" = Uuid, Path, description = "Chatbot configuration id")
210    ),
211    responses(
212        (
213            status = 200,
214            description = "Current chatbot conversation info",
215            body = ChatbotConversationInfo
216        )
217    )
218)]
219#[instrument(skip(pool, app_conf))]
220async fn current_conversation_info(
221    pool: web::Data<PgPool>,
222    user: AuthUser,
223    app_conf: web::Data<ApplicationConfiguration>,
224    params: web::Path<Uuid>,
225) -> ControllerResult<web::Json<ChatbotConversationInfo>> {
226    let token = skip_authorize();
227
228    let mut conn = pool.acquire().await?;
229    let chatbot_configuration =
230        models::chatbot_configurations::get_by_id(&mut conn, *params).await?;
231    let res = chatbot_conversations::get_current_conversation_info(
232        &mut conn,
233        user.id,
234        chatbot_configuration.id,
235    )
236    .await?;
237
238    if chatbot_configuration.suggest_next_messages
239        // suggested_messages is None if suggest_next_messages=false
240        && let Some(suggested_messages) = &res.suggested_messages
241        && suggested_messages.is_empty()
242        && let Some(current_conversation_messages) = &res.current_conversation_messages
243        && let Some(last_message) = current_conversation_messages.last()
244    {
245        let initial_suggested_messages = if last_message.order_number == 0 {
246            // for the first message, get initial_suggested_messages
247            let initial_suggested_messages = chatbot_configuration
248                .initial_suggested_messages
249                .unwrap_or(vec![]);
250            // take 3 random elements
251            if initial_suggested_messages.len() > 3 {
252                let mut rng = rand::rng();
253                initial_suggested_messages
254                    .sample(&mut rng, 3)
255                    .cloned()
256                    .collect()
257            } else {
258                initial_suggested_messages
259            }
260        } else {
261            // for other messages, generate suggested messages
262            let course_description =
263                models::courses::get_course(&mut conn, chatbot_configuration.course_id)
264                    .await?
265                    .description;
266            let message_suggest_llm =
267                models::application_task_default_language_models::get_for_task(
268                    &mut conn,
269                    ApplicationTask::MessageSuggestion,
270                )
271                .await?;
272            headless_lms_chatbot::message_suggestion::generate_suggested_messages(
273                &app_conf,
274                message_suggest_llm,
275                current_conversation_messages,
276                chatbot_configuration.initial_suggested_messages,
277                &res.course_name,
278                course_description,
279            )
280            .await?
281        };
282
283        if !initial_suggested_messages.is_empty() {
284            headless_lms_models::chatbot_conversation_suggested_messages::insert_batch(
285                &mut conn,
286                &last_message.id,
287                initial_suggested_messages,
288            )
289            .await?;
290        }
291
292        let res = chatbot_conversations::get_current_conversation_info(
293            &mut conn,
294            user.id,
295            chatbot_configuration.id,
296        )
297        .await?;
298        return token.authorized_ok(web::Json(res));
299    }
300
301    token.authorized_ok(web::Json(res))
302}
303
304/**
305Add a route for each controller in this module.
306
307The name starts with an underline in order to appear before other functions in the module documentation.
308
309We add the routes by calling the route method instead of using the route annotations because this method preserves the function signatures for documentation.
310*/
311pub fn _add_routes(cfg: &mut ServiceConfig) {
312    cfg.route(
313        "/{chatbot_configuration_id}/conversations/{conversation_id}/send-message",
314        web::post().to(send_message),
315    )
316    .route(
317        "/{chatbot_configuration_id}/conversations/current",
318        web::get().to(current_conversation_info),
319    )
320    .route(
321        "/{chatbot_configuration_id}/conversations/new",
322        web::post().to(new_conversation),
323    )
324    .route(
325        "/default-for-course/{course_id}",
326        web::get().to(get_default_chatbot_configuration_for_course),
327    );
328}