status(400)->json([ 'status' => 'error', 'message' => 'يرجى اختيار ملف PDF صالح للمنهاج الوزاري.' ]); return; } $file = $_FILES['pdf_file']; $origName = $file['name']; $tmpPath = $file['tmp_name']; $taskId = uniqid('task_'); // Setup processing directory $processingDir = __DIR__ . '/../../storage/curriculum/processing'; if (!is_dir($processingDir)) { mkdir($processingDir, 0777, true); } $savedPdfPath = $processingDir . '/' . $taskId . '.pdf'; move_uploaded_file($tmpPath, $savedPdfPath); // Initialize Task State $taskState = [ 'task_id' => $taskId, 'status' => 'queued', 'progress' => 0, 'message' => 'تم استلام الملف وجاري تحويله للمعالجة المعمقة...', 'orig_name' => $origName ]; file_put_contents($processingDir . '/' . $taskId . '.json', json_encode($taskState, JSON_UNESCAPED_UNICODE)); // Prepare task paths $scriptPath = realpath(__DIR__ . '/../../scripts/curriculum_worker.php'); $logPath = $processingDir . '/' . $taskId . '.log'; // Send processing response immediately $response->json([ 'status' => 'processing', 'task_id' => $taskId, 'message' => 'تم استلام الكتاب، وجاري المعالجة المعمقة بواسطة الذكاء الاصطناعي. يرجى الانتظار...' ]); // Terminate request and flush buffers so the client receives the JSON immediately if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } else { // Fallback for non-FPM ob_end_flush(); flush(); } // Run worker completely in the background via CLI try { $cmd = "php " . escapeshellarg($scriptPath) . " " . escapeshellarg($taskId) . " > /dev/null 2>&1 &"; exec($cmd); // Log that we spawned it file_put_contents($logPath, "[System] Spawning background worker: $cmd\n", FILE_APPEND); } catch (\Exception $e) { $err = "[Exception] " . $e->getMessage(); file_put_contents($logPath, $err . "\n", FILE_APPEND); // Update state file to error $stateFile = $processingDir . '/' . $taskId . '.json'; if (file_exists($stateFile)) { $st = json_decode(file_get_contents($stateFile), true); $st['status'] = 'error'; $st['message'] = 'تعطل النظام أثناء التحليل (Crash). يرجى مراجعة سجل الأخطاء.'; file_put_contents($stateFile, json_encode($st, JSON_UNESCAPED_UNICODE)); } } // Ensure script stops here so it doesn't return anything else exit; } /** * Poll Upload Status */ public function getUploadStatus(Request $request, Response $response): void { $taskId = $request->getQueryParams()['task_id'] ?? ''; if (empty($taskId)) { $response->status(400)->json(['status' => 'error', 'message' => 'رقم المهمة مفقود']); return; } $processingDir = __DIR__ . '/../../storage/curriculum/processing'; $taskId = basename($taskId); if (!preg_match('/^task_[A-Za-z0-9_.-]+$|^video_[A-Za-z0-9_.-]+$/', $taskId)) { $response->status(400)->json(['status' => 'error', 'message' => 'رقم المهمة غير صالح']); return; } $stateFile = $processingDir . '/' . $taskId . '.json'; if (!file_exists($stateFile)) { $response->status(404)->json(['status' => 'error', 'message' => 'المهمة غير موجودة']); return; } $state = json_decode(file_get_contents($stateFile), true); $response->json($state); } /** * Get Upload Log */ public function getUploadLog(Request $request, Response $response): void { $taskId = $request->getQueryParams()['task_id'] ?? ''; if (empty($taskId)) { $response->status(400)->json(['status' => 'error', 'message' => 'رقم المهمة مفقود']); return; } $processingDir = __DIR__ . '/../../storage/curriculum/processing'; $logFile = $processingDir . '/' . $taskId . '.log'; if (!file_exists($logFile)) { $response->status(404)->json(['status' => 'error', 'message' => 'سجل الأخطاء غير موجود']); return; } $logContent = file_get_contents($logFile); $response->json(['status' => 'success', 'log' => $logContent]); } /** * Get Complete Live Tree */ public function getTree(Request $request, Response $response): void { $response->json([ 'status' => 'success', 'data' => CurriculumService::getCurriculumTree() ]); } /** * Get Single Lesson Markdown Content */ public function getLessonContent(Request $request, Response $response): void { $file = $request->getQueryParams()['file'] ?? ''; if (empty($file)) { $response->status(400)->json(['status' => 'error', 'message' => 'مسار الملف مطلوب']); return; } $content = CurriculumService::getLessonMarkdown($file); $assets = CurriculumService::getLessonAiAssets($file); $serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/'); $response->json([ 'status' => 'success', 'file' => $file, 'server_full_path' => $serverFullPath, 'content' => $content, 'ai_assets' => $assets ]); } public function saveLessonContent(Request $request, Response $response): void { $body = $request->getBody(); $file = $body['file'] ?? ''; $content = $body['content'] ?? ''; $aiAssets = $body['ai_assets'] ?? null; if (empty($file) || empty($content)) { $response->status(400)->json(['status' => 'error', 'message' => 'بيانات الحفظ غير مكتملة']); return; } CurriculumService::saveLessonMarkdown($file, $content); if ($aiAssets !== null) { CurriculumService::saveLessonAiAssets($file, $aiAssets); } // Sync to MySQL Database Table `lessons` if connected try { $aiVideoUrl = $aiAssets['ai_video_url'] ?? null; $cheatSheet = $aiAssets['cheat_sheet'] ?? null; $socraticJson = isset($aiAssets['socratic_quiz']) ? json_encode($aiAssets['socratic_quiz'], JSON_UNESCAPED_UNICODE) : null; // Search lesson by matching filename or title $filename = basename($file, '.md'); \App\Core\Database::query( "UPDATE lessons SET markdown_content = ?, ai_video_url = COALESCE(?, ai_video_url), cheat_sheet_markdown = COALESCE(?, cheat_sheet_markdown), socratic_quiz_json = COALESCE(?, socratic_quiz_json) WHERE title LIKE ? OR markdown_content LIKE ?", [$content, $aiVideoUrl, $cheatSheet, $socraticJson, "%{$filename}%", "%{$file}%"] ); } catch (\Throwable $dbEx) { error_log("Curriculum DB save sync note: " . $dbEx->getMessage()); } $serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/'); $response->json([ 'status' => 'success', 'message' => 'تم حفظ واعتماد محتوى الدرس في المنهاج وقاعدة البيانات بنجاح!', 'server_full_path' => $serverFullPath ]); } public function generateAiAssets(Request $request, Response $response): void { $body = $request->getBody(); $file = $body['file'] ?? ''; if (empty($file)) { $response->status(400)->json(['status' => 'error', 'message' => 'مسار الملف مطلوب']); return; } $content = CurriculumService::getLessonMarkdown($file); if (empty($content)) { $response->status(400)->json(['status' => 'error', 'message' => 'محتوى الدرس فارغ']); return; } // Generate Assets require_once __DIR__ . '/../Services/AiLessonEnhancerService.php'; $assets = \App\Services\AiLessonEnhancerService::generateFromText('الدرس', $content); if ($assets) { CurriculumService::saveLessonAiAssets($file, $assets); $response->json(['status' => 'success', 'ai_assets' => $assets]); } else { $response->status(500)->json(['status' => 'error', 'message' => 'فشل توليد المخرجات الذكية']); } } /** * Direct Video Upload to Platform Storage with Semantic Lesson Binding */ public function uploadLessonVideo(Request $request, Response $response): void { $file = $_POST['file'] ?? ''; if (empty($file)) { $response->status(400)->json(['status' => 'error', 'message' => 'مسار الدرس مطلوب']); return; } if (empty($_FILES['video_file']) || $_FILES['video_file']['error'] !== UPLOAD_ERR_OK) { $response->status(400)->json(['status' => 'error', 'message' => 'يرجى اختيار ملف فيديو صالح (MP4, WebM, MOV)']); return; } try { // Video transcoding and R2 upload can take longer than a web request. // Persist the upload and hand the heavy work to a CLI worker. $processingDir = __DIR__ . '/../../storage/curriculum/processing'; if (!is_dir($processingDir)) mkdir($processingDir, 0755, true); $taskId = uniqid('video_', true); $extension = strtolower(pathinfo($_FILES['video_file']['name'], PATHINFO_EXTENSION)) ?: 'mp4'; $savedVideoPath = $processingDir . '/' . $taskId . '.' . $extension; if (!move_uploaded_file($_FILES['video_file']['tmp_name'], $savedVideoPath)) { throw new \RuntimeException('تعذر حفظ ملف الفيديو المؤقت على السيرفر.'); } file_put_contents($processingDir . '/' . $taskId . '.json', json_encode([ 'task_id' => $taskId, 'status' => 'queued', 'progress' => 5, 'message' => 'تم استلام الفيديو، وسيبدأ التحويل والرفع السحابي الآن.', 'file' => $file, 'video_path' => $savedVideoPath, 'original_name' => $_FILES['video_file']['name'] ], JSON_UNESCAPED_UNICODE)); $response->status(202)->json(['status' => 'processing', 'task_id' => $taskId, 'message' => 'تم استلام الفيديو وجاري تحويله ورفعه إلى Cloudflare R2...']); if (function_exists('fastcgi_finish_request')) fastcgi_finish_request(); elseif (ob_get_level()) { ob_end_flush(); flush(); } $scriptPath = realpath(__DIR__ . '/../../scripts/video_upload_worker.php'); $logPath = $processingDir . '/' . $taskId . '.log'; exec('php ' . escapeshellarg($scriptPath) . ' ' . escapeshellarg($taskId) . ' >> ' . escapeshellarg($logPath) . ' 2>&1 &'); return; } catch (\Throwable $e) { $response->status(500)->json([ 'status' => 'error', 'message' => $e->getMessage() ]); } } /** * Generate & Refresh AI Question Bank for any Unit or Lesson (Triggered from Curriculum Studio) * POST /api/curriculum/generate-questions */ public function generateQuestionBank(Request $request, Response $response): void { $body = $request->getBody(); $unitPath = $body['unit_path'] ?? 'grade_10/math_10/semester_1/unit_01'; $courseId = (int)($body['course_id'] ?? 1); $count = (int)($body['count'] ?? 50); try { require_once __DIR__ . '/../Services/AiQuestionBankGeneratorService.php'; $result = \App\Services\AiQuestionBankGeneratorService::generateUnitQuestionBank($unitPath, $courseId, $count); $response->json([ 'status' => 'success', 'message' => "تم توليد وتحديث بنك الأسئلة بالذكاء الاصطناعي بنجاح ({$result['total_in_bank']} سؤالاً معيارياً في قاعدة البيانات)!", 'data' => $result ]); } catch (\Throwable $e) { $response->status(500)->json([ 'status' => 'error', 'message' => 'تعذر توليد بنك الأسئلة: ' . $e->getMessage() ]); } } /** * List all available simulations for a subject or overall * GET /api/curriculum/simulations */ public function listSimulations(Request $request, Response $response): void { $subject = $request->getQueryParams()['subject'] ?? 'physics_10'; $simDir = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$subject}"; $simulations = []; if (is_dir($simDir)) { $files = glob("{$simDir}/*.html"); foreach ($files as $f) { $slug = basename($f, '.html'); $title = ($slug === 'vectors_lab') ? 'مختبر جمع وتحليل المتجهات التفاعلي' : (($slug === 'motion_1d_lab') ? 'مختبر الحركة في بُعد واحد والتسارع' : $slug); $unit = ($slug === 'vectors_lab') ? 'الوحدة 1: المتجهات والكميات' : (($slug === 'motion_1d_lab') ? 'الوحدة 2: الحركة والقوى' : 'تجارب تفاعلية'); $simulations[] = [ 'slug' => $slug, 'title' => $title, 'unit_title' => $unit, 'url' => "/api/curriculum/simulations/{$subject}/{$slug}", 'has_canvas' => true, 'watermark' => 'منصة صَقِل التعليمية الذكية © Saqel Lab', ]; } } $response->json([ 'status' => 'success', 'data' => $simulations ]); } /** * Get / Serve Interactive HTML5 Canvas Simulation * GET /api/curriculum/simulations/{subject}/{simName} */ public function getSimulation(Request $request, Response $response): void { $subject = preg_replace('/[^a-zA-Z0-9_-]/', '', $request->getParam('subject') ?? ''); $simName = preg_replace('/[^a-zA-Z0-9_-]/', '', $request->getParam('simName') ?? ''); $simPath = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$subject}/{$simName}.html"; if (!file_exists($simPath)) { // Fallback check in root simulations $simPath = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$simName}.html"; } if (!file_exists($simPath)) { $response->status(404)->json(['status' => 'error', 'message' => 'المحاكي التفاعلي غير موجود']); return; } header('Content-Type: text/html; charset=UTF-8'); header('X-Frame-Options: SAMEORIGIN'); readfile($simPath); exit; } /** * Get real document or textbook markdown content dynamically per subject * GET /api/curriculum/document */ public function getDocumentContent(Request $request, Response $response): void { $params = $request->getQueryParams(); $file = $params['file'] ?? ''; $subject = strtolower($params['subject'] ?? ''); $type = $params['type'] ?? 'textbook'; $storage = realpath(__DIR__ . '/../../storage/curriculum'); $resolvedFile = null; if (!empty($file) && !str_ends_with($file, '.pdf')) { $cleanFile = ltrim($file, '/'); if (file_exists("{$storage}/{$cleanFile}")) { $resolvedFile = "{$storage}/{$cleanFile}"; } } // If not resolved by exact path, resolve by subject if (!$resolvedFile && !empty($subject)) { if (str_contains($subject, 'english') || str_contains($subject, 'إنجليز')) { $mds = glob("{$storage}/grade_10/english_10/semester_1/*/*.md"); if (!empty($mds)) $resolvedFile = $mds[0]; } elseif (str_contains($subject, 'physic') || str_contains($subject, 'فيزياء')) { $mds = glob("{$storage}/grade_10/physics_10/semester_1/*/*.md"); if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0]; } elseif (str_contains($subject, 'math') || str_contains($subject, 'رياضيات')) { $mds = glob("{$storage}/grade_10/math_10/semester_1/*/*.md"); if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0]; } } if ($resolvedFile && file_exists($resolvedFile)) { $content = file_get_contents($resolvedFile); $response->json([ 'status' => 'success', 'file' => str_replace("{$storage}/", '', $resolvedFile), 'subject' => $subject, 'type' => $type, 'content' => $content ]); return; } // Tailored subject fallback $content = self::getDefaultSubjectDocument($subject, $type); $response->json([ 'status' => 'success', 'subject' => $subject, 'type' => $type, 'content' => $content ]); } private static function getDefaultSubjectDocument(string $subject, string $type): string { $s = strtolower($subject); if (str_contains($s, 'english') || str_contains($s, 'إنجليز')) { return "## Unit 01: Looking Good — Vocabulary & Reading\n\n### 1. Key Vocabulary & Word Formation\n- **Casual clothing:** Everyday informal garments (jeans, sneakers, hoodie).\n- **Traditional attire:** Cultural heritage wear (Jordanian Thobe and Keffiyeh).\n- **Subconscious influence:** The way external appearance affects cognitive confidence.\n\n### 2. Reading Text: The Power of First Impressions\nResearch shows that within the first seven seconds of meeting someone, people make subconscious judgments about character, competence, and reliability. In a famous experiment, doctors wearing clean white coats demonstrated higher diagnostic focus.\n\n### 3. Grammar Workshop: Articles (a, an, the, zero article)\n- Use **a / an** for non-specific singular countable nouns.\n- Use **the** when the listener knows which specific thing is meant.\n- Use **zero article** with plural or uncountable nouns spoken in general terms."; } if (str_contains($s, 'physic') || str_contains($s, 'فيزياء')) { return "## الوحدة الأولى: المتجهات والكميات الفيزيائية\n\n### 1. التمييز بين الكميات القياسية والمتجهة\n- **الكميات القياسية:** تُحدد بالمقدار ووحدة القياس فقط (الكتلة، الزمن، الطاقة، درجة الحرارة).\n- **الكميات المتجهة:** تُحدد بالمقدار والاتجاه ونقطة التأثير (القوة، السرعة المتجهة، التسارع، الإزاحة).\n\n### 2. جمع وتحليل المتجهات بيانياً وتحليلياً\n- **المركبة الأفقية:** A_x = A cos θ\n- **المركبة الرأسية:** A_y = A sin θ\n- **المحصلة:** R = √(R_x² + R_y²)\n- **زاوية الاتجاه:** tan θ = |R_y / R_x|"; } return "## ملخص الوحدة والنتاجات التعليمية المقررة\n\n### 1. الأهداف العامة للمادة\n- استيعاب المفاهيم والمصطلحات الأساسية وفق المنهاج الوزاري المعتمد.\n- التدرب على حل النماذج والتطبيقات العملية والأسئلة الوزارية.\n- التحقق من اكتساب المهارات من خلال بنك الأسئلة التكيفي والمختبر."; } /** * استرجاع محتوى المختبر التفاعلي الذكي (أمثلة الكتاب الوزاري + 10 تدريبات ذكاء اصطناعي) * يقرأ مباشرة من الملف المخبوز المسبق أو قاعدة البيانات بأقصى سرعة * GET /api/curriculum/interactive-lab */ public function getInteractiveLab(Request $request, Response $response): void { $file = (string) $request->getQuery('file', ''); $subject = (string) $request->getQuery('subject', 'arabic_10'); $topic = (string) $request->getQuery('topic', 'inna_and_sisters'); // 1. Check if companion file exists if (!empty($file)) { $cachedLab = CurriculumService::getLessonLabData($file); if (!empty($cachedLab)) { $response->json([ 'status' => 'success', 'source' => 'prebaked_companion_file', 'data' => $cachedLab ]); return; } } // 2. Otherwise serve and bake on-the-fly $data = \App\Services\AiHumanitiesLabGeneratorService::getLabContent($subject, $topic); if (!empty($file)) { CurriculumService::saveLessonLabData($file, $data); } $response->json([ 'status' => 'success', 'source' => 'generated_and_cached', 'data' => $data ]); } /** * بناء وتجهيز أصول المختبر التفاعلي مسبقاً وحفظها في المنهاج وقاعدة البيانات بنقرة واحدة من الاستوديو * POST /api/curriculum/bake-lab */ public function bakeInteractiveLab(Request $request, Response $response): void { $body = $request->getBody(); $file = $body['file'] ?? ''; $subject = $body['subject'] ?? 'arabic_10'; $topic = $body['topic'] ?? basename($file, '.md'); if (empty($file)) { $response->status(400)->json(['status' => 'error', 'message' => 'مسار ملف الدرس مطلوب']); return; } $content = CurriculumService::getLessonMarkdown($file); $labData = \App\Services\AiHumanitiesLabGeneratorService::bakeLessonLab($file, $subject, $topic, $content); $response->json([ 'status' => 'success', 'message' => 'تم بناء وتثبيت المختبر التفاعلي كأصل ثابت في ملفات المنهاج وقاعدة البيانات بنجاح', 'file' => $file, 'lab_data' => $labData ]); } }