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
42pub const RESPONSE_FORMAT_NAME: &str = "LLMDescriptionResponse";
45
46fn 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
95const 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 #[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}