Skip to main content

headless_lms_chatbot/
content_cleaner.rs

1use crate::azure_chatbot::{InputItem, LLMRequest, LLMRequestParams, NonThinkingParams};
2
3use crate::llm_utils::{
4    APIInputMessage, MessageContent, estimate_tokens, get_params_for_model,
5    make_blocking_llm_request, parse_text_completion,
6};
7use crate::prelude::*;
8use headless_lms_models::application_task_default_language_models::TaskLMSpec;
9use headless_lms_models::chatbot_conversation_message_messages::MessageRole;
10use headless_lms_utils::document_schema_processor::GutenbergBlock;
11use serde_json::Value;
12use tracing::{debug, error, info, instrument, warn};
13
14/// Temperature for requests, low for deterministic results
15pub const REQUEST_TEMPERATURE: f32 = 0.1;
16
17/// JSON markers for LLM prompt
18const JSON_BEGIN_MARKER: &str = "---BEGIN COURSE MATERIAL JSON---";
19const JSON_END_MARKER: &str = "---END COURSE MATERIAL JSON---";
20
21/// System prompt for converting course material to markdown
22const SYSTEM_PROMPT: &str = r#"You are given course material in an abstract JSON format from a headless CMS. Convert this into clean, semantic Markdown that includes all user-visible content to support full-text search.
23
24* Extract and include all meaningful text content: paragraphs, headings, list items, image captions, and similar.
25* Retain any inline formatting (like bold or italic text), converting HTML tags (`<strong>`, `<em>`, etc.) into equivalent Markdown formatting.
26* For images, use the standard Markdown format: `![caption](url)`, including a caption if available.
27* Preserve heading levels (e.g., level 2 → `##`, level 3 → `###`).
28* Include text content from any block type, even non-standard ones, if it appears user-visible.
29* For exercise blocks, include the exercise name, and assignment instructions. You may also include text from the exercise specification (public spec), if it can be formatted into markdown.
30* If you encounter blocks that don't have any visible text in the JSON but are likely still user-visible (placeholder blocks) — e.g. `glossary`, `exercises-in-this-chapter`, `course-progress` — generate a fake heading representing the expected content (e.g. `## Glossary`).
31* Do not generate headings for placeholder blocks that are not user-visible — e.g. `conditionally-visible-content`, `spacer`, `divider`.
32* Exclude all purely stylistic attributes (e.g. colors, alignment, font sizes).
33* Do not include any metadata, HTML tags (other than for formatting), or non-visible fields.
34* Output **only the Markdown content**, and nothing else.
35"#;
36
37/// User prompt for converting course material to markdown
38const USER_PROMPT_START: &str =
39    "Convert this JSON content to clean markdown. Output only the markdown, nothing else.";
40
41/// Cleans content by converting the material blocks to clean markdown using an LLM
42#[instrument(skip(blocks, app_config, task_lm), fields(num_blocks = blocks.len()))]
43pub async fn convert_material_blocks_to_markdown_with_llm(
44    blocks: &[GutenbergBlock],
45    app_config: &ApplicationConfiguration,
46    task_lm: &TaskLMSpec,
47) -> ChatbotResult<String> {
48    debug!("Starting content conversion with {} blocks", blocks.len());
49    let system_message = APIInputMessage {
50        message_type: InputItem::Message {
51            role: MessageRole::System,
52            content: MessageContent::Text(SYSTEM_PROMPT.to_string()),
53        },
54    };
55
56    let system_message_tokens = estimate_tokens(SYSTEM_PROMPT);
57    let safe_token_limit =
58        calculate_safe_token_limit(task_lm.context_size, task_lm.context_utilization);
59    let max_content_tokens = (safe_token_limit - system_message_tokens).max(1);
60
61    debug!(
62        "Token limits - system: {}, safe: {}, max content: {}",
63        system_message_tokens, safe_token_limit, max_content_tokens
64    );
65
66    let chunks = split_blocks_into_chunks(blocks, max_content_tokens)?;
67    debug!("Split content into {} chunks", chunks.len());
68    process_chunks(&chunks, &system_message, app_config, task_lm).await
69}
70
71/// Calculate the safe token limit based on context window and utilization
72pub fn calculate_safe_token_limit(context_window: i32, utilization: f32) -> i32 {
73    (context_window as f32 * utilization) as i32
74}
75
76/// Recursively removes all fields named "private_spec" from a JSON value
77fn remove_private_spec_recursive(value: &mut Value) {
78    match value {
79        Value::Object(map) => {
80            map.remove("private_spec");
81            for (_, v) in map.iter_mut() {
82                remove_private_spec_recursive(v);
83            }
84        }
85        Value::Array(arr) => {
86            for item in arr.iter_mut() {
87                remove_private_spec_recursive(item);
88            }
89        }
90        _ => {}
91    }
92}
93
94/// Converts a block to JSON string, removing any private_spec fields recursively
95fn block_to_json_string(block: &GutenbergBlock) -> ChatbotResult<String> {
96    let mut json_value = serde_json::to_value(block)?;
97    remove_private_spec_recursive(&mut json_value);
98    Ok(serde_json::to_string(&json_value)?)
99}
100
101/// Converts a vector of blocks to JSON string, removing any private_spec fields recursively
102fn blocks_to_json_string(blocks: &[GutenbergBlock]) -> ChatbotResult<String> {
103    let mut json_value = serde_json::to_value(blocks)?;
104    remove_private_spec_recursive(&mut json_value);
105    Ok(serde_json::to_string(&json_value)?)
106}
107
108/// Split blocks into chunks that fit within token limits
109#[instrument(skip(blocks), fields(max_content_tokens))]
110pub fn split_blocks_into_chunks(
111    blocks: &[GutenbergBlock],
112    max_content_tokens: i32,
113) -> ChatbotResult<Vec<String>> {
114    debug!("Starting to split {} blocks into chunks", blocks.len());
115    let mut chunks: Vec<String> = Vec::new();
116    let mut current_chunk: Vec<GutenbergBlock> = Vec::new();
117    let mut current_chunk_tokens = 0;
118
119    for block in blocks {
120        let block_json = block_to_json_string(block)?;
121        let block_tokens = estimate_tokens(&block_json);
122        debug!(
123            "Processing block {} with {} tokens",
124            block.client_id, block_tokens
125        );
126
127        // If this block alone exceeds the limit, split it into smaller chunks
128        if block_tokens > max_content_tokens {
129            warn!(
130                "Block {} exceeds max token limit ({} > {})",
131                block.client_id, block_tokens, max_content_tokens
132            );
133            // Add any accumulated blocks as a chunk
134            if !current_chunk.is_empty() {
135                chunks.push(blocks_to_json_string(&current_chunk)?);
136                current_chunk = Vec::new();
137                current_chunk_tokens = 0;
138            }
139
140            // Then we do some crude splitting for the oversized block
141            split_oversized_block(&block_json, max_content_tokens, &mut chunks)?;
142            continue;
143        }
144
145        if current_chunk_tokens + block_tokens > max_content_tokens {
146            debug!(
147                "Creating new chunk after {} blocks ({} tokens)",
148                current_chunk.len(),
149                current_chunk_tokens
150            );
151            chunks.push(blocks_to_json_string(&current_chunk)?);
152            current_chunk = Vec::new();
153            current_chunk_tokens = 0;
154        }
155
156        current_chunk.push(block.clone());
157        current_chunk_tokens += block_tokens;
158    }
159
160    if !current_chunk.is_empty() {
161        debug!(
162            "Adding final chunk with {} blocks ({} tokens)",
163            current_chunk.len(),
164            current_chunk_tokens
165        );
166        chunks.push(blocks_to_json_string(&current_chunk)?);
167    }
168
169    Ok(chunks)
170}
171
172/// Splits an oversized block into smaller string chunks
173#[instrument(skip(block_json, chunks), fields(max_tokens))]
174fn split_oversized_block(
175    block_json: &str,
176    max_tokens: i32,
177    chunks: &mut Vec<String>,
178) -> ChatbotResult<()> {
179    let total_tokens = estimate_tokens(block_json);
180    debug!(
181        "Splitting oversized block with {} tokens into chunks of max {} tokens",
182        total_tokens, max_tokens
183    );
184
185    // Make a very conservative estimate of the number of chunks we need
186    // Ensure max_tokens is at least 1 to avoid division by zero
187    let max_tokens_safe = max_tokens.max(1);
188    let num_chunks = (total_tokens as f32 / (max_tokens_safe as f32 * 0.5)).ceil() as usize;
189
190    if num_chunks <= 1 || num_chunks == 0 {
191        chunks.push(block_json.to_string());
192        return Ok(());
193    }
194
195    // Split by byte length (not character count) for efficiency,
196    // but ensure we only slice at UTF-8 character boundaries
197    let bytes_per_chunk = (block_json.len() / num_chunks).max(1);
198    debug!(
199        "Splitting into {} chunks of approximately {} bytes each",
200        num_chunks, bytes_per_chunk
201    );
202
203    let mut start = 0;
204    let mut iterations = 0;
205    const MAX_ITERATIONS: usize = 100;
206    while start < block_json.len() {
207        iterations += 1;
208        if iterations > MAX_ITERATIONS {
209            return Err(chatbot_err!(
210                ContentCleaning,
211                format!(
212                    "Infinite loop protection: exceeded {} iterations in split_oversized_block",
213                    MAX_ITERATIONS
214                )
215            ));
216        }
217
218        // Use checked arithmetic to prevent overflow
219        let end_candidate = start
220            .checked_add(bytes_per_chunk)
221            .unwrap_or(block_json.len())
222            .min(block_json.len());
223
224        let mut end = if end_candidate >= block_json.len() {
225            block_json.len()
226        } else {
227            end_candidate
228        };
229
230        // Adjust end backwards to the nearest UTF-8 character boundary
231        while !block_json.is_char_boundary(end) && end > start {
232            end -= 1;
233        }
234
235        // If backtracking resulted in end == start, advance forward to next boundary
236        if end == start {
237            // Find the next character boundary after start
238            let mut next_boundary = start
239                .checked_add(1)
240                .unwrap_or(block_json.len())
241                .min(block_json.len());
242
243            let mut boundary_iterations = 0;
244            const MAX_BOUNDARY_ITERATIONS: usize = 100;
245            while next_boundary < block_json.len() && !block_json.is_char_boundary(next_boundary) {
246                boundary_iterations += 1;
247                if boundary_iterations > MAX_BOUNDARY_ITERATIONS {
248                    return Err(chatbot_err!(
249                        ContentCleaning,
250                        format!(
251                            "Infinite loop protection: exceeded {} iterations finding character boundary",
252                            MAX_BOUNDARY_ITERATIONS
253                        )
254                    ));
255                }
256                next_boundary = next_boundary
257                    .checked_add(1)
258                    .unwrap_or(block_json.len())
259                    .min(block_json.len());
260            }
261            end = next_boundary.min(block_json.len());
262        }
263
264        // Ensure we have a non-empty slice and valid bounds
265        if end > start && end <= block_json.len() && start < block_json.len() {
266            // Double-check bounds before slicing
267            let chunk = block_json.get(start..end).ok_or_else(|| {
268                chatbot_err!(
269                    ContentCleaning,
270                    format!("Invalid string slice bounds: {}..{}", start, end)
271                )
272            })?;
273            chunks.push(chunk.to_string());
274            let new_start = end;
275            // Safety check: ensure start always advances
276            if new_start <= start {
277                return Err(chatbot_err!(
278                    ContentCleaning,
279                    format!(
280                        "Infinite loop protection: start did not advance ({} -> {})",
281                        start, new_start
282                    )
283                ));
284            }
285            start = new_start;
286        } else {
287            // Safety: if we can't make progress, break to avoid infinite loop
288            // Push remaining content if any
289            if start < block_json.len()
290                && let Some(remaining) = block_json.get(start..)
291                && !remaining.is_empty()
292            {
293                chunks.push(remaining.to_string());
294            }
295            break;
296        }
297    }
298
299    Ok(())
300}
301
302/// Appends markdown content to a result string with proper newline separators
303pub fn append_markdown_with_separator(result: &mut String, new_content: &str) {
304    if !result.is_empty() && !result.ends_with("\n\n") {
305        if result.ends_with('\n') {
306            result.push('\n');
307        } else {
308            result.push_str("\n\n");
309        }
310    }
311
312    result.push_str(new_content);
313}
314
315/// Process all chunks and combine the results
316#[instrument(skip(chunks, system_message, app_config, task_lm), fields(num_chunks = chunks.len()))]
317async fn process_chunks(
318    chunks: &[String],
319    system_message: &APIInputMessage,
320    app_config: &ApplicationConfiguration,
321    task_lm: &TaskLMSpec,
322) -> ChatbotResult<String> {
323    debug!("Processing {} chunks", chunks.len());
324    let mut result = String::new();
325
326    for (i, chunk) in chunks.iter().enumerate() {
327        debug!("Processing chunk {}/{}", i + 1, chunks.len());
328        let chunk_markdown =
329            process_block_chunk(chunk, system_message, app_config, task_lm).await?;
330        append_markdown_with_separator(&mut result, &chunk_markdown);
331    }
332
333    info!("Successfully cleaned content with LLM");
334    Ok(result)
335}
336
337/// Process a subset of blocks in a single LLM request
338#[instrument(skip(chunk, system_message, app_config, task_lm), fields(chunk_tokens = estimate_tokens(chunk)))]
339async fn process_block_chunk(
340    chunk: &str,
341    system_message: &APIInputMessage,
342    app_config: &ApplicationConfiguration,
343    task_lm: &TaskLMSpec,
344) -> ChatbotResult<String> {
345    let input = prepare_llm_messages(chunk, system_message);
346    let default_params = get_params_for_model(&task_lm.model, &task_lm.model_type, None);
347    let params = if let LLMRequestParams::GPTNonThinking(p) = default_params {
348        LLMRequestParams::GPTNonThinking(NonThinkingParams {
349            temperature: Some(REQUEST_TEMPERATURE),
350            ..p
351        })
352    } else {
353        default_params
354    };
355    let llm_base_request = LLMRequest {
356        input,
357        max_output_tokens: None,
358        model: task_lm.model.to_owned(),
359        tools: vec![],
360        tool_choice: None,
361        parallel_tool_calls: None,
362        params,
363        text: None,
364    };
365    info!(
366        "Processing chunk of approximately {} tokens",
367        estimate_tokens(chunk)
368    );
369
370    let completion = match make_blocking_llm_request(llm_base_request, app_config).await {
371        Ok(completion) => completion,
372        Err(e) => {
373            error!("Failed to process chunk: {}", e);
374            return Err(e);
375        }
376    };
377
378    parse_text_completion(completion)
379}
380
381/// Prepare messages for the LLM request
382pub fn prepare_llm_messages(chunk: &str, system_message: &APIInputMessage) -> Vec<APIInputMessage> {
383    let content = format!(
384        "{}\n\n{}{}\n{}",
385        USER_PROMPT_START, JSON_BEGIN_MARKER, chunk, JSON_END_MARKER
386    );
387    let messages = vec![
388        system_message.clone(),
389        APIInputMessage {
390            message_type: InputItem::Message {
391                role: MessageRole::User,
392                content: MessageContent::Text(content),
393            },
394        },
395    ];
396
397    messages
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403    use serde_json::json;
404
405    const TEST_BLOCK_NAME: &str = "test/block";
406
407    #[test]
408    fn test_calculate_safe_token_limit() {
409        assert_eq!(calculate_safe_token_limit(1000, 0.75), 750);
410        assert_eq!(calculate_safe_token_limit(16000, 0.75), 12000);
411        assert_eq!(calculate_safe_token_limit(8000, 0.5), 4000);
412    }
413
414    #[test]
415    fn test_append_markdown_with_separator() {
416        let mut result = String::new();
417        append_markdown_with_separator(&mut result, "New content");
418        assert_eq!(result, "New content");
419
420        let mut result = String::from("Existing content");
421        append_markdown_with_separator(&mut result, "New content");
422        assert_eq!(result, "Existing content\n\nNew content");
423
424        let mut result = String::from("Existing content\n");
425        append_markdown_with_separator(&mut result, "New content");
426        assert_eq!(result, "Existing content\n\nNew content");
427
428        let mut result = String::from("Existing content\n\n");
429        append_markdown_with_separator(&mut result, "New content");
430        assert_eq!(result, "Existing content\n\nNew content");
431    }
432
433    #[test]
434    fn test_split_blocks_into_chunks() -> ChatbotResult<()> {
435        // Use content strings of different lengths to influence token estimation
436        let block1 = create_test_block("a "); // short
437        let block2 = create_test_block("b b b b b b b b b b b b b b b b b b b b "); // longer
438        let block3 = create_test_block("c c c c c c c c c c c c c c c "); // medium
439
440        let blocks = vec![block1.clone(), block2.clone(), block3.clone()];
441
442        // Estimate tokens for each block
443        let t1 = estimate_tokens(&block_to_json_string(&block1)?);
444        let t2 = estimate_tokens(&block_to_json_string(&block2)?);
445        let t3 = estimate_tokens(&block_to_json_string(&block3)?);
446
447        // Test with a limit that fits all blocks
448        let chunks = split_blocks_into_chunks(&blocks, t1 + t2 + t3 + 10)?;
449        assert_eq!(chunks.len(), 1);
450
451        let deserialized_chunk: Vec<GutenbergBlock> = serde_json::from_str(&chunks[0])?;
452        assert_eq!(deserialized_chunk.len(), 3);
453
454        // Test with a limit that requires splitting after the first block
455        let chunks = split_blocks_into_chunks(&blocks, t1 + 1)?;
456
457        // First chunk should be a valid JSON array with one block
458        let first_chunk: Vec<GutenbergBlock> = serde_json::from_str(&chunks[0])?;
459        assert_eq!(first_chunk.len(), 1);
460        assert_eq!(first_chunk[0].client_id, block1.client_id);
461
462        // Remaining chunks might be split JSON strings, so we can't deserialize them
463        // Just verify they're not empty
464        for chunk in &chunks[1..] {
465            assert!(!chunk.is_empty());
466        }
467
468        Ok(())
469    }
470
471    #[test]
472    fn test_prepare_llm_messages() -> ChatbotResult<()> {
473        let blocks = vec![create_test_block("Test content")];
474        let blocks_json = blocks_to_json_string(&blocks)?;
475        let system_message = APIInputMessage {
476            message_type: InputItem::Message {
477                role: MessageRole::System,
478                content: MessageContent::Text("System prompt".to_string()),
479            },
480        };
481
482        let messages = prepare_llm_messages(&blocks_json, &system_message);
483
484        assert_eq!(messages.len(), 2);
485        let (msg1_content, msg1_role): (&str, Option<&MessageRole>) =
486            match &messages[0].message_type {
487                InputItem::Message { role, content } => {
488                    (&content.to_owned().get_content_text(), Some(role))
489                }
490                _ => ("", None),
491            };
492        let (msg2_content, msg2_role): (&str, Option<&MessageRole>) =
493            match &messages[1].message_type {
494                InputItem::Message { role, content } => {
495                    (&content.to_owned().get_content_text(), Some(role))
496                }
497                _ => ("", None),
498            };
499        assert_eq!(msg1_role, Some(&MessageRole::System));
500        assert_eq!(msg1_content, "System prompt");
501        assert_eq!(msg2_role, Some(&MessageRole::User));
502        assert!(msg2_content.contains(JSON_BEGIN_MARKER));
503        assert!(msg2_content.contains("Test content"));
504
505        Ok(())
506    }
507
508    fn create_test_block(content: &str) -> GutenbergBlock {
509        let client_id = uuid::Uuid::new_v4();
510        GutenbergBlock {
511            client_id,
512            name: TEST_BLOCK_NAME.to_string(),
513            is_valid: true,
514            attributes: {
515                let mut map = serde_json::Map::new();
516                map.insert("content".to_string(), json!(content));
517                map
518            },
519            inner_blocks: vec![],
520        }
521    }
522}