Files
zclaw_openfang/crates/zclaw-kernel/src/generation/mod.rs
iven d9b0b4f4f7
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(audit): Batch 7-9 dead_code 标注 + TODO 清理 + 文档同步
Batch 7: dead_code 标注统一 (16 处)
- crates/ 9 处: growth, kernel, pipeline, runtime, saas, skills
- src-tauri/ 7 处: classroom, intelligence, browser, mcp
- 统一格式: #[allow(dead_code)] // @reserved: <原因>

Batch 7+: EvolutionEngine L2/L3 10 个未使用 pub 函数
- 全部标注 @reserved: EvolutionEngine L2/L3, post-release integration

Batch 9: TODO → FUTURE 标记 (4 处)
- html.rs: template-based export
- nl_schedule.rs: LLM-assisted parsing
- knowledge/handlers.rs: category_id from upload
- personality_detector.rs: VikingStorage persistence

Batch 5+: Cargo.lock 更新 (serde_yaml_bw 迁移)

全量测试通过: 719 passed, 0 failed
2026-04-19 08:54:57 +08:00

1015 lines
34 KiB
Rust

//! 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<u32>,
color: Option<String>,
},
WhiteboardDrawShape {
shape: String,
x: f64,
y: f64,
width: f64,
height: f64,
fill: Option<String>,
},
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<u32>,
},
}
/// 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<SceneAction>,
pub duration_seconds: u32,
pub notes: Option<String>,
}
/// 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<String>,
pub duration_seconds: u32,
pub dependencies: Vec<String>,
}
/// 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<String>,
pub model: Option<String>,
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<String, serde_json::Value>,
}
/// 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<String>,
pub scenes: Vec<GeneratedScene>,
/// Agent profiles for this classroom (NEW)
pub agents: Vec<AgentProfile>,
pub metadata: ClassroomMetadata,
}
/// Generation request
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GenerationRequest {
pub topic: String,
pub document: Option<String>,
pub style: TeachingStyle,
pub level: DifficultyLevel,
pub target_duration_minutes: u32,
pub scene_count: Option<usize>,
pub custom_instructions: Option<String>,
pub language: Option<String>,
}
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<u32>,
}
/// Generation pipeline
pub struct GenerationPipeline {
stage: Arc<RwLock<GenerationStage>>,
progress: Arc<RwLock<GenerationProgress>>,
outline: Arc<RwLock<Vec<OutlineItem>>>,
scenes: Arc<RwLock<Vec<GeneratedScene>>>,
agents_store: Arc<RwLock<Vec<AgentProfile>>>,
driver: Option<Arc<dyn LlmDriver>>,
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<dyn LlmDriver>, 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<OutlineItem> {
self.outline.read().await.clone()
}
pub async fn get_scenes(&self) -> Vec<GeneratedScene> {
self.scenes.read().await.clone()
}
/// Stage 0: Generate agent profiles
pub async fn generate_agent_profiles(&self, request: &GenerationRequest) -> Vec<AgentProfile> {
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<Vec<OutlineItem>> {
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<Vec<GeneratedScene>> {
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<Classroom> {
// 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<Vec<OutlineItem>> {
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<GeneratedScene> {
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::<Vec<_>>()
.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<GeneratedScene> {
let json_text = Self::extract_json_static(text);
if let Ok(scene_data) = serde_json::from_str::<serde_json::Value>(&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<SceneAction> {
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<SceneAction> {
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<GeneratedScene> {
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<OutlineItem> {
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<Vec<OutlineItem>> {
let json_text = Self::extract_json_static(text);
if let Ok(full) = serde_json::from_str::<serde_json::Value>(&json_text) {
if let Some(outline) = full.get("outline").and_then(|o| o.as_array()) {
let items: Result<Vec<_>> = 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<OutlineItem> {
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<OutlineItem>,
scenes: Vec<GeneratedScene>,
agents: Vec<AgentProfile>,
is_placeholder: bool,
) -> Result<Classroom> {
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));
}
}