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:
813
crates/zclaw-hands/src/hands/quiz.rs
Normal file
813
crates/zclaw-hands/src/hands/quiz.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user