Files
zclaw_openfang/crates/zclaw-kernel/src/generation.rs
iven 8bcabbfb43
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
refactor: 代码质量清理 - 移除死代码和遗留别名
基于全面审计报告的 P0-P2 修复工作:

P0 (已完成):
- intelligence 模块: 精确注释 dead_code 标注原因(Tauri runtime 注册)
- compactor.rs: 实现 LLM 摘要生成(compact_with_llm)
- pipeline_commands.rs: 替换 println! 为 tracing 宏

P1 (已完成):
- 移除 8 个 gateway_* 向后兼容别名(OpenClaw 遗留)
- 前端 tauri-gateway.ts 改为调用 zclaw_* 命令
- 清理 generation.rs 6 个重复的实例方法(-217 行)
- A2A dead_code 注释更新

P2 (已完成):
- Predictor/Lead HAND.toml 设置 enabled=false
- Wasm/Native SkillMode 添加未实现说明
- browser/mod.rs 移除未使用的 re-export(消除 4 个警告)

文档更新:
- feature-checklist.md 从 v0.4.0 更新到 v0.6.0
- CLAUDE.md Hands 状态更新

验证: cargo check 零警告, 42 测试通过, 净减 371 行代码
2026-03-27 00:54:57 +08:00

1081 lines
35 KiB
Rust

//! 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<u32>,
color: Option<String>,
},
/// Whiteboard draw shape
WhiteboardDrawShape {
shape: String,
x: f64,
y: f64,
width: f64,
height: f64,
fill: Option<String>,
},
/// 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<u32>,
},
}
/// 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<SceneAction>,
/// Duration in seconds
pub duration_seconds: u32,
/// Teaching notes
pub notes: Option<String>,
}
/// 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<String>,
/// Estimated duration
pub duration_seconds: u32,
/// Dependencies (IDs of items that must complete first)
pub dependencies: Vec<String>,
}
/// 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<String>,
/// All scenes in order
pub scenes: Vec<GeneratedScene>,
/// 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<String>,
/// LLM model used
pub model: Option<String>,
/// Version
pub version: String,
/// Custom metadata
pub custom: serde_json::Map<String, serde_json::Value>,
}
/// Generation request
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerationRequest {
/// Topic or theme
pub topic: String,
/// Optional source document content
pub document: Option<String>,
/// 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<usize>,
/// Custom instructions
pub custom_instructions: Option<String>,
/// Language code
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)]
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<u32>,
}
/// Generation pipeline
pub struct GenerationPipeline {
/// Current stage
stage: Arc<RwLock<GenerationStage>>,
/// Current progress
progress: Arc<RwLock<GenerationProgress>>,
/// Generated outline
outline: Arc<RwLock<Vec<OutlineItem>>>,
/// Generated scenes
scenes: Arc<RwLock<Vec<GeneratedScene>>>,
/// Optional LLM driver for real generation
driver: Option<Arc<dyn LlmDriver>>,
}
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<dyn LlmDriver>) -> 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<OutlineItem> {
self.outline.read().await.clone()
}
/// Get generated scenes
pub async fn get_scenes(&self) -> Vec<GeneratedScene> {
self.scenes.read().await.clone()
}
/// Stage 1: Generate outline from request
pub async fn generate_outline(&self, request: &GenerationRequest) -> Result<Vec<OutlineItem>> {
// 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<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;
// 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<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",
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::<Vec<_>>()
.join("\n")
}
/// Static version of scene parsing
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: 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<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()
}
/// Static version of single action parsing
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,
}
}
/// 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<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,
})
}
/// Generate outline using LLM
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,
};
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::<Vec<_>>()
.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<Vec<OutlineItem>> {
// 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::<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;
}
}
// Fallback to placeholder
Ok(self.generate_outline_placeholder(request))
}
/// Parse single outline item
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(),
})
}
/// 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<Classroom> {
// 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<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()
}
/// Build classroom from components
fn build_classroom(
&self,
request: GenerationRequest,
outline: Vec<OutlineItem>,
scenes: Vec<GeneratedScene>,
) -> 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 {}",
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));
}
}