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

302 lines
12 KiB
PHP

<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Services\CurriculumService;
use App\Services\CurriculumExtractorService;
class CurriculumController
{
/**
* Upload and Queue Real Ministry PDF Document for Deep Extraction
*/
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 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()
]);
}
}
}