//! Generation Pipeline - Two-stage content generation //! //! Implements the OpenMAIC-style two-stage generation: //! Stage 1: Outline generation - analyze input and create structured outline //! Stage 2: Scene generation - expand each outline item into rich scenes use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; use futures::future::join_all; use zclaw_types::Result; use zclaw_runtime::{LlmDriver, CompletionRequest, CompletionResponse, ContentBlock}; /// Generation stage #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GenerationStage { /// Stage 1: Generate outline Outline, /// Stage 2: Generate scenes from outline Scene, /// Complete Complete, } /// Scene type (corresponds to OpenMAIC scene types) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SceneType { /// Slide/presentation content Slide, /// Quiz/assessment Quiz, /// Interactive HTML simulation Interactive, /// Project-based learning Pbl, /// Discussion prompt Discussion, /// Video/media content Media, /// Text content Text, } /// Action to execute during scene playback #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SceneAction { /// Speech/text output Speech { text: String, agent_role: String, }, /// Whiteboard draw text WhiteboardDrawText { x: f64, y: f64, text: String, font_size: Option, color: Option, }, /// Whiteboard draw shape WhiteboardDrawShape { shape: String, x: f64, y: f64, width: f64, height: f64, fill: Option, }, /// Whiteboard draw chart WhiteboardDrawChart { chart_type: String, data: serde_json::Value, x: f64, y: f64, width: f64, height: f64, }, /// Whiteboard draw LaTeX WhiteboardDrawLatex { latex: String, x: f64, y: f64, }, /// Whiteboard clear WhiteboardClear, /// Slideshow spotlight SlideshowSpotlight { element_id: String, }, /// Slideshow next slide SlideshowNext, /// Quiz show question QuizShow { quiz_id: String, }, /// Trigger discussion Discussion { topic: String, duration_seconds: Option, }, } /// Scene content (the actual teaching content) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SceneContent { /// Scene title pub title: String, /// Scene type pub scene_type: SceneType, /// Scene content (type-specific) pub content: serde_json::Value, /// Actions to execute pub actions: Vec, /// Duration in seconds pub duration_seconds: u32, /// Teaching notes pub notes: Option, } /// Outline item (Stage 1 output) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OutlineItem { /// Item ID pub id: String, /// Item title pub title: String, /// Item description pub description: String, /// Scene type to generate pub scene_type: SceneType, /// Key points to cover pub key_points: Vec, /// Estimated duration pub duration_seconds: u32, /// Dependencies (IDs of items that must complete first) pub dependencies: Vec, } /// Generated scene (Stage 2 output) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeneratedScene { /// Scene ID pub id: String, /// Corresponding outline item ID pub outline_id: String, /// Scene content pub content: SceneContent, /// Order in the classroom pub order: usize, } /// Complete classroom (final output) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Classroom { /// Classroom ID pub id: String, /// Classroom title pub title: String, /// Classroom description pub description: String, /// Topic/subject pub topic: String, /// Teaching style pub style: TeachingStyle, /// Difficulty level pub level: DifficultyLevel, /// Total duration in seconds pub total_duration: u32, /// Learning objectives pub objectives: Vec, /// All scenes in order pub scenes: Vec, /// Metadata pub metadata: ClassroomMetadata, } /// Teaching style #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum TeachingStyle { /// Lecture-style (teacher presents) #[default] Lecture, /// Discussion-style (interactive) Discussion, /// Project-based learning Pbl, /// Flipped classroom Flipped, /// Socratic method (question-driven) Socratic, } /// Difficulty level #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum DifficultyLevel { Beginner, #[default] Intermediate, Advanced, Expert, } /// Classroom metadata #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ClassroomMetadata { /// Generation timestamp pub generated_at: i64, /// Source document (if any) pub source_document: Option, /// LLM model used pub model: Option, /// Version pub version: String, /// Custom metadata pub custom: serde_json::Map, } /// Generation request #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerationRequest { /// Topic or theme pub topic: String, /// Optional source document content pub document: Option, /// Teaching style pub style: TeachingStyle, /// Difficulty level pub level: DifficultyLevel, /// Target duration in minutes pub target_duration_minutes: u32, /// Number of scenes to generate pub scene_count: Option, /// Custom instructions pub custom_instructions: Option, /// Language code pub language: Option, } impl Default for GenerationRequest { fn default() -> Self { Self { topic: String::new(), document: None, style: TeachingStyle::default(), level: DifficultyLevel::default(), target_duration_minutes: 30, scene_count: None, custom_instructions: None, language: Some("zh-CN".to_string()), } } } /// Generation progress #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GenerationProgress { /// Current stage pub stage: GenerationStage, /// Progress percentage (0-100) pub progress: u8, /// Current activity description pub activity: String, /// Items completed / total items pub items_progress: Option<(usize, usize)>, /// Estimated time remaining in seconds pub eta_seconds: Option, } /// Generation pipeline pub struct GenerationPipeline { /// Current stage stage: Arc>, /// Current progress progress: Arc>, /// Generated outline outline: Arc>>, /// Generated scenes scenes: Arc>>, /// Optional LLM driver for real generation driver: Option>, } impl GenerationPipeline { /// Create a new generation pipeline pub fn new() -> Self { Self { stage: Arc::new(RwLock::new(GenerationStage::Outline)), progress: Arc::new(RwLock::new(GenerationProgress { stage: GenerationStage::Outline, progress: 0, activity: "Initializing".to_string(), items_progress: None, eta_seconds: None, })), outline: Arc::new(RwLock::new(Vec::new())), scenes: Arc::new(RwLock::new(Vec::new())), driver: None, } } /// Create pipeline with LLM driver pub fn with_driver(driver: Arc) -> Self { Self { driver: Some(driver), ..Self::new() } } /// Get current progress pub async fn get_progress(&self) -> GenerationProgress { self.progress.read().await.clone() } /// Get current stage pub async fn get_stage(&self) -> GenerationStage { *self.stage.read().await } /// Get generated outline pub async fn get_outline(&self) -> Vec { self.outline.read().await.clone() } /// Get generated scenes pub async fn get_scenes(&self) -> Vec { self.scenes.read().await.clone() } /// Stage 1: Generate outline from request pub async fn generate_outline(&self, request: &GenerationRequest) -> Result> { // Update progress self.update_progress(GenerationStage::Outline, 10, "Analyzing topic...").await; // Build outline generation prompt let prompt = self.build_outline_prompt(request); // Update progress self.update_progress(GenerationStage::Outline, 30, "Generating outline...").await; let outline = if let Some(driver) = &self.driver { // Use LLM to generate outline self.generate_outline_with_llm(driver.as_ref(), &prompt, request).await? } else { // Fallback to placeholder self.generate_outline_placeholder(request) }; // Update progress self.update_progress(GenerationStage::Outline, 100, "Outline complete").await; // Store outline *self.outline.write().await = outline.clone(); // Move to scene stage *self.stage.write().await = GenerationStage::Scene; Ok(outline) } /// Stage 2: Generate scenes from outline (parallel) pub async fn generate_scenes(&self, outline: &[OutlineItem]) -> Result> { let total = outline.len(); if total == 0 { return Ok(Vec::new()); } self.update_progress( GenerationStage::Scene, 0, &format!("Generating {} scenes in parallel...", total), ).await; // Generate all scenes in parallel using futures::join_all let scene_futures: Vec<_> = outline .iter() .enumerate() .map(|(i, item)| { let driver = self.driver.clone(); let item = item.clone(); async move { if let Some(d) = driver { // Use LLM to generate scene Self::generate_scene_with_llm_static(d.as_ref(), &item, i).await } else { // Fallback to placeholder Self::generate_scene_for_item_static(&item, i) } } }) .collect(); // Wait for all scenes to complete let scene_results = join_all(scene_futures).await; // Collect results, preserving order let mut scenes = Vec::new(); for (i, result) in scene_results.into_iter().enumerate() { match result { Ok(scene) => { self.update_progress( GenerationStage::Scene, ((i + 1) as f64 / total as f64 * 100.0) as u8, &format!("Completed scene {} of {}: {}", i + 1, total, scene.content.title), ).await; scenes.push(scene); } Err(e) => { // Log error but continue with other scenes tracing::warn!("Failed to generate scene {}: {}", i, e); } } } // Sort by order to ensure correct sequence scenes.sort_by_key(|s| s.order); // Store scenes *self.scenes.write().await = scenes.clone(); // Mark complete self.update_progress(GenerationStage::Complete, 100, "Generation complete").await; *self.stage.write().await = GenerationStage::Complete; Ok(scenes) } /// Static version for parallel execution async fn generate_scene_with_llm_static( driver: &dyn LlmDriver, item: &OutlineItem, order: usize, ) -> Result { let prompt = format!( "Generate a detailed scene for the following outline item:\n\ Title: {}\n\ Description: {}\n\ Type: {:?}\n\ Key Points: {:?}\n\n\ Return a JSON object with:\n\ - title: scene title\n\ - content: scene content (object with relevant fields)\n\ - actions: array of actions to execute\n\ - duration_seconds: estimated duration", item.title, item.description, item.scene_type, item.key_points ); let llm_request = CompletionRequest { model: "default".to_string(), system: Some(Self::get_scene_system_prompt_static()), messages: vec![zclaw_types::Message::User { content: prompt, }], tools: vec![], max_tokens: Some(2048), temperature: Some(0.7), stop: vec![], stream: false, }; let response = driver.complete(llm_request).await?; let text = Self::extract_text_from_response_static(&response); // Parse scene from response Self::parse_scene_from_text_static(&text, item, order) } /// Static version of scene system prompt fn get_scene_system_prompt_static() -> String { r#"You are an expert educational content creator. Your task is to generate detailed teaching scenes. When given an outline item, you will: 1. Create rich, engaging content 2. Design appropriate actions (speech, whiteboard, quiz, etc.) 3. Ensure content matches the scene type You MUST respond with valid JSON in this exact format: { "title": "Scene Title", "content": { "description": "Detailed description", "key_points": ["Point 1", "Point 2"], "slides": [{"title": "...", "content": "..."}] }, "actions": [ {"type": "speech", "text": "Welcome to...", "agent_role": "teacher"}, {"type": "whiteboard_draw_text", "x": 100, "y": 100, "text": "Key Concept"} ], "duration_seconds": 300 } Actions can be: - speech: {"type": "speech", "text": "...", "agent_role": "teacher|assistant|student"} - whiteboard_draw_text: {"type": "whiteboard_draw_text", "x": 0, "y": 0, "text": "..."} - whiteboard_draw_shape: {"type": "whiteboard_draw_shape", "shape": "rectangle", "x": 0, "y": 0, "width": 100, "height": 50} - quiz_show: {"type": "quiz_show", "quiz_id": "..."} - discussion: {"type": "discussion", "topic": "..."}"#.to_string() } /// Static version of text extraction fn extract_text_from_response_static(response: &CompletionResponse) -> String { response.content.iter() .filter_map(|block| match block { ContentBlock::Text { text } => Some(text.clone()), _ => None, }) .collect::>() .join("\n") } /// Static version of scene parsing fn parse_scene_from_text_static(text: &str, item: &OutlineItem, order: usize) -> Result { let json_text = Self::extract_json_static(text); if let Ok(scene_data) = serde_json::from_str::(&json_text) { let actions = Self::parse_actions_static(&scene_data); Ok(GeneratedScene { id: format!("scene_{}", item.id), outline_id: item.id.clone(), content: SceneContent { title: scene_data.get("title") .and_then(|v| v.as_str()) .unwrap_or(&item.title) .to_string(), scene_type: item.scene_type.clone(), content: scene_data.get("content").cloned().unwrap_or(serde_json::json!({})), actions, duration_seconds: scene_data.get("duration_seconds") .and_then(|v| v.as_u64()) .unwrap_or(item.duration_seconds as u64) as u32, notes: None, }, order, }) } else { // Fallback Self::generate_scene_for_item_static(item, order) } } /// Static version of actions parsing fn parse_actions_static(scene_data: &serde_json::Value) -> Vec { scene_data.get("actions") .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|action| Self::parse_single_action_static(action)) .collect() }) .unwrap_or_default() } /// Static version of single action parsing fn parse_single_action_static(action: &serde_json::Value) -> Option { let action_type = action.get("type")?.as_str()?; match action_type { "speech" => Some(SceneAction::Speech { text: action.get("text")?.as_str()?.to_string(), agent_role: action.get("agent_role") .and_then(|v| v.as_str()) .unwrap_or("teacher") .to_string(), }), "whiteboard_draw_text" => Some(SceneAction::WhiteboardDrawText { x: action.get("x")?.as_f64()?, y: action.get("y")?.as_f64()?, text: action.get("text")?.as_str()?.to_string(), font_size: action.get("font_size").and_then(|v| v.as_u64()).map(|v| v as u32), color: action.get("color").and_then(|v| v.as_str()).map(String::from), }), "whiteboard_draw_shape" => Some(SceneAction::WhiteboardDrawShape { shape: action.get("shape")?.as_str()?.to_string(), x: action.get("x")?.as_f64()?, y: action.get("y")?.as_f64()?, width: action.get("width")?.as_f64()?, height: action.get("height")?.as_f64()?, fill: action.get("fill").and_then(|v| v.as_str()).map(String::from), }), "quiz_show" => Some(SceneAction::QuizShow { quiz_id: action.get("quiz_id")?.as_str()?.to_string(), }), "discussion" => Some(SceneAction::Discussion { topic: action.get("topic")?.as_str()?.to_string(), duration_seconds: action.get("duration_seconds").and_then(|v| v.as_u64()).map(|v| v as u32), }), _ => None, } } /// Static version of JSON extraction fn extract_json_static(text: &str) -> String { // Try to extract from markdown code block if let Some(start) = text.find("```json") { if let Some(end) = text[start..].find("```") { let json_start = start + 7; let json_end = start + end; if json_end > json_start { return text[json_start..json_end].trim().to_string(); } } } // Try to find JSON object directly if let Some(start) = text.find('{') { if let Some(end) = text.rfind('}') { if end > start { return text[start..=end].to_string(); } } } text.to_string() } /// Static version of scene generation for item fn generate_scene_for_item_static(item: &OutlineItem, order: usize) -> Result { let actions = match item.scene_type { SceneType::Slide => vec![ SceneAction::Speech { text: format!("Let's explore: {}", item.title), agent_role: "teacher".to_string(), }, SceneAction::WhiteboardDrawText { x: 100.0, y: 100.0, text: item.title.clone(), font_size: Some(32), color: Some("#333333".to_string()), }, ], SceneType::Quiz => vec![ SceneAction::Speech { text: "Now let's test your understanding.".to_string(), agent_role: "teacher".to_string(), }, SceneAction::QuizShow { quiz_id: format!("quiz_{}", item.id), }, ], SceneType::Discussion => vec![ SceneAction::Discussion { topic: item.title.clone(), duration_seconds: Some(300), }, ], _ => vec![ SceneAction::Speech { text: format!("Content for: {}", item.title), agent_role: "teacher".to_string(), }, ], }; Ok(GeneratedScene { id: format!("scene_{}", item.id), outline_id: item.id.clone(), content: SceneContent { title: item.title.clone(), scene_type: item.scene_type.clone(), content: serde_json::json!({ "description": item.description, "key_points": item.key_points, }), actions, duration_seconds: item.duration_seconds, notes: None, }, order, }) } /// Generate outline using LLM async fn generate_outline_with_llm( &self, driver: &dyn LlmDriver, prompt: &str, request: &GenerationRequest, ) -> Result> { let llm_request = CompletionRequest { model: "default".to_string(), system: Some(self.get_outline_system_prompt()), messages: vec![zclaw_types::Message::User { content: prompt.to_string(), }], tools: vec![], max_tokens: Some(4096), temperature: Some(0.7), stop: vec![], stream: false, }; let response = driver.complete(llm_request).await?; let text = self.extract_text_from_response(&response); // Parse JSON from response self.parse_outline_from_text(&text, request) } /// Extract text from LLM response fn extract_text_from_response(&self, response: &CompletionResponse) -> String { response.content.iter() .filter_map(|block| match block { ContentBlock::Text { text } => Some(text.clone()), _ => None, }) .collect::>() .join("\n") } /// Get system prompt for outline generation fn get_outline_system_prompt(&self) -> String { r#"You are an expert educational content designer. Your task is to generate structured course outlines. When given a topic, you will: 1. Analyze the topic and identify key learning objectives 2. Create a logical flow of scenes/modules 3. Assign appropriate scene types (slide, quiz, interactive, discussion) 4. Estimate duration for each section You MUST respond with valid JSON in this exact format: { "title": "Course Title", "description": "Course description", "objectives": ["Objective 1", "Objective 2"], "outline": [ { "id": "outline_1", "title": "Scene Title", "description": "What this scene covers", "scene_type": "slide", "key_points": ["Point 1", "Point 2"], "duration_seconds": 300, "dependencies": [] } ] } Ensure the outline is coherent and follows good pedagogical practices."#.to_string() } /// Parse outline from LLM response text fn parse_outline_from_text(&self, text: &str, request: &GenerationRequest) -> Result> { // Try to extract JSON from the response let json_text = self.extract_json(text); // Try to parse as full outline structure if let Ok(full) = serde_json::from_str::(&json_text) { if let Some(outline) = full.get("outline").and_then(|o| o.as_array()) { let items: Result> = outline.iter() .map(|item| self.parse_outline_item(item)) .collect(); return items; } } // Fallback to placeholder Ok(self.generate_outline_placeholder(request)) } /// Parse single outline item fn parse_outline_item(&self, value: &serde_json::Value) -> Result { Ok(OutlineItem { id: value.get("id") .and_then(|v| v.as_str()) .unwrap_or(&format!("outline_{}", uuid_v4())) .to_string(), title: value.get("title") .and_then(|v| v.as_str()) .unwrap_or("Untitled") .to_string(), description: value.get("description") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), scene_type: value.get("scene_type") .and_then(|v| v.as_str()) .and_then(|s| serde_json::from_str(&format!("\"{}\"", s)).ok()) .unwrap_or(SceneType::Slide), key_points: value.get("key_points") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(), duration_seconds: value.get("duration_seconds") .and_then(|v| v.as_u64()) .unwrap_or(300) as u32, dependencies: value.get("dependencies") .and_then(|v| v.as_array()) .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) .unwrap_or_default(), }) } /// Extract JSON from text (handles markdown code blocks) fn extract_json(&self, text: &str) -> String { // Try to extract from markdown code block if let Some(start) = text.find("```json") { if let Some(end) = text[start..].find("```") { let json_start = start + 7; let json_end = start + end; if json_end > json_start { return text[json_start..json_end].trim().to_string(); } } } // Try to find JSON object directly if let Some(start) = text.find('{') { if let Some(end) = text.rfind('}') { if end > start { return text[start..=end].to_string(); } } } text.to_string() } /// Generate complete classroom pub async fn generate(&self, request: GenerationRequest) -> Result { // Stage 1: Generate outline let outline = self.generate_outline(&request).await?; // Stage 2: Generate scenes let scenes = self.generate_scenes(&outline).await?; // Build classroom let classroom = self.build_classroom(request, outline, scenes)?; Ok(classroom) } /// Update progress async fn update_progress(&self, stage: GenerationStage, progress: u8, activity: &str) { let mut p = self.progress.write().await; p.stage = stage; p.progress = progress; p.activity = activity.to_string(); p.items_progress = None; } /// Build outline generation prompt fn build_outline_prompt(&self, request: &GenerationRequest) -> String { format!( r#"Generate a structured classroom outline for the following: Topic: {} Style: {:?} Level: {:?} Target Duration: {} minutes {} Please create an outline with the following format for each item: - id: unique identifier - title: scene title - description: what this scene covers - scene_type: slide/quiz/interactive/pbl/discussion/media/text - key_points: list of key points to cover - duration_seconds: estimated duration Generate {} outline items that flow logically and cover the topic comprehensively."#, request.topic, request.style, request.level, request.target_duration_minutes, request.custom_instructions.as_ref() .map(|s| format!("Additional instructions: {}", s)) .unwrap_or_default(), request.scene_count.unwrap_or_else(|| { (request.target_duration_minutes as usize / 5).max(3).min(10) }) ) } /// Generate placeholder outline (would be replaced by LLM call) fn generate_outline_placeholder(&self, request: &GenerationRequest) -> Vec { let count = request.scene_count.unwrap_or_else(|| { (request.target_duration_minutes as usize / 5).max(3).min(10) }); let base_duration = request.target_duration_minutes * 60 / count as u32; (0..count) .map(|i| OutlineItem { id: format!("outline_{}", i + 1), title: format!("Scene {}: {}", i + 1, request.topic), description: format!("Content for scene {} about {}", i + 1, request.topic), scene_type: if i % 4 == 3 { SceneType::Quiz } else { SceneType::Slide }, key_points: vec![ format!("Key point 1 for scene {}", i + 1), format!("Key point 2 for scene {}", i + 1), format!("Key point 3 for scene {}", i + 1), ], duration_seconds: base_duration, dependencies: if i > 0 { vec![format!("outline_{}", i)] } else { vec![] }, }) .collect() } /// Build classroom from components fn build_classroom( &self, request: GenerationRequest, outline: Vec, scenes: Vec, ) -> Result { let total_duration: u32 = scenes.iter() .map(|s| s.content.duration_seconds) .sum(); let objectives = outline.iter() .take(3) .map(|item| format!("Understand: {}", item.title)) .collect(); Ok(Classroom { id: uuid_v4(), title: format!("Classroom: {}", request.topic), description: format!("A {} style classroom about {}", match request.style { TeachingStyle::Lecture => "lecture", TeachingStyle::Discussion => "discussion", TeachingStyle::Pbl => "project-based", TeachingStyle::Flipped => "flipped classroom", TeachingStyle::Socratic => "Socratic", }, request.topic ), topic: request.topic, style: request.style, level: request.level, total_duration, objectives, scenes, metadata: ClassroomMetadata { generated_at: current_timestamp(), source_document: request.document.map(|_| "user_document".to_string()), model: None, version: "1.0.0".to_string(), custom: serde_json::Map::new(), }, }) } } impl Default for GenerationPipeline { fn default() -> Self { Self::new() } } // Helper functions /// Generate a cryptographically secure UUID v4 fn uuid_v4() -> String { Uuid::new_v4().to_string() } fn current_timestamp() -> i64 { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_millis() as i64 } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_pipeline_creation() { let pipeline = GenerationPipeline::new(); let stage = pipeline.get_stage().await; assert_eq!(stage, GenerationStage::Outline); let progress = pipeline.get_progress().await; assert_eq!(progress.progress, 0); } #[tokio::test] async fn test_generate_outline() { let pipeline = GenerationPipeline::new(); let request = GenerationRequest { topic: "Rust Ownership".to_string(), target_duration_minutes: 30, scene_count: Some(5), ..Default::default() }; let outline = pipeline.generate_outline(&request).await.unwrap(); assert_eq!(outline.len(), 5); // Check first item let first = &outline[0]; assert!(first.title.contains("Rust Ownership")); assert!(!first.key_points.is_empty()); } #[tokio::test] async fn test_generate_scenes() { let pipeline = GenerationPipeline::new(); let outline = vec![ OutlineItem { id: "outline_1".to_string(), title: "Introduction".to_string(), description: "Intro to topic".to_string(), scene_type: SceneType::Slide, key_points: vec!["Point 1".to_string()], duration_seconds: 300, dependencies: vec![], }, OutlineItem { id: "outline_2".to_string(), title: "Quiz".to_string(), description: "Test understanding".to_string(), scene_type: SceneType::Quiz, key_points: vec!["Test 1".to_string()], duration_seconds: 180, dependencies: vec!["outline_1".to_string()], }, ]; let scenes = pipeline.generate_scenes(&outline).await.unwrap(); assert_eq!(scenes.len(), 2); // Check first scene let first = &scenes[0]; assert_eq!(first.content.scene_type, SceneType::Slide); assert!(!first.content.actions.is_empty()); } #[tokio::test] async fn test_full_generation() { let pipeline = GenerationPipeline::new(); let request = GenerationRequest { topic: "Machine Learning Basics".to_string(), style: TeachingStyle::Lecture, level: DifficultyLevel::Beginner, target_duration_minutes: 15, scene_count: Some(3), ..Default::default() }; let classroom = pipeline.generate(request).await.unwrap(); assert!(classroom.title.contains("Machine Learning")); assert_eq!(classroom.scenes.len(), 3); assert!(classroom.total_duration > 0); assert!(!classroom.objectives.is_empty()); } #[test] fn test_scene_action_serialization() { let action = SceneAction::Speech { text: "Hello".to_string(), agent_role: "teacher".to_string(), }; let json = serde_json::to_string(&action).unwrap(); assert!(json.contains("speech")); let action2: SceneAction = serde_json::from_str(&json).unwrap(); match action2 { SceneAction::Speech { text, .. } => assert_eq!(text, "Hello"), _ => panic!("Wrong type"), } } #[test] fn test_teaching_style_default() { let style = TeachingStyle::default(); assert!(matches!(style, TeachingStyle::Lecture)); } }