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
14pub const REQUEST_TEMPERATURE: f32 = 0.1;
16
17const JSON_BEGIN_MARKER: &str = "---BEGIN COURSE MATERIAL JSON---";
19const JSON_END_MARKER: &str = "---END COURSE MATERIAL JSON---";
20
21const 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: ``, 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
37const USER_PROMPT_START: &str =
39 "Convert this JSON content to clean markdown. Output only the markdown, nothing else.";
40
41#[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
71pub fn calculate_safe_token_limit(context_window: i32, utilization: f32) -> i32 {
73 (context_window as f32 * utilization) as i32
74}
75
76fn 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
94fn 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
101fn 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#[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 block_tokens > max_content_tokens {
129 warn!(
130 "Block {} exceeds max token limit ({} > {})",
131 block.client_id, block_tokens, max_content_tokens
132 );
133 if !current_chunk.is_empty() {
135 chunks.push(blocks_to_json_string(¤t_chunk)?);
136 current_chunk = Vec::new();
137 current_chunk_tokens = 0;
138 }
139
140 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(¤t_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(¤t_chunk)?);
167 }
168
169 Ok(chunks)
170}
171
172#[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 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 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 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 while !block_json.is_char_boundary(end) && end > start {
232 end -= 1;
233 }
234
235 if end == start {
237 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 if end > start && end <= block_json.len() && start < block_json.len() {
266 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 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 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
302pub 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#[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#[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
381pub 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 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()];
441
442 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 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 let chunks = split_blocks_into_chunks(&blocks, t1 + 1)?;
456
457 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 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}