Harden published curriculum and student flows
This commit is contained in:
@@ -5,13 +5,14 @@ namespace App\Controllers;
|
||||
use App\Core\Database;
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Services\RemediationAttemptGrader;
|
||||
|
||||
class ErrorNotebookController
|
||||
{
|
||||
public function getErrorNotebook(Request $request, Response $response): void
|
||||
{
|
||||
$studentId = (int)$request->user_id;
|
||||
$sql = "SELECT * FROM student_error_notebook WHERE student_id = ?";
|
||||
$sql = "SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE student_id = ?";
|
||||
$params = [$studentId];
|
||||
$subject = trim((string)$request->getQuery('subject', ''));
|
||||
$status = trim((string)$request->getQuery('status', ''));
|
||||
@@ -69,9 +70,11 @@ class ErrorNotebookController
|
||||
|
||||
public function getRemediationQuiz(Request $request, Response $response): void
|
||||
{
|
||||
$uuid = trim((string)$request->getQuery('error_uuid', ''));
|
||||
$body = $request->getBody();
|
||||
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
||||
$error = Database::selectOne(
|
||||
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
"SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due
|
||||
FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
[$uuid, (int)$request->user_id]
|
||||
);
|
||||
if (!$error) {
|
||||
@@ -79,49 +82,62 @@ class ErrorNotebookController
|
||||
return;
|
||||
}
|
||||
|
||||
$questions = [];
|
||||
|
||||
// 1. Try fetching matching questions from database question bank
|
||||
$topicTag = '%' . $error['topic_name'] . '%';
|
||||
if ($error['status'] === 'mastered') {
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'هذه الفجوة متقنة بالفعل']);
|
||||
return;
|
||||
}
|
||||
if ($error['status'] === 'in_remediation' && (int)$error['retention_is_due'] !== 1) {
|
||||
$response->status(409)->json(['status' => 'not_due', 'message' => 'اختبار الاسترجاع متاح بعد موعده المحدد']);
|
||||
return;
|
||||
}
|
||||
if (empty($error['lesson_id'])) {
|
||||
$response->status(422)->json(['status' => 'unavailable', 'message' => 'لا يوجد درس محدد لهذه الفجوة لتكوين اختبار موثوق']);
|
||||
return;
|
||||
}
|
||||
$dbQuestions = Database::select(
|
||||
"SELECT q.id, q.question_text, q.explanation_text, q.ai_hint
|
||||
FROM questions q
|
||||
"SELECT q.id, q.question_text FROM questions q
|
||||
JOIN exams e ON e.id = q.exam_id
|
||||
WHERE q.topic_tag LIKE ? OR q.question_text LIKE ? OR e.title LIKE ?
|
||||
LIMIT 3",
|
||||
[$topicTag, $topicTag, $topicTag]
|
||||
WHERE e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ?
|
||||
AND q.question_type = 'multiple_choice'
|
||||
AND q.remediation_review_status = 'approved'
|
||||
AND q.remediation_reviewed_by IS NOT NULL
|
||||
AND q.remediation_reviewed_at IS NOT NULL
|
||||
AND q.remediation_source_reference IS NOT NULL
|
||||
ORDER BY q.id ASC LIMIT 20",
|
||||
[(int)$error['lesson_id'], $error['topic_name']]
|
||||
);
|
||||
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$opts = Database::select(
|
||||
"SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC",
|
||||
[(int)$dbQ['id']]
|
||||
$questions = [];
|
||||
$answerKey = [];
|
||||
foreach ($dbQuestions as $question) {
|
||||
$options = Database::select(
|
||||
'SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC',
|
||||
[(int)$question['id']]
|
||||
);
|
||||
if (count($opts) >= 2) {
|
||||
$optTexts = [];
|
||||
$correctIdx = 0;
|
||||
foreach ($opts as $i => $opt) {
|
||||
$optTexts[] = $opt['option_text'];
|
||||
if ((int)$opt['is_correct'] === 1) {
|
||||
$correctIdx = $i;
|
||||
}
|
||||
}
|
||||
$questions[] = [
|
||||
'id' => (int)$dbQ['id'],
|
||||
'question' => $dbQ['question_text'],
|
||||
'options' => $optTexts,
|
||||
'correct_index' => $correctIdx,
|
||||
'explanation' => $dbQ['explanation_text'] ?: ($dbQ['ai_hint'] ?: 'تطبيق مباشر لقوانين ومفاهيم المنهج المعتمد.'),
|
||||
];
|
||||
}
|
||||
if (count($options) < 2 || count(array_filter($options, fn($o) => (int)$o['is_correct'] === 1)) !== 1) continue;
|
||||
$questions[] = [
|
||||
'id' => (int)$question['id'], 'question' => $question['question_text'],
|
||||
'options' => array_map(fn($o) => ['id' => (int)$o['id'], 'text' => $o['option_text']], $options),
|
||||
];
|
||||
$answerKey[] = [
|
||||
'question_id' => (int)$question['id'],
|
||||
'option_ids' => array_map(fn($o) => (int)$o['id'], $options),
|
||||
'correct_option_id' => (int)array_values(array_filter($options, fn($o) => (int)$o['is_correct'] === 1))[0]['id'],
|
||||
];
|
||||
if (count($questions) === 3) break;
|
||||
}
|
||||
if (count($questions) !== 3) {
|
||||
$response->status(422)->json(['status' => 'unavailable', 'message' => 'لا توجد ثلاثة أسئلة منشورة ومراجعة لهذا الدرس والمفهوم']);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. If question bank has fewer than 2 questions, provide curriculum-aligned remedial questions
|
||||
if (count($questions) < 2) {
|
||||
$topic = $error['topic_name'];
|
||||
$subject = $error['subject_name'];
|
||||
$questions = self::generateCurriculumRemedialQuiz((int)$error['id'], $subject, $topic, $error['question_text']);
|
||||
}
|
||||
$attemptUuid = $this->uuid();
|
||||
Database::insert(
|
||||
"INSERT INTO error_notebook_quiz_attempts (uuid,error_id,student_id,phase,question_ids_json,answer_key_json,expires_at)
|
||||
VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 30 MINUTE))",
|
||||
[$attemptUuid, (int)$error['id'], (int)$request->user_id,
|
||||
$error['status'] === 'in_remediation' ? 'retention' : 'initial',
|
||||
json_encode(array_column($questions, 'id')), json_encode($answerKey)]
|
||||
);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
@@ -129,6 +145,7 @@ class ErrorNotebookController
|
||||
'error_uuid' => $uuid,
|
||||
'topic_name' => $error['topic_name'],
|
||||
'subject_name' => $error['subject_name'],
|
||||
'attempt_uuid' => $attemptUuid,
|
||||
'questions' => $questions,
|
||||
],
|
||||
]);
|
||||
@@ -138,111 +155,94 @@ class ErrorNotebookController
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
||||
$attemptUuid = trim((string)($body['attempt_uuid'] ?? ''));
|
||||
$studentId = (int)$request->user_id;
|
||||
|
||||
$error = Database::selectOne(
|
||||
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
[$uuid, $studentId]
|
||||
);
|
||||
if (!$error) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة أو غير مصرح بالوصول إليها']);
|
||||
$answers = $body['answers'] ?? null;
|
||||
if (!is_array($answers) || count($answers) !== 3) {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'يجب إرسال إجابات الأسئلة الثلاثة']);
|
||||
return;
|
||||
}
|
||||
|
||||
$isRetentionPhase = ($error['status'] === 'in_remediation');
|
||||
$nextStatus = $isRetentionPhase ? 'mastered' : 'in_remediation';
|
||||
|
||||
// Stage 1: Initial Remedial Drill passed -> enters Spaced Repetition (in_remediation)
|
||||
// Stage 2: Spaced Repetition Retention Challenge passed -> enters permanently Mastered
|
||||
Database::query(
|
||||
"UPDATE student_error_notebook
|
||||
SET status = ?,
|
||||
mastered_at = IF(? = 'mastered', NOW(), mastered_at),
|
||||
remediation_attempts_count = remediation_attempts_count + 1
|
||||
WHERE id = ?",
|
||||
[$nextStatus, $nextStatus, (int)$error['id']]
|
||||
);
|
||||
|
||||
// Update student mastery analytics and readiness
|
||||
$currentMastery = Database::selectOne(
|
||||
"SELECT id, tawjihi_readiness_score, mastery_percentage FROM student_mastery_analytics WHERE student_id = ? ORDER BY id DESC LIMIT 1",
|
||||
[$studentId]
|
||||
);
|
||||
if ($currentMastery) {
|
||||
$readinessGain = $isRetentionPhase ? 1.5 : 0.6;
|
||||
$masteryGain = $isRetentionPhase ? 1.2 : 0.5;
|
||||
$newReadiness = min(100.0, (float)$currentMastery['tawjihi_readiness_score'] + $readinessGain);
|
||||
$newMastery = min(100.0, (float)$currentMastery['mastery_percentage'] + $masteryGain);
|
||||
Database::query(
|
||||
"UPDATE student_mastery_analytics
|
||||
SET tawjihi_readiness_score = ?, mastery_percentage = ?, updated_at = NOW()
|
||||
WHERE id = ?",
|
||||
[$newReadiness, $newMastery, (int)$currentMastery['id']]
|
||||
$answersHash = hash('sha256', json_encode($answers));
|
||||
$pdo = Database::getConnection();
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$error = Database::selectOne(
|
||||
'SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE uuid = ? AND student_id = ? FOR UPDATE',
|
||||
[$uuid, $studentId]
|
||||
);
|
||||
$attempt = $error ? Database::selectOne(
|
||||
'SELECT *, (expires_at > NOW()) AS is_fresh FROM error_notebook_quiz_attempts WHERE uuid = ? AND error_id = ? AND student_id = ? FOR UPDATE',
|
||||
[$attemptUuid, (int)$error['id'], $studentId]
|
||||
) : null;
|
||||
if ($attempt && $attempt['status'] !== 'open' && hash_equals((string)($attempt['answers_sha256'] ?? ''), $answersHash)) {
|
||||
$pdo->commit();
|
||||
$response->json(['status' => 'success', 'passed' => $attempt['status'] === 'passed',
|
||||
'correct_count' => (int)$attempt['correct_count'],
|
||||
'new_status' => $attempt['status'] === 'passed' ? ($attempt['phase'] === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]);
|
||||
return;
|
||||
}
|
||||
if (!$attempt || $attempt['status'] !== 'open' || (int)$attempt['is_fresh'] !== 1) {
|
||||
$pdo->rollBack();
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'المحاولة غير متاحة أو انتهت صلاحيتها']);
|
||||
return;
|
||||
}
|
||||
$phase = $error['status'] === 'in_remediation' ? 'retention' : 'initial';
|
||||
if ($error['status'] === 'mastered' || $attempt['phase'] !== $phase ||
|
||||
($phase === 'retention' && (int)$error['retention_is_due'] !== 1)) {
|
||||
$pdo->rollBack();
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'حالة الفجوة تغيرت؛ ابدأ محاولة جديدة']);
|
||||
return;
|
||||
}
|
||||
$ids = json_decode($attempt['question_ids_json'], true);
|
||||
$answerKey = json_decode($attempt['answer_key_json'], true);
|
||||
if (!is_array($ids) || !is_array($answerKey)) {
|
||||
$pdo->rollBack();
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']);
|
||||
return;
|
||||
}
|
||||
foreach ($ids as $index => $questionId) {
|
||||
$published = Database::selectOne(
|
||||
"SELECT q.id FROM questions q JOIN exams e ON e.id = q.exam_id
|
||||
WHERE q.id = ? AND e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ?
|
||||
AND q.remediation_review_status = 'approved' AND q.remediation_reviewed_by IS NOT NULL
|
||||
AND q.remediation_reviewed_at IS NOT NULL AND q.remediation_source_reference IS NOT NULL FOR UPDATE",
|
||||
[(int)$questionId, (int)$error['lesson_id'], $error['topic_name']]
|
||||
);
|
||||
if (!$published) {
|
||||
$pdo->rollBack();
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'سُحب سؤال من هذه المحاولة؛ ابدأ محاولة جديدة']);
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
$correct = RemediationAttemptGrader::grade($ids, $answerKey, $answers);
|
||||
} catch (\InvalidArgumentException $invalidAnswer) {
|
||||
$pdo->rollBack();
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']);
|
||||
return;
|
||||
}
|
||||
$passed = $correct >= 2;
|
||||
Database::query(
|
||||
"UPDATE error_notebook_quiz_attempts SET status = ?, correct_count = ?, answers_sha256 = ?, submitted_at = NOW() WHERE id = ?",
|
||||
[$passed ? 'passed' : 'failed', $correct, $answersHash, (int)$attempt['id']]
|
||||
);
|
||||
if ($passed) {
|
||||
Database::query(
|
||||
"UPDATE student_error_notebook SET status = ?, remediation_attempts_count = remediation_attempts_count + 1,
|
||||
retention_due_at = IF(? = 'initial', DATE_ADD(NOW(), INTERVAL 7 DAY), retention_due_at),
|
||||
mastered_at = IF(? = 'retention', NOW(), mastered_at) WHERE id = ?",
|
||||
[$phase === 'retention' ? 'mastered' : 'in_remediation', $phase, $phase, (int)$error['id']]
|
||||
);
|
||||
} else {
|
||||
Database::query('UPDATE student_error_notebook SET remediation_attempts_count = remediation_attempts_count + 1 WHERE id = ?', [(int)$error['id']]);
|
||||
}
|
||||
$pdo->commit();
|
||||
$response->json(['status' => 'success', 'passed' => $passed, 'correct_count' => $correct,
|
||||
'new_status' => $passed ? ($phase === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]);
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($isRetentionPhase) {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'mastered' => true,
|
||||
'is_retention_verified' => true,
|
||||
'new_status' => 'mastered',
|
||||
'message' => 'تم اجتياز اختبار الاسترجاع والتثبيت بنجاح تام! اعتُمد المفهوم كمتقن في الذاكرة طويلة المدى.',
|
||||
]);
|
||||
} else {
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'mastered' => false,
|
||||
'is_retention_verified' => false,
|
||||
'new_status' => 'in_remediation',
|
||||
'message' => 'تم اجتياز الكويز العلاجي الأولي بنجاح! انتقل المفهوم إلى مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام ومنع النسيان).',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate curriculum-aligned remedial questions based on the mistaken topic.
|
||||
*/
|
||||
public static function generateCurriculumRemedialQuiz(int $errorId, string $subject, string $topic, string $originalQuestion): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => $errorId * 10 + 1,
|
||||
'question' => "سؤال علاجي في مفهوم [{$topic}]: ما المبدأ العلمي الأساسي الذي يحكم سلوك الظاهرة؟",
|
||||
'options' => [
|
||||
"الاعتماد المباشر على العلاقة الرياضية ومحددات الاتجاه للمتغيرات في المنهج",
|
||||
"تطبيق عشوائي للقيم دون ربطها بالقانون الفيزيائي أو الكيميائي",
|
||||
"إهمال الوحدات الأساسية والتحويل بين البادئات العلمية",
|
||||
"افتراض ثبات المتغيرات غير المقيسة بدون سند تجريبي"
|
||||
],
|
||||
'correct_index' => 0,
|
||||
'explanation' => "الأساس العلمي في دراسة {$topic} يقتضي الانطلاق دائماً من العلاقة الرياضية المعتمدة وتحديد المتغيرات التابعة والمستقلة بدقة.",
|
||||
],
|
||||
[
|
||||
'id' => $errorId * 10 + 2,
|
||||
'question' => "تطبيق بديل على [{$topic}]: إذا تضاعفت إحدى القوى أو المتغيرات المؤثرة مع ثبات العوامل الأخرى، ما النتيجة الحتمية؟",
|
||||
'options' => [
|
||||
"تظل النتيجة ثابتة دون أي تأثير يُذكر",
|
||||
"تتضاعف النتيجة طردياً بحسب العلاقة المباشرة في القانون المعتمد",
|
||||
"تنخفض القيمة إلى الصفر فوراً",
|
||||
"تنعكس الإشارة الرياضية للكمية القياسية"
|
||||
],
|
||||
'correct_index' => 1,
|
||||
'explanation' => "وفقاً لصياغة القانون المدرسي في {$subject}، التناسب الطردي بين المتغير والنتيجة يعني أن مضاعفة العامل تؤدي إلى مضاعفة المحصلة بنسبة مماثلة.",
|
||||
],
|
||||
[
|
||||
'id' => $errorId * 10 + 3,
|
||||
'question' => "فحص الفهم في [{$topic}]: كيف نتفادى الخطأ الحسابي أو المفاهيمي عند استخراج المعطيات؟",
|
||||
'options' => [
|
||||
"تدوين المعطيات بالوحدات الدولية المعتمدة والتحقق من القانون المناسب قبل التعويض",
|
||||
"حفظ الإجابات السابقة واستخدامها لجميع المسائل المتشابهة",
|
||||
"تخطي خطوة كتابة القانون والبدء بالضرب والقسمة مباشرة",
|
||||
"الاعتماد على التقريب الذهني السريع دون مراجعة الخطوات"
|
||||
],
|
||||
'correct_index' => 0,
|
||||
'explanation' => "تنظيم المعطيات ومواءمة الوحدات قبل التعويض الرياضي هو الضمان الأساسي لصحة الحل والوصول للناتج النموذجي.",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function uuid(): string
|
||||
|
||||
Reference in New Issue
Block a user