//! Classroom Generation Module //! //! Four-stage pipeline inspired by OpenMAIC: //! 1. Agent Profiles — generate classroom roles //! 2. Outline — structured course outline //! 3. Scenes — rich scene content with actions //! 4. Complete — assembled classroom pub mod agents; pub mod chat; 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}; pub use agents::{AgentProfile, AgentRole, AgentProfileRequest, generate_agent_profiles}; pub use chat::{ ClassroomChatMessage, ClassroomChatState, ClassroomChatRequest, ClassroomChatResponse, ClassroomChatState as ChatState, build_chat_prompt, parse_chat_responses, }; /// Generation stage (expanded from 2 to 4) #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GenerationStage { /// Stage 0: Generate agent profiles AgentProfiles, /// Stage 1: Generate outline Outline, /// Stage 2: Generate scenes from outline Scene, /// Complete Complete, } impl Default for GenerationStage { fn default() -> Self { Self::AgentProfiles } } /// Scene type (corresponds to OpenMAIC scene types) #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum SceneType { Slide, Quiz, Interactive, Pbl, Discussion, Media, Text, } /// Action to execute during scene playback #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SceneAction { Speech { text: String, #[serde(rename = "agentRole")] agent_role: String, }, WhiteboardDrawText { x: f64, y: f64, text: String, #[serde(rename = "fontSize")] font_size: Option, color: Option, }, WhiteboardDrawShape { shape: String, x: f64, y: f64, width: f64, height: f64, fill: Option, }, WhiteboardDrawChart { #[serde(rename = "chartType")] chart_type: String, data: serde_json::Value, x: f64, y: f64, width: f64, height: f64, }, WhiteboardDrawLatex { latex: String, x: f64, y: f64, }, WhiteboardClear, SlideshowSpotlight { #[serde(rename = "elementId")] element_id: String, }, SlideshowNext, QuizShow { #[serde(rename = "quizId")] quiz_id: String, }, Discussion { topic: String, #[serde(rename = "durationSeconds")] duration_seconds: Option, }, } /// Scene content (the actual teaching content) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SceneContent { pub title: String, pub scene_type: SceneType, pub content: serde_json::Value, pub actions: Vec, pub duration_seconds: u32, pub notes: Option, } /// Outline item (Stage 1 output) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct OutlineItem { pub id: String, pub title: String, pub description: String, pub scene_type: SceneType, pub key_points: Vec, pub duration_seconds: u32, pub dependencies: Vec, } /// Generated scene (Stage 2 output) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GeneratedScene { pub id: String, pub outline_id: String, pub content: SceneContent, pub order: usize, } /// Teaching style #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "snake_case")] pub enum TeachingStyle { #[default] Lecture, Discussion, Pbl, Flipped, 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)] #[serde(rename_all = "camelCase")] pub struct ClassroomMetadata { pub generated_at: i64, pub source_document: Option, pub model: Option, pub version: String, /// P2-10: Whether content was generated from placeholder fallback (not LLM) #[serde(default)] pub is_placeholder: bool, pub custom: serde_json::Map, } /// Complete classroom (final output) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Classroom { pub id: String, pub title: String, pub description: String, pub topic: String, pub style: TeachingStyle, pub level: DifficultyLevel, pub total_duration: u32, pub objectives: Vec, pub scenes: Vec, /// Agent profiles for this classroom (NEW) pub agents: Vec, pub metadata: ClassroomMetadata, } /// Generation request #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GenerationRequest { pub topic: String, pub document: Option, pub style: TeachingStyle, pub level: DifficultyLevel, pub target_duration_minutes: u32, pub scene_count: Option, pub custom_instructions: Option, 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)] #[serde(rename_all = "camelCase")] pub struct GenerationProgress { pub stage: GenerationStage, pub progress: u8, pub activity: String, pub items_progress: Option<(usize, usize)>, pub eta_seconds: Option, } /// Generation pipeline pub struct GenerationPipeline { stage: Arc>, progress: Arc>, outline: Arc>>, scenes: Arc>>, agents_store: Arc>>, driver: Option>, model: String, } impl GenerationPipeline { pub fn new() -> Self { Self { stage: Arc::new(RwLock::new(GenerationStage::AgentProfiles)), progress: Arc::new(RwLock::new(GenerationProgress { stage: GenerationStage::AgentProfiles, 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())), agents_store: Arc::new(RwLock::new(Vec::new())), driver: None, model: "default".to_string(), } } pub fn with_driver(driver: Arc, model: String) -> Self { Self { driver: Some(driver), model, ..Self::new() } } pub async fn get_progress(&self) -> GenerationProgress { self.progress.read().await.clone() } pub async fn get_stage(&self) -> GenerationStage { *self.stage.read().await } pub async fn get_outline(&self) -> Vec { self.outline.read().await.clone() } pub async fn get_scenes(&self) -> Vec { self.scenes.read().await.clone() } /// Stage 0: Generate agent profiles pub async fn generate_agent_profiles(&self, request: &GenerationRequest) -> Vec { self.update_progress(GenerationStage::AgentProfiles, 10, "Generating classroom roles...").await; let agents = generate_agent_profiles(&AgentProfileRequest { topic: request.topic.clone(), style: serde_json::to_string(&request.style).unwrap_or_else(|_| "\"lecture\"".to_string()).trim_matches('"').to_string(), level: serde_json::to_string(&request.level).unwrap_or_else(|_| "\"intermediate\"".to_string()).trim_matches('"').to_string(), agent_count: None, language: request.language.clone(), }); *self.agents_store.write().await = agents.clone(); self.update_progress(GenerationStage::AgentProfiles, 100, "Roles generated").await; *self.stage.write().await = GenerationStage::Outline; agents } /// Stage 1: Generate outline from request pub async fn generate_outline(&self, request: &GenerationRequest) -> Result> { self.update_progress(GenerationStage::Outline, 10, "Analyzing topic...").await; let prompt = self.build_outline_prompt(request); self.update_progress(GenerationStage::Outline, 30, "Generating outline...").await; let outline = if let Some(driver) = &self.driver { self.generate_outline_with_llm(driver.as_ref(), &prompt, request).await? } else { tracing::warn!("[P2-10] No LLM driver available, using placeholder outline"); self.generate_outline_placeholder(request) }; self.update_progress(GenerationStage::Outline, 100, "Outline complete").await; *self.outline.write().await = outline.clone(); *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; 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 { Self::generate_scene_with_llm_static(d.as_ref(), &self.model, &item, i).await } else { Self::generate_scene_for_item_static(&item, i) } } }) .collect(); let scene_results = join_all(scene_futures).await; 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) => { tracing::warn!("Failed to generate scene {}: {}", i, e); } } } scenes.sort_by_key(|s| s.order); *self.scenes.write().await = scenes.clone(); self.update_progress(GenerationStage::Complete, 100, "Generation complete").await; *self.stage.write().await = GenerationStage::Complete; Ok(scenes) } /// Full generation: 4-stage pipeline pub async fn generate(&self, request: GenerationRequest) -> Result { // Stage 0: Agent profiles let agents = self.generate_agent_profiles(&request).await; // Stage 1: Outline — track if placeholder was used (P2-10) let is_placeholder = self.driver.is_none(); let outline = self.generate_outline(&request).await?; // Stage 2: Scenes let scenes = self.generate_scenes(&outline).await?; // Build classroom self.build_classroom(request, outline, scenes, agents, is_placeholder) } // --- LLM integration methods --- async fn generate_outline_with_llm( &self, driver: &dyn LlmDriver, prompt: &str, request: &GenerationRequest, ) -> Result> { let llm_request = CompletionRequest { model: self.model.clone(), 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, thinking_enabled: false, reasoning_effort: None, plan_mode: false, }; let response = driver.complete(llm_request).await .map_err(|e| zclaw_types::ZclawError::LlmError( format!("Outline generation failed: {}", e) ))?; let text = Self::extract_text_from_response_static(&response); self.parse_outline_from_text(&text, request) } 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. Use Chinese if the topic is in Chinese. Include vivid metaphors and analogies."#.to_string() } async fn generate_scene_with_llm_static( driver: &dyn LlmDriver, model: &str, 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\n\ - notes: teaching notes", item.title, item.description, item.scene_type, item.key_points ); let llm_request = CompletionRequest { model: model.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, thinking_enabled: false, reasoning_effort: None, plan_mode: false, }; let response = driver.complete(llm_request).await .map_err(|e| zclaw_types::ZclawError::LlmError( format!("Scene '{}' generation failed: {}", item.title, e) ))?; let text = Self::extract_text_from_response_static(&response); Self::parse_scene_from_text_static(&text, item, order) } 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 with vivid metaphors and analogies 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, "notes": "Teaching notes for this scene" } Use Chinese if the topic is in Chinese. Include metaphors that relate to everyday life."#.to_string() } 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") } #[allow(dead_code)] // @reserved: instance-method convenience wrapper for static helper fn extract_text_from_response(&self, response: &CompletionResponse) -> String { Self::extract_text_from_response_static(response) } 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: scene_data.get("notes") .and_then(|v| v.as_str()) .map(String::from), }, order, }) } else { Self::generate_scene_for_item_static(item, order) } } 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() } 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, } } fn extract_json_static(text: &str) -> String { if let Some(start) = text.find("```json") { let content_start = start + 7; if let Some(end) = text[content_start..].find("```") { let json_end = content_start + end; if json_end > content_start { return text[content_start..json_end].trim().to_string(); } } } if let Some(start) = text.find('{') { if let Some(end) = text.rfind('}') { if end > start { return text[start..=end].to_string(); } } } text.to_string() } 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, }) } 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() } fn parse_outline_from_text(&self, text: &str, request: &GenerationRequest) -> Result> { let json_text = Self::extract_json_static(text); 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; } } Ok(self.generate_outline_placeholder(request)) } 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(), }) } fn build_classroom( &self, request: GenerationRequest, _outline: Vec, scenes: Vec, agents: Vec, is_placeholder: bool, ) -> 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 {}", request.style, request.topic), topic: request.topic, style: request.style, level: request.level, total_duration, objectives, scenes, agents, metadata: ClassroomMetadata { generated_at: current_timestamp(), source_document: request.document.map(|_| "user_document".to_string()), model: None, version: "2.0.0".to_string(), is_placeholder, // P2-10: mark placeholder content custom: serde_json::Map::new(), }, }) } 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. Include vivid metaphors and analogies that help students understand abstract concepts."#, 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) }) ) } 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; } } impl Default for GenerationPipeline { fn default() -> Self { Self::new() } } 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) .expect("system clock is valid") .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::AgentProfiles); let progress = pipeline.get_progress().await; assert_eq!(progress.progress, 0); } #[tokio::test] async fn test_generate_agent_profiles() { let pipeline = GenerationPipeline::new(); let request = GenerationRequest { topic: "Rust Ownership".to_string(), ..Default::default() }; let agents = pipeline.generate_agent_profiles(&request).await; assert_eq!(agents.len(), 5); // 1 teacher + 1 assistant + 3 students assert_eq!(agents[0].role, AgentRole::Teacher); assert!(agents[0].persona.contains("Rust Ownership")); } #[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); 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); 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_eq!(classroom.agents.len(), 5); 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)); } #[test] fn test_generation_stage_order() { assert!(matches!(GenerationStage::default(), GenerationStage::AgentProfiles)); } }