Update Saqel Platform: 2026-08-28 16:06:52
This commit is contained in:
@@ -10,7 +10,7 @@ use App\Services\CurriculumExtractorService;
|
||||
class CurriculumController
|
||||
{
|
||||
/**
|
||||
* Upload and Deep-Extract Real Ministry PDF Document
|
||||
* Upload and Queue Real Ministry PDF Document for Deep Extraction
|
||||
*/
|
||||
public function uploadPdf(Request $request, Response $response): void
|
||||
{
|
||||
@@ -25,52 +25,63 @@ class CurriculumController
|
||||
$file = $_FILES['pdf_file'];
|
||||
$origName = $file['name'];
|
||||
$tmpPath = $file['tmp_name'];
|
||||
$taskId = uniqid('task_');
|
||||
|
||||
// Save original uploaded PDF in storage/uploads/curriculum
|
||||
$uploadsDir = __DIR__ . '/../../storage/uploads/curriculum';
|
||||
if (!is_dir($uploadsDir)) {
|
||||
mkdir($uploadsDir, 0777, true);
|
||||
// Setup processing directory
|
||||
$processingDir = __DIR__ . '/../../storage/curriculum/processing';
|
||||
if (!is_dir($processingDir)) {
|
||||
mkdir($processingDir, 0777, true);
|
||||
}
|
||||
$savedPdfPath = $uploadsDir . '/' . time() . '_' . preg_replace('/[^A-Za-z0-9_\-\.]/u', '_', $origName);
|
||||
|
||||
$savedPdfPath = $processingDir . '/' . $taskId . '.pdf';
|
||||
move_uploaded_file($tmpPath, $savedPdfPath);
|
||||
|
||||
// Perform Deep Multi-Engine Extraction & Structuring
|
||||
$parsedStructure = CurriculumExtractorService::extractAndStructure($origName, $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));
|
||||
|
||||
// Merge into live tree and save to disk
|
||||
$updatedTree = CurriculumService::mergeExtractedCurriculum($parsedStructure);
|
||||
|
||||
$firstLessonFile = '';
|
||||
$firstLessonMd = '';
|
||||
$firstLessonTitle = '';
|
||||
$firstLessonOutcomes = [];
|
||||
$breadcrumb = '';
|
||||
|
||||
if (!empty($parsedStructure['units'][0]['lessons'][0])) {
|
||||
$firstLes = $parsedStructure['units'][0]['lessons'][0];
|
||||
$firstLessonFile = "{$parsedStructure['grade_key']}/{$parsedStructure['subject_key']}/{$parsedStructure['semester_key']}/{$parsedStructure['units'][0]['unit_key']}/{$firstLes['lesson_id']}.md";
|
||||
$firstLessonMd = CurriculumService::getLessonMarkdown($firstLessonFile);
|
||||
$firstLessonTitle = $firstLes['title'];
|
||||
$firstLessonOutcomes = $firstLes['outcomes'] ?? [];
|
||||
$breadcrumb = "{$parsedStructure['grade_name']} ⟵ {$parsedStructure['subject_name']} ⟵ {$parsedStructure['units'][0]['unit_name']}";
|
||||
}
|
||||
|
||||
$serverStorageDir = realpath(__DIR__ . '/../../storage/curriculum');
|
||||
// Launch Background Worker
|
||||
$scriptPath = realpath(__DIR__ . '/../../scripts/curriculum_worker.php');
|
||||
$logPath = $processingDir . '/' . $taskId . '.log';
|
||||
$cmd = "php " . escapeshellarg($scriptPath) . " " . escapeshellarg($taskId) . " > " . escapeshellarg($logPath) . " 2>&1 &";
|
||||
exec($cmd);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'message' => "تم تفريغ وفهرسة المنهاج [{$origName}] بنجاح في قاعدة المعرفة!",
|
||||
'extracted_data' => $parsedStructure,
|
||||
'tree' => $updatedTree,
|
||||
'active_file' => $firstLessonFile,
|
||||
'active_md' => $firstLessonMd,
|
||||
'active_title' => $firstLessonTitle,
|
||||
'active_outcomes' => $firstLessonOutcomes,
|
||||
'active_breadcrumb' => $breadcrumb,
|
||||
'server_storage_dir' => $serverStorageDir
|
||||
'status' => 'processing',
|
||||
'task_id' => $taskId,
|
||||
'message' => 'تم استلام الكتاب، وجاري المعالجة المعمقة بواسطة الذكاء الاصطناعي. يرجى الانتظار...'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
$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 Complete Live Tree
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use App\Services\CurriculumService;
|
||||
use App\Core\Env;
|
||||
|
||||
$taskId = $argv[1] ?? '';
|
||||
if (empty($taskId)) {
|
||||
die("Task ID required\n");
|
||||
}
|
||||
|
||||
$processingDir = __DIR__ . '/../storage/curriculum/processing';
|
||||
$stateFile = $processingDir . '/' . $taskId . '.json';
|
||||
$pdfFile = $processingDir . '/' . $taskId . '.pdf';
|
||||
|
||||
if (!file_exists($stateFile) || !file_exists($pdfFile)) {
|
||||
die("Files missing\n");
|
||||
}
|
||||
|
||||
function updateState($stateFile, $status, $progress, $message, $extra = []) {
|
||||
$state = json_decode(file_get_contents($stateFile), true);
|
||||
$state['status'] = $status;
|
||||
$state['progress'] = $progress;
|
||||
$state['message'] = $message;
|
||||
if (!empty($extra)) {
|
||||
$state = array_merge($state, $extra);
|
||||
}
|
||||
file_put_contents($stateFile, json_encode($state, JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
$state = json_decode(file_get_contents($stateFile), true);
|
||||
$origName = $state['orig_name'] ?? 'Unknown.pdf';
|
||||
|
||||
updateState($stateFile, 'extracting', 10, 'جاري استخراج النصوص بالكامل من الكتاب...');
|
||||
|
||||
// 1. Extract Text
|
||||
$cmd = "pdftotext -layout " . escapeshellarg($pdfFile) . " - 2>/dev/null";
|
||||
$rawText = @shell_exec($cmd);
|
||||
|
||||
if (empty($rawText) || mb_strlen(trim($rawText)) < 100) {
|
||||
updateState($stateFile, 'error', 0, 'فشل استخراج النصوص من الـ PDF. قد يكون الملف عبارة عن صور فقط (Scanned).');
|
||||
exit;
|
||||
}
|
||||
|
||||
updateState($stateFile, 'analyzing', 40, 'جاري تحليل بنية الوحدات والدروس من النصوص المستخرجة...');
|
||||
|
||||
Env::load(__DIR__ . '/../.env');
|
||||
$geminiKey = Env::get('GEMINI_API_KEY') ?: getenv('GEMINI_API_KEY');
|
||||
|
||||
$parsedStructure = null;
|
||||
|
||||
if (!empty($geminiKey)) {
|
||||
updateState($stateFile, 'analyzing', 50, 'يتم الآن تحليل المنهج عبر الذكاء الاصطناعي (Gemini)...');
|
||||
|
||||
// We send chunks or a large chunk to Gemini
|
||||
$prompt = "أنت خبير مناهج تعليمية. قم بتحليل هذا النص المستخرج من كتاب دراسي بعنوان '{$origName}' واستخراج الفهرس والوحدات والدروس بدقة.
|
||||
النص:
|
||||
" . mb_substr($rawText, 0, 15000) . "
|
||||
|
||||
المطلوب إرجاع JSON صالح فقط بالصيغة التالية (يجب أن يحتوي على النص الحقيقي للدرس وليس نصاً وهمياً):
|
||||
{
|
||||
\"grade_name\": \"اسم الصف\",
|
||||
\"grade_key\": \"grade_X\",
|
||||
\"subject_name\": \"اسم المادة\",
|
||||
\"subject_key\": \"subject_key\",
|
||||
\"semester_name\": \"الفصل الدراسي الأول\",
|
||||
\"semester_key\": \"semester_1\",
|
||||
\"units\": [
|
||||
{
|
||||
\"unit_key\": \"unit_1\",
|
||||
\"unit_name\": \"اسم الوحدة الحقيقي\",
|
||||
\"lessons\": [
|
||||
{
|
||||
\"lesson_id\": \"lesson_1\",
|
||||
\"title\": \"اسم الدرس الحقيقي\",
|
||||
\"outcomes\": [\"الهدف 1\"],
|
||||
\"markdown_content\": \"# عنوان الدرس\\n\\nالنص الحقيقي الكامل المستخرج للدرس...\"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=" . $geminiKey;
|
||||
$payload = [
|
||||
'contents' => [['parts' => [['text' => $prompt]]]],
|
||||
'generationConfig' => ['responseMimeType' => 'application/json', 'temperature' => 0.2]
|
||||
];
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => json_encode($payload),
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 60
|
||||
]);
|
||||
$res = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($code === 200 && !empty($res)) {
|
||||
$json = json_decode($res, true);
|
||||
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$parsedStructure = json_decode($text, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to Heuristic Regex Parser if Gemini failed or no key
|
||||
if (empty($parsedStructure) || empty($parsedStructure['units'])) {
|
||||
updateState($stateFile, 'analyzing', 60, 'يتم تحليل البنية باستخدام محرك التحليل الديناميكي العميق للنصوص...');
|
||||
|
||||
// Build an authentic structure based on ACTUAL text chunks
|
||||
$cleanTitle = trim(preg_replace('/\.[^.]+$/u', '', $origName));
|
||||
|
||||
// Simple heuristic: split text by "Unit" or "الوحدة" or "Module"
|
||||
$chunks = preg_split('/(Unit\s+\d+|Module\s+\d+|الوحدة\s+(?:الأولى|الثانية|الثالثة|الرابعة|الخامسة|\d+))/iu', $rawText, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
$units = [];
|
||||
$unitCounter = 1;
|
||||
|
||||
// First chunk is preamble
|
||||
for ($i = 1; $i < count($chunks); $i += 2) {
|
||||
$unitTitleRaw = trim($chunks[$i]);
|
||||
$unitContentRaw = trim($chunks[$i+1] ?? '');
|
||||
|
||||
$lines = explode("\n", $unitContentRaw);
|
||||
$unitNameAddition = trim($lines[0] ?? '');
|
||||
$unitName = $unitTitleRaw . ($unitNameAddition ? ': ' . mb_substr($unitNameAddition, 0, 40) : '');
|
||||
|
||||
$lessonChunks = preg_split('/(Lesson\s+\d+|الدرس\s+(?:الأول|الثاني|الثالث|الرابع|\d+))/iu', $unitContentRaw, -1, PREG_SPLIT_DELIM_CAPTURE);
|
||||
|
||||
$lessons = [];
|
||||
$lessonCounter = 1;
|
||||
|
||||
if (count($lessonChunks) > 1) {
|
||||
for ($j = 1; $j < count($lessonChunks); $j += 2) {
|
||||
$lessonTitleRaw = trim($lessonChunks[$j]);
|
||||
$lessonContentRaw = trim($lessonChunks[$j+1] ?? '');
|
||||
$lLines = explode("\n", $lessonContentRaw);
|
||||
$lNameAddition = trim($lLines[0] ?? '');
|
||||
|
||||
$lessons[] = [
|
||||
'lesson_id' => 'lesson_' . $lessonCounter,
|
||||
'title' => $lessonTitleRaw . ' ' . mb_substr($lNameAddition, 0, 40),
|
||||
'outcomes' => ['تم استخراج النص تلقائياً من الكتاب'],
|
||||
'markdown_content' => "# " . $lessonTitleRaw . "\n\n" . mb_substr($lessonContentRaw, 0, 5000)
|
||||
];
|
||||
$lessonCounter++;
|
||||
}
|
||||
} else {
|
||||
// No explicit lessons found, make chunks of 1500 chars
|
||||
$textLen = mb_strlen($unitContentRaw);
|
||||
$chunkSize = 2500;
|
||||
for ($c = 0; $c < $textLen; $c += $chunkSize) {
|
||||
$lessons[] = [
|
||||
'lesson_id' => 'part_' . $lessonCounter,
|
||||
'title' => 'Part ' . $lessonCounter,
|
||||
'outcomes' => ['نص مستخرج آلياً'],
|
||||
'markdown_content' => "# Part " . $lessonCounter . "\n\n" . mb_substr($unitContentRaw, $c, $chunkSize)
|
||||
];
|
||||
$lessonCounter++;
|
||||
}
|
||||
}
|
||||
|
||||
$units[] = [
|
||||
'unit_key' => 'unit_' . $unitCounter,
|
||||
'unit_name' => $unitName,
|
||||
'lessons' => $lessons
|
||||
];
|
||||
$unitCounter++;
|
||||
}
|
||||
|
||||
// If no units matched the regex at all, just split the whole book into 4 parts
|
||||
if (empty($units)) {
|
||||
$textLen = mb_strlen($rawText);
|
||||
$chunkSize = 3000;
|
||||
$lessons = [];
|
||||
for ($c = 0, $idx=1; $c < $textLen && $idx <= 10; $c += $chunkSize, $idx++) {
|
||||
$lessons[] = [
|
||||
'lesson_id' => 'section_' . $idx,
|
||||
'title' => 'Section ' . $idx,
|
||||
'outcomes' => ['نص حقيقي مستخرج آلياً'],
|
||||
'markdown_content' => "# Section " . $idx . "\n\n" . mb_substr($rawText, $c, $chunkSize)
|
||||
];
|
||||
}
|
||||
$units[] = [
|
||||
'unit_key' => 'unit_1',
|
||||
'unit_name' => 'Textbook Content',
|
||||
'lessons' => $lessons
|
||||
];
|
||||
}
|
||||
|
||||
$parsedStructure = [
|
||||
'grade_name' => 'الصف الأساسي / مستخرج',
|
||||
'grade_key' => 'grade_extracted',
|
||||
'subject_name' => $cleanTitle,
|
||||
'subject_key' => 'subject_' . substr(md5($cleanTitle), 0, 8),
|
||||
'semester_name' => 'الفصل المستخرج',
|
||||
'semester_key' => 'semester_1',
|
||||
'units' => $units
|
||||
];
|
||||
}
|
||||
|
||||
updateState($stateFile, 'generating', 80, 'جاري بناء هيكل المنهاج وملفات المارك داون على السيرفر...');
|
||||
|
||||
// Save to disk
|
||||
$updatedTree = CurriculumService::mergeExtractedCurriculum($parsedStructure);
|
||||
|
||||
$firstLessonFile = '';
|
||||
if (!empty($parsedStructure['units'][0]['lessons'][0])) {
|
||||
$firstLes = $parsedStructure['units'][0]['lessons'][0];
|
||||
$firstLessonFile = "{$parsedStructure['grade_key']}/{$parsedStructure['subject_key']}/{$parsedStructure['semester_key']}/{$parsedStructure['units'][0]['unit_key']}/{$firstLes['lesson_id']}.md";
|
||||
}
|
||||
|
||||
updateState($stateFile, 'completed', 100, 'تم الاستخراج والفهرسة بنجاح!', [
|
||||
'active_file' => $firstLessonFile,
|
||||
'tree' => $updatedTree,
|
||||
'extracted_data' => $parsedStructure
|
||||
]);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
import re
|
||||
|
||||
with open("backend/app/Views/CurriculumStudio.php", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
new_script = """ async function handleRealPdfUpload(input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
const file = input.files[0];
|
||||
|
||||
const btn = document.getElementById('btn_upload_pdf');
|
||||
const desc = document.getElementById('upload_status_desc');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span>⏳ جاري الرفع...</span>';
|
||||
desc.textContent = `جاري رفع ملف [${file.name}] وبدء عملية استخراج المناهج...`;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('pdf_file', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/curriculum/upload-pdf', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.status === 'processing') {
|
||||
// Start Polling
|
||||
pollTaskStatus(data.task_id, file.name);
|
||||
} else if (res.ok && data.status === 'success') {
|
||||
// Fallback if it returned sync
|
||||
processSuccessData(data, file.name);
|
||||
} else {
|
||||
alert('⚠️ ' + (data.message || 'فشلت معالجة الملف'));
|
||||
resetUploadBtn();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('PDF Upload error:', err);
|
||||
alert('حدث خطأ في الاتصال بالخادم أثناء رفع الملف.');
|
||||
resetUploadBtn();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollTaskStatus(taskId, fileName) {
|
||||
const btn = document.getElementById('btn_upload_pdf');
|
||||
const desc = document.getElementById('upload_status_desc');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/curriculum/upload-status?task_id=${taskId}`);
|
||||
if (!res.ok) throw new Error("Network response was not ok");
|
||||
const state = await res.json();
|
||||
|
||||
if (state.status === 'completed') {
|
||||
desc.textContent = `تم الانتهاء بنجاح! تم استخراج المنهج بالكامل لـ [${fileName}]`;
|
||||
btn.innerHTML = '<span>✅ اكتمل التفريغ</span>';
|
||||
setTimeout(() => { resetUploadBtn(); }, 3000);
|
||||
|
||||
if (state.tree) {
|
||||
window.CURRICULUM_TREE = state.tree;
|
||||
currentFilePath = state.active_file || '';
|
||||
renderCurriculumTree(state.tree);
|
||||
if (state.active_file) {
|
||||
selectLesson(state.active_file, 'الدرس المستخرج', 'المسار التعليمي', []);
|
||||
fetchLessonContent(state.active_file);
|
||||
}
|
||||
} else {
|
||||
// Reload tree completely
|
||||
loadCurriculumTree();
|
||||
}
|
||||
} else if (state.status === 'error') {
|
||||
alert('⚠️ فشل في معالجة الملف: ' + state.message);
|
||||
resetUploadBtn();
|
||||
} else {
|
||||
// Still processing
|
||||
btn.innerHTML = `<span>⏳ ${state.progress}% | ${state.status}</span>`;
|
||||
desc.textContent = state.message || "جاري المعالجة بالذكاء الاصطناعي... قد يستغرق دقيقة أو أكثر";
|
||||
setTimeout(() => pollTaskStatus(taskId, fileName), 2500); // poll every 2.5 seconds
|
||||
}
|
||||
} catch(e) {
|
||||
setTimeout(() => pollTaskStatus(taskId, fileName), 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function resetUploadBtn() {
|
||||
const btn = document.getElementById('btn_upload_pdf');
|
||||
const desc = document.getElementById('upload_status_desc');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<span>📄 رفع واستخراج المنهاج (PDF)</span>';
|
||||
document.getElementById('curriculum_pdf_input').value = '';
|
||||
}
|
||||
"""
|
||||
|
||||
content = re.sub(r'async function handleRealPdfUpload\(input\) \{.*?(?=async function selectLesson|function renderCurriculumTree)', new_script + '\n ', content, flags=re.DOTALL)
|
||||
|
||||
with open("backend/app/Views/CurriculumStudio.php", "w") as f:
|
||||
f.write(content)
|
||||
Reference in New Issue
Block a user