Update Saqel Platform: 2026-09-12 13:38:49

This commit is contained in:
Hamza-Ayed
2026-09-12 13:38:49 +03:00
parent 1f31369893
commit f4d4f02853
34 changed files with 9136 additions and 386 deletions
@@ -68,24 +68,160 @@ class ErrorNotebookController
{
$uuid = trim((string)$request->getQuery('error_uuid', ''));
$error = Database::selectOne(
"SELECT id FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
[$uuid, (int)$request->user_id]
);
if (!$error) {
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة']);
return;
}
$response->status(409)->json(['status' => 'error', 'message' => 'لم يُولّد الخادم اختباراً علاجياً موثقاً لهذه الفجوة بعد']);
$questions = [];
// 1. Try fetching matching questions from database question bank
$topicTag = '%' . $error['topic_name'] . '%';
$dbQuestions = Database::select(
"SELECT q.id, q.question_text, q.explanation_text, q.ai_hint
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]
);
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']]
);
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'] ?: 'تطبيق مباشر لقوانين ومفاهيم المنهج المعتمد.'),
];
}
}
// 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']);
}
$response->json([
'status' => 'success',
'data' => [
'error_uuid' => $uuid,
'topic_name' => $error['topic_name'],
'subject_name' => $error['subject_name'],
'questions' => $questions,
],
]);
}
public function resolveError(Request $request, Response $response): void
{
$response->status(409)->json([
'status' => 'error',
'message' => 'يتم اعتماد الإتقان حصراً بعد تسليم اختبار علاجي وتصحيحه على الخادم',
$body = $request->getBody();
$uuid = trim((string)($body['error_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' => 'الفجوة التعليمية غير موجودة أو غير مصرح بالوصول إليها']);
return;
}
// Mark error as mastered upon successful completion of remedial drill
Database::query(
"UPDATE student_error_notebook
SET status = 'mastered', mastered_at = NOW(), remediation_attempts_count = remediation_attempts_count + 1
WHERE id = ?",
[(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) {
$newReadiness = min(100.0, (float)$currentMastery['tawjihi_readiness_score'] + 1.2);
$newMastery = min(100.0, (float)$currentMastery['mastery_percentage'] + 1.0);
Database::query(
"UPDATE student_mastery_analytics
SET tawjihi_readiness_score = ?, mastery_percentage = ?, updated_at = NOW()
WHERE id = ?",
[$newReadiness, $newMastery, (int)$currentMastery['id']]
);
}
$response->json([
'status' => 'success',
'mastered' => true,
'message' => 'تم اعتماد الشفاء المعرفي للفجوة بنجاح وتحديث مؤشر الجاهزية الأكاديمية.',
]);
}
/**
* 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
{
$data = random_bytes(16);
+38 -9
View File
@@ -35,16 +35,25 @@ class ExamController
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;
$courseId = !empty($queryParams['course_id']) ? (int)$queryParams['course_id'] : null;
$lessonId = !empty($queryParams['lesson_id']) ? (int)$queryParams['lesson_id'] : null;
$scope = $queryParams['scope'] ?? null;
$subjectCode = !empty($queryParams['subject_code']) ? trim($queryParams['subject_code']) : 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";
FROM exams e ";
if ($subjectCode) {
$sql .= " JOIN courses c ON c.id = e.course_id
JOIN subjects s ON s.id = c.subject_id ";
}
$sql .= " LEFT JOIN questions q ON q.exam_id = e.id
WHERE e.is_published = 1";
$params = [];
if ($subjectCode) {
$sql .= " AND s.code = ?";
$params[] = $subjectCode;
}
if ($courseId) {
$sql .= " AND e.course_id = ?";
$params[] = $courseId;
@@ -86,9 +95,28 @@ class ExamController
self::ensureSchema();
$examId = (int)$request->getParam('id');
$isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin');
$queryParams = $request->getQueryParams();
$subjectCode = !empty($queryParams['subject_code']) ? trim($queryParams['subject_code']) : null;
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
if (!$exam) {
if (!$exam && $subjectCode) {
// Find exam specifically matching this subject
$exam = Database::selectOne(
"SELECT e.* FROM exams e
JOIN courses c ON c.id = e.course_id
JOIN subjects s ON s.id = c.subject_id
JOIN questions q ON q.exam_id = e.id
WHERE e.is_published = 1 AND s.code = ?
GROUP BY e.id HAVING COUNT(q.id) >= 10
ORDER BY e.id DESC LIMIT 1",
[$subjectCode]
);
if ($exam) {
$examId = (int)$exam['id'];
}
}
if (!$exam && !$subjectCode) {
// Fallback: Find published unit exam with questions
$exam = Database::selectOne(
"SELECT e.* FROM exams e
@@ -102,13 +130,14 @@ class ExamController
}
}
if (!$exam || (count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) {
// Only seed math unit 1 if requested for math or without subject restriction
if ((!$exam || (count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) && (!$subjectCode || str_contains($subjectCode, 'math'))) {
$examId = self::seedUnit1ComprehensiveExam();
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
}
if (!$exam) {
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود لهذا المبحث']);
return;
}
+73 -2
View File
@@ -126,7 +126,22 @@ class GuardianController
[$studentId]
)['cnt'] ?? 0;
// 4. Forensic Weakness Log (Recent exam attempts with AI diagnostic)
// 4. Real Error Notebook metrics from student_error_notebook
$errorStats = Database::selectOne(
"SELECT
COUNT(*) as total_errors,
COALESCE(SUM(CASE WHEN status = 'mastered' THEN 1 ELSE 0 END), 0) as mastered_count,
COALESCE(SUM(CASE WHEN status != 'mastered' THEN 1 ELSE 0 END), 0) as pending_count
FROM student_error_notebook
WHERE student_id = ?",
[$studentId]
);
$totalErrors = (int)($errorStats['total_errors'] ?? 0);
$masteredErrors = (int)($errorStats['mastered_count'] ?? 0);
$pendingErrors = (int)($errorStats['pending_count'] ?? 0);
$errorMasteryPercentage = $totalErrors > 0 ? round(($masteredErrors / $totalErrors) * 100, 1) : 100.0;
// 5. Forensic Weakness Log (Recent exam attempts with AI diagnostic)
$diagnosticLogs = Database::select(
"SELECT ea.percentage, ea.status, ea.weak_topics_json, ea.ai_diagnostic_report, ea.completed_at, e.title as exam_title, e.scope
FROM exam_attempts ea
@@ -159,7 +174,17 @@ class GuardianController
'checkpoints_passed' => (int)$checkpointsCount,
'remediations_flagged' => (int)$remediationCount,
'exams_passed_count' => $mastery ? (int)$mastery['exams_passed_count'] : 0,
'exams_total_count' => $mastery ? (int)$mastery['exams_total_count'] : 0
'exams_total_count' => $mastery ? (int)$mastery['exams_total_count'] : 0,
'error_notebook' => [
'total_errors' => $totalErrors,
'mastered_count' => $masteredErrors,
'pending_count' => $pendingErrors,
'mastery_percentage' => $errorMasteryPercentage,
],
'errors_total' => $totalErrors,
'errors_mastered' => $masteredErrors,
'errors_pending' => $pendingErrors,
'errors_mastery_rate' => $errorMasteryPercentage,
],
'diagnostics' => $diagnosticLogs
];
@@ -173,6 +198,52 @@ class GuardianController
]);
}
/**
* Get Child's Error Notebook for Guardian inspection
* GET /api/guardian/children/{id}/error-notebook
*/
public function getChildErrorNotebook(Request $request, Response $response): void
{
$guardianId = (int)$request->user_id;
$studentId = (int)$request->getParam('id');
// Verify guardian link and analytical authorization
$linked = Database::selectOne(
"SELECT id FROM guardian_students WHERE guardian_id = ? AND student_id = ? AND can_view_analytics = 1 LIMIT 1",
[$guardianId, $studentId]
);
if (!$linked) {
$response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح بالوصول إلى دفتر أخطاء هذا الطالب']);
return;
}
$items = Database::select(
"SELECT * FROM student_error_notebook WHERE student_id = ? ORDER BY created_at DESC",
[$studentId]
);
$mastered = count(array_filter($items, fn($item) => ($item['status'] ?? '') === 'mastered'));
$total = count($items);
$bySubject = [];
foreach ($items as $item) {
$name = (string)($item['subject_name'] ?? '');
if ($name !== '') $bySubject[$name] = ($bySubject[$name] ?? 0) + 1;
}
$response->json([
'status' => 'success',
'data' => [
'summary' => [
'total_errors' => $total,
'mastered_count' => $mastered,
'pending_count' => $total - $mastered,
'mastery_percentage' => $total > 0 ? round(($mastered / $total) * 100, 1) : 100.0,
'by_subject' => $bySubject,
],
'items' => $items,
],
]);
}
private function maskNationalId(string $storedValue): string
{
if ($storedValue === '') {
@@ -117,6 +117,70 @@ class SuperAdminController
$response->json(['status' => 'success', 'data' => [], 'measurement_status' => 'unavailable']);
}
public function publicationBundles(Request $request, Response $response): void
{
$bundles = Database::select(
"SELECT pb.id, pb.uuid, pb.bundle_version, pb.status, pb.published_at, pb.created_at,
cl.title AS lesson_title, cl.curriculum_edition, s.name AS subject_name, g.name AS grade_name
FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
JOIN curriculum_units cu ON cu.id = cl.unit_id
JOIN curriculum_courses cc ON cc.id = cu.course_id
JOIN subjects s ON s.id = cc.subject_id
JOIN grade_levels g ON g.id = cc.grade_level_id
ORDER BY pb.id DESC LIMIT 100"
);
$submissions = Database::select(
"SELECT vv.id AS version_id, vv.uuid AS version_uuid, vv.version_number, vv.status, vv.created_at,
t.full_name AS teacher_name, cl.title AS lesson_title
FROM video_versions vv
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
JOIN teachers t ON t.id = ts.teacher_id
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
ORDER BY vv.id DESC LIMIT 100"
);
$response->json([
'status' => 'success',
'data' => [
'bundles' => $bundles,
'submissions' => $submissions,
],
]);
}
public function reviewSubmission(Request $request, Response $response): void
{
$body = $request->getBody();
$versionUuid = trim((string)($body['version_uuid'] ?? ''));
$decision = (string)($body['decision'] ?? ''); // 'approved' or 'rejected'
if (!in_array($decision, ['approved', 'rejected'], true) || $versionUuid === '') {
$response->status(422)->json(['status' => 'error', 'message' => 'بيانات مراجعة النسخة غير صالحة']);
return;
}
$version = Database::selectOne(
"SELECT vv.id, vv.teacher_submission_id FROM video_versions vv WHERE vv.uuid = ? LIMIT 1",
[$versionUuid]
);
if (!$version) {
$response->status(404)->json(['status' => 'error', 'message' => 'نسخة الفيديو غير موجودة']);
return;
}
$status = $decision === 'approved' ? 'published' : 'rejected';
Database::query("UPDATE video_versions SET status = ?, reviewed_at = NOW() WHERE id = ?", [$status, (int)$version['id']]);
if ($decision === 'approved') {
Database::query(
"UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?",
[(int)$version['id'], (int)$version['teacher_submission_id']]
);
}
$response->json(['status' => 'success', 'message' => $decision === 'approved' ? 'تم اعتماد ونشر النسخة بنجاح' : 'تم رفض النسخة']);
}
private function count(string $table): int
{
$row = Database::selectOne("SELECT COUNT(*) AS count_value FROM `{$table}`");