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,345 @@
//! Agent Profile Generation for Interactive Classroom
//!
//! Generates multi-agent classroom roles (Teacher, Assistant, Students)
//! with distinct personas, avatars, and action permissions.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
/// Agent role in the classroom
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentRole {
Teacher,
Assistant,
Student,
}
impl Default for AgentRole {
fn default() -> Self {
Self::Teacher
}
}
impl std::fmt::Display for AgentRole {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AgentRole::Teacher => write!(f, "teacher"),
AgentRole::Assistant => write!(f, "assistant"),
AgentRole::Student => write!(f, "student"),
}
}
}
/// Agent profile for classroom participants
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentProfile {
/// Unique ID for this agent
pub id: String,
/// Display name (e.g., "陈老师", "小助手", "张伟")
pub name: String,
/// Role type
pub role: AgentRole,
/// Persona description (system prompt for this agent)
pub persona: String,
/// Avatar emoji or URL
pub avatar: String,
/// Theme color (hex)
pub color: String,
/// Actions this agent is allowed to perform
pub allowed_actions: Vec<String>,
/// Speaking priority (higher = speaks first in multi-agent)
pub priority: u8,
}
/// Request for generating agent profiles
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentProfileRequest {
/// Topic for context-aware persona generation
pub topic: String,
/// Teaching style hint
pub style: String,
/// Difficulty level hint
pub level: String,
/// Total agent count (default: 5)
pub agent_count: Option<usize>,
/// Language code (default: "zh-CN")
pub language: Option<String>,
}
impl Default for AgentProfileRequest {
fn default() -> Self {
Self {
topic: String::new(),
style: "lecture".to_string(),
level: "intermediate".to_string(),
agent_count: None,
language: Some("zh-CN".to_string()),
}
}
}
/// Generate agent profiles for a classroom session.
///
/// Returns hardcoded defaults that match the OpenMAIC experience.
/// Future: optionally use LLM for dynamic persona generation.
pub fn generate_agent_profiles(request: &AgentProfileRequest) -> Vec<AgentProfile> {
let lang = request.language.as_deref().unwrap_or("zh-CN");
let count = request.agent_count.unwrap_or(5);
let student_count = count.saturating_sub(2).max(1);
if lang.starts_with("zh") {
generate_chinese_profiles(&request.topic, &request.style, student_count)
} else {
generate_english_profiles(&request.topic, &request.style, student_count)
}
}
fn generate_chinese_profiles(topic: &str, style: &str, student_count: usize) -> Vec<AgentProfile> {
let style_desc = match style {
"discussion" => "善于引导讨论的",
"pbl" => "注重项目实践的",
"socratic" => "擅长提问式教学的",
_ => "经验丰富的",
};
let mut agents = Vec::with_capacity(student_count + 2);
// Teacher
agents.push(AgentProfile {
id: format!("agent_teacher_{}", Uuid::new_v4()),
name: "陈老师".to_string(),
role: AgentRole::Teacher,
persona: format!(
"你是一位{}教师,正在教授「{}」这个主题。你的教学风格清晰有条理,\
善于使用生活中的比喻和类比帮助学生理解抽象概念。你注重核心原理的透彻理解,\
会用通俗易懂的语言解释复杂概念。",
style_desc, topic
),
avatar: "👩‍🏫".to_string(),
color: "#4F46E5".to_string(),
allowed_actions: vec![
"speech".into(),
"whiteboard_draw".into(),
"slideshow_control".into(),
"quiz_create".into(),
],
priority: 10,
});
// Assistant
agents.push(AgentProfile {
id: format!("agent_assistant_{}", Uuid::new_v4()),
name: "小助手".to_string(),
role: AgentRole::Assistant,
persona: format!(
"你是一位耐心的助教,正在协助教授「{}」。你擅长用代码示例和图表辅助讲解,\
善于回答学生问题补充老师遗漏的知识点。你说话简洁明了喜欢用emoji点缀语气。",
topic
),
avatar: "🤝".to_string(),
color: "#10B981".to_string(),
allowed_actions: vec![
"speech".into(),
"whiteboard_draw".into(),
],
priority: 7,
});
// Students — up to 3 distinct personalities
let student_templates = [
(
"李思",
"你是一个好奇且活跃的学生,正在学习「{topic}」。你有一定编程基础,但概念理解上容易混淆。\
你经常问'为什么'和'如果...呢'这类深入问题,喜欢和老师互动。",
"🤔",
"#EF4444",
),
(
"王明",
"你是一个认真笔记的学生,正在学习「{topic}」。你学习态度端正,善于总结和归纳要点。\
你经常复述和确认自己的理解,喜欢有条理的讲解方式。",
"📝",
"#F59E0B",
),
(
"张伟",
"你是一个思维跳跃的学生,正在学习「{topic}」。你经常联想到其他概念和实际应用场景,\
善于举一反三但有时会跑题。你喜欢动手实践和探索。",
"💡",
"#8B5CF6",
),
];
for i in 0..student_count {
let (name, persona_tmpl, avatar, color) = &student_templates[i % student_templates.len()];
agents.push(AgentProfile {
id: format!("agent_student_{}_{}", i + 1, Uuid::new_v4()),
name: name.to_string(),
role: AgentRole::Student,
persona: persona_tmpl.replace("{topic}", topic),
avatar: avatar.to_string(),
color: color.to_string(),
allowed_actions: vec!["speech".into(), "ask_question".into()],
priority: (5 - i as u8).max(1),
});
}
agents
}
fn generate_english_profiles(topic: &str, style: &str, student_count: usize) -> Vec<AgentProfile> {
let style_desc = match style {
"discussion" => "discussion-oriented",
"pbl" => "project-based",
"socratic" => "Socratic method",
_ => "experienced",
};
let mut agents = Vec::with_capacity(student_count + 2);
// Teacher
agents.push(AgentProfile {
id: format!("agent_teacher_{}", Uuid::new_v4()),
name: "Prof. Chen".to_string(),
role: AgentRole::Teacher,
persona: format!(
"You are a {} instructor teaching 「{}」. Your teaching style is clear and organized, \
skilled at using metaphors and analogies to explain complex concepts in accessible language. \
You focus on thorough understanding of core principles.",
style_desc, topic
),
avatar: "👩‍🏫".to_string(),
color: "#4F46E5".to_string(),
allowed_actions: vec![
"speech".into(),
"whiteboard_draw".into(),
"slideshow_control".into(),
"quiz_create".into(),
],
priority: 10,
});
// Assistant
agents.push(AgentProfile {
id: format!("agent_assistant_{}", Uuid::new_v4()),
name: "TA Alex".to_string(),
role: AgentRole::Assistant,
persona: format!(
"You are a patient teaching assistant helping with 「{}」. \
You provide code examples, diagrams, and fill in gaps. You are concise and friendly.",
topic
),
avatar: "🤝".to_string(),
color: "#10B981".to_string(),
allowed_actions: vec!["speech".into(), "whiteboard_draw".into()],
priority: 7,
});
// Students
let student_templates = [
(
"Sam",
"A curious and active student learning 「{topic}」. Has some programming background \
but gets confused on concepts. Often asks 'why?' and 'what if?'",
"🤔",
"#EF4444",
),
(
"Jordan",
"A diligent note-taking student learning 「{topic}」. Methodical learner, \
good at summarizing key points. Prefers structured explanations.",
"📝",
"#F59E0B",
),
(
"Alex",
"A creative thinker learning 「{topic}」. Connects concepts to real-world applications. \
Good at lateral thinking but sometimes goes off-topic.",
"💡",
"#8B5CF6",
),
];
for i in 0..student_count {
let (name, persona_tmpl, avatar, color) = &student_templates[i % student_templates.len()];
agents.push(AgentProfile {
id: format!("agent_student_{}_{}", i + 1, Uuid::new_v4()),
name: name.to_string(),
role: AgentRole::Student,
persona: persona_tmpl.replace("{topic}", topic),
avatar: avatar.to_string(),
color: color.to_string(),
allowed_actions: vec!["speech".into(), "ask_question".into()],
priority: (5 - i as u8).max(1),
});
}
agents
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_chinese_profiles() {
let req = AgentProfileRequest {
topic: "Rust 所有权".to_string(),
style: "lecture".to_string(),
level: "intermediate".to_string(),
agent_count: Some(5),
language: Some("zh-CN".to_string()),
};
let agents = generate_agent_profiles(&req);
assert_eq!(agents.len(), 5);
assert_eq!(agents[0].role, AgentRole::Teacher);
assert!(agents[0].name.contains("陈老师"));
assert!(agents[0].persona.contains("Rust 所有权"));
assert_eq!(agents[1].role, AgentRole::Assistant);
assert!(agents[1].name.contains("小助手"));
assert_eq!(agents[2].role, AgentRole::Student);
assert_eq!(agents[3].role, AgentRole::Student);
assert_eq!(agents[4].role, AgentRole::Student);
// Priority ordering
assert!(agents[0].priority > agents[1].priority);
assert!(agents[1].priority > agents[2].priority);
}
#[test]
fn test_generate_english_profiles() {
let req = AgentProfileRequest {
topic: "Python Basics".to_string(),
style: "discussion".to_string(),
level: "beginner".to_string(),
agent_count: Some(4),
language: Some("en-US".to_string()),
};
let agents = generate_agent_profiles(&req);
assert_eq!(agents.len(), 4); // 1 teacher + 1 assistant + 2 students
assert_eq!(agents[0].role, AgentRole::Teacher);
assert!(agents[0].persona.contains("discussion-oriented"));
}
#[test]
fn test_agent_role_display() {
assert_eq!(format!("{}", AgentRole::Teacher), "teacher");
assert_eq!(format!("{}", AgentRole::Assistant), "assistant");
assert_eq!(format!("{}", AgentRole::Student), "student");
}
#[test]
fn test_default_request() {
let req = AgentProfileRequest::default();
assert!(req.topic.is_empty());
assert_eq!(req.agent_count, None);
}
}

View File

@@ -0,0 +1,337 @@
//! Classroom Multi-Agent Chat
//!
//! Handles multi-agent conversation within the classroom context.
//! A single LLM call generates responses from multiple agent perspectives.
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::agents::AgentProfile;
/// A single chat message in the classroom
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomChatMessage {
/// Unique message ID
pub id: String,
/// Agent profile ID of the sender
pub agent_id: String,
/// Display name of the sender
pub agent_name: String,
/// Avatar of the sender
pub agent_avatar: String,
/// Message content
pub content: String,
/// Unix timestamp (milliseconds)
pub timestamp: i64,
/// Role of the sender
pub role: String,
/// Theme color of the sender
pub color: String,
}
/// Chat state for a classroom session
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomChatState {
/// All chat messages
pub messages: Vec<ClassroomChatMessage>,
/// Whether chat is active
pub active: bool,
}
/// Request for generating a chat response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassroomChatRequest {
/// Classroom ID
pub classroom_id: String,
/// User's message
pub user_message: String,
/// Available agents
pub agents: Vec<AgentProfile>,
/// Current scene context (optional, for contextual responses)
pub scene_context: Option<String>,
/// Chat history for context
pub history: Vec<ClassroomChatMessage>,
}
/// Response from multi-agent chat generation
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClassroomChatResponse {
/// Agent responses (may be 1-3 agents responding)
pub responses: Vec<ClassroomChatMessage>,
}
impl ClassroomChatMessage {
/// Create a user message
pub fn user_message(content: &str) -> Self {
Self {
id: format!("msg_{}", Uuid::new_v4()),
agent_id: "user".to_string(),
agent_name: "You".to_string(),
agent_avatar: "👤".to_string(),
content: content.to_string(),
timestamp: current_timestamp_millis(),
role: "user".to_string(),
color: "#6B7280".to_string(),
}
}
/// Create an agent message
pub fn agent_message(agent: &AgentProfile, content: &str) -> Self {
Self {
id: format!("msg_{}", Uuid::new_v4()),
agent_id: agent.id.clone(),
agent_name: agent.name.clone(),
agent_avatar: agent.avatar.clone(),
content: content.to_string(),
timestamp: current_timestamp_millis(),
role: agent.role.to_string(),
color: agent.color.clone(),
}
}
}
/// Build the LLM prompt for multi-agent chat response generation.
///
/// This function constructs a prompt that instructs the LLM to generate
/// responses from multiple agent perspectives in a structured JSON format.
pub fn build_chat_prompt(request: &ClassroomChatRequest) -> String {
let agent_descriptions: Vec<String> = request.agents.iter()
.map(|a| format!(
"- **{}** ({}): {}",
a.name, a.role, a.persona
))
.collect();
let history_text = if request.history.is_empty() {
"No previous messages.".to_string()
} else {
request.history.iter()
.map(|m| format!("**{}**: {}", m.agent_name, m.content))
.collect::<Vec<_>>()
.join("\n")
};
let scene_hint = request.scene_context.as_deref()
.map(|ctx| format!("\n当前场景上下文:{}", ctx))
.unwrap_or_default();
format!(
r#"你是一个课堂多智能体讨论的协调器。根据学生的问题选择1-3个合适的角色来回复。
## 可用角色
{agents}
## 对话历史
{history}
{scene_hint}
## 学生最新问题
{question}
## 回复规则
1. 选择最合适的1-3个角色来回复
2. 老师角色应该给出权威、清晰的解释
3. 助教角色可以补充代码示例或图表说明
4. 学生角色可以表达理解、提出追问或分享自己的理解
5. 每个角色的回复应该符合其个性设定
6. 回复应该自然、有教育意义
## 输出格式
你必须返回合法的JSON数组每个元素包含
```json
[
{{
"agentName": "角色名",
"content": "回复内容"
}}
]
```
只返回JSON数组不要包含其他文字。"#,
agents = agent_descriptions.join("\n"),
history = history_text,
scene_hint = scene_hint,
question = request.user_message,
)
}
/// Parse multi-agent responses from LLM output.
///
/// Extracts agent messages from the LLM's JSON response.
/// Falls back to a single teacher response if parsing fails.
pub fn parse_chat_responses(
llm_output: &str,
agents: &[AgentProfile],
) -> Vec<ClassroomChatMessage> {
// Try to extract JSON from the response
let json_text = extract_json_array(llm_output);
// Try parsing as JSON array
if let Ok(parsed) = serde_json::from_str::<Vec<serde_json::Value>>(&json_text) {
let mut messages = Vec::new();
for item in &parsed {
if let (Some(name), Some(content)) = (
item.get("agentName").and_then(|v| v.as_str()),
item.get("content").and_then(|v| v.as_str()),
) {
// Find matching agent
if let Some(agent) = agents.iter().find(|a| a.name == name) {
messages.push(ClassroomChatMessage::agent_message(agent, content));
}
}
}
if !messages.is_empty() {
return messages;
}
}
// Fallback: teacher responds with the raw LLM output
if let Some(teacher) = agents.iter().find(|a| a.role == super::agents::AgentRole::Teacher) {
vec![ClassroomChatMessage::agent_message(
teacher,
&clean_fallback_response(llm_output),
)]
} else if let Some(first) = agents.first() {
vec![ClassroomChatMessage::agent_message(first, llm_output)]
} else {
vec![]
}
}
/// Extract JSON array from text (handles markdown code blocks)
fn extract_json_array(text: &str) -> String {
// Try markdown code block first
if let Some(start) = text.find("```json") {
if let Some(end) = text[start + 7..].find("```") {
return text[start + 7..start + 7 + end].trim().to_string();
}
}
// Try to find JSON array 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()
}
/// Clean up fallback response (remove JSON artifacts if present)
fn clean_fallback_response(text: &str) -> String {
let trimmed = text.trim();
// If it looks like JSON attempt, extract just the text content
if trimmed.starts_with('[') || trimmed.starts_with('{') {
if let Ok(values) = serde_json::from_str::<Vec<serde_json::Value>>(trimmed) {
if let Some(first) = values.first() {
if let Some(content) = first.get("content").and_then(|v| v.as_str()) {
return content.to_string();
}
}
}
}
trimmed.to_string()
}
fn current_timestamp_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generation::agents::{AgentProfile, AgentRole};
fn test_agents() -> Vec<AgentProfile> {
vec![
AgentProfile {
id: "t1".into(),
name: "陈老师".into(),
role: AgentRole::Teacher,
persona: "Test teacher".into(),
avatar: "👩‍🏫".into(),
color: "#4F46E5".into(),
allowed_actions: vec![],
priority: 10,
},
AgentProfile {
id: "s1".into(),
name: "李思".into(),
role: AgentRole::Student,
persona: "Curious student".into(),
avatar: "🤔".into(),
color: "#EF4444".into(),
allowed_actions: vec![],
priority: 5,
},
]
}
#[test]
fn test_parse_chat_responses_valid_json() {
let agents = test_agents();
let llm_output = r#"```json
[
{"agentName": "陈老师", "content": "好问题!让我来解释一下..."},
{"agentName": "李思", "content": "原来如此,那如果..."}
]
```"#;
let messages = parse_chat_responses(llm_output, &agents);
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].agent_name, "陈老师");
assert_eq!(messages[1].agent_name, "李思");
}
#[test]
fn test_parse_chat_responses_fallback() {
let agents = test_agents();
let llm_output = "这是一个关于Rust的好问题。所有权意味着每个值只有一个主人。";
let messages = parse_chat_responses(llm_output, &agents);
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].agent_name, "陈老师"); // Falls back to teacher
}
#[test]
fn test_build_chat_prompt() {
let agents = test_agents();
let request = ClassroomChatRequest {
classroom_id: "test".into(),
user_message: "什么是所有权?".into(),
agents,
scene_context: Some("Rust 所有权核心规则".into()),
history: vec![],
};
let prompt = build_chat_prompt(&request);
assert!(prompt.contains("陈老师"));
assert!(prompt.contains("什么是所有权?"));
assert!(prompt.contains("Rust 所有权核心规则"));
}
#[test]
fn test_user_message() {
let msg = ClassroomChatMessage::user_message("Hello");
assert_eq!(msg.agent_name, "You");
assert_eq!(msg.role, "user");
}
#[test]
fn test_agent_message() {
let agent = &test_agents()[0];
let msg = ClassroomChatMessage::agent_message(agent, "Test");
assert_eq!(msg.agent_name, "陈老师");
assert_eq!(msg.role, "teacher");
}
}

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));
}
}