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 {
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
96const 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 #[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}