feat: Implement AI-powered audio response evaluation with score, keywords, and feedback, integrating it into the AudioResponsePlayer component.

This commit is contained in:
2026-01-21 17:05:11 -03:00
parent 00ae5ac16b
commit 360cf520e8
9 changed files with 254 additions and 67 deletions
+35 -7
View File
@@ -938,7 +938,7 @@ pub async fn summarize_lesson(
"messages": [ "messages": [
{ {
"role": "system", "role": "system",
"content": "You are a professional educational assistant. Summarize the following lesson transcription into a high-quality summary suited for a course platform. Keep it concise but informative (max 150 words). Focus on the key learning objectives." "content": "You are an expert English Teacher. Summarize the following lesson content. Focus on grammar, vocabulary, and key expressions. Provide the summary in English, but if the content is bilingual, ensure the summary reflects both languages. Keep it under 150 words."
}, },
{ {
"role": "user", "role": "user",
@@ -1056,7 +1056,7 @@ pub async fn generate_quiz(
"messages": [ "messages": [
{ {
"role": "system", "role": "system",
"content": "You are an educational content designer. Generate 3 multiple-choice questions based on the lesson transcription. Return ONLY a JSON object with a field 'blocks' which is an array. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correctAnswer\": 0, \"explanation\": \"Explain why the answer is correct.\" } ] } }" "content": "You are an expert English Teacher. Generate 3 multiple-choice questions based on the lesson content to test the student's understanding of English grammar, vocabulary, or comprehension. Instructions can be in Spanish or English. Return ONLY a JSON object with a field 'blocks' which is an array of content blocks. Each block in the array must follow this exact structure: { \"id\": \"string-uuid\", \"type\": \"quiz\", \"title\": \"Quiz: Concept Check\", \"quiz_data\": { \"questions\": [ { \"id\": \"q-string\", \"type\": \"multiple-choice\", \"question\": \"String\", \"options\": [\"Option 1\", \"Option 2\", \"Option 3\", \"Option 4\"], \"correct\": [0], \"explanation\": \"Explain why the answer is correct.\" } ] } }. Important: 'correct' MUST be an array of integers."
}, },
{ {
"role": "user", "role": "user",
@@ -1092,9 +1092,37 @@ pub async fn generate_quiz(
let mut quiz_data_parsed: serde_json::Value = let mut quiz_data_parsed: serde_json::Value =
serde_json::from_str(quiz_json_str).unwrap_or(json!({})); serde_json::from_str(quiz_json_str).unwrap_or(json!({}));
// Post-processing: Normalize questions to ensure frontend doesn't crash
if let Some(blocks) = quiz_data_parsed.get_mut("blocks").and_then(|b| b.as_array_mut()) {
for block in blocks {
if block.get("type").and_then(|t| t.as_str()) == Some("quiz") {
if let Some(questions) = block.get_mut("quiz_data").and_then(|qd| qd.get_mut("questions")).and_then(|q| q.as_array_mut()) {
for q in questions {
// Ensure options exists
if q.get("options").is_none() {
q["options"] = json!([]);
}
// Ensure correct exists and is an array (handle LLM returning correctAnswer as a number)
if q.get("correct").is_none() {
if let Some(ca) = q.get("correctAnswer").and_then(|ca| ca.as_i64()) {
q["correct"] = json!([ca]);
} else {
q["correct"] = json!([0]);
}
} else if q["correct"].is_number() {
// Convert single number to array
let num = q["correct"].as_i64().unwrap_or(0);
q["correct"] = json!([num]);
}
}
}
}
}
}
// Ensure we return just the blocks array as the frontend expects // Ensure we return just the blocks array as the frontend expects
let quiz_blocks = quiz_data_parsed let quiz_blocks = quiz_data_parsed
.get_mut("blocks") .get("blocks")
.cloned() .cloned()
.unwrap_or(json!([])); .unwrap_or(json!([]));
@@ -3073,12 +3101,12 @@ pub async fn generate_course(
) )
}; };
let system_prompt = r#"You are a curriculum expert. let system_prompt = r#"You are an expert English Teacher and curriculum designer.
Generate a course in JSON. Generate an English language course in JSON. You can receive instructions in Spanish or English.
Structure: Structure:
{ {
"title": "Course Title", "title": "Course Title",
"description": "Description", "description": "Description (Include language level like A1, B2, etc.)",
"modules": [ "modules": [
{ {
"title": "Module Title", "title": "Module Title",
@@ -3090,7 +3118,7 @@ Structure:
} }
RULES: RULES:
1. content_type MUST be one of: text, video, quiz. 1. content_type MUST be one of: text, video, quiz.
2. NO nested lessons. 2. The tone should be that of a helpful English Teacher.
3. Return ONLY the JSON object."#; 3. Return ONLY the JSON object."#;
let mut request = client.post(&url).json(&json!({ let mut request = client.post(&url).json(&json!({
+83 -2
View File
@@ -10,7 +10,7 @@ use common::models::{
AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics, AuthResponse, Course, CourseAnalytics, Enrollment, HeatmapPoint, Lesson, LessonAnalytics,
Module, Notification, Organization, RecommendationResponse, User, UserResponse, Module, Notification, Organization, RecommendationResponse, User, UserResponse,
}; };
use serde::Deserialize; use serde::{Deserialize, Serialize};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use std::env; use std::env;
use uuid::Uuid; use uuid::Uuid;
@@ -105,6 +105,20 @@ pub struct GradeSubmissionPayload {
pub metadata: Option<serde_json::Value>, pub metadata: Option<serde_json::Value>,
} }
#[derive(Deserialize)]
pub struct AudioGradingPayload {
pub transcript: String,
pub prompt: String,
pub keywords: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct AudioGradingResponse {
pub score: i32,
pub found_keywords: Vec<String>,
pub feedback: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct InteractionPayload { pub struct InteractionPayload {
pub video_timestamp: Option<f64>, pub video_timestamp: Option<f64>,
@@ -1120,7 +1134,7 @@ pub async fn get_recommendations(
"messages": [ "messages": [
{ {
"role": "system", "role": "system",
"content": "You are a helpful educational AI tutor. Based on the student's performance, suggest 3 highly personalized study recommendations. Focus on areas where they scored low. Return ONLY a valid JSON object starting with { \"recommendations\": [...] }. Each object MUST have: 'title', 'description', 'lesson_id' (a valid UUID or null), 'priority' ('high', 'medium', 'low'), and 'reason'. Respond in Spanish." "content": "You are a supportive and professional English Tutor. Based on the student's performance, suggest 3 highly personalized study recommendations to improve their English skills (grammar, vocabulary, speaking). Focus on areas where they scored low. Return ONLY a valid JSON object starting with { \"recommendations\": [...] }. Each object MUST have: 'title', 'description', 'lesson_id' (a valid UUID or null), 'priority' ('high', 'medium', 'low'), and 'reason'. Respond in Spanish in a motivating and encouraging tone."
}, },
{ {
"role": "user", "role": "user",
@@ -1140,3 +1154,70 @@ pub async fn get_recommendations(
Ok(Json(ai_response)) Ok(Json(ai_response))
} }
pub async fn evaluate_audio_response(
Org(_org_ctx): Org,
_claims: Claims,
Json(payload): Json<AudioGradingPayload>,
) -> Result<Json<AudioGradingResponse>, (StatusCode, String)> {
let client = reqwest::Client::new();
let provider = env::var("AI_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let (url, auth_header, model) = if provider == "local" {
let base_url = env::var("LOCAL_OLLAMA_URL").unwrap_or_else(|_| "http://ollama:11434".to_string());
let model = env::var("LOCAL_LLM_MODEL").unwrap_or_else(|_| "llama3:8b".to_string());
(format!("{}/v1/chat/completions", base_url), "".to_string(), model)
} else {
(
"https://api.openai.com/v1/chat/completions".to_string(),
format!("Bearer {}", env::var("OPENAI_API_KEY").unwrap_or_default()),
"gpt-4-turbo".to_string(),
)
};
let system_prompt = "You are an expert English Teacher. Evaluate the student's spoken response transcript. \
Compare it against the prompt and expected keywords. \
Provide a score from 0 to 100. \
Identify which keywords were used. \
Give constructive feedback in Spanish about their pronunciation (based on the transcript quality) and content. \
Return ONLY a JSON object: { \"score\": number, \"found_keywords\": [string], \"feedback\": string }.";
let user_content = format!(
"Prompt: {}\nExpected Keywords: {:?}\nStudent Transcript: {}",
payload.prompt, payload.keywords, payload.transcript
);
let response = client.post(&url)
.header("Content-Type", "application/json")
.header("Authorization", auth_header)
.json(&serde_json::json!({
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": user_content }
],
"response_format": { "type": "json_object" }
}))
.send()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let ai_data: serde_json::Value = response.json().await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("AI response parse failed: {}", e)))?;
let grading: AudioGradingResponse = serde_json::from_value(
ai_data["choices"][0]["message"]["content"]
.as_str()
.and_then(|c| serde_json::from_str(c).ok())
.unwrap_or_else(|| {
// Fallback in case AI doesn't return clean JSON
serde_json::json!({
"score": 50,
"found_keywords": vec![] as Vec<String>,
"feedback": "Lo siento, tuve un problema analizando tu respuesta. ¡Sigue practicando!"
})
})
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?;
Ok(Json(grading))
}
+5 -1
View File
@@ -75,7 +75,11 @@ async fn main() {
"/lessons/{id}/interactions", "/lessons/{id}/interactions",
post(handlers::record_interaction), post(handlers::record_interaction),
) )
.route("/lessons/{id}/heatmap", get(handlers::get_lesson_heatmap)) .route(
"/lessons/{id}/heatmap",
get(handlers::get_lesson_heatmap),
)
.route("/audio/evaluate", post(handlers::evaluate_audio_response))
.route("/notifications", get(handlers::get_notifications)) .route("/notifications", get(handlers::get_notifications))
.route( .route(
"/notifications/{id}/read", "/notifications/{id}/read",
@@ -16,7 +16,8 @@ import ShortAnswerPlayer from "@/components/blocks/ShortAnswerPlayer";
import CodeExercisePlayer from "@/components/blocks/CodeExercisePlayer"; import CodeExercisePlayer from "@/components/blocks/CodeExercisePlayer";
import HotspotPlayer from "@/components/blocks/HotspotPlayer"; import HotspotPlayer from "@/components/blocks/HotspotPlayer";
import MemoryPlayer from "@/components/blocks/MemoryPlayer"; import MemoryPlayer from "@/components/blocks/MemoryPlayer";
import DocumentPlayer from "@/components/blocks/DocumentPlayer"; // Added import import DocumentPlayer from "@/components/blocks/DocumentPlayer";
import AudioResponsePlayer from "@/components/blocks/AudioResponsePlayer";
import InteractiveTranscript from "@/components/InteractiveTranscript"; import InteractiveTranscript from "@/components/InteractiveTranscript";
import { ListMusic } from "lucide-react"; import { ListMusic } from "lucide-react";
@@ -276,6 +277,17 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
allowRetry={lesson.allow_retry} allowRetry={lesson.allow_retry}
/> />
); );
case 'audio-response':
return (
<AudioResponsePlayer
id={block.id}
prompt={block.prompt || ""}
keywords={block.keywords}
timeLimit={block.timeLimit}
isGraded={lesson.is_graded}
onComplete={(score) => handleBlockComplete(block.id, score)}
/>
);
case 'code': case 'code':
return ( return (
<CodeExercisePlayer <CodeExercisePlayer
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { Mic, Square, Play, RotateCcw, Check, X, Clock } from "lucide-react"; import { lmsApi } from "@/lib/api";
import { Mic, Square, Play, RotateCcw, Check, X, Clock, BrainCircuit } from "lucide-react";
interface AudioResponsePlayerProps { interface AudioResponsePlayerProps {
id: string; id: string;
@@ -26,7 +27,7 @@ export default function AudioResponsePlayer({
const [isTranscribing, setIsTranscribing] = useState(false); const [isTranscribing, setIsTranscribing] = useState(false);
const [recordingTime, setRecordingTime] = useState(0); const [recordingTime, setRecordingTime] = useState(0);
const [submitted, setSubmitted] = useState(false); const [submitted, setSubmitted] = useState(false);
const [evaluation, setEvaluation] = useState<{ score: number; foundKeywords: string[] } | null>(null); const [evaluation, setEvaluation] = useState<{ score: number; foundKeywords: string[]; feedback: string } | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null); const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]); const audioChunksRef = useRef<Blob[]>([]);
@@ -133,26 +134,30 @@ export default function AudioResponsePlayer({
} }
}; };
const evaluateResponse = () => { const evaluateResponse = async () => {
if (!transcript.trim()) { if (!transcript.trim()) {
alert("No speech detected. Please try recording again."); alert("No speech detected. Please try recording again.");
return; return;
} }
const transcriptLower = transcript.toLowerCase(); setIsTranscribing(true);
const foundKeywords = keywords.filter(kw => try {
transcriptLower.includes(kw.toLowerCase()) const result = await lmsApi.evaluateAudio(transcript, prompt, keywords);
); setEvaluation({
score: result.score,
foundKeywords: result.found_keywords,
feedback: result.feedback
});
setSubmitted(true);
const score = keywords.length > 0 if (onComplete) {
? Math.round((foundKeywords.length / keywords.length) * 100) onComplete(result.score, transcript);
: 100; // If no keywords specified, give full credit for any response }
} catch (err) {
setEvaluation({ score, foundKeywords }); console.error("Evaluation failed", err);
setSubmitted(true); alert("Evaluation failed. Please try again.");
} finally {
if (onComplete) { setIsTranscribing(false);
onComplete(score, transcript);
} }
}; };
@@ -269,10 +274,10 @@ export default function AudioResponsePlayer({
{submitted && evaluation && ( {submitted && evaluation && (
<div className="space-y-4"> <div className="space-y-4">
<div className={`p-6 rounded-2xl border-2 ${evaluation.score >= 70 <div className={`p-6 rounded-2xl border-2 ${evaluation.score >= 70
? 'bg-green-500/10 border-green-500' ? 'bg-green-500/10 border-green-500'
: evaluation.score >= 40 : evaluation.score >= 40
? 'bg-yellow-500/10 border-yellow-500' ? 'bg-yellow-500/10 border-yellow-500'
: 'bg-red-500/10 border-red-500' : 'bg-red-500/10 border-red-500'
}`}> }`}>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="text-sm font-bold uppercase tracking-wider text-gray-400">Your Score</span> <span className="text-sm font-bold uppercase tracking-wider text-gray-400">Your Score</span>
@@ -289,8 +294,8 @@ export default function AudioResponsePlayer({
<span <span
key={i} key={i}
className={`px-3 py-1 rounded-lg text-sm font-medium flex items-center gap-1 ${found className={`px-3 py-1 rounded-lg text-sm font-medium flex items-center gap-1 ${found
? 'bg-green-500/20 border border-green-500/50 text-green-300' ? 'bg-green-500/20 border border-green-500/50 text-green-300'
: 'bg-gray-500/20 border border-gray-500/50 text-gray-400' : 'bg-gray-500/20 border border-gray-500/50 text-gray-400'
}`} }`}
> >
{found ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />} {found ? <Check className="w-3 h-3" /> : <X className="w-3 h-3" />}
@@ -303,6 +308,21 @@ export default function AudioResponsePlayer({
)} )}
</div> </div>
{evaluation.feedback && (
<div className="p-6 bg-blue-500/10 border-2 border-blue-500/20 rounded-2xl space-y-3 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BrainCircuit className="w-20 h-20 text-blue-400" />
</div>
<div className="flex items-center gap-2 text-xs font-black uppercase tracking-widest text-blue-400">
<BrainCircuit className="w-4 h-4" />
AI Teacher Feedback
</div>
<p className="text-gray-200 leading-relaxed italic text-lg relative z-10">
&quot;{evaluation.feedback}&quot;
</p>
</div>
)}
<div className="p-4 bg-white/5 border border-white/10 rounded-xl"> <div className="p-4 bg-white/5 border border-white/10 rounded-xl">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Your Transcript:</p> <p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Your Transcript:</p>
<p className="text-sm text-gray-300">{transcript}</p> <p className="text-sm text-gray-300">{transcript}</p>
+15 -1
View File
@@ -28,6 +28,12 @@ export interface RecommendationResponse {
recommendations: Recommendation[]; recommendations: Recommendation[];
} }
export interface AudioGradingResponse {
score: number;
found_keywords: string[];
feedback: string;
}
export interface Course { export interface Course {
id: string; id: string;
title: string; title: string;
@@ -51,7 +57,7 @@ export interface QuizQuestion {
export interface Block { export interface Block {
id: string; id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document'; type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker';
title: string; title: string;
content?: string; content?: string;
url?: string; url?: string;
@@ -66,6 +72,8 @@ export interface Block {
correctAnswers?: string[]; correctAnswers?: string[];
instructions?: string; instructions?: string;
initialCode?: string; initialCode?: string;
keywords?: string[];
timeLimit?: number;
metadata?: any; metadata?: any;
} }
@@ -298,5 +306,11 @@ export const lmsApi = {
}, },
async getRecommendations(courseId: string): Promise<RecommendationResponse> { async getRecommendations(courseId: string): Promise<RecommendationResponse> {
return apiFetch(`/courses/${courseId}/recommendations`); return apiFetch(`/courses/${courseId}/recommendations`);
},
async evaluateAudio(transcript: string, prompt: string, keywords: string[]): Promise<AudioGradingResponse> {
return apiFetch('/audio/evaluate', {
method: 'POST',
body: JSON.stringify({ transcript, prompt, keywords })
});
} }
}; };
@@ -12,6 +12,7 @@ import OrderingBlock from "@/components/blocks/OrderingBlock";
import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock"; import ShortAnswerBlock from "@/components/blocks/ShortAnswerBlock";
import DocumentBlock from "@/components/blocks/DocumentBlock"; import DocumentBlock from "@/components/blocks/DocumentBlock";
import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock"; import VideoMarkerBlock from "@/components/blocks/VideoMarkerBlock";
import AudioResponseBlock from "@/components/blocks/AudioResponseBlock";
import { import {
Save, Save,
X, X,
@@ -155,7 +156,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
} }
}; };
const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker') => { const addBlock = (type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response') => {
const newBlock: Block = { const newBlock: Block = {
id: Math.random().toString(36).substr(2, 9), id: Math.random().toString(36).substr(2, 9),
type, type,
@@ -168,6 +169,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
...(type === 'short-answer' && { prompt: "Question?", correctAnswers: ["Answer"] }), ...(type === 'short-answer' && { prompt: "Question?", correctAnswers: ["Answer"] }),
...(type === 'document' && { url: "", title: "" }), ...(type === 'document' && { url: "", title: "" }),
...(type === 'video_marker' && { url: "", title: "Video Interactivo", markers: [] }), ...(type === 'video_marker' && { url: "", title: "Video Interactivo", markers: [] }),
...(type === 'audio-response' && { prompt: "Ask a question for the student to record their answer...", keywords: [], timeLimit: 60 }),
}; };
setBlocks([...blocks, newBlock]); setBlocks([...blocks, newBlock]);
}; };
@@ -585,6 +587,17 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
onChange={(updates) => updateBlock(block.id, updates)} onChange={(updates) => updateBlock(block.id, updates)}
/> />
)} )}
{block.type === 'audio-response' && (
<AudioResponseBlock
id={block.id}
title={block.title}
prompt={block.prompt || ""}
keywords={block.keywords || []}
timeLimit={block.timeLimit}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
</div> </div>
</div> </div>
))} ))}
@@ -657,6 +670,13 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
<span className="text-2xl group-hover:scale-110 transition-transform">📚</span> <span className="text-2xl group-hover:scale-110 transition-transform">📚</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Reading</span> <span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Reading</span>
</button> </button>
<button
onClick={() => addBlock('audio-response')}
className="flex flex-col items-center gap-2 p-6 glass hover:border-purple-500/50 transition-all group w-32"
>
<span className="text-2xl group-hover:scale-110 transition-transform">🎤</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-400">Audio</span>
</button>
<div className="w-px h-12 bg-white/5"></div> <div className="w-px h-12 bg-white/5"></div>
+37 -31
View File
@@ -149,7 +149,7 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
))} ))}
</div> </div>
) : ( ) : (
q.options.map((opt, oIdx) => ( q.options?.map((opt, oIdx) => (
<div key={oIdx} className="flex gap-3 items-center group/opt"> <div key={oIdx} className="flex gap-3 items-center group/opt">
<input <input
type={q.type === 'multiple-select' ? "checkbox" : "radio"} type={q.type === 'multiple-select' ? "checkbox" : "radio"}
@@ -207,38 +207,44 @@ export default function QuizBlock({ id, title, quizData, editMode, onChange }: Q
<div key={q.id} className="space-y-4 p-6 glass border-white/5 rounded-2xl"> <div key={q.id} className="space-y-4 p-6 glass border-white/5 rounded-2xl">
<h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4> <h4 className="font-bold text-xl text-gray-100 leading-tight">{q.question}</h4>
<div className="grid gap-3"> <div className="grid gap-3">
{q.options.map((opt, oIdx) => { {q.options && q.options.length > 0 ? (
const isSelected = userAnswers[q.id]?.includes(oIdx); q.options.map((opt, oIdx) => {
const isCorrect = q.correct?.includes(oIdx); const isSelected = userAnswers[q.id]?.includes(oIdx);
const isActuallyCorrect = isCorrect && isSelected; const isCorrect = q.correct?.includes(oIdx);
const isWrongSelection = !isCorrect && isSelected; const isActuallyCorrect = isCorrect && isSelected;
const missedCorrect = isCorrect && !isSelected; const isWrongSelection = !isCorrect && isSelected;
const missedCorrect = isCorrect && !isSelected;
let style = "glass border-white/10 hover:bg-white/5"; let style = "glass border-white/10 hover:bg-white/5";
if (submitted) { if (submitted) {
if (isActuallyCorrect) style = "bg-green-500/20 border-green-500 text-green-400"; if (isActuallyCorrect) style = "bg-green-500/20 border-green-500 text-green-400";
else if (isWrongSelection) style = "bg-red-500/20 border-red-500 text-red-100"; else if (isWrongSelection) style = "bg-red-500/20 border-red-500 text-red-100";
else if (missedCorrect) style = "border-orange-500/50 text-orange-400 animate-pulse"; else if (missedCorrect) style = "border-orange-500/50 text-orange-400 animate-pulse";
else style = "opacity-50 grayscale border-white/5"; else style = "opacity-50 grayscale border-white/5";
} else if (isSelected) { } else if (isSelected) {
style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]"; style = "bg-blue-500/20 border-blue-500 text-white shadow-[0_0_20px_rgba(59,130,246,0.2)]";
} }
return ( return (
<button <button
key={oIdx} key={oIdx}
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')} onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`} className={`p-5 rounded-xl border transition-all text-left text-sm font-bold ${style}`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{opt}</span> <span>{opt}</span>
{submitted && isActuallyCorrect && <span></span>} {submitted && isActuallyCorrect && <span></span>}
{submitted && isWrongSelection && <span></span>} {submitted && isWrongSelection && <span></span>}
{submitted && missedCorrect && <span className="text-[10px] uppercase font-black tracking-tighter">Correct Answer</span>} {submitted && missedCorrect && <span className="text-[10px] uppercase font-black tracking-tighter">Correct Answer</span>}
</div> </div>
</button> </button>
); );
})} })
) : (
<div className="text-xs text-orange-400/50 font-medium italic p-4 border border-dashed border-orange-500/20 rounded-xl">
No options generated for this question.
</div>
)}
</div> </div>
</div> </div>
))} ))}
+3 -1
View File
@@ -44,7 +44,7 @@ export interface QuizQuestion {
export interface Block { export interface Block {
id: string; id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker'; type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match';
title?: string; title?: string;
content?: string; content?: string;
url?: string; url?: string;
@@ -57,6 +57,8 @@ export interface Block {
items?: string[]; items?: string[];
prompt?: string; prompt?: string;
correctAnswers?: string[]; correctAnswers?: string[];
keywords?: string[];
timeLimit?: number;
markers?: { markers?: {
timestamp: number; timestamp: number;
question: string; question: string;