פיתוח מערכת מבחנים ושאלונים עבור LMS
אנו משלבים מערכת מבחנים גמישה לתוך מערכת ה-LMS שלך שמתמודדת עם כל תרחיש: מסקרים פשוטים ועד מבחני הסמכה עם הגנה מפני רמאות. יחד נזהה את סוגי השאלות הנדרשים, הגדרות זמן וכללי ניקוד אוטומטיים. התוצאה היא מודול מוכן עם API, טיימר בצד הלקוח ופאנל ניהול. לדוגמה, בפרויקט אחד לאוניברסיטה תאגידית החלפנו מערכת פנימית מיושנת. מספר התקלות הטכניות ירד ב-80%, וזמן השלמת המבחן הממוצע ירד מ-30 ל-18 דקות. הסטודנטים קיבלו משוב מיידי, והמורים קיבלו סטטיסטיקות מפורטות לכל שאלה. המערכת מתוכננת להתאים בקלות לשינויים בתכנית הלימודים ללא צורך בשכתוב קוד.
אילו בעיות אנו פותרים?
בעיה 1: סוגי שאלות מונוטוניים. רק בחירה יחידה? הסטודנטים מסתגלים במהירות. אנו מוסיפים 7+ סוגים: מסידור ועד השלמת משפטים. זה מגביר מעורבות ודיוק בהערכה. מידע נוסף על סוגי שאלות במבחנים.
בעיה 2: רמאות ומניפולציות. ללא הגנה, התוצאות מאבדות משמעות. אנו כוללים: ערבוב, טיימר, הגבלת ניסיונות, אי-חזרה לאחור, זיהוי רמאות. בפרויקט אחד, השלמות כנות עלו ב-40%.
בעיה 3: ניקוד ידני. אנו הופכים לאוטומטיים את מה שניתן לאוטומציה. תשובות קצרות נבדקות לפי מילות מפתח, בחירה מרובה מקבלת ניקוד חלקי. המורה רק מנחה מקרים חריגים. בדיקת תשובות קצרות אוטומטית מהירה פי 5 מניקוד ידני, עם דיוק של עד 95% כאשר מילות המפתח נבחרות היטב.
בעיה 4: אינטגרציה עם LMS קיים. לעיתים קרובות מודול המבחנים עובד בבידוד, והנתונים לא מסונכרנים. אנו מטמיעים את המערכת דרך REST API, ומסנכרנים משתמשים, קורסים והתקדמות. זה מבטל העברת תוצאות ידנית ומונע שגיאות.
איך אנחנו עושים את זה
אנו משתמשים ב-TypeScript על מחסנית React + Node.js. מסד נתונים: PostgreSQL (ליחסים מורכבים) או Redis (לטיימרים). אנו משתמשים ב-Redis caching לטיימרים ותוצאות כדי להאיץ חישובים. דוגמה למערכת טיפוסית (מפושטת):
type QuestionType =
| 'single_choice' // один правильный ответ
| 'multiple_choice' // несколько правильных
| 'true_false'
| 'short_answer' // текст, проверяется по ключевым словам или вручную
| 'ordering' // расставить в правильном порядке
| 'matching' // соответствие пар
| 'fill_blank'; // заполнить пропуск
interface Question {
id: string;
type: QuestionType;
text: string;
points: number;
explanation?: string; // показывается после ответа
answers: Answer[]; // Для fill_blank
blanks?: string[]; // Для matching
pairs?: Array<{ left: string; right: string }>;
}
interface QuizSettings {
timeLimit?: number; // секунды, null = без ограничения
maxAttempts: number; // 0 = неограниченно
passingScore: number; // процент
shuffleQuestions: boolean;
shuffleAnswers: boolean;
showCorrectAnswers: 'never' | 'after_attempt' | 'after_passing';
allowReview: boolean; // можно вернуться к предыдущим вопросам
}אחסון שאלות ותשובות במסד הנתונים מתוכנן עם נורמליזציה: טבלת type QuestionType = | 'single_choice' // один правильный ответ | 'multiple_choice' // несколько правильных | 'true_false' | 'short_answer' // текст, проверяется по ключевым словам или вручную | 'ordering' // расставить в правильном порядке | 'matching' // соответствие пар | 'fill_blank'; // заполнить пропуск interface Question { id: string; type: QuestionType; text: string; points: number; explanation?: string; // показывается после ответа answers: Answer[]; // Для fill_blank blanks?: string[]; // Для matching pairs?: Array<{ left: string; right: string }>; } interface QuizSettings { timeLimit?: number; // секунды, null = без ограничения maxAttempts: number; // 0 = неограниченно passingScore: number; // процент shuffleQuestions: boolean; shuffleAnswers: boolean; showCorrectAnswers: 'never' | 'after_attempt' | 'after_passing'; allowReview: boolean; // можно вернуться к предыдущим вопросам } מכילה שדות type, text, points; טבלת questions — id, text, isCorrect, questionId; עבור סידור והתאמה — טבלאות עזר עם סדר וזוגות.
איך אנו מיישמים הגנה מפני רמאות
התחלת ניסיון נעשית בצד השרת: אנו מערבבים שאלות ותשובות, בודקים את מגבלת הניסיונות. רק מזהים וטקסט נשלחים ללקוח, לעולם לא תשובות נכונות. בעת הגשה, הטיימר מאומת בשרת. אימות בצד השרת מונע מניפולציות זמן בצד הלקוח.
app.post('/api/quizzes/:quizId/attempts', authenticate, async (req, res) => {
const quiz = await db.quizzes.findById(req.params.quizId);
const enrollment = await db.enrollments.findByUserAndCourse(req.user.id, quiz.courseId);
// Проверить лимит попыток
const previousAttempts = await db.quizAttempts.countByUserAndQuiz(
req.user.id,
quiz.id
);
if (quiz.settings.maxAttempts > 0 && previousAttempts >= quiz.settings.maxAttempts) {
return res.status(429).json({ error: 'Max attempts reached' });
}
// Перемешать вопросы
let questions = quiz.questions;
if (quiz.settings.shuffleQuestions) {
questions = shuffleArray([...questions]);
}
if (quiz.settings.shuffleAnswers) {
questions = questions.map(q => ({
...q,
answers: q.type !== 'ordering' ? shuffleArray([...q.answers]) : q.answers,
}));
}
const attempt = await db.quizAttempts.create({
quizId: quiz.id,
userId: req.user.id,
enrollmentId: enrollment.id,
questions: questions.map(q => ({
id: q.id,
// НЕ отправляем isCorrect ответов клиенту
answers: q.answers.map(a => ({ id: a.id, text: a.text })),
})),
startedAt: new Date(),
expiresAt: quiz.settings.timeLimit ? new Date(Date.now() + quiz.settings.timeLimit * 1000) : null,
});
res.json({
attemptId: attempt.id,
questions: attempt.questions,
expiresAt: attempt.expiresAt,
});
});
למה חשוב להגדיר את הטיימר
הטיימר הוא לא רק מגבלת זמן. רכיב ה-React בצד הלקוח answers מציג ספירה לאחור, מזהיר דקה לפני הסיום, ומגיש את המבחן אוטומטית. בשרת, אנו מוודאים שהניסיון לא חרג מהמגבלה — זה מבטיח הוגנות. אנו משתמשים ב-app.post('/api/quizzes/:quizId/attempts', authenticate, async (req, res) => { const quiz = await db.quizzes.findById(req.params.quizId); const enrollment = await db.enrollments.findByUserAndCourse(req.user.id, quiz.courseId); // Проверить лимит попыток const previousAttempts = await db.quizAttempts.countByUserAndQuiz( req.user.id, quiz.id ); if (quiz.settings.maxAttempts > 0 && previousAttempts >= quiz.settings.maxAttempts) { return res.status(429).json({ error: 'Max attempts reached' }); } // Перемешать вопросы let questions = quiz.questions; if (quiz.settings.shuffleQuestions) { questions = shuffleArray([...questions]); } if (quiz.settings.shuffleAnswers) { questions = questions.map(q => ({ ...q, answers: q.type !== 'ordering' ? shuffleArray([...q.answers]) : q.answers, })); } const attempt = await db.quizAttempts.create({ quizId: quiz.id, userId: req.user.id, enrollmentId: enrollment.id, questions: questions.map(q => ({ id: q.id, // НЕ отправляем isCorrect ответов клиенту answers: q.answers.map(a => ({ id: a.id, text: a.text })), })), startedAt: new Date(), expiresAt: quiz.settings.timeLimit ? new Date(Date.now() + quiz.settings.timeLimit * 1000) : null, }); res.json({ attemptId: attempt.id, questions: attempt.questions, expiresAt: attempt.expiresAt, }); }); ו-QuizTimer מ-תיעוד React.
function QuizTimer({ expiresAt, onExpired }) {
const [remaining, setRemaining] = useState(0);
useEffect(() => {
const update = () => {
const diff = Math.max(0, new Date(expiresAt).getTime() - Date.now());
setRemaining(Math.floor(diff / 1000));
if (diff <= 0) onExpired();
};
update();
const timer = setInterval(update, 1000);
return () => clearInterval(timer);
}, [expiresAt]);
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
const isUrgent = remaining < 60;
return (
<div className={`font-mono text-lg font-bold ${isUrgent ? 'text-red-600 animate-pulse' : 'text-gray-700'}`}>
{String(minutes).padStart(2, '0')}:{String(seconds).padStart(2, '0')}
</div>
);
}בנוסף לטיימר בצד הלקוח, בכל בקשת הגשה השרת בודק שהזמן לא פג. אם ניסיון מוגש לאחר המועד, אנו מחזירים שגיאה 408. זה מונע רמאות על ידי מניפולציה של זמן הלקוח.
איך תשובות נבדקות
לאחר הגשה, השרת מחשב ציונים, כולל ניקוד חלקי לבחירה מרובה ומילות מפתח לתשובות קצרות. השוואה בין ניקוד אוטומטי לידני:
| שיטת ניקוד | זמן ל-100 תשובות | דיוק |
|---|---|---|
| אוטומטי | 0.5 שניות | 95% |
| ידני | שעתיים | 98% |
app.post('/api/attempts/:attemptId/submit', authenticate, async (req, res) => {
const attempt = await db.quizAttempts.findById(req.params.attemptId);
if (attempt.userId !== req.user.id) return res.status(403).end();
if (attempt.submittedAt) return res.status(409).json({ error: 'Already submitted' });
// Проверить таймер
if (attempt.expiresAt && new Date() > attempt.expiresAt) {
return res.status(408).json({ error: 'Time expired' });
}
const { answers } = req.body; // { questionId: answerId | answerId[] | string }
const quiz = await db.quizzes.findById(attempt.quizId);
let totalPoints = 0;
let earnedPoints = 0;
const results = quiz.questions.map(question => {
totalPoints += question.points;
const userAnswer = answers[question.id];
let isCorrect = false;
let pointsEarned = 0;
switch (question.type) {
case 'single_choice':
case 'true_false':
const correctAnswer = question.answers.find(a => a.isCorrect);
isCorrect = userAnswer === correctAnswer?.id;
pointsEarned = isCorrect ? question.points : 0;
break;
case 'multiple_choice':
const correctIds = new Set(question.answers.filter(a => a.isCorrect).map(a => a.id));
const userIds = new Set(Array.isArray(userAnswer) ? userAnswer : []);
isCorrect = correctIds.size === userIds.size && [...correctIds].every(id => userIds.has(id));
// Частичные баллы за множественный выбор
const correctSelected = [...userIds].filter(id => correctIds.has(id)).length;
const wrongSelected = [...userIds].filter(id => !correctIds.has(id)).length;
pointsEarned = Math.max(0, (correctSelected / correctIds.size - wrongSelected / correctIds.size) * question.points );
break;
case 'ordering':
const correctOrder = question.answers.map(a => a.id);
isCorrect = JSON.stringify(userAnswer) === JSON.stringify(correctOrder);
pointsEarned = isCorrect ? question.points : 0;
break;
case 'short_answer':
// Автопроверка по ключевым словам, ручная — отдельно
const keywords = question.answers[0]?.keywords ?? [];
const matchCount = keywords.filter(kw => (userAnswer as string).toLowerCase().includes(kw.toLowerCase()) ).length;
isCorrect = matchCount >= (question.answers[0]?.minKeywords ?? 1);
pointsEarned = isCorrect ? question.points : 0;
break;
}
earnedPoints += pointsEarned;
return { questionId: question.id, isCorrect, pointsEarned, correctAnswer: question.answers.filter(a => a.isCorrect) };
});
const scorePercent = Math.round((earnedPoints / totalPoints) * 100);
const passed = scorePercent >= quiz.settings.passingScore;
await db.quizAttempts.update(attempt.id, {
answers,
results,
score: scorePercent,
passed,
submittedAt: new Date(),
timeSpent: Math.round((Date.now() - new Date(attempt.startedAt).getTime()) / 1000),
});
if (passed) {
await db.lessonProgress.markCompleted(req.user.id, quiz.lessonId);
}
const showAnswers = quiz.settings.showCorrectAnswers === 'after_attempt' || (quiz.settings.showCorrectAnswers === 'after_passing' && passed);
res.json({
score: scorePercent,
passed,
earnedPoints,
totalPoints,
results: showAnswers ? results : results.map(r => ({ questionId: r.questionId, isCorrect: r.isCorrect })),
});
});
תהליך העבודה
- ניתוח — לימוד מערכת ה-LMS שלך, רשימת סוגי שאלות, דרישות הגנה.
- עיצוב — סכמת מסד נתונים, API, עיבוד שאלות, לוגיקת טיימר.
- יישום — כתיבת קוד, בדיקה על נתונים אמיתיים.
- בדיקות — בדיקות עומס, בדיקות הגנה, שימושיות.
- פריסה ותמיכה — פריסה, מסירת תיעוד, הדרכת מנהלים.
לוח זמנים ומה כלול
| רכיב | לוח זמנים |
|---|---|
| 4–5 סוגי שאלות + טיימר + ניסיונות | 1–1.5 שבועות |
| סוגים נוספים (>5) | +2–3 ימים |
| אינטגרציה עם LMS קיים | החל מ-3 ימים |
| ניקוד ידני של תשובות חיבור | החל מ-2 ימים |
כלול: API, רכיב טיימר בצד הלקוח, פאנל ניהול לניהול מבחנים, מיגרציות מסד נתונים, תיעוד, הדרכה, תמיכה לאחר השקה.
קבלו ייעוץ לפרויקט שלכם — נייעץ לכם אילו סוגי שאלות מתאימים לכם. הזמינו פיתוח מערכת מבחנים: מסקר פשוט ועד מבחן הסמכה. לצוות שלנו יש ניסיון של 5+ שנים ומעל 50 הטמעות של מערכות מבחנים במערכות LMS שונות. אנו מבטיחים יציבות ואבטחת מידע.







