1use crate::azure_chatbot::azure::protocol::{
2 InputItem, LLMRequest, LLMRequestParams, NonThinkingParams,
3};
4
5use crate::llm_utils::{
6 APIInputMessage, MessageContent, estimate_tokens, get_params_for_model,
7 make_blocking_llm_request, parse_text_completion,
8};
9use crate::prelude::*;
10use headless_lms_models::application_task_default_language_models::TaskLMSpec;
11use headless_lms_models::chatbot_conversation_message_messages::MessageRole;
12use headless_lms_utils::document_schema_processor::GutenbergBlock;
13use serde_json::Value;
14use tracing::{debug, error, info, instrument, warn};
15
16pub const REQUEST_TEMPERATURE: f32 = 0.1;
18
19const JSON_BEGIN_MARKER: &str = "---BEGIN COURSE MATERIAL JSON---";
21const JSON_END_MARKER: &str = "---END COURSE MATERIAL JSON---";
22
23const 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.
25
26* Extract and include all meaningful text content: paragraphs, headings, list items, image captions, and similar.
27* Retain any inline formatting (like bold or italic text), converting HTML tags (`<strong>`, `<em>`, etc.) into equivalent Markdown formatting.
28* For images, use the standard Markdown format: ``, including a caption if available.
29* Preserve heading levels (e.g., level 2 → `##`, level 3 → `###`).
30* Include text content from any block type, even non-standard ones, if it appears user-visible.
31* 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.
32* 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`).
33* Do not generate headings for placeholder blocks that are not user-visible — e.g. `conditionally-visible-content`, `spacer`, `divider`.
34* Exclude all purely stylistic attributes (e.g. colors, alignment, font sizes).
35* Do not include any metadata, HTML tags (other than for formatting), or non-visible fields.
36* Output **only the Markdown content**, and nothing else.
37"#;
38
39const USER_PROMPT_START: &str =
41 "Convert this JSON content to clean markdown. Output only the markdown, nothing else.";
42
43#[instrument(skip(blocks, app_config, task_lm), fields(num_blocks = blocks.len()))]
45pub async fn convert_material_blocks_to_markdown_with_llm(
46 blocks: &[GutenbergBlock],
47 app_config: &ApplicationConfiguration,
48 task_lm: &TaskLMSpec,
49) -> ChatbotResult<String> {
50 debug!("Starting content conversion with {} blocks", blocks.len());
51 let system_message = APIInputMessage {
52 message_type: InputItem::Message {
53 role: MessageRole::System,
54 content: MessageContent::Text(SYSTEM_PROMPT.to_string()),
55 },
56 };
57
58 let system_message_tokens = estimate_tokens(SYSTEM_PROMPT);
59 let safe_token_limit =
60 calculate_safe_token_limit(task_lm.context_size, task_lm.context_utilization);
61 let max_content_tokens = (safe_token_limit - system_message_tokens).max(1);
62
63 debug!(
64 "Token limits - system: {}, safe: {}, max content: {}",
65 system_message_tokens, safe_token_limit, max_content_tokens
66 );
67
68 let chunks = split_blocks_into_chunks(blocks, max_content_tokens)?;
69 debug!("Split content into {} chunks", chunks.len());
70 process_chunks(&chunks, &system_message, app_config, task_lm).await
71}
72
73pub fn calculate_safe_token_limit(context_window: i32, utilization: f32) -> i32 {
75 (context_window as f32 * utilization) as i32
76}
77
78fn remove_private_spec_recursive(value: &mut Value) {
80 match value {
81 Value::Object(map) => {
82 map.remove("private_spec");
83 for (_, v) in map.iter_mut() {
84 remove_private_spec_recursive(v);
85 }
86 }
87 Value::Array(arr) => {
88 for item in arr.iter_mut() {
89 remove_private_spec_recursive(item);
90 }
91 }
92 _ => {}
93 }
94}
95
96fn block_to_json_string(block: &GutenbergBlock) -> ChatbotResult<String> {
98 let mut json_value = serde_json::to_value(block)?;
99 remove_private_spec_recursive(&mut json_value);
100 Ok(serde_json::to_string(&json_value)?)
101}
102
103fn blocks_to_json_string(blocks: &[GutenbergBlock]) -> ChatbotResult<String> {
105 let mut json_value = serde_json::to_value(blocks)?;
106 remove_private_spec_recursive(&mut json_value);
107 Ok(serde_json::to_string(&json_value)?)
108}
109
110#[instrument(skip(blocks), fields(max_content_tokens))]
112pub fn split_blocks_into_chunks(
113 blocks: &[GutenbergBlock],
114 max_content_tokens: i32,
115) -> ChatbotResult<Vec<String>> {
116 debug!("Starting to split {} blocks into chunks", blocks.len());
117 let mut chunks: Vec<String> = Vec::new();
118 let mut current_chunk: Vec<GutenbergBlock> = Vec::new();
119 let mut current_chunk_tokens = 0;
120
121 for block in blocks {
122 let block_json = block_to_json_string(block)?;
123 let block_tokens = estimate_tokens(&block_json);
124 debug!(
125 "Processing block {} with {} tokens",
126 block.client_id, block_tokens
127 );
128
129 if block_tokens > max_content_tokens {
131 warn!(
132 "Block {} exceeds max token limit ({} > {})",
133 block.client_id, block_tokens, max_content_tokens
134 );
135 if !current_chunk.is_empty() {
137 chunks.push(blocks_to_json_string(¤t_chunk)?);
138 current_chunk = Vec::new();
139 current_chunk_tokens = 0;
140 }
141
142 split_oversized_block(&block_json, max_content_tokens, &mut chunks)?;
144 continue;
145 }
146
147 if current_chunk_tokens + block_tokens > max_content_tokens {
148 debug!(
149 "Creating new chunk after {} blocks ({} tokens)",
150 current_chunk.len(),
151 current_chunk_tokens
152 );
153 chunks.push(blocks_to_json_string(¤t_chunk)?);
154 current_chunk = Vec::new();
155 current_chunk_tokens = 0;
156 }
157
158 current_chunk.push(block.clone());
159 current_chunk_tokens += block_tokens;
160 }
161
162 if !current_chunk.is_empty() {
163 debug!(
164 "Adding final chunk with {} blocks ({} tokens)",
165 current_chunk.len(),
166 current_chunk_tokens
167 );
168 chunks.push(blocks_to_json_string(¤t_chunk)?);
169 }
170
171 Ok(chunks)
172}
173
174#[instrument(skip(block_json, chunks), fields(max_tokens))]
176fn split_oversized_block(
177 block_json: &str,
178 max_tokens: i32,
179 chunks: &mut Vec<String>,
180) -> ChatbotResult<()> {
181 let total_tokens = estimate_tokens(block_json);
182 debug!(
183 "Splitting oversized block with {} tokens into chunks of max {} tokens",
184 total_tokens, max_tokens
185 );
186
187 let max_tokens_safe = max_tokens.max(1);
190 let num_chunks = (total_tokens as f32 / (max_tokens_safe as f32 * 0.5)).ceil() as usize;
191
192 if num_chunks <= 1 || num_chunks == 0 {
193 chunks.push(block_json.to_string());
194 return Ok(());
195 }
196
197 let bytes_per_chunk = (block_json.len() / num_chunks).max(1);
200 debug!(
201 "Splitting into {} chunks of approximately {} bytes each",
202 num_chunks, bytes_per_chunk
203 );
204
205 let mut start = 0;
206 let mut iterations = 0;
207 const MAX_ITERATIONS: usize = 100;
208 while start < block_json.len() {
209 iterations += 1;
210 if iterations > MAX_ITERATIONS {
211 return Err(chatbot_err!(
212 ContentCleaning,
213 format!(
214 "Infinite loop protection: exceeded {} iterations in split_oversized_block",
215 MAX_ITERATIONS
216 )
217 ));
218 }
219
220 let end_candidate = start
222 .checked_add(bytes_per_chunk)
223 .unwrap_or(block_json.len())
224 .min(block_json.len());
225
226 let mut end = if end_candidate >= block_json.len() {
227 block_json.len()
228 } else {
229 end_candidate
230 };
231
232 while !block_json.is_char_boundary(end) && end > start {
234 end -= 1;
235 }
236
237 if end == start {
239 let mut next_boundary = start
241 .checked_add(1)
242 .unwrap_or(block_json.len())
243 .min(block_json.len());
244
245 let mut boundary_iterations = 0;
246 const MAX_BOUNDARY_ITERATIONS: usize = 100;
247 while next_boundary < block_json.len() && !block_json.is_char_boundary(next_boundary) {
248 boundary_iterations += 1;
249 if boundary_iterations > MAX_BOUNDARY_ITERATIONS {
250 return Err(chatbot_err!(
251 ContentCleaning,
252 format!(
253 "Infinite loop protection: exceeded {} iterations finding character boundary",
254 MAX_BOUNDARY_ITERATIONS
255 )
256 ));
257 }
258 next_boundary = next_boundary
259 .checked_add(1)
260 .unwrap_or(block_json.len())
261 .min(block_json.len());
262 }
263 end = next_boundary.min(block_json.len());
264 }
265
266 if end > start && end <= block_json.len() && start < block_json.len() {
268 let chunk = block_json.get(start..end).ok_or_else(|| {
270 chatbot_err!(
271 ContentCleaning,
272 format!("Invalid string slice bounds: {}..{}", start, end)
273 )
274 })?;
275 chunks.push(chunk.to_string());
276 let new_start = end;
277 if new_start <= start {
279 return Err(chatbot_err!(
280 ContentCleaning,
281 format!(
282 "Infinite loop protection: start did not advance ({} -> {})",
283 start, new_start
284 )
285 ));
286 }
287 start = new_start;
288 } else {
289 if start < block_json.len()
292 && let Some(remaining) = block_json.get(start..)
293 && !remaining.is_empty()
294 {
295 chunks.push(remaining.to_string());
296 }
297 break;
298 }
299 }
300
301 Ok(())
302}
303
304pub fn append_markdown_with_separator(result: &mut String, new_content: &str) {
306 if !result.is_empty() && !result.ends_with("\n\n") {
307 if result.ends_with('\n') {
308 result.push('\n');
309 } else {
310 result.push_str("\n\n");
311 }
312 }
313
314 result.push_str(new_content);
315}
316
317#[instrument(skip(chunks, system_message, app_config, task_lm), fields(num_chunks = chunks.len()))]
319async fn process_chunks(
320 chunks: &[String],
321 system_message: &APIInputMessage,
322 app_config: &ApplicationConfiguration,
323 task_lm: &TaskLMSpec,
324) -> ChatbotResult<String> {
325 debug!("Processing {} chunks", chunks.len());
326 let mut result = String::new();
327
328 for (i, chunk) in chunks.iter().enumerate() {
329 debug!("Processing chunk {}/{}", i + 1, chunks.len());
330 let chunk_markdown =
331 process_block_chunk(chunk, system_message, app_config, task_lm).await?;
332 append_markdown_with_separator(&mut result, &chunk_markdown);
333 }
334
335 info!("Successfully cleaned content with LLM");
336 Ok(result)
337}
338
339#[instrument(skip(chunk, system_message, app_config, task_lm), fields(chunk_tokens = estimate_tokens(chunk)))]
341async fn process_block_chunk(
342 chunk: &str,
343 system_message: &APIInputMessage,
344 app_config: &ApplicationConfiguration,
345 task_lm: &TaskLMSpec,
346) -> ChatbotResult<String> {
347 let input = prepare_llm_messages(chunk, system_message);
348 let default_params = get_params_for_model(&task_lm.model, &task_lm.model_type, None);
349 let params = if let LLMRequestParams::GPTNonThinking(p) = default_params {
350 LLMRequestParams::GPTNonThinking(NonThinkingParams {
351 temperature: Some(REQUEST_TEMPERATURE),
352 ..p
353 })
354 } else {
355 default_params
356 };
357 let llm_base_request = LLMRequest::new(task_lm.model.to_owned(), input, params);
358 info!(
359 "Processing chunk of approximately {} tokens",
360 estimate_tokens(chunk)
361 );
362
363 let completion = match make_blocking_llm_request(llm_base_request, app_config).await {
364 Ok(completion) => completion,
365 Err(e) => {
366 error!("Failed to process chunk: {}", e);
367 return Err(e);
368 }
369 };
370
371 parse_text_completion(completion)
372}
373
374pub fn prepare_llm_messages(chunk: &str, system_message: &APIInputMessage) -> Vec<APIInputMessage> {
376 let content = format!(
377 "{}\n\n{}{}\n{}",
378 USER_PROMPT_START, JSON_BEGIN_MARKER, chunk, JSON_END_MARKER
379 );
380 let messages = vec![
381 system_message.clone(),
382 APIInputMessage {
383 message_type: InputItem::Message {
384 role: MessageRole::User,
385 content: MessageContent::Text(content),
386 },
387 },
388 ];
389
390 messages
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use serde_json::json;
397
398 const TEST_BLOCK_NAME: &str = "test/block";
399
400 #[test]
401 fn test_calculate_safe_token_limit() {
402 assert_eq!(calculate_safe_token_limit(1000, 0.75), 750);
403 assert_eq!(calculate_safe_token_limit(16000, 0.75), 12000);
404 assert_eq!(calculate_safe_token_limit(8000, 0.5), 4000);
405 }
406
407 #[test]
408 fn test_append_markdown_with_separator() {
409 let mut result = String::new();
410 append_markdown_with_separator(&mut result, "New content");
411 assert_eq!(result, "New content");
412
413 let mut result = String::from("Existing content");
414 append_markdown_with_separator(&mut result, "New content");
415 assert_eq!(result, "Existing content\n\nNew content");
416
417 let mut result = String::from("Existing content\n");
418 append_markdown_with_separator(&mut result, "New content");
419 assert_eq!(result, "Existing content\n\nNew content");
420
421 let mut result = String::from("Existing content\n\n");
422 append_markdown_with_separator(&mut result, "New content");
423 assert_eq!(result, "Existing content\n\nNew content");
424 }
425
426 #[test]
427 fn test_split_blocks_into_chunks() -> ChatbotResult<()> {
428 let block1 = create_test_block("a "); 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 "); let block3 = create_test_block("c c c c c c c c c c c c c c c "); let blocks = vec![block1.clone(), block2.clone(), block3.clone()];
434
435 let t1 = estimate_tokens(&block_to_json_string(&block1)?);
437 let t2 = estimate_tokens(&block_to_json_string(&block2)?);
438 let t3 = estimate_tokens(&block_to_json_string(&block3)?);
439
440 let chunks = split_blocks_into_chunks(&blocks, t1 + t2 + t3 + 10)?;
442 assert_eq!(chunks.len(), 1);
443
444 let deserialized_chunk: Vec<GutenbergBlock> = serde_json::from_str(&chunks[0])?;
445 assert_eq!(deserialized_chunk.len(), 3);
446
447 let chunks = split_blocks_into_chunks(&blocks, t1 + 1)?;
449
450 let first_chunk: Vec<GutenbergBlock> = serde_json::from_str(&chunks[0])?;
452 assert_eq!(first_chunk.len(), 1);
453 assert_eq!(first_chunk[0].client_id, block1.client_id);
454
455 for chunk in &chunks[1..] {
458 assert!(!chunk.is_empty());
459 }
460
461 Ok(())
462 }
463
464 #[test]
465 fn test_prepare_llm_messages() -> ChatbotResult<()> {
466 let blocks = vec![create_test_block("Test content")];
467 let blocks_json = blocks_to_json_string(&blocks)?;
468 let system_message = APIInputMessage {
469 message_type: InputItem::Message {
470 role: MessageRole::System,
471 content: MessageContent::Text("System prompt".to_string()),
472 },
473 };
474
475 let messages = prepare_llm_messages(&blocks_json, &system_message);
476
477 assert_eq!(messages.len(), 2);
478 let (msg1_content, msg1_role): (&str, Option<&MessageRole>) =
479 match &messages[0].message_type {
480 InputItem::Message { role, content } => {
481 (&content.to_owned().get_content_text(), Some(role))
482 }
483 _ => ("", None),
484 };
485 let (msg2_content, msg2_role): (&str, Option<&MessageRole>) =
486 match &messages[1].message_type {
487 InputItem::Message { role, content } => {
488 (&content.to_owned().get_content_text(), Some(role))
489 }
490 _ => ("", None),
491 };
492 assert_eq!(msg1_role, Some(&MessageRole::System));
493 assert_eq!(msg1_content, "System prompt");
494 assert_eq!(msg2_role, Some(&MessageRole::User));
495 assert!(msg2_content.contains(JSON_BEGIN_MARKER));
496 assert!(msg2_content.contains("Test content"));
497
498 Ok(())
499 }
500
501 fn create_test_block(content: &str) -> GutenbergBlock {
502 let client_id = uuid::Uuid::new_v4();
503 GutenbergBlock {
504 client_id,
505 name: TEST_BLOCK_NAME.to_string(),
506 is_valid: true,
507 attributes: {
508 let mut map = serde_json::Map::new();
509 map.insert("content".to_string(), json!(content));
510 map
511 },
512 inner_blocks: vec![],
513 }
514 }
515}