headless_lms_chatbot/
cms_ai_suggestion.rs

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_models::application_task_default_language_models::TaskLMSpec;
16use headless_lms_models::chatbot_conversation_messages::MessageRole;
17use headless_lms_models::cms_ai::ParagraphSuggestionAction;
18use headless_lms_utils::{ApplicationConfiguration, prelude::BackendError};
19
20/// Structured LLM response for CMS paragraph suggestions.
21#[derive(serde::Deserialize)]
22struct CmsParagraphSuggestionResponse {
23    suggestions: Vec<String>,
24}
25
26/// Returns a short human-readable instruction for the given action id. Used so the model
27/// sees a precise operation description instead of an opaque id, and so we can stress
28/// "only this, nothing else" per action.
29fn action_instruction(action: ParagraphSuggestionAction) -> String {
30    let s = match action {
31        ParagraphSuggestionAction::GenerateDraftFromNotes => {
32            "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."
33        }
34        ParagraphSuggestionAction::GenerateContinueParagraph => {
35            "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."
36        }
37        ParagraphSuggestionAction::GenerateAddExample => {
38            "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."
39        }
40        ParagraphSuggestionAction::GenerateAddCounterpoint => {
41            "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."
42        }
43        ParagraphSuggestionAction::GenerateAddConcludingSentence => {
44            "Append exactly one concluding sentence that reinforces the paragraph's main point. Do not introduce new claims, and keep the existing text unchanged."
45        }
46        ParagraphSuggestionAction::FixSpelling => {
47            "Correct spelling, grammar, punctuation, and obvious typos only. Do not change meaning, tone, structure, or phrasing beyond what is required for correctness."
48        }
49        ParagraphSuggestionAction::ImproveClarity => {
50            "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."
51        }
52        ParagraphSuggestionAction::ImproveFlow => {
53            "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."
54        }
55        ParagraphSuggestionAction::ImproveConcise => {
56            "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."
57        }
58        ParagraphSuggestionAction::ImproveExpandDetail => {
59            "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."
60        }
61        ParagraphSuggestionAction::ImproveAcademicStyle => {
62            "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."
63        }
64        ParagraphSuggestionAction::StructureCreateTopicSentence => {
65            "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."
66        }
67        ParagraphSuggestionAction::StructureReorderSentences => {
68            "Reorder the existing sentences to improve logical progression. Do not add, remove, split, combine, or materially rewrite sentence content."
69        }
70        ParagraphSuggestionAction::StructureSplitIntoParagraphs => {
71            "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."
72        }
73        ParagraphSuggestionAction::StructureCombineIntoOne => {
74            "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."
75        }
76        ParagraphSuggestionAction::StructureToBullets => {
77            "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."
78        }
79        ParagraphSuggestionAction::StructureFromBullets => {
80            "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."
81        }
82        ParagraphSuggestionAction::LearningSimplifyBeginners => {
83            "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."
84        }
85        ParagraphSuggestionAction::LearningAddDefinitions => {
86            "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."
87        }
88        ParagraphSuggestionAction::LearningAddAnalogy => {
89            "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."
90        }
91        ParagraphSuggestionAction::LearningAddPracticeQuestion => {
92            "Append exactly one practice question that tests the paragraph's key idea. Do not include an answer, and keep the existing text unchanged."
93        }
94        ParagraphSuggestionAction::LearningAddCheckUnderstanding => {
95            "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."
96        }
97        ParagraphSuggestionAction::SummariesOneSentence => {
98            "Summarize the paragraph in exactly one sentence. Preserve the central meaning, and do not add interpretation, advice, or extra detail."
99        }
100        ParagraphSuggestionAction::SummariesTwoThreeSentences => {
101            "Summarize the paragraph in exactly two or three sentences. Preserve the central meaning, and do not add interpretation, advice, or extra detail."
102        }
103        ParagraphSuggestionAction::SummariesKeyTakeaway => {
104            "State exactly one sentence describing the single most important takeaway for students. Do not add extra interpretation, advice, or supporting detail."
105        }
106        ParagraphSuggestionAction::ToneAcademicFormal
107        | ParagraphSuggestionAction::ToneFriendlyConversational
108        | ParagraphSuggestionAction::ToneEncouragingSupportive
109        | ParagraphSuggestionAction::ToneNeutralObjective
110        | ParagraphSuggestionAction::ToneConfident
111        | ParagraphSuggestionAction::ToneSerious => {
112            "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."
113        }
114        ParagraphSuggestionAction::TranslateEnglish
115        | ParagraphSuggestionAction::TranslateFinnish
116        | ParagraphSuggestionAction::TranslateNorwegian
117        | ParagraphSuggestionAction::TranslateSwedish => {
118            "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."
119        }
120    };
121    s.to_string()
122}
123
124/// System prompt for generating multiple alternative paragraph suggestions for CMS content.
125const SYSTEM_PROMPT: &str = r#"You are helping course staff improve a single paragraph of course material.
126
127Your task is to generate several alternative versions of the given paragraph based on the requested action.
128
129Critical: 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.
130
131General rules:
132- Always preserve the original meaning and important details unless the action explicitly asks to add or remove content.
133- Maintain a clear, pedagogical tone appropriate for course materials.
134- Do not invent facts that contradict the original paragraph.
135
136About the suggestions:
137- Produce multiple alternative rewrites of the same paragraph.
138- Do not output duplicate or near-duplicate suggestions.
139- Keep each suggestion self-contained and suitable for direct insertion into the material.
140
141You will receive:
142- The original paragraph text.
143- The requested action (a precise instruction; follow it and do nothing else).
144- Optional metadata such as target tone and target language.
145
146Your output must follow the JSON schema exactly:
147{
148  "suggestions": ["...", "...", "..."]
149}"#;
150
151/// User prompt prefix; the concrete action instruction and paragraph will be appended.
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.";
153
154/// Input payload for CMS paragraph suggestions.
155pub struct CmsParagraphSuggestionInput {
156    pub action: ParagraphSuggestionAction,
157    pub content: String,
158    pub is_html: bool,
159    pub meta_tone: Option<String>,
160    pub meta_language: Option<String>,
161    pub meta_setting_type: Option<String>,
162}
163
164/// Generate multiple paragraph suggestions for CMS using an LLM with structured JSON output.
165pub async fn generate_paragraph_suggestions(
166    app_config: &ApplicationConfiguration,
167    task_lm: TaskLMSpec,
168    input: &CmsParagraphSuggestionInput,
169) -> ChatbotResult<Vec<String>> {
170    let CmsParagraphSuggestionInput {
171        action,
172        content,
173        is_html: _,
174        meta_tone,
175        meta_language,
176        meta_setting_type,
177    } = input;
178
179    let action_instruction = action_instruction(*action);
180
181    let mut system_instructions = SYSTEM_PROMPT.to_owned();
182    system_instructions.push_str("\n\nRequested action: ");
183    system_instructions.push_str(&action_instruction);
184    if let Some(tone) = meta_tone {
185        system_instructions.push_str("\nTarget tone: ");
186        system_instructions.push_str(tone);
187    }
188    if let Some(lang) = meta_language {
189        system_instructions.push_str("\nTarget language: ");
190        system_instructions.push_str(lang);
191    }
192    if let Some(setting_type) = meta_setting_type {
193        system_instructions.push_str("\nSetting type: ");
194        system_instructions.push_str(setting_type);
195    }
196
197    let paragraph_source = content.as_str();
198
199    let user_message_content = format!(
200        "{prefix}\n\nRequested action: {action_instruction}\n\nOriginal paragraph (may include inline HTML):\n{paragraph}",
201        prefix = USER_PROMPT_PREFIX,
202        action_instruction = action_instruction,
203        paragraph = paragraph_source
204    );
205
206    let used_tokens =
207        estimate_tokens(&system_instructions) + estimate_tokens(&user_message_content);
208    let token_budget =
209        calculate_safe_token_limit(task_lm.context_size, task_lm.context_utilization);
210
211    if used_tokens > token_budget {
212        return Err(ChatbotError::new(
213            ChatbotErrorType::ChatbotMessageSuggestError,
214            "Input paragraph is too long for the CMS AI suggestion context window.".to_string(),
215            None,
216        ));
217    }
218
219    let system_message = APIMessage {
220        role: MessageRole::System,
221        fields: APIMessageKind::Text(APIMessageText {
222            content: system_instructions,
223        }),
224    };
225
226    let user_message = APIMessage {
227        role: MessageRole::User,
228        fields: APIMessageKind::Text(APIMessageText {
229            content: user_message_content,
230        }),
231    };
232
233    let params = if task_lm.thinking {
234        LLMRequestParams::Thinking(ThinkingParams {
235            max_completion_tokens: Some(4000),
236            verbosity: None,
237            reasoning_effort: None,
238            tools: vec![],
239            tool_choice: None,
240        })
241    } else {
242        LLMRequestParams::NonThinking(NonThinkingParams {
243            max_tokens: Some(2000),
244            temperature: None,
245            top_p: None,
246            frequency_penalty: None,
247            presence_penalty: None,
248        })
249    };
250
251    let chat_request = LLMRequest {
252        messages: vec![system_message, user_message],
253        data_sources: vec![],
254        params,
255        response_format: Some(LLMRequestResponseFormatParam {
256            format_type: JSONType::JsonSchema,
257            json_schema: JSONSchema {
258                name: "CmsParagraphSuggestionResponse".to_string(),
259                strict: true,
260                schema: Schema {
261                    type_field: JSONType::Object,
262                    properties: HashMap::from([(
263                        "suggestions".to_string(),
264                        ArrayProperty {
265                            type_field: JSONType::Array,
266                            items: ArrayItem {
267                                type_field: JSONType::String,
268                            },
269                        },
270                    )]),
271                    required: vec!["suggestions".to_string()],
272                    additional_properties: false,
273                },
274            },
275        }),
276        stop: None,
277    };
278
279    let completion = make_blocking_llm_request(chat_request, app_config, &task_lm).await?;
280
281    let completion_content: &String = &parse_text_completion(completion)?;
282    let response: CmsParagraphSuggestionResponse = serde_json::from_str(completion_content)
283        .map_err(|_| {
284            ChatbotError::new(
285                ChatbotErrorType::ChatbotMessageSuggestError,
286                "The CMS paragraph suggestion LLM returned an incorrectly formatted response."
287                    .to_string(),
288                None,
289            )
290        })?;
291
292    if response.suggestions.is_empty() {
293        return Err(ChatbotError::new(
294            ChatbotErrorType::ChatbotMessageSuggestError,
295            "The CMS paragraph suggestion LLM returned an empty suggestions list.".to_string(),
296            None,
297        ));
298    }
299
300    Ok(response.suggestions)
301}