release(v0.2.0): streaming, MCP protocol, Browser Hand, security enhancements

## Major Features

### Streaming Response System
- Implement LlmDriver trait with `stream()` method returning async Stream
- Add SSE parsing for Anthropic and OpenAI API streaming
- Integrate Tauri event system for frontend streaming (`stream:chunk` events)
- Add StreamChunk types: Delta, ToolStart, ToolEnd, Complete, Error

### MCP Protocol Implementation
- Add MCP JSON-RPC 2.0 types (mcp_types.rs)
- Implement stdio-based MCP transport (mcp_transport.rs)
- Support tool discovery, execution, and resource operations

### Browser Hand Implementation
- Complete browser automation with Playwright-style actions
- Support Navigate, Click, Type, Scrape, Screenshot, Wait actions
- Add educational Hands: Whiteboard, Slideshow, Speech, Quiz

### Security Enhancements
- Implement command whitelist/blacklist for shell_exec tool
- Add SSRF protection with private IP blocking
- Create security.toml configuration file

## Test Improvements
- Fix test import paths (security-utils, setup)
- Fix vi.mock hoisting issues with vi.hoisted()
- Update test expectations for validateUrl and sanitizeFilename
- Add getUnsupportedLocalGatewayStatus mock

## Documentation Updates
- Update architecture documentation
- Improve configuration reference
- Add quick-start guide updates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-24 03:24:24 +08:00
parent e49ba4460b
commit 3ff08faa56
78 changed files with 29575 additions and 1682 deletions

View File

@@ -0,0 +1,813 @@
//! Quiz Hand - Assessment and evaluation capabilities
//!
//! Provides quiz functionality for teaching:
//! - generate: Generate quiz questions from content
//! - show: Display a quiz to users
//! - submit: Submit an answer
//! - grade: Grade submitted answers
//! - analyze: Analyze quiz performance
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
use zclaw_types::Result;
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
/// Trait for generating quiz questions using LLM or other AI
#[async_trait]
pub trait QuizGenerator: Send + Sync {
/// Generate quiz questions based on topic and content
async fn generate_questions(
&self,
topic: &str,
content: Option<&str>,
count: usize,
difficulty: &DifficultyLevel,
question_types: &[QuestionType],
) -> Result<Vec<QuizQuestion>>;
}
/// Default placeholder generator (used when no LLM is configured)
pub struct DefaultQuizGenerator;
#[async_trait]
impl QuizGenerator for DefaultQuizGenerator {
async fn generate_questions(
&self,
topic: &str,
_content: Option<&str>,
count: usize,
difficulty: &DifficultyLevel,
_question_types: &[QuestionType],
) -> Result<Vec<QuizQuestion>> {
// Generate placeholder questions
Ok((0..count)
.map(|i| QuizQuestion {
id: uuid_v4(),
question_type: QuestionType::MultipleChoice,
question: format!("Question {} about {}", i + 1, topic),
options: Some(vec![
"Option A".to_string(),
"Option B".to_string(),
"Option C".to_string(),
"Option D".to_string(),
]),
correct_answer: Answer::Single("Option A".to_string()),
explanation: Some(format!("Explanation for question {}", i + 1)),
hints: Some(vec![format!("Hint 1 for question {}", i + 1)]),
points: 10.0,
difficulty: difficulty.clone(),
tags: vec![topic.to_string()],
})
.collect())
}
}
/// Quiz action types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum QuizAction {
/// Generate quiz from content
Generate {
topic: String,
content: Option<String>,
question_count: Option<usize>,
difficulty: Option<DifficultyLevel>,
question_types: Option<Vec<QuestionType>>,
},
/// Show quiz question
Show {
quiz_id: String,
question_index: Option<usize>,
},
/// Submit answer
Submit {
quiz_id: String,
question_id: String,
answer: Answer,
},
/// Grade quiz
Grade {
quiz_id: String,
show_correct: Option<bool>,
show_explanation: Option<bool>,
},
/// Analyze results
Analyze {
quiz_id: String,
},
/// Get hint
Hint {
quiz_id: String,
question_id: String,
hint_level: Option<u32>,
},
/// Show explanation
Explain {
quiz_id: String,
question_id: String,
},
/// Get next question (adaptive)
NextQuestion {
quiz_id: String,
current_score: Option<f64>,
},
/// Create quiz from template
CreateFromTemplate {
template: QuizTemplate,
},
/// Export quiz
Export {
quiz_id: String,
format: ExportFormat,
},
}
/// Question types
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum QuestionType {
#[default]
MultipleChoice,
TrueFalse,
FillBlank,
ShortAnswer,
Matching,
Ordering,
Essay,
}
/// Difficulty levels
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum DifficultyLevel {
Easy,
#[default]
Medium,
Hard,
Adaptive,
}
/// Answer types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Answer {
Single(String),
Multiple(Vec<String>),
Text(String),
Ordering(Vec<String>),
Matching(Vec<(String, String)>),
}
/// Quiz template
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuizTemplate {
pub title: String,
pub description: String,
pub time_limit_seconds: Option<u32>,
pub passing_score: Option<f64>,
pub allow_retry: bool,
pub show_feedback: bool,
pub shuffle_questions: bool,
pub shuffle_options: bool,
}
/// Quiz question
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuizQuestion {
pub id: String,
pub question_type: QuestionType,
pub question: String,
pub options: Option<Vec<String>>,
pub correct_answer: Answer,
pub explanation: Option<String>,
pub hints: Option<Vec<String>>,
pub points: f64,
pub difficulty: DifficultyLevel,
pub tags: Vec<String>,
}
/// Quiz definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Quiz {
pub id: String,
pub title: String,
pub description: String,
pub questions: Vec<QuizQuestion>,
pub time_limit_seconds: Option<u32>,
pub passing_score: f64,
pub allow_retry: bool,
pub show_feedback: bool,
pub shuffle_questions: bool,
pub shuffle_options: bool,
pub created_at: i64,
}
/// Quiz attempt
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct QuizAttempt {
pub quiz_id: String,
pub answers: Vec<AnswerSubmission>,
pub score: Option<f64>,
pub started_at: i64,
pub completed_at: Option<i64>,
pub time_spent_seconds: Option<u32>,
}
/// Answer submission
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnswerSubmission {
pub question_id: String,
pub answer: Answer,
pub is_correct: Option<bool>,
pub points_earned: Option<f64>,
pub feedback: Option<String>,
}
/// Export format
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ExportFormat {
#[default]
Json,
Qti,
Gift,
Markdown,
}
/// Quiz state
#[derive(Debug, Clone, Default)]
pub struct QuizState {
pub quizzes: Vec<Quiz>,
pub attempts: Vec<QuizAttempt>,
pub current_quiz_id: Option<String>,
pub current_question_index: usize,
}
/// Quiz Hand implementation
pub struct QuizHand {
config: HandConfig,
state: Arc<RwLock<QuizState>>,
quiz_generator: Arc<dyn QuizGenerator>,
}
impl QuizHand {
/// Create a new quiz hand with default generator
pub fn new() -> Self {
Self {
config: HandConfig {
id: "quiz".to_string(),
name: "Quiz".to_string(),
description: "Generate and manage quizzes for assessment".to_string(),
needs_approval: false,
dependencies: vec![],
input_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"quiz_id": { "type": "string" },
"topic": { "type": "string" },
}
})),
tags: vec!["assessment".to_string(), "education".to_string()],
enabled: true,
},
state: Arc::new(RwLock::new(QuizState::default())),
quiz_generator: Arc::new(DefaultQuizGenerator),
}
}
/// Create a quiz hand with custom generator (e.g., LLM-powered)
pub fn with_generator(generator: Arc<dyn QuizGenerator>) -> Self {
let mut hand = Self::new();
hand.quiz_generator = generator;
hand
}
/// Set the quiz generator at runtime
pub fn set_generator(&mut self, generator: Arc<dyn QuizGenerator>) {
self.quiz_generator = generator;
}
/// Execute a quiz action
pub async fn execute_action(&self, action: QuizAction) -> Result<HandResult> {
match action {
QuizAction::Generate { topic, content, question_count, difficulty, question_types } => {
let count = question_count.unwrap_or(5);
let diff = difficulty.unwrap_or_default();
let types = question_types.unwrap_or_else(|| vec![QuestionType::MultipleChoice]);
// Use the configured generator (LLM or placeholder)
let questions = self.quiz_generator
.generate_questions(&topic, content.as_deref(), count, &diff, &types)
.await?;
let quiz = Quiz {
id: uuid_v4(),
title: format!("Quiz: {}", topic),
description: format!("Test your knowledge of {}", topic),
questions,
time_limit_seconds: None,
passing_score: 60.0,
allow_retry: true,
show_feedback: true,
shuffle_questions: false,
shuffle_options: true,
created_at: current_timestamp(),
};
let mut state = self.state.write().await;
state.quizzes.push(quiz.clone());
state.current_quiz_id = Some(quiz.id.clone());
Ok(HandResult::success(serde_json::json!({
"status": "generated",
"quiz": quiz,
})))
}
other => self.execute_other_action(other).await,
}
}
/// Execute non-generate actions (requires lock)
async fn execute_other_action(&self, action: QuizAction) -> Result<HandResult> {
let mut state = self.state.write().await;
match action {
QuizAction::Show { quiz_id, question_index } => {
let quiz = state.quizzes.iter()
.find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let idx = question_index.unwrap_or(state.current_question_index);
if idx < quiz.questions.len() {
let question = &quiz.questions[idx];
// Hide correct answer when showing
let question_for_display = serde_json::json!({
"id": question.id,
"type": question.question_type,
"question": question.question,
"options": question.options,
"points": question.points,
"difficulty": question.difficulty,
});
Ok(HandResult::success(serde_json::json!({
"status": "showing",
"question": question_for_display,
"question_index": idx,
"total_questions": quiz.questions.len(),
})))
} else {
Ok(HandResult::error("Question index out of range"))
}
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::Submit { quiz_id, question_id, answer } => {
let submission = AnswerSubmission {
question_id,
answer,
is_correct: None,
points_earned: None,
feedback: None,
};
// Find or create attempt
let attempt = state.attempts.iter_mut()
.find(|a| a.quiz_id == quiz_id && a.completed_at.is_none());
match attempt {
Some(attempt) => {
attempt.answers.push(submission);
}
None => {
let mut new_attempt = QuizAttempt {
quiz_id,
started_at: current_timestamp(),
..Default::default()
};
new_attempt.answers.push(submission);
state.attempts.push(new_attempt);
}
}
Ok(HandResult::success(serde_json::json!({
"status": "submitted",
})))
}
QuizAction::Grade { quiz_id, show_correct, show_explanation } => {
// First, find the quiz and clone necessary data
let quiz_data = state.quizzes.iter()
.find(|q| q.id == quiz_id)
.map(|quiz| (quiz.clone(), quiz.passing_score));
let attempt = state.attempts.iter_mut()
.find(|a| a.quiz_id == quiz_id && a.completed_at.is_none());
match (quiz_data, attempt) {
(Some((quiz, passing_score)), Some(attempt)) => {
let mut total_points = 0.0;
let mut earned_points = 0.0;
for submission in &mut attempt.answers {
if let Some(question) = quiz.questions.iter()
.find(|q| q.id == submission.question_id)
{
let is_correct = self.check_answer(&submission.answer, &question.correct_answer);
submission.is_correct = Some(is_correct);
submission.points_earned = Some(if is_correct { question.points } else { 0.0 });
total_points += question.points;
earned_points += submission.points_earned.unwrap();
if show_explanation.unwrap_or(true) {
submission.feedback = question.explanation.clone();
}
}
}
let score = if total_points > 0.0 {
(earned_points / total_points) * 100.0
} else {
0.0
};
attempt.score = Some(score);
attempt.completed_at = Some(current_timestamp());
Ok(HandResult::success(serde_json::json!({
"status": "graded",
"score": score,
"total_points": total_points,
"earned_points": earned_points,
"passed": score >= passing_score,
"answers": if show_correct.unwrap_or(true) {
serde_json::to_value(&attempt.answers).unwrap_or(serde_json::Value::Null)
} else {
serde_json::Value::Null
},
})))
}
_ => Ok(HandResult::error("Quiz or attempt not found")),
}
}
QuizAction::Analyze { quiz_id } => {
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
let attempts: Vec<_> = state.attempts.iter()
.filter(|a| a.quiz_id == quiz_id && a.completed_at.is_some())
.collect();
match quiz {
Some(quiz) => {
let scores: Vec<f64> = attempts.iter()
.filter_map(|a| a.score)
.collect();
let avg_score = if !scores.is_empty() {
scores.iter().sum::<f64>() / scores.len() as f64
} else {
0.0
};
Ok(HandResult::success(serde_json::json!({
"status": "analyzed",
"quiz_title": quiz.title,
"total_attempts": attempts.len(),
"average_score": avg_score,
"pass_rate": scores.iter().filter(|&&s| s >= quiz.passing_score).count() as f64 / scores.len().max(1) as f64 * 100.0,
})))
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::Hint { quiz_id, question_id, hint_level } => {
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let question = quiz.questions.iter()
.find(|q| q.id == question_id);
match question {
Some(q) => {
let level = hint_level.unwrap_or(1) as usize;
let hint = q.hints.as_ref()
.and_then(|h| h.get(level.saturating_sub(1)))
.map(|s| s.as_str())
.unwrap_or("No hint available at this level");
Ok(HandResult::success(serde_json::json!({
"status": "hint",
"hint": hint,
"level": level,
})))
}
None => Ok(HandResult::error("Question not found")),
}
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::Explain { quiz_id, question_id } => {
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let question = quiz.questions.iter()
.find(|q| q.id == question_id);
match question {
Some(q) => {
Ok(HandResult::success(serde_json::json!({
"status": "explanation",
"question": q.question,
"correct_answer": q.correct_answer,
"explanation": q.explanation,
})))
}
None => Ok(HandResult::error("Question not found")),
}
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::NextQuestion { quiz_id, current_score } => {
// Adaptive quiz - select next question based on performance
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let score = current_score.unwrap_or(0.0);
let next_idx = state.current_question_index + 1;
if next_idx < quiz.questions.len() {
state.current_question_index = next_idx;
Ok(HandResult::success(serde_json::json!({
"status": "next",
"question_index": next_idx,
})))
} else {
Ok(HandResult::success(serde_json::json!({
"status": "complete",
})))
}
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
QuizAction::CreateFromTemplate { template } => {
let quiz = Quiz {
id: uuid_v4(),
title: template.title,
description: template.description,
questions: Vec::new(), // Would be filled in
time_limit_seconds: template.time_limit_seconds,
passing_score: template.passing_score.unwrap_or(60.0),
allow_retry: template.allow_retry,
show_feedback: template.show_feedback,
shuffle_questions: template.shuffle_questions,
shuffle_options: template.shuffle_options,
created_at: current_timestamp(),
};
state.quizzes.push(quiz.clone());
Ok(HandResult::success(serde_json::json!({
"status": "created",
"quiz_id": quiz.id,
})))
}
QuizAction::Export { quiz_id, format } => {
let quiz = state.quizzes.iter().find(|q| q.id == quiz_id);
match quiz {
Some(quiz) => {
let content = match format {
ExportFormat::Json => serde_json::to_string_pretty(&quiz).unwrap_or_default(),
ExportFormat::Markdown => self.export_markdown(quiz),
_ => format!("{:?}", quiz),
};
Ok(HandResult::success(serde_json::json!({
"status": "exported",
"format": format,
"content": content,
})))
}
None => Ok(HandResult::error(format!("Quiz not found: {}", quiz_id))),
}
}
// Generate is handled in execute_action, this is just for exhaustiveness
QuizAction::Generate { .. } => {
Ok(HandResult::error("Generate action should be handled in execute_action"))
}
}
}
/// Check if answer is correct
fn check_answer(&self, submitted: &Answer, correct: &Answer) -> bool {
match (submitted, correct) {
(Answer::Single(s), Answer::Single(c)) => s == c,
(Answer::Multiple(s), Answer::Multiple(c)) => {
let mut s_sorted = s.clone();
let mut c_sorted = c.clone();
s_sorted.sort();
c_sorted.sort();
s_sorted == c_sorted
}
(Answer::Text(s), Answer::Text(c)) => s.trim().to_lowercase() == c.trim().to_lowercase(),
_ => false,
}
}
/// Export quiz as markdown
fn export_markdown(&self, quiz: &Quiz) -> String {
let mut md = format!("# {}\n\n{}\n\n", quiz.title, quiz.description);
for (i, q) in quiz.questions.iter().enumerate() {
md.push_str(&format!("## Question {}\n\n{}\n\n", i + 1, q.question));
if let Some(options) = &q.options {
for opt in options {
md.push_str(&format!("- {}\n", opt));
}
md.push_str("\n");
}
if let Some(explanation) = &q.explanation {
md.push_str(&format!("**Explanation:** {}\n\n", explanation));
}
}
md
}
/// Get current state
pub async fn get_state(&self) -> QuizState {
self.state.read().await.clone()
}
}
impl Default for QuizHand {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Hand for QuizHand {
fn config(&self) -> &HandConfig {
&self.config
}
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
let action: QuizAction = match serde_json::from_value(input) {
Ok(a) => a,
Err(e) => {
return Ok(HandResult::error(format!("Invalid quiz action: {}", e)));
}
};
self.execute_action(action).await
}
fn status(&self) -> HandStatus {
HandStatus::Idle
}
}
// Helper functions
/// Generate a cryptographically secure UUID v4
fn uuid_v4() -> String {
Uuid::new_v4().to_string()
}
fn current_timestamp() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_quiz_creation() {
let hand = QuizHand::new();
assert_eq!(hand.config().id, "quiz");
}
#[tokio::test]
async fn test_generate_quiz() {
let hand = QuizHand::new();
let action = QuizAction::Generate {
topic: "Rust Ownership".to_string(),
content: None,
question_count: Some(5),
difficulty: Some(DifficultyLevel::Medium),
question_types: None,
};
let result = hand.execute_action(action).await.unwrap();
assert!(result.success);
let state = hand.get_state().await;
assert_eq!(state.quizzes.len(), 1);
assert_eq!(state.quizzes[0].questions.len(), 5);
}
#[tokio::test]
async fn test_show_question() {
let hand = QuizHand::new();
// Generate first
hand.execute_action(QuizAction::Generate {
topic: "Test".to_string(),
content: None,
question_count: Some(3),
difficulty: None,
question_types: None,
}).await.unwrap();
let quiz_id = hand.get_state().await.quizzes[0].id.clone();
let result = hand.execute_action(QuizAction::Show {
quiz_id,
question_index: Some(0),
}).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_submit_and_grade() {
let hand = QuizHand::new();
// Generate
hand.execute_action(QuizAction::Generate {
topic: "Test".to_string(),
content: None,
question_count: Some(2),
difficulty: None,
question_types: None,
}).await.unwrap();
let state = hand.get_state().await;
let quiz_id = state.quizzes[0].id.clone();
let q1_id = state.quizzes[0].questions[0].id.clone();
let q2_id = state.quizzes[0].questions[1].id.clone();
// Submit answers
hand.execute_action(QuizAction::Submit {
quiz_id: quiz_id.clone(),
question_id: q1_id,
answer: Answer::Single("Option A".to_string()),
}).await.unwrap();
hand.execute_action(QuizAction::Submit {
quiz_id: quiz_id.clone(),
question_id: q2_id,
answer: Answer::Single("Option A".to_string()),
}).await.unwrap();
// Grade
let result = hand.execute_action(QuizAction::Grade {
quiz_id,
show_correct: Some(true),
show_explanation: Some(true),
}).await.unwrap();
assert!(result.success);
}
#[tokio::test]
async fn test_export_markdown() {
let hand = QuizHand::new();
hand.execute_action(QuizAction::Generate {
topic: "Test".to_string(),
content: None,
question_count: Some(2),
difficulty: None,
question_types: None,
}).await.unwrap();
let quiz_id = hand.get_state().await.quizzes[0].id.clone();
let result = hand.execute_action(QuizAction::Export {
quiz_id,
format: ExportFormat::Markdown,
}).await.unwrap();
assert!(result.success);
}
}