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:
345
crates/zclaw-kernel/src/generation/agents.rs
Normal file
345
crates/zclaw-kernel/src/generation/agents.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
337
crates/zclaw-kernel/src/generation/chat.rs
Normal file
337
crates/zclaw-kernel/src/generation/chat.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
997
crates/zclaw-kernel/src/generation/mod.rs
Normal file
997
crates/zclaw-kernel/src/generation/mod.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user