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    pub embeddable: bool,
125}
126
127impl Default for ChatbotConfiguration {
128    fn default() -> Self {
129        Self {
130            id: Uuid::nil(),
131            created_at: Default::default(),
132            updated_at: Default::default(),
133            deleted_at: None,
134            course_id: Default::default(),
135            enabled_to_students: false,
136            chatbot_name: Default::default(),
137            model_id: Uuid::nil(),
138            prompt: Default::default(),
139            initial_message: Default::default(),
140            weekly_tokens_per_user: 20000 * 5,
141            daily_tokens_per_user: 20000,
142            max_output_tokens: 20_000,
143            temperature: 0.7,
144            top_p: 1.0,
145            frequency_penalty: 0.0,
146            presence_penalty: 0.0,
147            reasoning_effort: ReasoningEffortLevel::Medium,
148            verbosity: VerbosityLevel::Medium,
149            use_azure_search: true,
150            maintain_azure_search_index: true,
151            hide_citations: false,
152            use_semantic_reranking: false,
153            enabled_tool_categories: vec![
154                ToolCategory::CourseMaterial,
155                ToolCategory::CourseInfo,
156                ToolCategory::CourseCatalog,
157                ToolCategory::Interaction,
158            ],
159            default_chatbot: false,
160            suggest_next_messages: true,
161            initial_suggested_messages: None,
162            publicly_accessible: false,
163            embeddable: false,
164        }
165    }
166}
167
168#[derive(Clone, PartialEq, Deserialize, Serialize, Debug, ToSchema)]
169
170pub struct NewChatbotConf {
171    pub course_id: Option<Uuid>,
172    pub enabled_to_students: bool,
173    pub chatbot_name: String,
174    pub model_id: Uuid,
175    pub prompt: String,
176    pub initial_message: String,
177    pub weekly_tokens_per_user: i32,
178    pub daily_tokens_per_user: i32,
179    pub temperature: f32,
180    pub top_p: f32,
181    pub frequency_penalty: f32,
182    pub presence_penalty: f32,
183    pub max_output_tokens: i32,
184    pub verbosity: VerbosityLevel,
185    pub reasoning_effort: ReasoningEffortLevel,
186    pub use_azure_search: bool,
187    pub maintain_azure_search_index: bool,
188    pub hide_citations: bool,
189    pub use_semantic_reranking: bool,
190    pub enabled_tool_categories: Vec<ToolCategory>,
191    pub default_chatbot: bool,
192    pub chatbotconf_id: Option<Uuid>,
193    pub suggest_next_messages: bool,
194    pub initial_suggested_messages: Option<Vec<String>>,
195    pub publicly_accessible: bool,
196    #[serde(default)]
197    pub embeddable: bool,
198}
199
200impl Default for NewChatbotConf {
201    fn default() -> Self {
202        let chatbot_conf: ChatbotConfiguration = ChatbotConfiguration::default();
203        Self {
204            course_id: chatbot_conf.course_id,
205            enabled_to_students: chatbot_conf.enabled_to_students,
206            chatbot_name: chatbot_conf.chatbot_name,
207            model_id: chatbot_conf.model_id,
208            prompt: chatbot_conf.prompt,
209            initial_message: chatbot_conf.initial_message,
210            weekly_tokens_per_user: chatbot_conf.weekly_tokens_per_user,
211            daily_tokens_per_user: chatbot_conf.daily_tokens_per_user,
212            temperature: chatbot_conf.temperature,
213            top_p: chatbot_conf.top_p,
214            frequency_penalty: chatbot_conf.frequency_penalty,
215            presence_penalty: chatbot_conf.presence_penalty,
216            max_output_tokens: chatbot_conf.max_output_tokens,
217            verbosity: chatbot_conf.verbosity,
218            reasoning_effort: chatbot_conf.reasoning_effort,
219            use_azure_search: chatbot_conf.use_azure_search,
220            maintain_azure_search_index: chatbot_conf.maintain_azure_search_index,
221            hide_citations: chatbot_conf.hide_citations,
222            use_semantic_reranking: chatbot_conf.use_semantic_reranking,
223            enabled_tool_categories: chatbot_conf.enabled_tool_categories,
224            default_chatbot: chatbot_conf.default_chatbot,
225            chatbotconf_id: None,
226            suggest_next_messages: chatbot_conf.suggest_next_messages,
227            initial_suggested_messages: chatbot_conf.initial_suggested_messages,
228            publicly_accessible: chatbot_conf.publicly_accessible,
229            embeddable: chatbot_conf.embeddable,
230        }
231    }
232}
233
234impl From<ChatbotConfiguration> for NewChatbotConf {
235    fn from(v: ChatbotConfiguration) -> Self {
236        Self {
237            course_id: v.course_id,
238            enabled_to_students: v.enabled_to_students,
239            chatbot_name: v.chatbot_name,
240            model_id: v.model_id,
241            prompt: v.prompt,
242            initial_message: v.initial_message,
243            weekly_tokens_per_user: v.weekly_tokens_per_user,
244            daily_tokens_per_user: v.daily_tokens_per_user,
245            temperature: v.temperature,
246            top_p: v.top_p,
247            frequency_penalty: v.frequency_penalty,
248            presence_penalty: v.presence_penalty,
249            max_output_tokens: v.max_output_tokens,
250            verbosity: v.verbosity,
251            reasoning_effort: v.reasoning_effort,
252            use_azure_search: v.use_azure_search,
253            maintain_azure_search_index: v.maintain_azure_search_index,
254            hide_citations: v.hide_citations,
255            use_semantic_reranking: v.use_semantic_reranking,
256            enabled_tool_categories: v.enabled_tool_categories,
257            default_chatbot: v.default_chatbot,
258            chatbotconf_id: Some(v.id),
259            suggest_next_messages: v.suggest_next_messages,
260            initial_suggested_messages: v.initial_suggested_messages,
261            publicly_accessible: v.publicly_accessible,
262            embeddable: v.embeddable,
263        }
264    }
265}
266
267/// Minimum `max_output_tokens` allowed for a configuration. Too small a budget cannot produce a
268/// usable response — and with reasoning models the hidden reasoning tokens are spent from the same
269/// budget, so the floor needs to leave room for the actual answer either way.
270const MIN_MAX_OUTPUT_TOKENS: i32 = 10_000;
271
272/// Rejects configurations whose `max_output_tokens` is too small to produce a usable response.
273fn validate_max_output_tokens(input: &NewChatbotConf) -> ModelResult<()> {
274    if input.max_output_tokens < MIN_MAX_OUTPUT_TOKENS {
275        return Err(model_err!(
276            PreconditionFailed,
277            format!("max_output_tokens must be at least {MIN_MAX_OUTPUT_TOKENS}.")
278        ));
279    }
280    Ok(())
281}
282
283/// Whether the configuration may use Azure search, which needs a course: the search index it
284/// queries is built per course, so a configuration without one has nothing to search.
285fn azure_search_allowed(input: &NewChatbotConf, course_id: Option<Uuid>) -> bool {
286    input.use_azure_search && course_id.is_some()
287}
288
289/// Dedupes and sorts into [`ToolCategory::ALL`] order, so the stored array is canonical and two
290/// equal sets (e.g. compared by the `edit_chatbot` controller) compare equal regardless of the
291/// order the caller submitted them in.
292pub fn normalized_tool_categories(input: &[ToolCategory]) -> Vec<ToolCategory> {
293    ToolCategory::ALL
294        .into_iter()
295        .filter(|category| input.contains(category))
296        .collect()
297}
298
299pub async fn get_by_id(conn: &mut PgConnection, id: Uuid) -> ModelResult<ChatbotConfiguration> {
300    let res = sqlx::query_as!(
301        ChatbotConfiguration,
302        r#"
303SELECT *
304FROM chatbot_configurations
305WHERE id = $1
306AND deleted_at IS NULL
307        "#,
308        id
309    )
310    .fetch_one(conn)
311    .await?;
312    Ok(res)
313}
314
315pub async fn insert(
316    conn: &mut PgConnection,
317    pkey_policy: PKeyPolicy<Uuid>,
318    input: NewChatbotConf,
319) -> ModelResult<ChatbotConfiguration> {
320    validate_max_output_tokens(&input)?;
321    let use_azure_search = azure_search_allowed(&input, input.course_id);
322    let maintain_azure_search_index = use_azure_search;
323    let enabled_tool_categories = normalized_tool_categories(&input.enabled_tool_categories);
324    let res = sqlx::query_as!(
325        ChatbotConfiguration,
326        r#"
327INSERT INTO chatbot_configurations (
328    id,
329    course_id,
330    enabled_to_students,
331    chatbot_name,
332    model_id,
333    prompt,
334    initial_message,
335    weekly_tokens_per_user,
336    daily_tokens_per_user,
337    temperature,
338    top_p,
339    hide_citations,
340    frequency_penalty,
341    presence_penalty,
342    max_output_tokens,
343    verbosity,
344    reasoning_effort,
345    use_azure_search,
346    enabled_tool_categories,
347    maintain_azure_search_index,
348    default_chatbot,
349    suggest_next_messages,
350    initial_suggested_messages,
351    publicly_accessible,
352    embeddable
353  )
354VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25)
355RETURNING *
356        "#,
357        pkey_policy.into_uuid(),
358        input.course_id,
359        input.enabled_to_students,
360        input.chatbot_name,
361        input.model_id,
362        input.prompt,
363        input.initial_message,
364        input.weekly_tokens_per_user,
365        input.daily_tokens_per_user,
366        input.temperature,
367        input.top_p,
368        input.hide_citations,
369        input.frequency_penalty,
370        input.presence_penalty,
371        input.max_output_tokens,
372        input.verbosity as VerbosityLevel,
373        input.reasoning_effort as ReasoningEffortLevel,
374        use_azure_search,
375        &enabled_tool_categories as &[ToolCategory],
376        maintain_azure_search_index,
377        input.default_chatbot,
378        input.suggest_next_messages,
379        input.initial_suggested_messages.as_deref(),
380        input.publicly_accessible,
381        input.embeddable
382    )
383    .fetch_one(conn)
384    .await?;
385    Ok(res)
386}
387
388pub async fn edit(
389    conn: &mut PgConnection,
390    input: NewChatbotConf,
391    chatbot_configuration_id: Uuid,
392) -> ModelResult<ChatbotConfiguration> {
393    validate_max_output_tokens(&input)?;
394    // The course the configuration is already attached to, not the one the caller sent: `edit`
395    // never moves a configuration between courses.
396    let course_id = get_by_id(conn, chatbot_configuration_id).await?.course_id;
397    let use_azure_search = azure_search_allowed(&input, course_id);
398    let enabled_tool_categories = normalized_tool_categories(&input.enabled_tool_categories);
399    let res = sqlx::query_as!(
400        ChatbotConfiguration,
401        r#"
402UPDATE chatbot_configurations
403SET
404    enabled_to_students = $1,
405    chatbot_name = $2,
406    prompt = $3,
407    initial_message = $4,
408    weekly_tokens_per_user = $5,
409    daily_tokens_per_user = $6,
410    temperature = $7,
411    top_p = $8,
412    frequency_penalty = $9,
413    presence_penalty = $10,
414    max_output_tokens = $11,
415    use_azure_search = $12,
416    maintain_azure_search_index = $13,
417    hide_citations = $14,
418    use_semantic_reranking = $15,
419    default_chatbot = $16,
420    model_id = $17,
421    verbosity = $18,
422    reasoning_effort = $19,
423    enabled_tool_categories = $20,
424    suggest_next_messages = $21,
425    initial_suggested_messages = $22,
426    publicly_accessible = $23,
427    embeddable = $24
428WHERE id = $25
429    AND deleted_at IS NULL
430RETURNING *
431"#,
432        input.enabled_to_students,
433        input.chatbot_name,
434        input.prompt,
435        input.initial_message,
436        input.weekly_tokens_per_user,
437        input.daily_tokens_per_user,
438        input.temperature,
439        input.top_p,
440        input.frequency_penalty,
441        input.presence_penalty,
442        input.max_output_tokens,
443        use_azure_search,
444        use_azure_search,
445        input.hide_citations,
446        input.use_semantic_reranking,
447        input.default_chatbot,
448        input.model_id,
449        input.verbosity as VerbosityLevel,
450        input.reasoning_effort as ReasoningEffortLevel,
451        &enabled_tool_categories as &[ToolCategory],
452        input.suggest_next_messages,
453        input.initial_suggested_messages.as_deref(),
454        input.publicly_accessible,
455        input.embeddable,
456        chatbot_configuration_id,
457    )
458    .fetch_one(conn)
459    .await?;
460    Ok(res)
461}
462
463pub async fn delete(conn: &mut PgConnection, chatbot_configuration_id: Uuid) -> ModelResult<()> {
464    sqlx::query!(
465        r#"
466UPDATE chatbot_configurations
467SET deleted_at = now()
468WHERE id = $1
469AND deleted_at IS NULL
470        "#,
471        chatbot_configuration_id
472    )
473    .execute(conn)
474    .await?;
475    Ok(())
476}
477
478pub async fn get_for_course(
479    conn: &mut PgConnection,
480    course_id: Uuid,
481) -> ModelResult<Vec<ChatbotConfiguration>> {
482    let res = sqlx::query_as!(
483        ChatbotConfiguration,
484        r#"
485SELECT *
486FROM chatbot_configurations
487WHERE course_id = $1
488AND deleted_at IS NULL
489"#,
490        course_id
491    )
492    .fetch_all(conn)
493    .await?;
494    Ok(res)
495}
496
497pub async fn get_enabled_nondefault_for_course(
498    conn: &mut PgConnection,
499    course_id: Uuid,
500) -> ModelResult<Vec<ChatbotConfiguration>> {
501    let res = sqlx::query_as!(
502        ChatbotConfiguration,
503        r#"
504SELECT *
505FROM chatbot_configurations
506WHERE course_id = $1
507AND default_chatbot IS false
508AND enabled_to_students IS true
509AND deleted_at IS NULL
510"#,
511        course_id
512    )
513    .fetch_all(conn)
514    .await?;
515    Ok(res)
516}
517
518pub async fn get_for_azure_search_maintenance(
519    conn: &mut PgConnection,
520) -> ModelResult<Vec<ChatbotConfiguration>> {
521    let res = sqlx::query_as!(
522        ChatbotConfiguration,
523        r#"
524SELECT *
525FROM chatbot_configurations
526WHERE maintain_azure_search_index = true
527AND deleted_at IS NULL
528"#,
529    )
530    .fetch_all(conn)
531    .await?;
532    Ok(res)
533}
534
535pub async fn remove_default_chatbot_from_course(
536    conn: &mut PgConnection,
537    course_id: Uuid,
538) -> ModelResult<()> {
539    sqlx::query!(
540        r#"
541UPDATE chatbot_configurations
542SET default_chatbot = false
543WHERE course_id = $1
544AND default_chatbot = true
545AND deleted_at IS NULL
546"#,
547        course_id,
548    )
549    .execute(conn)
550    .await?;
551    Ok(())
552}
553
554pub async fn set_default_chatbot_for_course(
555    conn: &mut PgConnection,
556    chatbot_configuration_id: Uuid,
557) -> ModelResult<ChatbotConfiguration> {
558    let res = sqlx::query_as!(
559        ChatbotConfiguration,
560        r#"
561UPDATE chatbot_configurations
562SET default_chatbot = TRUE
563WHERE id = $1
564  AND deleted_at IS NULL
565RETURNING *
566"#,
567        chatbot_configuration_id,
568    )
569    .fetch_one(conn)
570    .await?;
571    Ok(res)
572}
573
574pub async fn get_all_chatbots(conn: &mut PgConnection) -> ModelResult<Vec<ChatbotConfiguration>> {
575    let res = sqlx::query_as!(
576        ChatbotConfiguration,
577        r#"
578    SELECT *
579    FROM chatbot_configurations
580    WHERE deleted_at IS NULL
581    "#,
582    )
583    .fetch_all(conn)
584    .await?;
585    Ok(res)
586}