Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Batch fix covering multiple modules:
- P2-01: HandRegistry Semaphore-based max_concurrent enforcement
- P2-03: Populate toolCount/metricCount from Hand trait methods
- P2-06: heartbeat_update_config minimum interval validation
- P2-07: ReflectionResult used_fallback marker for rule-based fallback
- P2-08/09: identity_propose_change parameter naming consistency
- P2-10: ClassroomMetadata is_placeholder flag for LLM failure
- P2-11: classroomStore userDidCloseDuringGeneration intent tracking
- P2-12: workflowStore pipeline_create sends actionType
- P2-13/14: PipelineInfo step_count + PipelineStepInfo for proper step mapping
- P2-15: Pipe transform support in context.resolve (8 transforms)
- P2-16: Mustache {{...}} → \${...} auto-normalization
- P2-17: SaaSLogin password placeholder 6→8
- P2-19: serialize_skill_md + update_skill preserve tools field
- P2-22: ToolOutputGuard sensitive patterns from warn→block
- P2-23: Mutex::unwrap() → unwrap_or_else in relay/service.rs
- P3-01/03/07/08/09: Various P3 fixes
- DEFECT_LIST.md: comprehensive status sync (43/51 fixed, 8 remaining)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1043 lines
36 KiB
Rust
1043 lines
36 KiB
Rust
//! 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> {
|
||
// P2-04: Reject oversized input before deserialization
|
||
const MAX_INPUT_SIZE: usize = 50_000; // 50KB limit
|
||
let input_str = serde_json::to_string(&input).unwrap_or_default();
|
||
if input_str.len() > MAX_INPUT_SIZE {
|
||
return Ok(HandResult::error(format!(
|
||
"Input too large ({} bytes, max {} bytes)",
|
||
input_str.len(), MAX_INPUT_SIZE
|
||
)));
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|