665 lines
37 KiB
PHP
665 lines
37 KiB
PHP
<?php
|
|
/**
|
|
* ==============================================================================
|
|
* SAQEL ENTERPRISE (EDTECH 2.0) - EXAM & ADAPTIVE ASSESSMENT CONTROLLER
|
|
* ==============================================================================
|
|
*
|
|
* ملف: ExamController.php
|
|
* الهدف المعماري:
|
|
* إدارة منظومة القياس والتقويم والامتحانات التكيفية في منصة صَقِل.
|
|
* يقوم هذا الملف بالوظائف الجوهرية التالية:
|
|
* 1. استرجاع قائمة الامتحانات والفحوصات السقراطية (in_video_checkpoint, lesson_exam, unit_exam).
|
|
* 2. محرك الاختيار التكيفي (Adaptive 60/40 Engine): فحص نقاط ضعف الطالب وسحب 60% من أسئلة الامتحان
|
|
* من المفاهيم التي تعثر فيها الطالب سابقاً لسد الثغرات وتحقيق الإتقان، و 40% من باقي مفاهيم الوحدة.
|
|
* 3. استلام وتصحيح إجابات الامتحانات لحظياً وحساب نسبة الإتقان وتوليد تقرير التشخيص الذكي (AI Diagnostic Report).
|
|
* 4. إطلاق إشعار الواتساب التوجيهي الهادئ لولي الأمر عبر بوابة "نبّه" عند رصد حاجة الطالب للمعالجة.
|
|
* 5. حساب مؤشرات الجاهزية للتوجيهي ونقاط الإتقان لكل مادة دراسية.
|
|
*/
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Core\Request;
|
|
use App\Core\Response;
|
|
use App\Core\Database;
|
|
use App\Core\Validator;
|
|
|
|
class ExamController
|
|
{
|
|
/**
|
|
* جلب قائمة الامتحانات المتاحة لمادة أو درس معين مع عدد الأسئلة
|
|
* GET /api/exams?course_id=1&lesson_id=2&scope=in_video_checkpoint
|
|
*
|
|
* @param Request $request طلب الـ HTTP المحتوي على معاملات التصفية (course_id, lesson_id, scope)
|
|
* @param Response $response كائن الاستجابة لإرجاع مصفوفة الامتحانات بصيغة JSON
|
|
*/
|
|
public function getExams(Request $request, Response $response): void
|
|
{
|
|
$queryParams = $request->getQueryParams();
|
|
$courseId = !empty($queryParams['course_id']) ? (int)$queryParams['course_id'] : null;
|
|
$lessonId = !empty($queryParams['lesson_id']) ? (int)$queryParams['lesson_id'] : null;
|
|
$scope = $queryParams['scope'] ?? null;
|
|
|
|
$sql = "SELECT e.*, COUNT(q.id) as questions_count
|
|
FROM exams e
|
|
LEFT JOIN questions q ON q.exam_id = e.id
|
|
WHERE e.is_published = 1";
|
|
$params = [];
|
|
|
|
if ($courseId) {
|
|
$sql .= " AND e.course_id = ?";
|
|
$params[] = $courseId;
|
|
}
|
|
if ($lessonId) {
|
|
$sql .= " AND e.lesson_id = ?";
|
|
$params[] = $lessonId;
|
|
}
|
|
if ($scope) {
|
|
$sql .= " AND e.scope = ?";
|
|
$params[] = $scope;
|
|
}
|
|
|
|
$sql .= " GROUP BY e.id ORDER BY e.timestamp_seconds ASC, e.id ASC";
|
|
|
|
$exams = Database::select($sql, $params);
|
|
|
|
$response->json([
|
|
'status' => 'success',
|
|
'data' => $exams
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* استرجاع تفاصيل الامتحان مع الأسئلة والخيارات المنقحة (ومحرك الاختيار التكيفي 60/40)
|
|
* GET /api/exams/{id}
|
|
*
|
|
* آلية العمل:
|
|
* 1. التحقق من وجود الامتحان، وإذا كان غير مفهرس يتم استدعاء باني الأسئلة التلقائي للوحدة الأولى.
|
|
* 2. تطبيق خوارزمية التعلم التكيفي (Adaptive Sampling): إذا كان بنك الأسئلة يحتوي على أكثر من 15 سؤالاً،
|
|
* يتم فحص تاريخ الطالب واستخراج المفاهيم التي أخطأ فيها سابقاً وسحب 60% من أسئلة الامتحان منها.
|
|
* 3. تنقيح خيارات الإجابة وحجب حقل (is_correct) عن الطالب منعاً للغش وإظهاره فقط للمعلم أو الأدمن.
|
|
*
|
|
* @param Request $request طلب الـ HTTP المحتوي على معرف الامتحان في المسار ومعرف الطالب المصادق عليه
|
|
* @param Response $response كائن الاستجابة لإرجاع كائن الامتحان بأسئلته الـ 15 التكيفية
|
|
*/
|
|
public function getExamDetails(Request $request, Response $response): void
|
|
{
|
|
self::ensureSchema();
|
|
$examId = (int)$request->getParam('id');
|
|
$isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin');
|
|
|
|
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
|
if (!$exam || $examId === 1 || (str_contains($exam['title'] ?? '', 'الوحدة الأولى') && count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) {
|
|
$examId = self::seedUnit1ComprehensiveExam();
|
|
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
|
}
|
|
|
|
if (!$exam) {
|
|
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
|
|
return;
|
|
}
|
|
|
|
$allQuestions = Database::select("SELECT * FROM questions WHERE exam_id = ? ORDER BY id ASC", [$examId]);
|
|
|
|
$questions = [];
|
|
if (count($allQuestions) > 15) {
|
|
// Adaptive Sampling Engine based on Student Weakness History
|
|
$studentId = $request->user_id ?? 0;
|
|
$weakTopics = [];
|
|
|
|
if ($studentId > 0) {
|
|
// Fetch failed topics from student's answers
|
|
$fails = Database::select(
|
|
"SELECT q.topic_tag, COUNT(*) as cnt
|
|
FROM student_question_answers sqa
|
|
JOIN questions q ON sqa.question_id = q.id
|
|
WHERE sqa.student_id = ? AND sqa.is_correct = 0 AND q.topic_tag IS NOT NULL
|
|
GROUP BY q.topic_tag ORDER BY cnt DESC LIMIT 5",
|
|
[$studentId]
|
|
);
|
|
foreach ($fails as $f) {
|
|
$weakTopics[] = $f['topic_tag'];
|
|
}
|
|
}
|
|
|
|
// Split questions into weak vs other topics
|
|
$weakPool = [];
|
|
$otherPool = [];
|
|
|
|
foreach ($allQuestions as $q) {
|
|
if (!empty($weakTopics) && in_array($q['topic_tag'], $weakTopics)) {
|
|
$weakPool[] = $q;
|
|
} else {
|
|
$otherPool[] = $q;
|
|
}
|
|
}
|
|
|
|
shuffle($weakPool);
|
|
shuffle($otherPool);
|
|
|
|
if (!empty($weakPool)) {
|
|
// 60% focused on weak concepts for adaptive remediation
|
|
$targetWeakCount = min(count($weakPool), 9);
|
|
$questions = array_slice($weakPool, 0, $targetWeakCount);
|
|
|
|
// 40% from rest of unit concepts
|
|
$needed = 15 - count($questions);
|
|
$questions = array_merge($questions, array_slice($otherPool, 0, $needed));
|
|
} else {
|
|
// Stratified random sampling from the comprehensive bank
|
|
$questions = array_slice($otherPool, 0, 15);
|
|
}
|
|
|
|
shuffle($questions);
|
|
} else {
|
|
$questions = $allQuestions;
|
|
}
|
|
|
|
foreach ($questions as &$q) {
|
|
$options = Database::select("SELECT id, option_text, feedback_text" . ($isTeacher ? ", is_correct" : "") . " FROM question_options WHERE question_id = ? ORDER BY id ASC", [$q['id']]);
|
|
$q['options'] = $options;
|
|
}
|
|
|
|
$exam['questions'] = $questions;
|
|
$exam['questions_count'] = count($questions);
|
|
|
|
$response->json([
|
|
'status' => 'success',
|
|
'data' => $exam
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* تصحيح الامتحان لحظياً بالذكاء الاصطناعي وتوليد التقرير التشخيصي وإشعار ولي الأمر
|
|
* POST /api/exams/{id}/submit
|
|
*
|
|
* الوظائف المنجزة:
|
|
* 1. التحقق من إجابات الطالب ومقارنتها بالخيارات الصحيحة وحساب مجموع النقاط ونسبة الإتقان المئوية.
|
|
* 2. تسجيل كل إجابة في جدول (student_question_answers) لبناء ملف الضعف والقوة للتعلم التكيفي المستقبلي.
|
|
* 3. توليد تقرير تشخيصي ذكي (AI Diagnostic Report) يبرز المفاهيم المتقنة والمفاهيم المتعثر فيها بدقة.
|
|
* 4. فحص شرط النجاح: إذا كانت النتيجة أقل من نسبة النجاح، يتم ضبط الحالة على needs_remediation
|
|
* وإطلاق رسالة توجيهية هادئة عبر الواتساب لولي الأمر عبر بوابة نبّه (Nabeh Gateway).
|
|
* 5. تحديث جدول إتقان الطالب (student_course_mastery) ونقاط جاهزية التوجيهي.
|
|
*
|
|
* @param Request $request طلب الـ HTTP المحتوي على الإجابات ووقت المحاولة
|
|
* @param Response $response كائن الاستجابة بالنتيجة والتقرير وتفاصيل الحل النموذجي
|
|
*/
|
|
public function submitExam(Request $request, Response $response): void
|
|
{
|
|
self::ensureAttemptSchema();
|
|
$studentId = $request->user_id;
|
|
$examId = (int)$request->getParam('id');
|
|
$body = $request->getBody();
|
|
|
|
$answers = $body['answers'] ?? []; // Array of ['question_id' => X, 'selected_option_id' => Y]
|
|
$timeSpent = (int)($body['time_spent_seconds'] ?? 0);
|
|
|
|
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
|
if (!$exam) {
|
|
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
|
|
return;
|
|
}
|
|
|
|
// Fetch all questions and correct options
|
|
$questions = Database::select("SELECT id, points, topic_tag, explanation_text, ai_hint FROM questions WHERE exam_id = ?", [$examId]);
|
|
$questionMap = [];
|
|
$totalPoints = 0;
|
|
foreach ($questions as $q) {
|
|
$questionMap[$q['id']] = $q;
|
|
$totalPoints += (int)$q['points'];
|
|
}
|
|
|
|
$options = Database::select("SELECT id, question_id, is_correct, feedback_text FROM question_options WHERE question_id IN (" . implode(',', array_keys($questionMap)) . ")");
|
|
$correctOptions = [];
|
|
foreach ($options as $opt) {
|
|
if ($opt['is_correct']) {
|
|
$correctOptions[$opt['question_id']] = (int)$opt['id'];
|
|
}
|
|
}
|
|
|
|
// Score evaluation
|
|
$earnedScore = 0;
|
|
$weakTopics = [];
|
|
$detailedAnswers = [];
|
|
|
|
foreach ($answers as $ans) {
|
|
$qId = (int)($ans['question_id'] ?? 0);
|
|
$selectedOptId = (int)($ans['selected_option_id'] ?? 0);
|
|
|
|
if (!isset($questionMap[$qId])) continue;
|
|
|
|
$isCorrect = (isset($correctOptions[$qId]) && $correctOptions[$qId] === $selectedOptId);
|
|
$qPoints = (int)$questionMap[$qId]['points'];
|
|
$awarded = $isCorrect ? $qPoints : 0;
|
|
$earnedScore += $awarded;
|
|
|
|
if (!$isCorrect) {
|
|
$topic = $questionMap[$qId]['topic_tag'] ?: 'المفاهيم العامة';
|
|
$weakTopics[$topic] = ($weakTopics[$topic] ?? 0) + 1;
|
|
}
|
|
|
|
$detailedAnswers[] = [
|
|
'question_id' => $qId,
|
|
'selected_option_id' => $selectedOptId,
|
|
'is_correct' => $isCorrect ? 1 : 0,
|
|
'points_awarded' => $awarded,
|
|
'explanation' => $questionMap[$qId]['explanation_text'] ?? null,
|
|
'ai_hint' => $questionMap[$qId]['ai_hint'] ?? null
|
|
];
|
|
}
|
|
|
|
$percentage = $totalPoints > 0 ? round(($earnedScore / $totalPoints) * 100, 2) : 0;
|
|
$passed = ($percentage >= (float)$exam['passing_percentage']);
|
|
$status = $passed ? 'passed' : 'needs_remediation';
|
|
|
|
// AI Diagnostic Report
|
|
$aiReport = $passed
|
|
? "أداء ممتاز! استيعاب قوي للمفاهيم بنسبة {$percentage}%. أنت جاهز تماماً للانتقال للدرس التالي."
|
|
: "تحتاج لمراجعة المفاهيم المتعلقة بـ: " . implode('، ', array_keys($weakTopics)) . ". يُنصح بمشاهدة مقطع الشرح الموصى به.";
|
|
|
|
// Insert Attempt Record
|
|
self::ensureSchema();
|
|
|
|
$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
|
mt_rand(0, 0xffff),
|
|
mt_rand(0, 0x0fff) | 0x4000,
|
|
mt_rand(0, 0x3fff) | 0x8000,
|
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
|
);
|
|
|
|
$attemptId = Database::insert(
|
|
"INSERT INTO exam_attempts (uuid, student_id, exam_id, score, total_score, percentage, status, time_spent_seconds, weak_topics_json, ai_diagnostic_report, completed_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())",
|
|
[$uuid, $studentId, $examId, $earnedScore, $totalPoints, $percentage, $status, $timeSpent, json_encode(array_keys($weakTopics), JSON_UNESCAPED_UNICODE), $aiReport]
|
|
);
|
|
|
|
// Save detailed question answers safely
|
|
foreach ($detailedAnswers as $dAns) {
|
|
try {
|
|
Database::insert(
|
|
"INSERT INTO student_question_answers (attempt_id, student_id, question_id, selected_option_id, is_correct, points_awarded, time_spent_seconds)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
[$attemptId, $studentId, $dAns['question_id'], $dAns['selected_option_id'], $dAns['is_correct'], $dAns['points_awarded'], 0]
|
|
);
|
|
} catch (\Throwable $e) {
|
|
error_log("student_question_answers insert notice: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// Update Student Cumulative Mastery & Tawjihi Readiness Score
|
|
$courseId = (int)$exam['course_id'];
|
|
$subjectId = (int)Database::selectOne("SELECT subject_id FROM courses WHERE id = ? LIMIT 1", [$courseId])['subject_id'];
|
|
|
|
$allAttempts = Database::select(
|
|
"SELECT ea.percentage, ea.status FROM exam_attempts ea
|
|
JOIN exams e ON e.id = ea.exam_id
|
|
WHERE ea.student_id = ? AND e.course_id = ?",
|
|
[$studentId, $courseId]
|
|
);
|
|
|
|
$totalExams = count($allAttempts);
|
|
$passedExams = 0;
|
|
$sumPct = 0;
|
|
foreach ($allAttempts as $att) {
|
|
$sumPct += (float)$att['percentage'];
|
|
if ($att['status'] === 'passed') $passedExams++;
|
|
}
|
|
|
|
$avgMastery = $totalExams > 0 ? round($sumPct / $totalExams, 2) : 0;
|
|
$tawjihiIndex = round(($avgMastery * 0.7) + (($passedExams / max(1, $totalExams)) * 30), 1);
|
|
|
|
Database::query("
|
|
INSERT INTO student_mastery_analytics
|
|
(student_id, course_id, subject_id, mastery_percentage, tawjihi_readiness_score, exams_passed_count, exams_total_count, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
mastery_percentage = VALUES(mastery_percentage),
|
|
tawjihi_readiness_score = VALUES(tawjihi_readiness_score),
|
|
exams_passed_count = VALUES(exams_passed_count),
|
|
exams_total_count = VALUES(exams_total_count),
|
|
updated_at = NOW()
|
|
", [$studentId, $courseId, $subjectId, $avgMastery, $tawjihiIndex, $passedExams, $totalExams]);
|
|
|
|
// Trigger Guardian Alert via Nabeh WhatsApp if remediation needed
|
|
if (!$passed && !empty($weakTopics)) {
|
|
try {
|
|
$studentInfo = Database::selectOne(
|
|
"SELECT s.full_name, sub.name AS subject_name
|
|
FROM students s
|
|
LEFT JOIN courses c ON c.id = ?
|
|
LEFT JOIN subjects sub ON sub.id = c.subject_id
|
|
WHERE s.id = ? LIMIT 1",
|
|
[$courseId, $studentId]
|
|
);
|
|
$guardian = Database::selectOne(
|
|
"SELECT ai.phone_number
|
|
FROM guardian_students gs
|
|
JOIN guardians g ON g.id = gs.guardian_id
|
|
JOIN auth_identities ai ON ai.id = g.auth_identity_id
|
|
WHERE gs.student_id = ? LIMIT 1",
|
|
[$studentId]
|
|
);
|
|
if ($guardian && !empty($guardian['phone_number'])) {
|
|
$nabeh = new \App\Services\NabehService();
|
|
$weakTopicStr = implode('، ', array_keys($weakTopics));
|
|
$nabeh->sendGuardianRemedialAlert(
|
|
(string)$guardian['phone_number'],
|
|
$studentInfo['full_name'] ?? 'الطالب',
|
|
$studentInfo['subject_name'] ?? 'المبحث المقرر',
|
|
$weakTopicStr
|
|
);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log("Guardian remedial alert notice: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
$response->json([
|
|
'status' => 'success',
|
|
'data' => [
|
|
'attempt_id' => $attemptId,
|
|
'score' => $earnedScore,
|
|
'total_score' => $totalPoints,
|
|
'percentage' => $percentage,
|
|
'passed' => $passed,
|
|
'rewind_seconds' => $passed ? 0 : (int)$exam['rewind_on_fail_seconds'],
|
|
'ai_diagnostic_report' => $aiReport,
|
|
'weak_topics' => array_keys($weakTopics),
|
|
'tawjihi_readiness_score'=> $tawjihiIndex,
|
|
'detailed_answers' => $detailedAnswers
|
|
]
|
|
]);
|
|
}
|
|
|
|
/** Additive production migration for installations created before completed_at. */
|
|
private static function ensureAttemptSchema(): void
|
|
{
|
|
try {
|
|
$column = Database::selectOne(
|
|
"SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts'
|
|
AND COLUMN_NAME = 'completed_at' LIMIT 1"
|
|
);
|
|
if (empty($column['cnt'])) {
|
|
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT NULL AFTER ai_diagnostic_report");
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log('Exam schema migration note: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* استرجاع مقاييس إتقان الطالب ومؤشر الجاهزية لامتحان التوجيهي
|
|
* GET /api/student/progress/mastery?course_id=1
|
|
*
|
|
* @param Request $request طلب الـ HTTP المحتوي على معرف الطالب ومعرف المادة
|
|
* @param Response $response كائن الاستجابة بنسبة الإتقان والمحاولات الأخيرة
|
|
*/
|
|
public function getMastery(Request $request, Response $response): void
|
|
{
|
|
$studentId = $request->user_id;
|
|
$courseId = (int)$request->getParam('course_id');
|
|
|
|
$analytics = Database::selectOne(
|
|
"SELECT * FROM student_mastery_analytics WHERE student_id = ? AND course_id = ? LIMIT 1",
|
|
[$studentId, $courseId]
|
|
);
|
|
|
|
$recentAttempts = Database::select(
|
|
"SELECT ea.id, ea.score, ea.total_score, ea.percentage, ea.status, ea.ai_diagnostic_report, ea.completed_at, e.title as exam_title, e.scope
|
|
FROM exam_attempts ea
|
|
JOIN exams e ON e.id = ea.exam_id
|
|
WHERE ea.student_id = ? AND e.course_id = ?
|
|
ORDER BY ea.id DESC LIMIT 10",
|
|
[$studentId, $courseId]
|
|
);
|
|
|
|
$response->json([
|
|
'status' => 'success',
|
|
'data' => [
|
|
'analytics' => $analytics ?: [
|
|
'mastery_percentage' => 0,
|
|
'tawjihi_readiness_score'=> 0,
|
|
'exams_passed_count' => 0,
|
|
'exams_total_count' => 0
|
|
],
|
|
'recent_attempts' => $recentAttempts
|
|
]
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* فحص التوافقية الذاتية وترقية جداول الامتحانات والتقييم في قاعدة البيانات
|
|
* تقوم بإنشاء جدول (student_question_answers) والتأكد من وجود عمود (completed_at) وتوسيع الـ ENUM
|
|
*/
|
|
public static function ensureSchema(): void
|
|
{
|
|
try {
|
|
Database::query(
|
|
"CREATE TABLE IF NOT EXISTS `student_question_answers` (
|
|
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
|
`attempt_id` BIGINT UNSIGNED NOT NULL,
|
|
`student_id` BIGINT UNSIGNED NOT NULL,
|
|
`question_id` BIGINT UNSIGNED NOT NULL,
|
|
`selected_option_id` BIGINT UNSIGNED NULL,
|
|
`is_correct` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`points_awarded` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
|
|
`time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
KEY `idx_sqa_attempt` (`attempt_id`),
|
|
KEY `idx_sqa_student` (`student_id`),
|
|
KEY `idx_sqa_question` (`question_id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
|
);
|
|
|
|
// Ensure completed_at in exam_attempts
|
|
$colCheck = Database::selectOne(
|
|
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts' AND COLUMN_NAME = 'completed_at' LIMIT 1"
|
|
);
|
|
if (!$colCheck) {
|
|
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER ai_diagnostic_report");
|
|
}
|
|
|
|
// Safely ensure scope ENUM covers unit_comprehensive if ever passed
|
|
try {
|
|
Database::query("ALTER TABLE exams MODIFY COLUMN scope ENUM('in_video_checkpoint', 'lesson_exam', 'unit_exam', 'unit_comprehensive', 'semester_final') NOT NULL DEFAULT 'in_video_checkpoint'");
|
|
} catch (\Throwable $ign) {}
|
|
} catch (\Throwable $e) {
|
|
error_log("ExamController ensureSchema notice: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* التهيئة والتوليد الذاتي لامتحان الوحدة الأولى الشامل وحقن الأسئلة من بنك الذكاء الاصطناعي
|
|
*
|
|
* @return int معرف الامتحان في قاعدة البيانات
|
|
*/
|
|
public static function seedUnit1ComprehensiveExam(): int
|
|
{
|
|
self::ensureSchema();
|
|
|
|
$exam = Database::selectOne(
|
|
"SELECT id FROM exams WHERE (scope = 'unit_exam' OR scope = 'unit_comprehensive' OR id = 1) AND (title LIKE '%الوحدة الأولى%' OR id = 1) LIMIT 1"
|
|
);
|
|
|
|
$examId = $exam ? (int)$exam['id'] : 0;
|
|
if (!$examId) {
|
|
$examId = (int)Database::insert(
|
|
"INSERT INTO exams (uuid, course_id, lesson_id, creator_type, scope, title, passing_percentage, total_points, duration_minutes, is_mandatory, is_published)
|
|
VALUES (UUID(), 1, NULL, 'ai_adaptive', 'unit_exam', 'اختبار الفهم الشامل: الوحدة الأولى — أنظمة المعادلات', 70.00, 100, 45, 1, 1)"
|
|
);
|
|
} else {
|
|
Database::query(
|
|
"UPDATE exams SET title = 'اختبار الفهم الشامل: الوحدة الأولى — أنظمة المعادلات', duration_minutes = 45, total_points = 100, passing_percentage = 70.00, scope = 'unit_exam', lesson_id = NULL, is_published = 1 WHERE id = ?",
|
|
[$examId]
|
|
);
|
|
}
|
|
|
|
$existingQuestions = Database::select("SELECT id, question_text FROM questions WHERE exam_id = ?", [$examId]);
|
|
$hasCalculus = false;
|
|
foreach ($existingQuestions as $eq) {
|
|
if (str_contains($eq['question_text'], 'مشتق') || str_contains($eq['question_text'], "f'(x)")) {
|
|
$hasCalculus = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (count($existingQuestions) < 45 || $hasCalculus) {
|
|
// Wipe legacy corrupt questions
|
|
foreach ($existingQuestions as $oq) {
|
|
Database::query("DELETE FROM question_options WHERE question_id = ?", [$oq['id']]);
|
|
}
|
|
Database::query("DELETE FROM questions WHERE exam_id = ?", [$examId]);
|
|
|
|
require_once __DIR__ . '/../Services/AiQuestionBankGeneratorService.php';
|
|
$unitPath = 'grade_10/math_10/semester_1/unit_01';
|
|
$res = \App\Services\AiQuestionBankGeneratorService::generateUnitQuestionBank($unitPath, 1, 50);
|
|
return (int)$res['exam_id'];
|
|
}
|
|
|
|
return $examId;
|
|
}
|
|
|
|
public static function getUnit1QuestionBank(): array
|
|
{
|
|
return [
|
|
[
|
|
'question_text' => 'ما هي مجموعة حل المعادلة الحقيقية: x³ - 4x = 0؟',
|
|
'bloom' => 'recall',
|
|
'topic' => 'حل المعادلات بإخراج العامل المشترك الأكبر',
|
|
'explanation' => 'بإخراج x كعامل مشترك: x(x² - 4) = 0 ومنها x(x - 2)(x + 2) = 0، فيكون الحل x = 0, 2, -2.',
|
|
'points' => 6,
|
|
'options' => ['{ -2, 0, 2 }', '{ 0, 4 }', '{ -2, 2 }', '{ 0, 2 }'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'حل المعادلة x⁴ - 5x² + 4 = 0 في مجموعة الأعداد الحقيقية هو:',
|
|
'bloom' => 'comprehension',
|
|
'topic' => 'المعادلات في الصورة التربيعية',
|
|
'explanation' => 'بالتحليل كمعادلة تربيعية: (x² - 4)(x² - 1) = 0، ومنها x² = 4 أو x² = 1، فالجذور هي ±2 و ±1.',
|
|
'points' => 7,
|
|
'options' => ['{ -2, -1, 1, 2 }', '{ 1, 4 }', '{ -1, 1 }', '{ -4, 4 }'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'كم حلاً حقيقياً يحقق المعادلة: x³ + 8 = 0؟',
|
|
'bloom' => 'comprehension',
|
|
'topic' => 'تحليل مجموع المكعبين والحلول الحقيقية',
|
|
'explanation' => 'بتحليل مجموع مكعبين: (x + 2)(x² - 2x + 4) = 0. القوس التربيعي مميزه سالب (-12) فلا يعطي جذوراً حقيقية، والحل الحقيقي الوحيد هو x = -2.',
|
|
'points' => 6,
|
|
'options' => ['حل حقيقي واحد فقط وهو x = -2', 'ثلاثة حلول حقيقية', 'حلّان حقيقيان', 'لا يوجد أي حل حقيقي'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'ما هو أقصى عدد ممكن لنقاط التقاطع بين مستقيم وقطع مكافئ في المستوى الإحداثي؟',
|
|
'bloom' => 'recall',
|
|
'topic' => 'التمثيل الهندسي لنظام خطي وتربيعي',
|
|
'explanation' => 'المستقيم يقطع القطع المكافئ في نقطتين كحد أقصى (حلان)، أو يمسه في نقطة (حل واحد)، أو لا يقطعه (لا يوجد حل حقيقي).',
|
|
'points' => 6,
|
|
'options' => ['نقطتان كحد أقصى (حلّان حقيقيان)', 'ثلاث نقاط تقاطع', 'أربع نقاط تقاطع', 'نقطة واحدة فقط دائماً'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'إذا كان لدينا النظام: y = x + 1 و y = x² + 1، فما هي نقاط تقاطع المنحنيين؟',
|
|
'bloom' => 'application',
|
|
'topic' => 'حل نظام خطي وتربيعي بطريقة التعويض',
|
|
'explanation' => 'بالمساواة: x² + 1 = x + 1 ومنها x² - x = 0 أي x(x - 1) = 0، فيكون x = 0 أو x = 1. بالتعويض نجد y = 1 أو y = 2.',
|
|
'points' => 7,
|
|
'options' => ['(0, 1) و (1, 2)', '(0, 0) و (1, 1)', '(1, 2) فقط', '(-1, 0) و (1, 2)'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'متى لا يوجد أي حل حقيقي لنظام مكوّن من معادلة خطية وأخرى تربيعية؟',
|
|
'bloom' => 'analysis',
|
|
'topic' => 'استخدام المميز لتحديد عدد حلول النظام',
|
|
'explanation' => 'إذا كان مميز المعادلة التربيعية الناتجة عن التعويض سالباً (Δ = b² - 4ac < 0)، فإن المستقيم لا يتقاطع مع المنحنى.',
|
|
'points' => 7,
|
|
'options' => ['عندما يكون مميز المعادلة التربيعية الناتجة سالباً (Δ < 0)', 'عندما يكون المميز مساوياً لصفر', 'عندما يكون المميز موجباً تماماً', 'إذا كان ميل المستقيم يساوي صفراً'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'إذا كان لنظام مكوّن من مستقيم وقطع مكافئ حل حقيقي وحيد فقط، فإن المستقيم يعتبر:',
|
|
'bloom' => 'comprehension',
|
|
'topic' => 'المستقيم المماس لمنحنى تربيعي',
|
|
'explanation' => 'وجود حل حقيقي وحيد لنظام خطي-تربيعي يعني هندسياً أن المستقيم يمس المنحنى عند نقطة واحدة فقط.',
|
|
'points' => 6,
|
|
'options' => ['مماساً لمنحنى القطع المكافئ عند نقطة التماس', 'قاطعاً للمنحنى في نقطتين', 'خط تقارب رأسي للمنحنى', 'محور تماثل للقطع المكافئ'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'في النظام: x² + y² = 25 و x² - y² = 7، ما هي قيمة x²؟',
|
|
'bloom' => 'application',
|
|
'topic' => 'حل نظام تربيعي بطريقة الحذف',
|
|
'explanation' => 'بجمع المعادلتين طرفاً لطرف: 2x² = 32 ومنها x² = 16.',
|
|
'points' => 7,
|
|
'options' => ['x² = 16', 'x² = 9', 'x² = 32', 'x² = 18'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'ما هو أقصى عدد ممكن من نقاط التقاطع بين دائرة وقطع مكافئ في المستوى الإحداثي؟',
|
|
'bloom' => 'comprehension',
|
|
'topic' => 'التقاطع الهندسي بين منحنيين تربيعيين',
|
|
'explanation' => 'يمكن لدائرة وقطع مكافئ أن يتقاطعا في 0، أو 1، أو 2، أو 3، أو 4 نقاط كحد أقصى.',
|
|
'points' => 7,
|
|
'options' => ['4 نقاط تقاطع (أربعة حلول)', 'حلان فقط', '6 نقاط تقاطع', 'حل وحيد فقط دائماً'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'النظام: x² + y² = 13 و y = x² + 1 يتقاطع في النقطتين:',
|
|
'bloom' => 'application',
|
|
'topic' => 'التعويض بين معادلتين تربيعيتين',
|
|
'explanation' => 'بتعويض x² = y - 1 في الأولى: y - 1 + y² = 13 أي y² + y - 14 = 0 أو نجد بالنظام y=3 و x=±2.',
|
|
'points' => 7,
|
|
'options' => ['(-2, 3) و (2, 3)', '(3, -2) و (3, 2)', '(0, 1) فقط', '(2, 5) و (-2, 5)'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'إذا كانت النقطة (a, b) حلاً لنظام يتكون من دائرة مركزها نقطة الأصل ومعادلة متماثلة حول المحور الصادي، فإن:',
|
|
'bloom' => 'analysis',
|
|
'topic' => 'خصائص التناظر في أنظمة المعادلات',
|
|
'explanation' => 'التماثل حول محور الصادات يعني أن استبدال x بـ (-x) يعطي نفس النتيجة، فالنقطة (-a, b) تكون أيضاً حلاً للنظام.',
|
|
'points' => 7,
|
|
'options' => ['(-a, b) تكون أيضاً حلاً للنظام', '(a, -b) هو الحل الوحيد دائماً', 'لا يوجد أي تناظر هندسي', 'الحل سالب دائماً'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'في برمجية جيوجبرا (GeoGebra)، ما هي الأداة المخصصة لإيجاد حل نظام المعادلات بيانياً؟',
|
|
'bloom' => 'recall',
|
|
'topic' => 'برمجية جيوجبرا وحل الأنظمة بيانياً',
|
|
'explanation' => 'أداة التقاطع (Intersect Tool) في جيوجبرا تُحدد إحداثيات نقاط تقاطع المنحنيات التي تمثل حلول النظام مباشرة.',
|
|
'points' => 6,
|
|
'options' => ['أداة التقاطع (Intersect Tool)', 'أداة القياس (Measure)', 'أداة الانعكاس (Reflect)', 'أداة المماس (Tangent)'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'عند تمثيل معادلتين في جيوجبرا وظهور نقطة التقاطع A = (3, -2)، فهذا يعني هندسياً وجبرياً أن:',
|
|
'bloom' => 'comprehension',
|
|
'topic' => 'تفسير مخرجات برمجية جيوجبرا',
|
|
'explanation' => 'إحداثيات نقطة التقاطع (x, y) تعني أن الزوج المرتب يحقق كلتا المعادلتين معاً في آن واحد، وهو حل النظام.',
|
|
'points' => 6,
|
|
'options' => ['x = 3 و y = -2 هو حل يحقق كلتا المعادلتين معاً', 'x = -2 و y = 3 هو الحل', 'المنحنيان متباعدان ولا حل لهما', 'النظام له حلول غير منتهية'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'لماذا يستخدم خبراء الأرصاد الجوية أنظمة معادلات غير خطية في التنبؤ بالطقس كما ورد في كتاب الطالب؟',
|
|
'bloom' => 'comprehension',
|
|
'topic' => 'التطبيقات الحياتية لأنظمة المعادلات',
|
|
'explanation' => 'لأن أي تغير في أحد العوامل (كالضغط ودرجة الحرارة وسرعة الرياح) يؤدي إلى تغير غير خطي في العوامل الأخرى.',
|
|
'points' => 7,
|
|
'options' => ['لأن أي تغير في أحد العوامل يؤدي إلى تغير غير خطي في العوامل الأخرى', 'لأن درجة الحرارة ثابتة دائماً على مدار السنة', 'للتخلص من قياس الضغط الجوي', 'لأن سرعة الرياح لا ترتبط بحركة الغلاف الجوي'],
|
|
'correct_index' => 0
|
|
],
|
|
[
|
|
'question_text' => 'سياج مستطيل الشكل محيطه 20 متراً ومساحته 24 متراً مربعاً. ما هما بعدا المستطيل؟',
|
|
'bloom' => 'application',
|
|
'topic' => 'حل المسائل الهندسية الحياتية باستخدام الأنظمة',
|
|
'explanation' => 'النظام: 2(x + y) = 20 ومنها x + y = 10، والمساحة x * y = 24. العددان اللذان مجموعهما 10 وحاصل ضربهما 24 هما 6 و 4.',
|
|
'points' => 8,
|
|
'options' => ['الطول 6 m والعرض 4 m', 'الطول 8 m والعرض 2 m', 'الطول 10 m والعرض 2.4 m', 'الطول 5 m والعرض 5 m'],
|
|
'correct_index' => 0
|
|
]
|
|
];
|
|
}
|
|
}
|