refactor(crates): kernel/generation module split + DeerFlow optimizations + middleware + dead code cleanup

- Split zclaw-kernel/kernel.rs (1486 lines) into 9 domain modules
- Split zclaw-kernel/generation.rs (1080 lines) into 3 modules
- Add DeerFlow-inspired middleware: DanglingTool, SubagentLimit, ToolError, ToolOutputGuard
- Add PromptBuilder for structured system prompt assembly
- Add FactStore (zclaw-memory) for persistent fact extraction
- Add task builtin tool for agent task management
- Driver improvements: Anthropic/OpenAI extended thinking, Gemini safety settings
- Replace let _ = with proper log::warn! across SaaS handlers
- Remove unused dependency (url) from zclaw-hands
This commit is contained in:
iven
2026-04-03 00:28:03 +08:00
parent 0a04b260a4
commit 52bdafa633
55 changed files with 4130 additions and 1959 deletions

View File

@@ -0,0 +1,997 @@
//! 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,
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>>,
}
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,
}
}
pub fn with_driver(driver: Arc<dyn LlmDriver>) -> Self {
Self {
driver: Some(driver),
..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 {
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(), &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
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)
}
// --- 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: "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,
thinking_enabled: false,
reasoning_effort: None,
plan_mode: false,
};
let response = driver.complete(llm_request).await?;
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,
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: "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,
thinking_enabled: false,
reasoning_effort: None,
plan_mode: false,
};
let response = driver.complete(llm_request).await?;
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)]
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>,
) -> 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(),
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)
.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::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));
}
}