Skip to main content

headless_lms_models/
chatbot_configurations.rs

1use crate::prelude::*;
2use utoipa::ToSchema;
3
4#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
5#[sqlx(type_name = "reasoning_effort_level", rename_all = "snake_case")]
6#[serde(rename_all = "snake_case")]
7pub enum ReasoningEffortLevel {
8    None,
9    Minimal,
10    Low,
11    Medium,
12    High,
13    Xhigh,
14}
15
16#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
17#[sqlx(type_name = "verbosity_level", rename_all = "snake_case")]
18#[serde(rename_all = "snake_case")]
19pub enum VerbosityLevel {
20    Low,
21    Medium,
22    High,
23}
24
25/// The UI/authorization grouping a [`ToolCategory`] belongs to. Derived from the leaf, never
26/// stored: the database and the wire format only ever carry [`ToolCategory`] values.
27#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
28#[serde(rename_all = "snake_case")]
29pub enum ToolCategoryGroup {
30    CourseAssistance,
31    CourseDiscovery,
32    Interaction,
33    AdminSupport,
34}
35
36/// A category of chatbot tools a configuration can choose to offer the LLM. Independent of the
37/// chatbot crate's per-tool `ToolPermission` check: a category answers "does this chatbot offer
38/// this kind of tool", not "may this caller use it".
39#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, Type, ToSchema)]
40#[sqlx(type_name = "chatbot_tool_category", rename_all = "snake_case")]
41#[serde(rename_all = "snake_case")]
42pub enum ToolCategory {
43    CourseMaterial,
44    CourseInfo,
45    CourseCatalog,
46    Interaction,
47    AdminSupportAccounts,
48    AdminSupportCourses,
49    AdminSupportLearningProgress,
50    AdminSupportAcademicIntegrity,
51}
52
53impl ToolCategory {
54    /// Canonical order for the UI, seeds, and normalization -- keep in sync with the enum
55    /// definition above and with the `chatbot_tool_category` Postgres enum's value order.
56    pub const ALL: [ToolCategory; 8] = [
57        ToolCategory::CourseMaterial,
58        ToolCategory::CourseInfo,
59        ToolCategory::CourseCatalog,
60        ToolCategory::Interaction,
61        ToolCategory::AdminSupportAccounts,
62        ToolCategory::AdminSupportCourses,
63        ToolCategory::AdminSupportLearningProgress,
64        ToolCategory::AdminSupportAcademicIntegrity,
65    ];
66
67    pub const fn group(self) -> ToolCategoryGroup {
68        match self {
69            ToolCategory::CourseMaterial | ToolCategory::CourseInfo => {
70                ToolCategoryGroup::CourseAssistance
71            }
72            ToolCategory::CourseCatalog => ToolCategoryGroup::CourseDiscovery,
73            ToolCategory::Interaction => ToolCategoryGroup::Interaction,
74            ToolCategory::AdminSupportAccounts
75            | ToolCategory::AdminSupportCourses
76            | ToolCategory::AdminSupportLearningProgress
77            | ToolCategory::AdminSupportAcademicIntegrity => ToolCategoryGroup::AdminSupport,
78        }
79    }
80
81    pub const fn requires_global_admin(self) -> bool {
82        matches!(self.group(), ToolCategoryGroup::AdminSupport)
83    }
84}
85
86#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
87pub struct CreateChatbotRequest {
88    pub name: String,
89    pub course_id: Option<Uuid>,
90    pub purpose: String,
91    pub skip_azure_stuff: bool,
92}
93
94#[derive(Clone, PartialEq, Deserialize, Serialize, ToSchema)]
95pub struct ChatbotConfiguration {
96    pub id: Uuid,
97    pub created_at: DateTime<Utc>,
98    pub updated_at: DateTime<Utc>,
99    pub deleted_at: Option<DateTime<Utc>>,
100    pub course_id: Option<Uuid>,
101    pub enabled_to_students: bool,
102    pub chatbot_name: String,
103    pub model_id: Uuid,
104    pub prompt: String,
105    pub initial_message: String,
106    pub weekly_tokens_per_user: i32,
107    pub daily_tokens_per_user: i32,
108    pub temperature: f32,
109    pub top_p: f32,
110    pub frequency_penalty: f32,
111    pub presence_penalty: f32,
112    pub max_output_tokens: i32,
113    pub verbosity: VerbosityLevel,
114    pub reasoning_effort: ReasoningEffortLevel,
115    pub use_azure_search: bool,
116    pub maintain_azure_search_index: bool,
117    pub hide_citations: bool,
118    pub use_semantic_reranking: bool,
119    pub enabled_tool_categories: Vec<ToolCategory>,
120    pub default_chatbot: bool,
121    pub suggest_next_messages: bool,
122    pub initial_suggested_messages: Option<Vec<String>>,
123    pub publicly_accessible: bool,
124}
125
126impl Default for ChatbotConfiguration {
127    fn default() -> Self {
128        Self {
129            id: Uuid::nil(),
130            created_at: Default::default(),
131            updated_at: Default::default(),
132            deleted_at: None,
133            course_id: Default::default(),
134            enabled_to_students: false,
135            chatbot_name: Default::default(),
136            model_id: Uuid::nil(),
137            prompt: Default::default(),
138            initial_message: Default::default(),
139            weekly_tokens_per_user: 20000 * 5,
140            daily_tokens_per_user: 20000,
141            max_output_tokens: 20_000,
142            temperature: 0.7,
143            top_p: 1.0,
144            frequency_penalty: 0.0,
145            presence_penalty: 0.0,
146            reasoning_effort: ReasoningEffortLevel::Medium,
147            verbosity: VerbosityLevel::Medium,
148            use_azure_search: true,
149            maintain_azure_search_index: true,
150            hide_citations: false,
151            use_semantic_reranking: false,
152            enabled_tool_categories: vec![
153                ToolCategory::CourseMaterial,
154                ToolCategory::CourseInfo,
155                ToolCategory::CourseCatalog,
156                ToolCategory::Interaction,
157            ],
158            default_chatbot: false,
159            suggest_next_messages: true,
160            initial_suggested_messages: None,
161            publicly_accessible: false,
162        }
163    }
164}
165
166#[derive(Clone, PartialEq, Deserialize, Serialize, Debug, ToSchema)]
167
168pub struct NewChatbotConf {
169    pub course_id: Option<Uuid>,
170    pub enabled_to_students: bool,
171    pub chatbot_name: String,
172    pub model_id: Uuid,
173    pub prompt: String,
174    pub initial_message: String,
175    pub weekly_tokens_per_user: i32,
176    pub daily_tokens_per_user: i32,
177    pub temperature: f32,
178    pub top_p: f32,
179    pub frequency_penalty: f32,
180    pub presence_penalty: f32,
181    pub max_output_tokens: i32,
182    pub verbosity: VerbosityLevel,
183    pub reasoning_effort: ReasoningEffortLevel,
184    pub use_azure_search: bool,
185    pub maintain_azure_search_index: bool,
186    pub hide_citations: bool,
187    pub use_semantic_reranking: bool,
188    pub enabled_tool_categories: Vec<ToolCategory>,
189    pub default_chatbot: bool,
190    pub chatbotconf_id: Option<Uuid>,
191    pub suggest_next_messages: bool,
192    pub initial_suggested_messages: Option<Vec<String>>,
193    pub publicly_accessible: bool,
194}
195
196impl Default for NewChatbotConf {
197    fn default() -> Self {
198        let chatbot_conf: ChatbotConfiguration = ChatbotConfiguration::default();
199        Self {
200            course_id: chatbot_conf.course_id,
201            enabled_to_students: chatbot_conf.enabled_to_students,
202            chatbot_name: chatbot_conf.chatbot_name,
203            model_id: chatbot_conf.model_id,
204            prompt: chatbot_conf.prompt,
205            initial_message: chatbot_conf.initial_message,
206            weekly_tokens_per_user: chatbot_conf.weekly_tokens_per_user,
207            daily_tokens_per_user: chatbot_conf.daily_tokens_per_user,
208            temperature: chatbot_conf.temperature,
209            top_p: chatbot_conf.top_p,
210            frequency_penalty: chatbot_conf.frequency_penalty,
211            presence_penalty: chatbot_conf.presence_penalty,
212            max_output_tokens: chatbot_conf.max_output_tokens,
213            verbosity: chatbot_conf.verbosity,
214            reasoning_effort: chatbot_conf.reasoning_effort,
215            use_azure_search: chatbot_conf.use_azure_search,
216            maintain_azure_search_index: chatbot_conf.maintain_azure_search_index,
217            hide_citations: chatbot_conf.hide_citations,
218            use_semantic_reranking: chatbot_conf.use_semantic_reranking,
219            enabled_tool_categories: chatbot_conf.enabled_tool_categories,
220            default_chatbot: chatbot_conf.default_chatbot,
221            chatbotconf_id: None,
222            suggest_next_messages: chatbot_conf.suggest_next_messages,
223            initial_suggested_messages: chatbot_conf.initial_suggested_messages,
224            publicly_accessible: chatbot_conf.publicly_accessible,
225        }
226    }
227}
228
229impl From<ChatbotConfiguration> for NewChatbotConf {
230    fn from(v: ChatbotConfiguration) -> Self {
231        Self {
232            course_id: v.course_id,
233            enabled_to_students: v.enabled_to_students,
234            chatbot_name: v.chatbot_name,
235            model_id: v.model_id,
236            prompt: v.prompt,
237            initial_message: v.initial_message,
238            weekly_tokens_per_user: v.weekly_tokens_per_user,
239            daily_tokens_per_user: v.daily_tokens_per_user,
240            temperature: v.temperature,
241            top_p: v.top_p,
242            frequency_penalty: v.frequency_penalty,
243            presence_penalty: v.presence_penalty,
244            max_output_tokens: v.max_output_tokens,
245            verbosity: v.verbosity,
246            reasoning_effort: v.reasoning_effort,
247            use_azure_search: v.use_azure_search,
248            maintain_azure_search_index: v.maintain_azure_search_index,
249            hide_citations: v.hide_citations,
250            use_semantic_reranking: v.use_semantic_reranking,
251            enabled_tool_categories: v.enabled_tool_categories,
252            default_chatbot: v.default_chatbot,
253            chatbotconf_id: Some(v.id),
254            suggest_next_messages: v.suggest_next_messages,
255            initial_suggested_messages: v.initial_suggested_messages,
256            publicly_accessible: v.publicly_accessible,
257        }
258    }
259}
260
261/// Minimum `max_output_tokens` allowed for a configuration. Too small a budget cannot produce a
262/// usable response — and with reasoning models the hidden reasoning tokens are spent from the same
263/// budget, so the floor needs to leave room for the actual answer either way.
264const MIN_MAX_OUTPUT_TOKENS: i32 = 10_000;
265
266/// Rejects configurations whose `max_output_tokens` is too small to produce a usable response.
267fn validate_max_output_tokens(input: &NewChatbotConf) -> ModelResult<()> {
268    if input.max_output_tokens < MIN_MAX_OUTPUT_TOKENS {
269        return Err(model_err!(
270            PreconditionFailed,
271            format!("max_output_tokens must be at least {MIN_MAX_OUTPUT_TOKENS}.")
272        ));
273    }
274    Ok(())
275}
276
277/// Whether the configuration may use Azure search, which needs a course: the search index it
278/// queries is built per course, so a configuration without one has nothing to search.
279fn azure_search_allowed(input: &NewChatbotConf, course_id: Option<Uuid>) -> bool {
280    input.use_azure_search && course_id.is_some()
281}
282
283/// Dedupes and sorts into [`ToolCategory::ALL`] order, so the stored array is canonical and two
284/// equal sets (e.g. compared by the `edit_chatbot` controller) compare equal regardless of the
285/// order the caller submitted them in.
286pub fn normalized_tool_categories(input: &[ToolCategory]) -> Vec<ToolCategory> {
287    ToolCategory::ALL
288        .into_iter()
289        .filter(|category| input.contains(category))
290        .collect()
291}
292
293pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ChatbotConfiguration> {
294    let res = sqlx::query_as!(
295        ChatbotConfiguration,
296        r#"
297SELECT *
298FROM chatbot_configurations
299WHERE id = $1
300AND deleted_at IS NULL
301        "#,
302        id
303    )
304    .fetch_one(conn)
305    .await?;
306    Ok(res)
307}
308
309pub async fn insert(
310    conn: &mut PgConnection,
311    pkey_policy: PKeyPolicy<Uuid>,
312    input: NewChatbotConf,
313) -> ModelResult<ChatbotConfiguration> {
314    validate_max_output_tokens(&input)?;
315    let use_azure_search = azure_search_allowed(&input, input.course_id);
316    let maintain_azure_search_index = use_azure_search;
317    let enabled_tool_categories = normalized_tool_categories(&input.enabled_tool_categories);
318    let res = sqlx::query_as!(
319        ChatbotConfiguration,
320        r#"
321INSERT INTO chatbot_configurations (
322    id,
323    course_id,
324    enabled_to_students,
325    chatbot_name,
326    model_id,
327    prompt,
328    initial_message,
329    weekly_tokens_per_user,
330    daily_tokens_per_user,
331    temperature,
332    top_p,
333    hide_citations,
334    frequency_penalty,
335    presence_penalty,
336    max_output_tokens,
337    verbosity,
338    reasoning_effort,
339    use_azure_search,
340    enabled_tool_categories,
341    maintain_azure_search_index,
342    default_chatbot,
343    suggest_next_messages,
344    initial_suggested_messages,
345    publicly_accessible
346  )
347VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
348RETURNING *
349        "#,
350        pkey_policy.into_uuid(),
351        input.course_id,
352        input.enabled_to_students,
353        input.chatbot_name,
354        input.model_id,
355        input.prompt,
356        input.initial_message,
357        input.weekly_tokens_per_user,
358        input.daily_tokens_per_user,
359        input.temperature,
360        input.top_p,
361        input.hide_citations,
362        input.frequency_penalty,
363        input.presence_penalty,
364        input.max_output_tokens,
365        input.verbosity as VerbosityLevel,
366        input.reasoning_effort as ReasoningEffortLevel,
367        use_azure_search,
368        &enabled_tool_categories as &[ToolCategory],
369        maintain_azure_search_index,
370        input.default_chatbot,
371        input.suggest_next_messages,
372        input.initial_suggested_messages.as_deref(),
373        input.publicly_accessible
374    )
375    .fetch_one(conn)
376    .await?;
377    Ok(res)
378}
379
380pub async fn edit(
381    conn: &mut PgConnection,
382    input: NewChatbotConf,
383    chatbot_configuration_id: Uuid,
384) -> ModelResult<ChatbotConfiguration> {
385    validate_max_output_tokens(&input)?;
386    // The course the configuration is already attached to, not the one the caller sent: `edit`
387    // never moves a configuration between courses.
388    let course_id = get_by_id(conn, chatbot_configuration_id).await?.course_id;
389    let use_azure_search = azure_search_allowed(&input, course_id);
390    let enabled_tool_categories = normalized_tool_categories(&input.enabled_tool_categories);
391    let res = sqlx::query_as!(
392        ChatbotConfiguration,
393        r#"
394UPDATE chatbot_configurations
395SET
396    enabled_to_students = $1,
397    chatbot_name = $2,
398    prompt = $3,
399    initial_message = $4,
400    weekly_tokens_per_user = $5,
401    daily_tokens_per_user = $6,
402    temperature = $7,
403    top_p = $8,
404    frequency_penalty = $9,
405    presence_penalty = $10,
406    max_output_tokens = $11,
407    use_azure_search = $12,
408    maintain_azure_search_index = $13,
409    hide_citations = $14,
410    use_semantic_reranking = $15,
411    default_chatbot = $16,
412    model_id = $17,
413    verbosity = $18,
414    reasoning_effort = $19,
415    enabled_tool_categories = $20,
416    suggest_next_messages = $21,
417    initial_suggested_messages = $22,
418    publicly_accessible = $23
419WHERE id = $24
420    AND deleted_at IS NULL
421RETURNING *
422"#,
423        input.enabled_to_students,
424        input.chatbot_name,
425        input.prompt,
426        input.initial_message,
427        input.weekly_tokens_per_user,
428        input.daily_tokens_per_user,
429        input.temperature,
430        input.top_p,
431        input.frequency_penalty,
432        input.presence_penalty,
433        input.max_output_tokens,
434        use_azure_search,
435        use_azure_search,
436        input.hide_citations,
437        input.use_semantic_reranking,
438        input.default_chatbot,
439        input.model_id,
440        input.verbosity as VerbosityLevel,
441        input.reasoning_effort as ReasoningEffortLevel,
442        &enabled_tool_categories as &[ToolCategory],
443        input.suggest_next_messages,
444        input.initial_suggested_messages.as_deref(),
445        input.publicly_accessible,
446        chatbot_configuration_id,
447    )
448    .fetch_one(conn)
449    .await?;
450    Ok(res)
451}
452
453pub async fn delete(conn: &mut PgConnection, chatbot_configuration_id: Uuid) -> ModelResult<()> {
454    sqlx::query!(
455        r#"
456UPDATE chatbot_configurations
457SET deleted_at = now()
458WHERE id = $1
459AND deleted_at IS NULL
460        "#,
461        chatbot_configuration_id
462    )
463    .execute(conn)
464    .await?;
465    Ok(())
466}
467
468pub async fn get_for_course(
469    conn: &mut PgConnection,
470    course_id: Uuid,
471) -> ModelResult<Vec<ChatbotConfiguration>> {
472    let res = sqlx::query_as!(
473        ChatbotConfiguration,
474        r#"
475SELECT *
476FROM chatbot_configurations
477WHERE course_id = $1
478AND deleted_at IS NULL
479"#,
480        course_id
481    )
482    .fetch_all(conn)
483    .await?;
484    Ok(res)
485}
486
487pub async fn get_enabled_nondefault_for_course(
488    conn: &mut PgConnection,
489    course_id: Uuid,
490) -> ModelResult<Vec<ChatbotConfiguration>> {
491    let res = sqlx::query_as!(
492        ChatbotConfiguration,
493        r#"
494SELECT *
495FROM chatbot_configurations
496WHERE course_id = $1
497AND default_chatbot IS false
498AND enabled_to_students IS true
499AND deleted_at IS NULL
500"#,
501        course_id
502    )
503    .fetch_all(conn)
504    .await?;
505    Ok(res)
506}
507
508pub async fn get_for_azure_search_maintenance(
509    conn: &mut PgConnection,
510) -> ModelResult<Vec<ChatbotConfiguration>> {
511    let res = sqlx::query_as!(
512        ChatbotConfiguration,
513        r#"
514SELECT *
515FROM chatbot_configurations
516WHERE maintain_azure_search_index = true
517AND deleted_at IS NULL
518"#,
519    )
520    .fetch_all(conn)
521    .await?;
522    Ok(res)
523}
524
525pub async fn remove_default_chatbot_from_course(
526    conn: &mut PgConnection,
527    course_id: Uuid,
528) -> ModelResult<()> {
529    sqlx::query!(
530        r#"
531UPDATE chatbot_configurations
532SET default_chatbot = false
533WHERE course_id = $1
534AND default_chatbot = true
535AND deleted_at IS NULL
536"#,
537        course_id,
538    )
539    .execute(conn)
540    .await?;
541    Ok(())
542}
543
544pub async fn set_default_chatbot_for_course(
545    conn: &mut PgConnection,
546    chatbot_configuration_id: Uuid,
547) -> ModelResult<ChatbotConfiguration> {
548    let res = sqlx::query_as!(
549        ChatbotConfiguration,
550        r#"
551UPDATE chatbot_configurations
552SET default_chatbot = TRUE
553WHERE id = $1
554  AND deleted_at IS NULL
555RETURNING *
556"#,
557        chatbot_configuration_id,
558    )
559    .fetch_one(conn)
560    .await?;
561    Ok(res)
562}
563
564pub async fn get_all_chatbots(conn: &mut PgConnection) -> ModelResult<Vec<ChatbotConfiguration>> {
565    let res = sqlx::query_as!(
566        ChatbotConfiguration,
567        r#"
568    SELECT *
569    FROM chatbot_configurations
570    WHERE deleted_at IS NULL
571    "#,
572    )
573    .fetch_all(conn)
574    .await?;
575    Ok(res)
576}