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::JsonSchema {
50        name: RESPONSE_FORMAT_NAME.to_string(),
51        schema: Schema::strict_object(
52            IndexMap::from([
53                (
54                    "course_description".to_string(),
55                    SchemaPropertyType::Item(JsonItem {
56                        type_field: JSONType::String,
57                        description: None,
58                    }),
59                ),
60                ("audience".to_string(), string_array_property(None)),
61                (
62                    "modules".to_string(),
63                    SchemaPropertyType::ArrayProperty(ArrayProperty {
64                        type_field: JSONType::Array,
65                        description: None,
66                        items: ArrayItem::Schema(Schema::strict_object(
67                            IndexMap::from([
68                                (
69                                    "course_code".to_string(),
70                                    SchemaPropertyType::Item(JsonItem {
71                                        type_field: JSONType::String,
72                                        description: None,
73                                    }),
74                                ),
75                                (
76                                    "description".to_string(),
77                                    SchemaPropertyType::Item(JsonItem {
78                                        type_field: JSONType::String,
79                                        description: None,
80                                    }),
81                                ),
82                                ("prerequisites".to_string(), string_array_property(None)),
83                            ]),
84                            None,
85                        )),
86                    }),
87                ),
88            ]),
89            None,
90        ),
91        strict: true,
92    }
93}
94
95// 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.
96
97const SYSTEM_PROMPT: &str = r#"
98Your task is to
991. Generate a single general description combining the information from all the different modules behind the key "course_description".
1002. 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.
1013. 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".
102  3.1 The "course_code" field will have the corresponing module code.
103  3.2 The "description" field will be a description summarized from all the information you are given on the specific module.
104  3.3 The "prerequisites" field will be an array, with each prerequisite differentiated as an item in the list.
105
106
107When generating the description:
108- Use the same language in the description that is used in the given information.
109- Use same style of writing as in the given information.
110- Ignore all the information that is not relevant for the course description.
111- Ignore all the html tags inside the given information.
112- When generating module descriptions don't use filler words such as 'this course', give only relevant information.
113
114Constraints:
115- Base the summarization only on the information given to you.
116- Only output the summarized description, nothing else.
117- The maximum length for the description is 100 words.
118- If there is only one module in the course, use exactly the same description for both course description and module description.
119
120Your output must follow the JSON schema exactly:
121{
122    "course_description": "...",
123    "audience": ["..."],
124    "modules": [
125        {
126            "course_code": "...",
127            "description": "...",
128            "prerequisites": ["...", "...", "..."]
129        }
130    ]
131}"#;
132
133pub const USER_PROMPT: &str = r#"Give description based on the given information."#;
134
135pub async fn generate_description(
136    app_config: &ApplicationConfiguration,
137    task_lm: TaskLMSpec,
138    sisu_course_info: HashMap<String, SisuDescriptions>,
139) -> ChatbotResult<SisuDescriptionResponse> {
140    let serialized_sisu_course_info = serde_json::to_string(&sisu_course_info)?;
141    let prompt: String = format!("{USER_PROMPT} Course information: {serialized_sisu_course_info}");
142
143    let system_prompt = APIInputMessage {
144        message_type: InputItem::Message {
145            role: MessageRole::System,
146            content: MessageContent::Text(SYSTEM_PROMPT.to_string()),
147        },
148    };
149
150    let user_prompt = APIInputMessage {
151        message_type: InputItem::Message {
152            role: MessageRole::User,
153            content: MessageContent::Text(prompt),
154        },
155    };
156
157    let (params, max_output_tokens) = if model_is_thinking(task_lm.model_type) {
158        (
159            LLMRequestParams::GPTThinking(ThinkingParams { reasoning: None }),
160            Some(7000),
161        )
162    } else {
163        (
164            LLMRequestParams::GPTNonThinking(NonThinkingParams {
165                temperature: None,
166                top_p: None,
167                frequency_penalty: None,
168                presence_penalty: None,
169            }),
170            Some(4000),
171        )
172    };
173
174    let descriptions: SisuDescriptionResponse = request_structured_json(
175        vec![system_prompt, user_prompt],
176        task_lm.model.to_owned(),
177        params,
178        max_output_tokens,
179        response_format(),
180        app_config,
181        || {
182            chatbot_err!(
183                SisuDescriptionError,
184                "Sisu description LLM returned an incorrectly formatted response.".to_string()
185            )
186        },
187    )
188    .await?;
189    Ok(descriptions)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    /// Pins the JSON sent to Azure, not the Rust value, so that adding fields to the
197    /// shared schema types cannot change what this feature asks the LLM for.
198    #[test]
199    fn response_format_json_is_unchanged() {
200        let serialized =
201            serde_json::to_value(response_format()).expect("The response format serializes");
202        assert_eq!(
203            serialized,
204            serde_json::json!({
205                "type": "json_schema",
206                "name": "LLMDescriptionResponse",
207                "schema": {
208                    "type": "object",
209                    "properties": {
210                        "course_description": { "type": "string" },
211                        "audience": {
212                            "type": "array",
213                            "items": { "type": "string" }
214                        },
215                        "modules": {
216                            "type": "array",
217                            "items": {
218                                "type": "object",
219                                "properties": {
220                                    "course_code": { "type": "string" },
221                                    "description": { "type": "string" },
222                                    "prerequisites": {
223                                        "type": "array",
224                                        "items": { "type": "string" }
225                                    }
226                                },
227                                "required": ["course_code", "description", "prerequisites"],
228                                "additionalProperties": false
229                            }
230                        }
231                    },
232                    "required": ["course_description", "audience", "modules"],
233                    "additionalProperties": false
234                },
235                "strict": true
236            })
237        );
238    }
239}