Files
zclaw_openfang/crates/zclaw-hands/src/hands/quiz.rs
iven 59f660b93b
Some checks failed
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(hands): add max_concurrent + timeout_secs fields + hand timeout enforcement
M3-04/M3-05 audit fixes:
- HandConfig: add max_concurrent (u32) and timeout_secs (u64) with serde defaults
- Kernel execute_hand: enforce timeout via tokio::time::timeout, cancel on expiry
- All 9 hand implementations: add max_concurrent: 0, timeout_secs: 0
- Agent createClone: pass soul field through to kernel
- Fix duplicate soul block in agent_create command
2026-04-04 18:41:15 +08:00

1033 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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 zclaw_runtime::driver::{LlmDriver, CompletionRequest};
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 with randomized correct answers
let options_pool: Vec<Vec<String>> = vec![
vec!["光合作用".into(), "呼吸作用".into(), "蒸腾作用".into(), "运输作用".into()],
vec!["牛顿".into(), "爱因斯坦".into(), "伽利略".into(), "开普勒".into()],
vec!["太平洋".into(), "大西洋".into(), "印度洋".into(), "北冰洋".into()],
vec!["DNA".into(), "RNA".into(), "蛋白质".into(), "碳水化合物".into()],
vec!["引力".into(), "电磁力".into(), "强力".into(), "弱力".into()],
];
Ok((0..count)
.map(|i| {
let pool_idx = i % options_pool.len();
let mut opts = options_pool[pool_idx].clone();
// Shuffle options to randomize correct answer position
let correct_idx = (i * 3 + 1) % opts.len();
opts.swap(0, correct_idx);
let correct = opts[0].clone();
QuizQuestion {
id: uuid_v4(),
question_type: QuestionType::MultipleChoice,
question: format!("关于{}的第{}题({}难度)", topic, i + 1, match difficulty {
DifficultyLevel::Easy => "简单",
DifficultyLevel::Medium => "中等",
DifficultyLevel::Hard => "困难",
DifficultyLevel::Adaptive => "自适应",
}),
options: Some(opts),
correct_answer: Answer::Single(correct),
explanation: Some(format!("{}题的详细解释", i + 1)),
hints: Some(vec![format!("提示:仔细阅读关于{}的内容", topic)]),
points: 10.0,
difficulty: difficulty.clone(),
tags: vec![topic.to_string()],
}
})
.collect())
}
}
/// LLM-powered quiz generator that produces real questions via an LLM driver.
pub struct LlmQuizGenerator {
driver: Arc<dyn LlmDriver>,
model: String,
}
impl LlmQuizGenerator {
pub fn new(driver: Arc<dyn LlmDriver>, model: String) -> Self {
Self { driver, model }
}
}
#[async_trait]
impl QuizGenerator for LlmQuizGenerator {
async fn generate_questions(
&self,
topic: &str,
content: Option<&str>,
count: usize,
difficulty: &DifficultyLevel,
question_types: &[QuestionType],
) -> Result<Vec<QuizQuestion>> {
let difficulty_str = match difficulty {
DifficultyLevel::Easy => "简单",
DifficultyLevel::Medium => "中等",
DifficultyLevel::Hard => "困难",
DifficultyLevel::Adaptive => "中等",
};
let type_str = if question_types.is_empty() {
String::from("选择题(multiple_choice)")
} else {
question_types
.iter()
.map(|t| match t {
QuestionType::MultipleChoice => "选择题",
QuestionType::TrueFalse => "判断题",
QuestionType::FillBlank => "填空题",
QuestionType::ShortAnswer => "简答题",
QuestionType::Essay => "论述题",
_ => "选择题",
})
.collect::<Vec<_>>()
.join(",")
};
let content_section = match content {
Some(c) if !c.is_empty() => format!("\n\n参考内容:\n{}", &c[..c.len().min(3000)]),
_ => String::new(),
};
let content_note = if content.is_some() && content.map_or(false, |c| !c.is_empty()) {
"(基于提供的参考内容出题)"
} else {
""
};
let prompt = format!(
r#"你是一个专业的出题专家。请根据以下要求生成测验题目:
主题: {}
难度: {}
题目类型: {}
数量: {}{}
{}
请严格按照以下 JSON 格式输出,不要添加任何其他文字:
```json
[
{{
"question": "题目内容",
"options": ["选项A", "选项B", "选项C", "选项D"],
"correct_answer": "正确答案与options中某项完全一致",
"explanation": "答案解释",
"hint": "提示信息"
}}
]
```
要求:
1. 题目要有实际内容,不要使用占位符
2. 正确答案必须随机分布(不要总在第一个选项)
3. 每道题的选项要有区分度,干扰项要合理
4. 解释要清晰准确
5. 直接输出 JSON不要有 markdown 包裹"#,
topic, difficulty_str, type_str, count, content_section, content_note,
);
let request = CompletionRequest {
model: self.model.clone(),
system: Some("你是一个专业的出题专家只输出纯JSON格式。".to_string()),
messages: vec![zclaw_types::Message::user(&prompt)],
tools: Vec::new(),
max_tokens: Some(4096),
temperature: Some(0.7),
stop: Vec::new(),
stream: false,
thinking_enabled: false,
reasoning_effort: None,
plan_mode: false,
};
let response = self.driver.complete(request).await.map_err(|e| {
zclaw_types::ZclawError::Internal(format!("LLM quiz generation failed: {}", e))
})?;
// Extract text from response
let text: String = response
.content
.iter()
.filter_map(|block| match block {
zclaw_runtime::driver::ContentBlock::Text { text } => Some(text.clone()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
// Parse JSON from response (handle markdown code fences)
let json_str = extract_json(&text);
let raw_questions: Vec<serde_json::Value> =
serde_json::from_str(json_str).map_err(|e| {
zclaw_types::ZclawError::Internal(format!(
"Failed to parse quiz JSON: {}. Raw: {}",
e,
&text[..text.len().min(200)]
))
})?;
let questions: Vec<QuizQuestion> = raw_questions
.into_iter()
.take(count)
.map(|q| {
let options: Vec<String> = q["options"]
.as_array()
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let correct = q["correct_answer"]
.as_str()
.unwrap_or("")
.to_string();
QuizQuestion {
id: uuid_v4(),
question_type: QuestionType::MultipleChoice,
question: q["question"].as_str().unwrap_or("未知题目").to_string(),
options: if options.is_empty() { None } else { Some(options) },
correct_answer: Answer::Single(correct),
explanation: q["explanation"].as_str().map(String::from),
hints: q["hint"].as_str().map(|h| vec![h.to_string()]),
points: 10.0,
difficulty: difficulty.clone(),
tags: vec![topic.to_string()],
}
})
.collect();
if questions.is_empty() {
// Fallback to default if LLM returns nothing parseable
return DefaultQuizGenerator
.generate_questions(topic, content, count, difficulty, question_types)
.await;
}
Ok(questions)
}
}
/// Extract JSON from a string that may be wrapped in markdown code fences.
fn extract_json(text: &str) -> &str {
let trimmed = text.trim();
// Try to find ```json ... ``` block
if let Some(start) = trimmed.find("```json") {
let after_start = &trimmed[start + 7..];
if let Some(end) = after_start.find("```") {
return after_start[..end].trim();
}
}
// Try to find ``` ... ``` block
if let Some(start) = trimmed.find("```") {
let after_start = &trimmed[start + 3..];
if let Some(end) = after_start.find("```") {
return after_start[..end].trim();
}
}
// Try to find raw JSON array
if let Some(start) = trimmed.find('[') {
if let Some(end) = trimmed.rfind(']') {
return &trimmed[start..=end];
}
}
trimmed
}
/// 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: "测验".to_string(),
description: "生成和管理测验题目,评估答案,提供反馈".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,
max_concurrent: 0,
timeout_secs: 0,
},
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);
}
}