1use std::collections::HashMap;
2
3use crate::{
4 azure_chatbot::{
5 ArrayItem, ArrayProperty, JSONSchema, JSONType, LLMRequest, LLMRequestParams,
6 LLMRequestResponseFormatParam, NonThinkingParams, Schema, ThinkingParams,
7 },
8 content_cleaner::calculate_safe_token_limit,
9 llm_utils::{
10 APIMessage, APIMessageKind, APIMessageText, estimate_tokens, make_blocking_llm_request,
11 parse_text_completion,
12 },
13 prelude::{ChatbotError, ChatbotErrorType, ChatbotResult},
14};
15use headless_lms_base::config::ApplicationConfiguration;
16use headless_lms_base::error::backend_error::BackendError;
17use headless_lms_models::application_task_default_language_models::TaskLMSpec;
18use headless_lms_models::chatbot_conversation_messages::MessageRole;
19use headless_lms_models::cms_ai::ParagraphSuggestionAction;
20
21#[derive(serde::Deserialize)]
23struct CmsParagraphSuggestionResponse {
24 suggestions: Vec<String>,
25}
26
27fn action_instruction(action: ParagraphSuggestionAction) -> String {
31 let s = match action {
32 ParagraphSuggestionAction::GenerateDraftFromNotes => {
33 "Write a coherent draft paragraph using only the ideas present in the notes. Fill in connective wording as needed, but do not invent facts, examples, or claims not supported by the notes."
34 }
35 ParagraphSuggestionAction::GenerateContinueParagraph => {
36 "Append 1-3 new sentences that continue the paragraph's current idea and tone. Keep the existing paragraph text verbatim, and do not rewrite, reorder, or delete any existing words."
37 }
38 ParagraphSuggestionAction::GenerateAddExample => {
39 "Append one concise example that directly supports the paragraph's main point. Keep the existing text unchanged, and do not introduce unrelated facts or a new topic."
40 }
41 ParagraphSuggestionAction::GenerateAddCounterpoint => {
42 "Append one concise, relevant counterpoint or limitation that stays in scope with the paragraph's topic. Keep the existing text unchanged, and do not drift into a different topic."
43 }
44 ParagraphSuggestionAction::GenerateAddConcludingSentence => {
45 "Append exactly one concluding sentence that reinforces the paragraph's main point. Do not introduce new claims, and keep the existing text unchanged."
46 }
47 ParagraphSuggestionAction::FixSpelling => {
48 "Correct spelling, grammar, punctuation, and obvious typos only. Do not change meaning, tone, structure, or phrasing beyond what is required for correctness."
49 }
50 ParagraphSuggestionAction::ImproveClarity => {
51 "Improve clarity and readability with minimal rewrites. Preserve meaning and tone, and do not add new ideas, remove important details, or change the overall message."
52 }
53 ParagraphSuggestionAction::ImproveFlow => {
54 "Improve flow by refining transitions and, if needed, lightly reordering sentences for smoother reading. Preserve all original claims and details, and do not add new content or change the tone."
55 }
56 ParagraphSuggestionAction::ImproveConcise => {
57 "Make the paragraph more concise by removing redundancy and tightening phrasing. Preserve meaning and tone, and do not remove important details or add new ideas."
58 }
59 ParagraphSuggestionAction::ImproveExpandDetail => {
60 "Expand the paragraph with specific, on-topic supporting detail that deepens existing ideas. Preserve the original claims and tone, and do not add unrelated content or change the topic."
61 }
62 ParagraphSuggestionAction::ImproveAcademicStyle => {
63 "Strengthen the academic style by making the wording more precise, formal, and discipline-appropriate. Preserve meaning and evidence level, and do not add or remove content beyond stylistic refinement."
64 }
65 ParagraphSuggestionAction::StructureCreateTopicSentence => {
66 "Add or refine a topic sentence that clearly introduces the paragraph's main idea. Do not rewrite unrelated parts of the paragraph beyond minimal adjustments needed to fit the topic sentence."
67 }
68 ParagraphSuggestionAction::StructureReorderSentences => {
69 "Reorder the existing sentences to improve logical progression. Do not add, remove, split, combine, or materially rewrite sentence content."
70 }
71 ParagraphSuggestionAction::StructureSplitIntoParagraphs => {
72 "Split the content into multiple paragraphs at appropriate break points. Preserve wording and order, and do not add, remove, or rewrite content beyond paragraph breaks."
73 }
74 ParagraphSuggestionAction::StructureCombineIntoOne => {
75 "Combine the content into a single well-formed paragraph. Preserve wording and order, and do not add, remove, or rewrite content beyond joining the paragraphs."
76 }
77 ParagraphSuggestionAction::StructureToBullets => {
78 "Convert the content into bullet points while preserving the original information and order. Do not add, remove, or materially rewrite content beyond the format change."
79 }
80 ParagraphSuggestionAction::StructureFromBullets => {
81 "Convert the bullet points into prose while preserving the original information and order. Do not add, remove, or materially rewrite content beyond the format change."
82 }
83 ParagraphSuggestionAction::LearningSimplifyBeginners => {
84 "Simplify the explanation for beginners using clearer language and less assumed prior knowledge. Preserve factual accuracy, and do not add new concepts, tangents, or unrelated examples."
85 }
86 ParagraphSuggestionAction::LearningAddDefinitions => {
87 "Add brief inline definitions for technical or unfamiliar terms at their first relevant mention. Keep the existing explanation intact, and do not rewrite unrelated parts of the paragraph."
88 }
89 ParagraphSuggestionAction::LearningAddAnalogy => {
90 "Append one short, relevant analogy that clarifies the core concept. Keep the existing text unchanged, and do not introduce unrelated ideas or a separate topic."
91 }
92 ParagraphSuggestionAction::LearningAddPracticeQuestion => {
93 "Append exactly one practice question that tests the paragraph's key idea. Do not include an answer, and keep the existing text unchanged."
94 }
95 ParagraphSuggestionAction::LearningAddCheckUnderstanding => {
96 "Append exactly one quick check-for-understanding question followed by a one-sentence model answer. Keep the existing text unchanged, and do not add extra explanation beyond that."
97 }
98 ParagraphSuggestionAction::SummariesOneSentence => {
99 "Summarize the paragraph in exactly one sentence. Preserve the central meaning, and do not add interpretation, advice, or extra detail."
100 }
101 ParagraphSuggestionAction::SummariesTwoThreeSentences => {
102 "Summarize the paragraph in exactly two or three sentences. Preserve the central meaning, and do not add interpretation, advice, or extra detail."
103 }
104 ParagraphSuggestionAction::SummariesKeyTakeaway => {
105 "State exactly one sentence describing the single most important takeaway for students. Do not add extra interpretation, advice, or supporting detail."
106 }
107 ParagraphSuggestionAction::ToneAcademicFormal
108 | ParagraphSuggestionAction::ToneFriendlyConversational
109 | ParagraphSuggestionAction::ToneEncouragingSupportive
110 | ParagraphSuggestionAction::ToneNeutralObjective
111 | ParagraphSuggestionAction::ToneConfident
112 | ParagraphSuggestionAction::ToneSerious => {
113 "Adjust wording only as needed to match the target tone. Preserve all facts, claims, detail level, and overall structure, and do not add, remove, or materially reframe content."
114 }
115 ParagraphSuggestionAction::TranslateEnglish
116 | ParagraphSuggestionAction::TranslateFinnish
117 | ParagraphSuggestionAction::TranslateNorwegian
118 | ParagraphSuggestionAction::TranslateSwedish => {
119 "Translate the paragraph into the target language while preserving meaning, domain terminology, and inline formatting. Do not add, omit, simplify, paraphrase, or reinterpret the content."
120 }
121 };
122 s.to_string()
123}
124
125const SYSTEM_PROMPT: &str = r#"You are helping course staff improve a single paragraph of course material.
127
128Your task is to generate several alternative versions of the given paragraph based on the requested action.
129
130Critical: Perform only the requested action. Do not make any additional edits beyond what is strictly necessary to complete that action. Preserve wording, structure, tone, detail level, and sentence order unless the requested action explicitly requires changing them. For example: if the action is spelling/grammar, only fix spelling and grammar; if it is translation, only translate; if it is tone, only change tone; if it is clarity, only improve clarity. Do not also summarize, expand, simplify, reorder, or otherwise rewrite unless the requested action requires it.
131
132General rules:
133- Always preserve the original meaning and important details unless the action explicitly asks to add or remove content.
134- Maintain a clear, pedagogical tone appropriate for course materials.
135- Do not invent facts that contradict the original paragraph.
136
137About the suggestions:
138- Produce multiple alternative rewrites of the same paragraph.
139- Do not output duplicate or near-duplicate suggestions.
140- Keep each suggestion self-contained and suitable for direct insertion into the material.
141
142You will receive:
143- The original paragraph text.
144- The requested action (a precise instruction; follow it and do nothing else).
145- Optional metadata such as target tone and target language.
146
147Your output must follow the JSON schema exactly:
148{
149 "suggestions": ["...", "...", "..."]
150}"#;
151
152pub const USER_PROMPT_PREFIX: &str = "Apply only the requested action to the paragraph below. Do not make any other changes. The paragraph may contain inline HTML markup valid inside a Gutenberg paragraph; preserve existing inline tags (links, emphasis, code, sub/superscripts) where possible, do not introduce block-level elements, and do not add new formatting to spans of text that were previously unformatted. Return JSON only.";
154
155pub struct CmsParagraphSuggestionInput {
157 pub action: ParagraphSuggestionAction,
158 pub content: String,
159 pub is_html: bool,
160 pub meta_tone: Option<String>,
161 pub meta_language: Option<String>,
162 pub meta_setting_type: Option<String>,
163}
164
165pub async fn generate_paragraph_suggestions(
167 app_config: &ApplicationConfiguration,
168 task_lm: TaskLMSpec,
169 input: &CmsParagraphSuggestionInput,
170) -> ChatbotResult<Vec<String>> {
171 let CmsParagraphSuggestionInput {
172 action,
173 content,
174 is_html: _,
175 meta_tone,
176 meta_language,
177 meta_setting_type,
178 } = input;
179
180 let action_instruction = action_instruction(*action);
181
182 let mut system_instructions = SYSTEM_PROMPT.to_owned();
183 system_instructions.push_str("\n\nRequested action: ");
184 system_instructions.push_str(&action_instruction);
185 if let Some(tone) = meta_tone {
186 system_instructions.push_str("\nTarget tone: ");
187 system_instructions.push_str(tone);
188 }
189 if let Some(lang) = meta_language {
190 system_instructions.push_str("\nTarget language: ");
191 system_instructions.push_str(lang);
192 }
193 if let Some(setting_type) = meta_setting_type {
194 system_instructions.push_str("\nSetting type: ");
195 system_instructions.push_str(setting_type);
196 }
197
198 let paragraph_source = content.as_str();
199
200 let user_message_content = format!(
201 "{prefix}\n\nRequested action: {action_instruction}\n\nOriginal paragraph (may include inline HTML):\n{paragraph}",
202 prefix = USER_PROMPT_PREFIX,
203 action_instruction = action_instruction,
204 paragraph = paragraph_source
205 );
206
207 let used_tokens =
208 estimate_tokens(&system_instructions) + estimate_tokens(&user_message_content);
209 let token_budget =
210 calculate_safe_token_limit(task_lm.context_size, task_lm.context_utilization);
211
212 if used_tokens > token_budget {
213 return Err(ChatbotError::new(
214 ChatbotErrorType::ChatbotMessageSuggestError,
215 "Input paragraph is too long for the CMS AI suggestion context window.".to_string(),
216 None,
217 ));
218 }
219
220 let system_message = APIMessage {
221 role: MessageRole::System,
222 fields: APIMessageKind::Text(APIMessageText {
223 content: system_instructions,
224 }),
225 };
226
227 let user_message = APIMessage {
228 role: MessageRole::User,
229 fields: APIMessageKind::Text(APIMessageText {
230 content: user_message_content,
231 }),
232 };
233
234 let params = if task_lm.thinking {
235 LLMRequestParams::Thinking(ThinkingParams {
236 max_completion_tokens: Some(4000),
237 verbosity: None,
238 reasoning_effort: None,
239 tools: vec![],
240 tool_choice: None,
241 })
242 } else {
243 LLMRequestParams::NonThinking(NonThinkingParams {
244 max_tokens: Some(2000),
245 temperature: None,
246 top_p: None,
247 frequency_penalty: None,
248 presence_penalty: None,
249 })
250 };
251
252 let chat_request = LLMRequest {
253 messages: vec![system_message, user_message],
254 data_sources: vec![],
255 params,
256 response_format: Some(LLMRequestResponseFormatParam {
257 format_type: JSONType::JsonSchema,
258 json_schema: JSONSchema {
259 name: "CmsParagraphSuggestionResponse".to_string(),
260 strict: true,
261 schema: Schema {
262 type_field: JSONType::Object,
263 properties: HashMap::from([(
264 "suggestions".to_string(),
265 ArrayProperty {
266 type_field: JSONType::Array,
267 items: ArrayItem {
268 type_field: JSONType::String,
269 },
270 },
271 )]),
272 required: vec!["suggestions".to_string()],
273 additional_properties: false,
274 },
275 },
276 }),
277 stop: None,
278 };
279
280 let completion = make_blocking_llm_request(chat_request, app_config, &task_lm).await?;
281
282 let completion_content: &String = &parse_text_completion(completion)?;
283 let response: CmsParagraphSuggestionResponse = serde_json::from_str(completion_content)
284 .map_err(|_| {
285 ChatbotError::new(
286 ChatbotErrorType::ChatbotMessageSuggestError,
287 "The CMS paragraph suggestion LLM returned an incorrectly formatted response."
288 .to_string(),
289 None,
290 )
291 })?;
292
293 if response.suggestions.is_empty() {
294 return Err(ChatbotError::new(
295 ChatbotErrorType::ChatbotMessageSuggestError,
296 "The CMS paragraph suggestion LLM returned an empty suggestions list.".to_string(),
297 None,
298 ));
299 }
300
301 Ok(response.suggestions)
302}