Files
saqel/backend/app/Controllers/CurriculumController.php
T

763 lines
33 KiB
PHP

<?php
/**
* ==============================================================================
* SAQEL ENTERPRISE (EDTECH 2.0) - CURRICULUM & STUDIO CONTROLLER
* ==============================================================================
*
* ملف: CurriculumController.php
* الهدف المعماري:
* إدارة منظومة تفريغ وفهرسة كتب المناهج الوزارية واستوديو المناهج (Curriculum Studio).
* يتولى هذا الملف المهام التالية:
* 1. استلام كتب الـ PDF الوزارية المرفوعة وتمريرها لعمال الخلفية لاستخراج شجرة الماركداون.
* 2. متابعة حالة مهام المعالجة والتفريغ التلقائي وإرجاع سجلات التشغيل الحية (Live Logs).
* 3. استرجاع الشجرة الهرمية للمنهاج (المرحلة، المادة، الفصل، الوحدة، الدرس).
* 4. حفظ وتحديث محتوى ملفات الماركداون للدروس وربطها مع قاعدة البيانات.
* 5. توليد الملخصات الذكية (Cheat Sheets) والفحوصات السقراطية عبر Gemini.
* 6. توليد وتحديث بنوك الأسئلة (50 سؤالاً للوحدة) بالذكاء الاصطناعي بنقرة واحدة من الاستوديو.
* 7. استضافة ورفع ملفات الفيديو مباشرة وربطها بالدروس.
*/
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Database;
use App\Services\CurriculumService;
use App\Services\CurriculumExtractorService;
use App\Services\PublishedContentService;
use App\Services\LearningPackageService;
class CurriculumController
{
/**
* استلام ورفع كتاب المنهاج الوزاري بصيغة PDF وجدولته في طابور المعالجة المعمقة
* POST /api/curriculum/upload-pdf
*
* @param Request $request طلب الـ HTTP المحتوي على ملف الـ PDF المرفوع
* @param Response $response كائن الاستجابة بمعرف المهمة ومسار المتابعة
*/
public function uploadPdf(Request $request, Response $response): void
{
if (empty($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
$response->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 /api/curriculum/lessons/{lessonId}/english-package */
public function getPublishedEnglishPackage(Request $request, Response $response): void
{
try {
$result=LearningPackageService::publishedEnglish(trim((string)$request->getParam('lessonId','')),(int)$request->user_id,$request->getHeader('x-national-id'));
$response->status((int)$result['http_status'])->json(array_diff_key($result,['http_status'=>true]));
} catch (\Throwable $e) { error_log('English package read failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر قراءة حزمة الإنجليزية.']); }
}
/**
* Get Complete Live Tree
*/
public function getTree(Request $request, Response $response): void
{
$tree = CurriculumService::getCurriculumTree();
// Manifest resource paths are intake metadata, not student-facing grants.
// Until they are represented by approved assets in a published bundle, do
// not expose them as available textbooks or worksheets.
$removeUnpublishedResources = function (&$node) use (&$removeUnpublishedResources): void {
if (!is_array($node)) {
return;
}
if (isset($node['resources']) && is_array($node['resources'])) {
foreach ($node['resources'] as &$resourceGroup) {
if (is_array($resourceGroup) && array_key_exists('items', $resourceGroup)) {
$resourceGroup['items'] = [];
}
}
unset($resourceGroup);
}
foreach ($node as &$child) {
$removeUnpublishedResources($child);
}
unset($child);
};
$removeUnpublishedResources($tree);
try {
$approvedLessons = Database::select("SELECT id, uuid, source_manifest_path FROM curriculum_lessons WHERE source_status='approved'");
$byPath = [];
$lessonMap = [];
foreach ($approvedLessons as $row) {
$p = (string)$row['source_manifest_path'];
$byPath[$p] = [
'curriculum_lesson_id' => (string)$row['uuid'],
'has_video' => false,
];
$lessonMap[(int)$row['id']] = $p;
}
if (!empty($lessonMap)) {
try {
$videoCounts = Database::select(
"SELECT ts.curriculum_lesson_id, COUNT(vv.id) AS video_count
FROM teacher_submissions ts
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
WHERE ts.status = 'published'
GROUP BY ts.curriculum_lesson_id"
);
foreach ($videoCounts as $vc) {
$lid = (int)$vc['curriculum_lesson_id'];
if (isset($lessonMap[$lid]) && (int)$vc['video_count'] > 0) {
$byPath[$lessonMap[$lid]]['has_video'] = true;
}
}
} catch (\Throwable $ve) {
// teacher_submissions or video_versions not yet populated or migrated; keep has_video = false
}
}
foreach ($tree as &$grade) foreach (($grade['subjects'] ?? []) as &$subject) foreach (($subject['semesters'] ?? []) as &$semester) foreach (($semester['units'] ?? []) as &$unit) foreach (($unit['lessons'] ?? []) as &$lesson) {
$path = (string)($lesson['file'] ?? '');
if (isset($byPath[$path])) $lesson = array_merge($lesson, $byPath[$path]);
}
unset($grade, $subject, $semester, $unit, $lesson);
// The manifest describes intake files only. Student-visible resources
// are rebuilt from approved, rights-cleared assets in a published
// bundle, and expose an opaque asset UUID rather than a storage path.
$resourceRows = Database::select(
"SELECT cl.subject_key, a.uuid AS asset_id, a.asset_type, a.mime_type,
pba.role, pba.sort_order
FROM publication_bundles pb
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
JOIN publication_bundle_assets pba ON pba.publication_bundle_id = pb.id
JOIN content_assets a ON a.id = pba.content_asset_id
WHERE pb.status = 'published'
AND cl.source_status = 'approved'
AND a.review_status = 'approved'
AND a.rights_status = 'cleared'
AND pba.role IN ('textbook', 'worksheet')
ORDER BY cl.subject_key, pba.role, pba.sort_order, a.id"
);
$resourcesBySubject = [];
foreach ($resourceRows as $row) {
$group = $row['role'] === 'textbook' ? 'textbooks' : 'worksheets';
$subjectKey = (string)$row['subject_key'];
$resourcesBySubject[$subjectKey] ??= ['textbooks' => [], 'worksheets' => []];
$resourcesBySubject[$subjectKey][$group][] = [
'asset_id' => (string)$row['asset_id'],
'asset_type' => (string)$row['asset_type'],
'mime_type' => (string)$row['mime_type'],
'type' => $group === 'textbooks' ? 'textbook' : 'worksheet',
];
}
foreach ($tree as &$grade) foreach (($grade['subjects'] ?? []) as $subjectKey => &$subject) {
$subjectResources = $resourcesBySubject[(string)$subjectKey] ?? ['textbooks' => [], 'worksheets' => []];
foreach (['textbooks', 'worksheets'] as $group) {
foreach ($subjectResources[$group] as $index => &$resource) {
$resource['title'] = $group === 'textbooks'
? 'كتاب منشور ' . ($index + 1)
: 'ورقة عمل منشورة ' . ($index + 1);
}
unset($resource);
}
$subject['resources'] = [
'textbooks' => ['items' => $subjectResources['textbooks']],
'worksheets' => ['items' => $subjectResources['worksheets']],
];
}
unset($grade, $subject);
} catch (\Throwable $e) { error_log('Published curriculum tree enrichment unavailable: '.$e->getMessage()); }
$response->json(['status'=>'success','data'=>$tree]);
}
/**
* 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');
if (!$storage || empty($file) || !str_ends_with(strtolower($file), '.md')) {
$response->status(400)->json([
'status' => 'error',
'message' => 'يجب تحديد ملف Markdown معتمد للوثيقة المطلوبة.'
]);
return;
}
$candidate = realpath($storage . '/' . ltrim($file, '/'));
$storagePrefix = rtrim($storage, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if ($candidate && str_starts_with($candidate, $storagePrefix) && is_file($candidate)) {
$resolvedFile = $candidate;
$content = file_get_contents($resolvedFile);
$response->json([
'status' => 'success',
'file' => str_replace("{$storage}/", '', $resolvedFile),
'subject' => $subject,
'type' => $type,
'content' => $content
]);
return;
}
$response->status(404)->json([
'status' => 'error',
'message' => 'ملف الوثيقة المعتمد غير موجود أو غير متاح حالياً.'
]);
}
/**
* Serves an approved asset from a published curriculum bundle by UUID only.
* GET /api/curriculum/assets/{assetId}
*/
public function getPublishedAsset(Request $request, Response $response): void
{
$assetId = trim((string)$request->getParam('assetId', ''));
try {
$asset = PublishedContentService::findPublishedAsset($assetId);
} catch (\Throwable $e) {
error_log('Published asset lookup failed: ' . $e->getMessage());
$response->status(503)->json([
'status' => 'unavailable',
'message' => 'فهرس الأصول المنشورة غير متاح حالياً.'
]);
return;
}
if (!$asset) {
$response->status(404)->json([
'status' => 'error',
'message' => 'الأصل المطلوب غير منشور أو غير متاح لك.'
]);
return;
}
$path = PublishedContentService::readLocalAsset($asset);
if (!$path) {
$response->status(404)->json([
'status' => 'error',
'message' => 'النسخة المنشورة من الأصل غير متاحة في التخزين.'
]);
return;
}
if (str_starts_with((string)$asset['mime_type'], 'text/')) {
$content = file_get_contents($path);
if ($content === false) {
$response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر قراءة الأصل المنشور.']);
return;
}
$response->json([
'status' => 'success',
'asset' => self::assetMetadata($asset),
'content' => $content,
]);
return;
}
$response->setHeader('Content-Type', (string)$asset['mime_type']);
$response->setHeader('Content-Length', (string)$asset['byte_size']);
$response->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"');
$response->sendHeaders();
readfile($path);
exit;
}
private static function assetMetadata(array $asset): array
{
return [
'asset_id' => $asset['uuid'],
'asset_type' => $asset['asset_type'],
'mime_type' => $asset['mime_type'],
'byte_size' => (int)$asset['byte_size'],
'sha256' => $asset['sha256'],
'bundle_version' => $asset['bundle_version'],
'curriculum_lesson_id' => $asset['curriculum_lesson_uuid'],
'identity' => [
'grade' => $asset['grade_key'],
'subject' => $asset['subject_key'],
'semester' => $asset['semester_key'],
'unit' => $asset['unit_key'],
'lesson' => $asset['lesson_key'],
],
];
}
/**
* استرجاع محتوى المختبر التفاعلي الذكي (أمثلة الكتاب الوزاري + 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
]);
}
/**
* استرجاع شجرة العائلات والقطاعات المهنية لـ 140 تخصصاً لمؤسسة التدريب المهني (VTC)
* GET /api/vocational/tree
*/
public function getVocationalTree(Request $request, Response $response): void
{
$taxonomy = \App\Services\VocationalCurriculumService::getVocationalTaxonomy();
$response->json([
'status' => 'success',
'data' => $taxonomy
]);
}
/**
* استرجاع تفاصيل التخصص المهني والوحدات النمطية وبطاقات التمارين
* GET /api/vocational/specialization
*/
public function getVocationalSpecialization(Request $request, Response $response): void
{
$details = \App\Services\VocationalCurriculumService::getPilotSpecializationDetails();
$response->json([
'status' => 'success',
'data' => $details
]);
}
/**
* استرجاع بيانات المحاكي والمختبر الافتراضي للتخصص المهني
* GET /api/vocational/simulation
*/
public function getVocationalSimulation(Request $request, Response $response): void
{
$simKey = $request->getQueryParams()['key'] ?? 'ev_diagnostic_lab';
$meta = \App\Services\VocationalCurriculumService::getSimulationMetadata($simKey);
if (!$meta) {
$response->status(404)->json([
'status' => 'error',
'message' => 'المحاكي المطلوب غير متوفر'
]);
return;
}
$response->json([
'status' => 'success',
'data' => $meta
]);
}
}