Skip to main content

headless_lms_chatbot/
course_description_summary.rs

1use headless_lms_utils::{
2    json_schema_types::{
3        ArrayItem, ArrayProperty, JSONType, JsonItem, Schema, SchemaPropertyType,
4        string_array_property,
5    },
6    services::sisu::SisuDescriptions,
7};
8use indexmap::IndexMap;
9use std::collections::HashMap;
10
11use crate::{
12    azure_chatbot::azure::protocol::{
13        InputItem, LLMRequestParams, LLMRequestResponseFormatParam, NonThinkingParams,
14        ThinkingParams,
15    },
16    chatbot_error::chatbot_err,
17    llm_utils::{APIInputMessage, MessageContent, model_is_thinking, request_structured_json},
18    prelude::{ChatbotError, ChatbotErrorType, ChatbotResult},
19};
20use headless_lms_base::config::ApplicationConfiguration;
21use headless_lms_base::error::backend_error::BackendError;
22use headless_lms_models::{
23    application_task_default_language_models::TaskLMSpec,
24    chatbot_conversation_message_messages::MessageRole,
25};
26use utoipa::ToSchema;
27
28#[derive(serde::Serialize, serde::Deserialize, ToSchema, Debug)]
29pub struct SisuDescriptionResponse {
30    pub course_description: String,
31    pub audience: Vec<String>,
32    pub modules: Vec<Module>,
33}
34
35#[derive(serde::Serialize, serde::Deserialize, ToSchema, Debug)]
36pub struct Module {
37    pub course_code: String,
38    pub description: String,
39    pub prerequisites: Vec<String>,
40}
41
42/// Names this feature's structured output to Azure. The test-mode mock Azure API picks its canned
43/// answer for this feature by this name.
44pub const RESPONSE_FORMAT_NAME: &str = "LLMDescriptionResponse";
45
46/// The structured output format the description LLM is asked to answer in. Must stay in
47/// sync with [SisuDescriptionResponse].
48fn response_format() -> LLMRequestResponseFormatParam {
49    LLMRequestResponseFormatParam {
50        format_type: JSONType::JsonSchema,
51        name: RESPONSE_FORMAT_NAME.to_string(),
52        schema: Schema::strict_object(
53            IndexMap::from([
54                (
55                    "course_description".to_string(),
56                    SchemaPropertyType::Item(JsonItem {
57                        type_field: JSONType::String,
58                        description: None,
59                    }),
60                ),
61                ("audience".to_string(), string_array_property(None)),
62                (
63                    "modules".to_string(),
64                    SchemaPropertyType::ArrayProperty(ArrayProperty {
65                        type_field: JSONType::Array,
66                        description: None,
67                        items: ArrayItem::Schema(Schema::strict_object(
68                            IndexMap::from([
69                                (
70                                    "course_code".to_string(),
71                                    SchemaPropertyType::Item(JsonItem {
72                                        type_field: JSONType::String,
73                                        description: None,
74                                    }),
75                                ),
76                                (
77                                    "description".to_string(),
78                                    SchemaPropertyType::Item(JsonItem {
79                                        type_field: JSONType::String,
80                                        description: None,
81                                    }),
82                                ),
83                                ("prerequisites".to_string(), string_array_property(None)),
84                            ]),
85                            None,
86                        )),
87                    }),
88                ),
89            ]),
90            None,
91        ),
92        strict: true,
93    }
94}
95
96// You are given different type of information for an university course. There can exist multiple modules for the course which are differentiated by the module code as the key. Your task is to generate a single description combining information from all different modules but also generate module specific descriptions and prerequisites for each module. The prerequisites should be given in a list of individual requisites.
97
98const SYSTEM_PROMPT: &str = r#"
99Your task is to
1001. Generate a single general description combining the information from all the different modules behind the key "course_description".
1012. Behind the "audience" key you should create an array with suitable audience types as items on the list. By default this should always be just "everyone", unless the course material information truly specifies suitable audience types. Audience types should be general, for example "students" or "veterans". If you output more specific audience types also include "everyone" in the list unless it can be understood that the course is not for everyone. It is fine to mention some groups in addition to "everyone" that would just mean that the course works particularly well for those groups but everyone can take it. Please note that remarks about which study programme can choose this course are bureocratic university boilerplate and does not necessarily indicate the audience the course is meant for. Audience types like "Bachelor's degree students" are too specific.
1023. Behind the "modules" key, you will generate an array of items, where each item represents one module, and thus the array has as many items as there are module codes. Each module item inside the array will have three fields, "course_code", "description" and "prerequisites".
103  3.1 The "course_code" field will have the corresponing module code.
104  3.2 The "description" field will be a description summarized from all the information you are given on the specific module.
105  3.3 The "prerequisites" field will be an array, with each prerequisite differentiated as an item in the list.
106
107
108When generating the description:
109- Use the same language in the description that is used in the given information.
110- Use same style of writing as in the given information.
111- Ignore all the information that is not relevant for the course description.
112- Ignore all the html tags inside the given information.
113- When generating module descriptions don't use filler words such as 'this course', give only relevant information.
114
115Constraints:
116- Base the summarization only on the information given to you.
117- Only output the summarized description, nothing else.
118- The maximum length for the description is 100 words.
119- If there is only one module in the course, use exactly the same description for both course description and module description.
120
121Your output must follow the JSON schema exactly:
122{
123    "course_description": "...",
124    "audience": ["..."],
125    "modules": [
126        {
127            "course_code": "...",
128            "description": "...",
129            "prerequisites": ["...", "...", "..."]
130        }
131    ]
132}"#;
133
134pub const USER_PROMPT: &str = r#"Give description based on the given information."#;
135
136pub async fn generate_description(
137    app_config: &ApplicationConfiguration,
138    task_lm: TaskLMSpec,
139    sisu_course_info: HashMap<String, SisuDescriptions>,
140) -> ChatbotResult<SisuDescriptionResponse> {
141    let serialized_sisu_course_info = serde_json::to_string(&sisu_course_info)?;
142    let prompt: String = format!("{USER_PROMPT} Course information: {serialized_sisu_course_info}");
143
144    let system_prompt = APIInputMessage {
145        message_type: InputItem::Message {
146            role: MessageRole::System,
147            content: MessageContent::Text(SYSTEM_PROMPT.to_string()),
148        },
149    };
150
151    let user_prompt = APIInputMessage {
152        message_type: InputItem::Message {
153            role: MessageRole::User,
154            content: MessageContent::Text(prompt),
155        },
156    };
157
158    let (params, max_output_tokens) = if model_is_thinking(task_lm.model_type) {
159        (
160            LLMRequestParams::GPTThinking(ThinkingParams { reasoning: None }),
161            Some(7000),
162        )
163    } else {
164        (
165            LLMRequestParams::GPTNonThinking(NonThinkingParams {
166                temperature: None,
167                top_p: None,
168                frequency_penalty: None,
169                presence_penalty: None,
170            }),
171            Some(4000),
172        )
173    };
174
175    let descriptions: SisuDescriptionResponse = request_structured_json(
176        vec![system_prompt, user_prompt],
177        task_lm.model.to_owned(),
178        params,
179        max_output_tokens,
180        response_format(),
181        app_config,
182        || {
183            chatbot_err!(
184                SisuDescriptionError,
185                "Sisu description LLM returned an incorrectly formatted response.".to_string()
186            )
187        },
188    )
189    .await?;
190    Ok(descriptions)
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    /// Pins the JSON sent to Azure, not the Rust value, so that adding fields to the
198    /// shared schema types cannot change what this feature asks the LLM for.
199    #[test]
200    fn response_format_json_is_unchanged() {
201        let serialized =
202            serde_json::to_value(response_format()).expect("The response format serializes");
203        assert_eq!(
204            serialized,
205            serde_json::json!({
206                "type": "json_schema",
207                "name": "LLMDescriptionResponse",
208                "schema": {
209                    "type": "object",
210                    "properties": {
211                        "course_description": { "type": "string" },
212                        "audience": {
213                            "type": "array",
214                            "items": { "type": "string" }
215                        },
216                        "modules": {
217                            "type": "array",
218                            "items": {
219                                "type": "object",
220                                "properties": {
221                                    "course_code": { "type": "string" },
222                                    "description": { "type": "string" },
223                                    "prerequisites": {
224                                        "type": "array",
225                                        "items": { "type": "string" }
226                                    }
227                                },
228                                "required": ["course_code", "description", "prerequisites"],
229                                "additionalProperties": false
230                            }
231                        }
232                    },
233                    "required": ["course_description", "audience", "modules"],
234                    "additionalProperties": false
235                },
236                "strict": true
237            })
238        );
239    }
240}