492 lines
20 KiB
PHP
492 lines
20 KiB
PHP
<?php
|
|
/**
|
|
* ==============================================================================
|
|
* SAQEL ENTERPRISE (EDTECH 2.0) - DYNAMIC CURRICULUM SERVICE
|
|
* ==============================================================================
|
|
*
|
|
* ملف: CurriculumService.php
|
|
* الهدف المعماري:
|
|
* إدارة المنظومة الحية للمناهج والكتب الوزارية وشجرة الدروس (Zero Mock Data):
|
|
* 1. مسح وتوليد الشجرة الهرمية للمناهج من ملفات (manifest.json) وملفات الماركداون الحية.
|
|
* 2. البحث الدلالي اللحظي في عناوين الدروس ونتاجات التعلم والمفاهيم.
|
|
* 3. قراءة وحفظ وتحديث نصوص الماركداون للدروس والمصادر والملخصات.
|
|
* 4. الربط المرجعي بين شجرة المناهج وقاعدة بيانات الدورات والدروس والفيديوهات.
|
|
*/
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\Database;
|
|
|
|
class CurriculumService
|
|
{
|
|
/**
|
|
* التحقق التوافقي التلقائي من بنية قاعدة البيانات للمناهج
|
|
*/
|
|
public static function ensureSchema(): void
|
|
{
|
|
// No-op (schema managed via database_schema.sql)
|
|
}
|
|
|
|
/**
|
|
* Runtime migration: add is_system_curriculum (and related columns) to `courses`
|
|
* if they are missing. Safe to call multiple times — uses IF NOT EXISTS / IGNORE.
|
|
*/
|
|
public static function ensureSystemCurriculumColumn(): void
|
|
{
|
|
static $ran = false;
|
|
if ($ran) return;
|
|
$ran = true;
|
|
|
|
try {
|
|
// Check whether the column exists to avoid noisy ALTER errors on every request
|
|
$row = Database::selectOne(
|
|
"SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = 'courses'
|
|
AND COLUMN_NAME = 'is_system_curriculum'
|
|
LIMIT 1"
|
|
);
|
|
|
|
if (empty($row['cnt'])) {
|
|
Database::query(
|
|
"ALTER TABLE `courses`
|
|
ADD COLUMN `is_system_curriculum` TINYINT(1) NOT NULL DEFAULT 0
|
|
COMMENT 'الدورة الرئيسية للمنهاج الوزاري'
|
|
AFTER `is_school_exclusive`"
|
|
);
|
|
}
|
|
|
|
// Also ensure grade_level + stream columns exist (for student filtering)
|
|
$gradeRow = Database::selectOne(
|
|
"SELECT COUNT(*) as cnt FROM information_schema.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = 'courses'
|
|
AND COLUMN_NAME = 'grade_level'
|
|
LIMIT 1"
|
|
);
|
|
if (empty($gradeRow['cnt'])) {
|
|
Database::query(
|
|
"ALTER TABLE `courses`
|
|
ADD COLUMN `grade_level` VARCHAR(50) DEFAULT NULL
|
|
COMMENT 'المرحلة الدراسية مثل: tawjihi_2008'
|
|
AFTER `is_system_curriculum`,
|
|
ADD COLUMN `stream` ENUM('scientific','literary','vocational','general') DEFAULT NULL
|
|
COMMENT 'الفرع الدراسي'
|
|
AFTER `grade_level`"
|
|
);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log('[CurriculumService] ensureSystemCurriculumColumn error: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve or create the master system curriculum course.
|
|
*
|
|
* Returns a guaranteed valid `course_id` that satisfies the FK constraint on
|
|
* `lessons.course_id → courses.id`. Called by CurriculumController when
|
|
* uploading AI/ministry videos without an explicit teacher course.
|
|
*
|
|
* @return int Valid course_id
|
|
* @throws \RuntimeException if no subjects or teachers exist and creation fails
|
|
*/
|
|
public static function getOrCreateSystemCourse(): int
|
|
{
|
|
// Ensure the column exists before querying it (runtime migration)
|
|
self::ensureSystemCurriculumColumn();
|
|
|
|
// 1. Look for existing master system course
|
|
$existing = Database::selectOne(
|
|
"SELECT id FROM courses WHERE is_system_curriculum = 1 LIMIT 1"
|
|
);
|
|
if ($existing && !empty($existing['id'])) {
|
|
return (int)$existing['id'];
|
|
}
|
|
|
|
// 2. Also try by canonical title in case migration was run after records existed
|
|
$byTitle = Database::selectOne(
|
|
"SELECT id FROM courses WHERE title LIKE '%منهاج وزارة التربية%' LIMIT 1"
|
|
);
|
|
if ($byTitle && !empty($byTitle['id'])) {
|
|
// Stamp it so future lookups are fast
|
|
Database::query(
|
|
"UPDATE courses SET is_system_curriculum = 1 WHERE id = ?",
|
|
[(int)$byTitle['id']]
|
|
);
|
|
return (int)$byTitle['id'];
|
|
}
|
|
|
|
// 3. Create a new master course — resolve required FK dependencies first
|
|
// Resolve subject_id (use first available subject or create a placeholder)
|
|
$subject = Database::selectOne("SELECT id FROM subjects LIMIT 1");
|
|
if (!$subject) {
|
|
$subjectId = Database::insert(
|
|
"INSERT INTO subjects (name, description) VALUES ('المنهاج الوزاري الأردني', 'مقررات وزارة التربية والتعليم المعتمدة') ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)"
|
|
);
|
|
} else {
|
|
$subjectId = (int)$subject['id'];
|
|
}
|
|
|
|
// Resolve teacher_id (use system teacher with id=1 or first available)
|
|
$teacher = Database::selectOne("SELECT id FROM teachers WHERE id = 1 LIMIT 1");
|
|
if (!$teacher) {
|
|
$teacher = Database::selectOne("SELECT id FROM teachers LIMIT 1");
|
|
}
|
|
if (!$teacher) {
|
|
$tUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff));
|
|
$teacherId = Database::insert(
|
|
"INSERT INTO teachers (uuid, full_name, specialization, is_school_exclusive, is_marketplace_public)
|
|
VALUES (?, 'منهاج الذكاء الاصطناعي والوزارة', 'المنهاج الوزاري المعتمد', 0, 1)",
|
|
[$tUuid]
|
|
);
|
|
} else {
|
|
$teacherId = (int)$teacher['id'];
|
|
}
|
|
|
|
// Generate stable UUID for the master course
|
|
$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
|
mt_rand(0, 0xffff),
|
|
mt_rand(0, 0x0fff) | 0x4000,
|
|
mt_rand(0, 0x3fff) | 0x8000,
|
|
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
|
);
|
|
|
|
$courseId = Database::insert(
|
|
"INSERT INTO courses
|
|
(uuid, subject_id, teacher_id, title, description, semester,
|
|
price_jod, is_published, is_system_curriculum, grade_level, stream)
|
|
VALUES
|
|
(?, ?, ?, 'منهاج وزارة التربية والتعليم الأساسي',
|
|
'الدورة الرئيسية للمنهاج الوزاري — تُستخدم لرفع وتنظيم فيديوهات الذكاء الاصطناعي التعليمية',
|
|
'full_year', 0.00, 1, 1, 'tawjihi_2008', 'scientific')",
|
|
[$uuid, $subjectId, $teacherId]
|
|
);
|
|
|
|
if (!$courseId) {
|
|
throw new \RuntimeException('فشل إنشاء الدورة الرئيسية للمنهاج — تحقق من وجود records في جداول subjects و teachers');
|
|
}
|
|
|
|
error_log("[CurriculumService] Created master system course id={$courseId}");
|
|
return (int)$courseId;
|
|
}
|
|
|
|
private static string $storagePath = __DIR__ . '/../../storage/curriculum';
|
|
private static string $manifestFile = __DIR__ . '/../../storage/curriculum/manifest.json';
|
|
|
|
public static function ensureStorage(): void
|
|
{
|
|
if (!is_dir(self::$storagePath)) {
|
|
mkdir(self::$storagePath, 0777, true);
|
|
}
|
|
if (!file_exists(self::$manifestFile)) {
|
|
file_put_contents(self::$manifestFile, json_encode(new \stdClass(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get Complete Live Curriculum Tree from Storage Manifest
|
|
*/
|
|
public static function getCurriculumTree(): array
|
|
{
|
|
self::ensureStorage();
|
|
if (file_exists(self::$manifestFile)) {
|
|
$json = file_get_contents(self::$manifestFile);
|
|
$tree = json_decode($json, true);
|
|
if (is_array($tree)) {
|
|
return $tree;
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** Resolve a manifest lesson slug to its canonical title/file. */
|
|
public static function findLessonById(string $lessonId): ?array
|
|
{
|
|
$walk = function ($node) use (&$walk, $lessonId): ?array {
|
|
if (!is_array($node)) return null;
|
|
if (isset($node['lessons']) && is_array($node['lessons'])) {
|
|
foreach ($node['lessons'] as $lesson) {
|
|
if (is_array($lesson) && (string)($lesson['id'] ?? '') === $lessonId) return $lesson;
|
|
}
|
|
}
|
|
foreach ($node as $value) {
|
|
$found = $walk($value);
|
|
if ($found !== null) return $found;
|
|
}
|
|
return null;
|
|
};
|
|
return $walk(self::getCurriculumTree());
|
|
}
|
|
|
|
/** Resolve a manifest file path to its canonical lesson record. */
|
|
public static function findLessonByFile(string $file): ?array
|
|
{
|
|
$walk = function ($node) use (&$walk, $file): ?array {
|
|
if (!is_array($node)) return null;
|
|
if (isset($node['lessons']) && is_array($node['lessons'])) {
|
|
foreach ($node['lessons'] as $lesson) {
|
|
if (is_array($lesson) && (string)($lesson['file'] ?? '') === $file) return $lesson;
|
|
}
|
|
}
|
|
foreach ($node as $value) {
|
|
$found = $walk($value);
|
|
if ($found !== null) return $found;
|
|
}
|
|
return null;
|
|
};
|
|
return $walk(self::getCurriculumTree());
|
|
}
|
|
|
|
/**
|
|
* Save/Update Complete Curriculum Tree Manifest
|
|
*/
|
|
public static function saveCurriculumTree(array $tree): bool
|
|
{
|
|
self::ensureStorage();
|
|
return file_put_contents(self::$manifestFile, json_encode($tree, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false;
|
|
}
|
|
|
|
/**
|
|
* Merge Newly Extracted PDF Structure into Live Tree
|
|
*/
|
|
public static function mergeExtractedCurriculum(array $extracted): array
|
|
{
|
|
self::ensureStorage();
|
|
$tree = self::getCurriculumTree();
|
|
|
|
$gradeKey = $extracted['grade_key'] ?? 'general_grade';
|
|
$gradeName = $extracted['grade_name'] ?? 'الصف الدراسي';
|
|
|
|
$subKey = $extracted['subject_key'] ?? 'general_subject';
|
|
$subName = $extracted['subject_name'] ?? 'المبحث';
|
|
|
|
$semKey = $extracted['semester_key'] ?? 'semester_1';
|
|
$semName = $extracted['semester_name'] ?? 'الفصل الدراسي الأول';
|
|
|
|
if (!isset($tree[$gradeKey])) {
|
|
$tree[$gradeKey] = [
|
|
'name' => $gradeName,
|
|
'subjects' => []
|
|
];
|
|
}
|
|
|
|
if (!isset($tree[$gradeKey]['subjects'][$subKey])) {
|
|
$tree[$gradeKey]['subjects'][$subKey] = [
|
|
'name' => $subName,
|
|
'semesters' => []
|
|
];
|
|
}
|
|
|
|
if (!isset($tree[$gradeKey]['subjects'][$subKey]['semesters'][$semKey])) {
|
|
$tree[$gradeKey]['subjects'][$subKey]['semesters'][$semKey] = [
|
|
'name' => $semName,
|
|
'units' => []
|
|
];
|
|
}
|
|
|
|
// Add / Update Units & Lessons
|
|
foreach ($extracted['units'] ?? [] as $unit) {
|
|
$unitKey = $unit['unit_key'] ?? ('unit_' . mt_rand(100, 999));
|
|
$unitName = $unit['unit_name'] ?? 'الوحدة الدراسية';
|
|
|
|
$unitLessons = [];
|
|
foreach ($unit['lessons'] ?? [] as $les) {
|
|
$lesId = $les['lesson_id'] ?? ('les_' . mt_rand(1000, 9999));
|
|
$lesTitle = $les['title'] ?? 'الدرس';
|
|
$lesOutcomes = $les['outcomes'] ?? [];
|
|
$relFilePath = "{$gradeKey}/{$subKey}/{$semKey}/{$unitKey}/{$lesId}.md";
|
|
$mdContent = $les['markdown_content'] ?? "# {$lesTitle}\n\nنتاجات التعلم المستهدفة للمنهاج الرسمي.";
|
|
|
|
// Save MD file to disk
|
|
self::saveLessonMarkdown($relFilePath, $mdContent);
|
|
|
|
$unitLessons[] = [
|
|
'id' => $lesId,
|
|
'title' => $lesTitle,
|
|
'outcomes' => $lesOutcomes,
|
|
'file' => $relFilePath
|
|
];
|
|
}
|
|
|
|
$tree[$gradeKey]['subjects'][$subKey]['semesters'][$semKey]['units'][$unitKey] = [
|
|
'name' => $unitName,
|
|
'lessons' => $unitLessons
|
|
];
|
|
}
|
|
|
|
self::saveCurriculumTree($tree);
|
|
return $tree;
|
|
}
|
|
|
|
/**
|
|
* Save Single Lesson Markdown Content to Hierarchical File System
|
|
*/
|
|
public static function saveLessonMarkdown(string $relativePath, string $content): bool
|
|
{
|
|
self::ensureStorage();
|
|
$fullPath = self::$storagePath . '/' . ltrim($relativePath, '/');
|
|
$dir = dirname($fullPath);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0777, true);
|
|
}
|
|
return file_put_contents($fullPath, $content) !== false;
|
|
}
|
|
|
|
/**
|
|
* Retrieve Lesson Markdown Content
|
|
*/
|
|
public static function getLessonMarkdown(string $relativePath): string
|
|
{
|
|
self::ensureStorage();
|
|
$fullPath = self::$storagePath . '/' . ltrim($relativePath, '/');
|
|
if (file_exists($fullPath)) {
|
|
return file_get_contents($fullPath);
|
|
}
|
|
return "# محتوى المنهاج\nالمحتوى المعتمد للمنهاج الرسمي.";
|
|
}
|
|
|
|
public static function saveLessonAiAssets(string $relativePath, array $assets): bool
|
|
{
|
|
self::ensureStorage();
|
|
$baseName = preg_replace('/\.md$/i', '', ltrim($relativePath, '/'));
|
|
$jsonPath = self::$storagePath . '/' . $baseName . '_ai_assets.json';
|
|
|
|
$dir = dirname($jsonPath);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0777, true);
|
|
}
|
|
return file_put_contents($jsonPath, json_encode($assets, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false;
|
|
}
|
|
|
|
public static function getLessonAiAssets(string $relativePath): array
|
|
{
|
|
self::ensureStorage();
|
|
$baseName = preg_replace('/\.md$/i', '', ltrim($relativePath, '/'));
|
|
$jsonPath = self::$storagePath . '/' . $baseName . '_ai_assets.json';
|
|
|
|
if (file_exists($jsonPath)) {
|
|
return json_decode(file_get_contents($jsonPath), true) ?: [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
public static function saveLessonLabData(string $relativePath, array $labData): bool
|
|
{
|
|
self::ensureStorage();
|
|
$baseName = preg_replace('/\.md$/i', '', ltrim($relativePath, '/'));
|
|
$jsonPath = self::$storagePath . '/' . $baseName . '_lab.json';
|
|
|
|
$dir = dirname($jsonPath);
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0777, true);
|
|
}
|
|
return file_put_contents($jsonPath, json_encode($labData, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)) !== false;
|
|
}
|
|
|
|
public static function getLessonLabData(string $relativePath): array
|
|
{
|
|
self::ensureStorage();
|
|
$baseName = preg_replace('/\.md$/i', '', ltrim($relativePath, '/'));
|
|
$jsonPath = self::$storagePath . '/' . $baseName . '_lab.json';
|
|
|
|
if (file_exists($jsonPath)) {
|
|
return json_decode(file_get_contents($jsonPath), true) ?: [];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Fast Keyword Search across All Markdown Files
|
|
*/
|
|
public static function searchCurriculum(string $query): array
|
|
{
|
|
$tree = self::getCurriculumTree();
|
|
$results = [];
|
|
$queryClean = mb_strtolower(trim($query));
|
|
if (empty($queryClean)) return [];
|
|
|
|
foreach ($tree as $gradeKey => $grade) {
|
|
foreach ($grade['subjects'] ?? [] as $subKey => $subject) {
|
|
foreach ($subject['semesters'] ?? [] as $semKey => $semester) {
|
|
foreach ($semester['units'] ?? [] as $unitKey => $unit) {
|
|
foreach ($unit['lessons'] ?? [] as $lesson) {
|
|
$matches = false;
|
|
if (str_contains(mb_strtolower($lesson['title']), $queryClean) ||
|
|
str_contains(mb_strtolower($unit['name']), $queryClean) ||
|
|
str_contains(mb_strtolower($subject['name']), $queryClean)) {
|
|
$matches = true;
|
|
}
|
|
if (!$matches && !empty($lesson['outcomes'])) {
|
|
foreach ($lesson['outcomes'] as $out) {
|
|
if (str_contains(mb_strtolower($out), $queryClean)) {
|
|
$matches = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if ($matches) {
|
|
$results[] = [
|
|
'grade' => $grade['name'],
|
|
'subject' => $subject['name'],
|
|
'unit' => $unit['name'],
|
|
'lesson' => $lesson['title'],
|
|
'file_path' => $lesson['file'],
|
|
'outcomes' => $lesson['outcomes']
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Retrieve Context for Video Analysis
|
|
*/
|
|
public static function getCurriculumContext(int $courseId, string $lessonTitle): array
|
|
{
|
|
$tree = self::getCurriculumTree();
|
|
$walk = function ($node, string $subject = 'المبحث الدراسي', string $unit = 'الوحدة التعليمية') use (&$walk, $lessonTitle): ?array {
|
|
if (!is_array($node)) return null;
|
|
if (isset($node['semesters']) && isset($node['name'])) {
|
|
$subject = (string)$node['name'];
|
|
}
|
|
if (isset($node['lessons']) && is_array($node['lessons'])) {
|
|
$unit = (string)($node['name'] ?? $unit);
|
|
foreach ($node['lessons'] as $lesson) {
|
|
if (!is_array($lesson)) continue;
|
|
if ((string)($lesson['title'] ?? '') === $lessonTitle) {
|
|
return [
|
|
'grade' => 'الصف العاشر',
|
|
'subject' => $subject,
|
|
'unit' => $unit,
|
|
'core_topics' => array_values(array_filter(array_map('strval', $lesson['outcomes'] ?? []))),
|
|
'lesson_file' => (string)($lesson['file'] ?? '')
|
|
];
|
|
}
|
|
}
|
|
}
|
|
foreach ($node as $value) {
|
|
$found = $walk($value, $subject, $unit);
|
|
if ($found !== null) return $found;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
$matched = $walk($tree);
|
|
if ($matched !== null) return $matched;
|
|
|
|
// Safe generic context when the lesson is not in the manifest.
|
|
return [
|
|
'grade' => 'المرحلة التعليمية المعتمدة',
|
|
'subject' => 'المبحث الدراسي',
|
|
'unit' => 'الوحدة التعليمية',
|
|
'core_topics' => ['تحليل المفاهيم الأساسية', 'التطبيقات والمسائل النموذجية']
|
|
];
|
|
}
|
|
}
|