Skip to main content

headless_lms_chatbot/
cms_ai_suggestion.rs

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