1use headless_lms_utils::services::sisu::SisuDescriptions;
2use std::collections::HashMap;
3
4use crate::{
5 azure_chatbot::{
6 ArrayItem, ArrayProperty, InputItem, JSONType, JsonItem, LLMRequest, LLMRequestParams,
7 LLMRequestResponseFormatParam, NonThinkingParams, RequestTextOptions, Schema,
8 SchemaPropertyType, ThinkingParams,
9 },
10 chatbot_error::chatbot_err,
11 llm_utils::{
12 APIInputMessage, MessageContent, make_blocking_llm_request, model_is_thinking,
13 parse_text_completion,
14 },
15 prelude::{ChatbotError, ChatbotErrorType, ChatbotResult},
16};
17use headless_lms_base::config::ApplicationConfiguration;
18use headless_lms_base::error::backend_error::BackendError;
19use headless_lms_models::{
20 application_task_default_language_models::TaskLMSpec,
21 chatbot_conversation_message_messages::MessageRole,
22};
23use utoipa::ToSchema;
24
25#[derive(serde::Serialize, serde::Deserialize, ToSchema, Debug)]
26pub struct SisuDescriptionResponse {
27 pub course_description: String,
28 pub audience: Vec<String>,
29 pub modules: Vec<Module>,
30}
31
32#[derive(serde::Serialize, serde::Deserialize, ToSchema, Debug)]
33pub struct Module {
34 pub course_code: String,
35 pub description: String,
36 pub prerequisites: Vec<String>,
37}
38
39const SYSTEM_PROMPT: &str = r#"
42Your task is to
431. Generate a single general description combining the information from all the different modules behind the key "course_description".
442. 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.
453. 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".
46 3.1 The "course_code" field will have the corresponing module code.
47 3.2 The "description" field will be a description summarized from all the information you are given on the specific module.
48 3.3 The "prerequisites" field will be an array, with each prerequisite differentiated as an item in the list.
49
50
51When generating the description:
52- Use the same language in the description that is used in the given information.
53- Use same style of writing as in the given information.
54- Ignore all the information that is not relevant for the course description.
55- Ignore all the html tags inside the given information.
56- When generating module descriptions don't use filler words such as 'this course', give only relevant information.
57
58Constraints:
59- Base the summarization only on the information given to you.
60- Only output the summarized description, nothing else.
61- The maximum length for the description is 100 words.
62- If there is only one module in the course, use exactly the same description for both course description and module description.
63
64Your output must follow the JSON schema exactly:
65{
66 "course_description": "...",
67 "audience": ["..."],
68 "modules": [
69 {
70 "course_code": "...",
71 "description": "...",
72 "prerequisites": ["...", "...", "..."]
73 }
74 ]
75}"#;
76
77pub const USER_PROMPT: &str = r#"Give description based on the given information."#;
78
79pub async fn generate_description(
80 app_config: &ApplicationConfiguration,
81 task_lm: TaskLMSpec,
82 sisu_course_info: HashMap<String, SisuDescriptions>,
83) -> ChatbotResult<SisuDescriptionResponse> {
84 let serialized_sisu_course_info = serde_json::to_string(&sisu_course_info)?;
85 let prompt: String = format!("{USER_PROMPT} Course information: {serialized_sisu_course_info}");
86
87 let system_prompt = APIInputMessage {
88 message_type: InputItem::Message {
89 role: MessageRole::System,
90 content: MessageContent::Text(SYSTEM_PROMPT.to_string()),
91 },
92 };
93
94 let user_prompt = APIInputMessage {
95 message_type: InputItem::Message {
96 role: MessageRole::User,
97 content: MessageContent::Text(prompt),
98 },
99 };
100
101 let (params, max_output_tokens) = if model_is_thinking(task_lm.model_type) {
102 (
103 LLMRequestParams::GPTThinking(ThinkingParams { reasoning: None }),
104 Some(7000),
105 )
106 } else {
107 (
108 LLMRequestParams::GPTNonThinking(NonThinkingParams {
109 temperature: None,
110 top_p: None,
111 frequency_penalty: None,
112 presence_penalty: None,
113 }),
114 Some(4000),
115 )
116 };
117
118 let chat_request = LLMRequest {
119 input: vec![system_prompt, user_prompt],
120 model: task_lm.model.to_owned(),
121 max_output_tokens,
122 tools: vec![],
123 tool_choice: None,
124 parallel_tool_calls: None,
125 params,
126 text: Some(RequestTextOptions {
127 verbosity: None,
128 format: Some(LLMRequestResponseFormatParam {
129 format_type: JSONType::JsonSchema,
130 name: "LLMDescriptionResponse".to_string(),
131 schema: Schema {
132 type_field: JSONType::Object,
133 properties: HashMap::from([
134 (
135 "course_description".to_string(),
136 SchemaPropertyType::Item(JsonItem {
137 type_field: JSONType::String,
138 }),
139 ),
140 (
141 "audience".to_string(),
142 SchemaPropertyType::ArrayProperty(ArrayProperty {
143 type_field: JSONType::Array,
144 items: ArrayItem::JsonItem(JsonItem {
145 type_field: JSONType::String,
146 }),
147 }),
148 ),
149 (
150 "modules".to_string(),
151 SchemaPropertyType::ArrayProperty(ArrayProperty {
152 type_field: JSONType::Array,
153 items: ArrayItem::Schema(Schema {
154 type_field: JSONType::Object,
155 properties: HashMap::from([
156 (
157 "course_code".to_string(),
158 SchemaPropertyType::Item(JsonItem {
159 type_field: JSONType::String,
160 }),
161 ),
162 (
163 "description".to_string(),
164 SchemaPropertyType::Item(JsonItem {
165 type_field: JSONType::String,
166 }),
167 ),
168 (
169 "prerequisites".to_string(),
170 SchemaPropertyType::ArrayProperty(ArrayProperty {
171 type_field: JSONType::Array,
172 items: ArrayItem::JsonItem(JsonItem {
173 type_field: JSONType::String,
174 }),
175 }),
176 ),
177 ]),
178 required: Vec::from([
179 "course_code".to_string(),
180 "description".to_string(),
181 "prerequisites".to_string(),
182 ]),
183 additional_properties: false,
184 }),
185 }),
186 ),
187 ]),
188 required: Vec::from([
189 "course_description".to_string(),
190 "audience".to_string(),
191 "modules".to_string(),
192 ]),
193 additional_properties: false,
194 },
195 strict: true,
196 }),
197 }),
198 };
199
200 let completion = make_blocking_llm_request(chat_request, app_config).await?;
201
202 let completion_content: &String = &parse_text_completion(completion)?;
203
204 let descriptions: SisuDescriptionResponse =
205 serde_json::from_str(completion_content).map_err(|_| {
206 chatbot_err!(
207 SisuDescriptionError,
208 "Sisu description LLM returned an incorrectly formatted response.".to_string()
209 )
210 })?;
211 Ok(descriptions)
212}