feat: STEM Virtual Lab, English TTS companion, dynamic textbooks and zero-demo video fallback

This commit is contained in:
Hamza-Ayed
2026-09-03 17:37:15 +03:00
parent 445a1aa0e3
commit 58c3c48f69
19 changed files with 2162 additions and 258 deletions
@@ -347,4 +347,137 @@ class CurriculumController
]);
}
}
/**
* 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- التحقق من اكتساب المهارات من خلال بنك الأسئلة التكيفي والمختبر.";
}
}
@@ -0,0 +1,222 @@
<?php
/**
* ==============================================================================
* SAQEL ENTERPRISE (EDTECH 2.0) - AI INTERACTIVE LAB GENERATOR SERVICE
* ==============================================================================
*
* ملف: AiInteractiveLabGeneratorService.php
* الهدف المعماري:
* محرك الذكاء الاصطناعي لتوليد مختبرات ومحاكيات العلوم والرياضيات التفاعلية (HTML5 Canvas)
* 1. قراءة وتحليل ملف الماركداون للدرس أو الوحدة واستخلاص المفاهيم الفيزيائية/الكيميائية/الرياضية.
* 2. استخدام Google Gemini لبرمجة محاكي Canvas أحادي الملف (Standalone Single-File HTML5).
* 3. تضمين العلامة المائية الدائمة لحفظ حقوق المنصة (Watermark) وحماية الكود من النسخ.
* 4. حفظ المحاكي في مجلد simulations وإتاحته عبر الـ API للتطبيق والمتصفح.
*/
namespace App\Services;
use App\Core\Database;
class AiInteractiveLabGeneratorService
{
private static string $storagePath = __DIR__ . '/../../storage/curriculum';
/**
* Generate an interactive HTML5 Canvas lab from a lesson markdown file.
*
* @param string $relativeLessonPath e.g. "grade_10/physics_10/semester_1/unit_01/lesson_01.md"
* @param string $subjectSlug e.g. "physics_10"
* @param string|null $simSlug e.g. "vectors_lab" (auto-inferred if null)
* @return array
*/
public static function generateLabFromLesson(string $relativeLessonPath, string $subjectSlug = 'physics_10', ?string $simSlug = null): array
{
$fullPath = realpath(self::$storagePath) . '/' . ltrim($relativeLessonPath, '/');
if (!file_exists($fullPath)) {
throw new \InvalidArgumentException("ملف الدرس غير موجود: {$relativeLessonPath}");
}
$lessonContent = file_get_contents($fullPath);
if (empty($lessonContent)) {
throw new \RuntimeException("ملف الدرس فارغ: {$relativeLessonPath}");
}
if (!$simSlug) {
$baseName = basename($relativeLessonPath, '.md');
$simSlug = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '_', $baseName)) . '_lab';
}
$apiKey = self::resolveGeminiKey();
if (!$apiKey) {
throw new \RuntimeException("مفتاح Gemini API غير مهيأ (GEMINI_API_KEY).");
}
$prompt = self::buildSimulationPrompt($lessonContent, $subjectSlug, $simSlug);
$htmlCode = self::callGemini($apiKey, $prompt);
// Sanitize code: remove markdown code fences if model returned them
$htmlCode = preg_replace('/^```(?:html)?\s*/i', '', trim($htmlCode));
$htmlCode = preg_replace('/\s*```$/', '', $htmlCode);
// Ensure target simulation directory exists
$simDir = realpath(self::$storagePath) . "/simulations/{$subjectSlug}";
if (!is_dir($simDir)) {
mkdir($simDir, 0777, true);
}
$targetFile = "{$simDir}/{$simSlug}.html";
file_put_contents($targetFile, $htmlCode);
$relativeUrl = "/api/curriculum/simulations/{$subjectSlug}/{$simSlug}";
return [
'status' => 'success',
'sim_slug' => $simSlug,
'subject' => $subjectSlug,
'file_path' => $targetFile,
'url' => $relativeUrl,
'byte_size' => strlen($htmlCode)
];
}
/**
* Build the prompt for Gemini.
*/
private static function buildSimulationPrompt(string $lessonMarkdown, string $subjectSlug, string $simSlug): string
{
return <<<PROMPT
أنت كبير مهندسي المحاكاة البصرية والتطوير التعليمي التفاعلي لمنصة "صَقِل" (Saqel EdTech 2.0).
المطلوب منك برمجة صفحة ويب تفاعلية فائقة الجودة والفخامة (Standalone Single-File HTML5 Canvas Simulation) تُجسد المفاهيم العلمية والتطبيقية الواردة في نص الدرس المرفق.
المتطلبات التصميمية والتقنية الصارمة:
1. التصميم بأسلوب كوبرتينو الفاخر الداكن (Apple Cupertino Dark Luxury):
- خلفية أنيقة جداً #06090f مع حاويات #0d111a وحدود ناعمة شبه شفافة rgba(255,255,255,0.12).
- ألوان مميزة للمتغيرات: السماوي #00f5d4، الذهبي #ffd166، الزمردي #10b981، الأزرق الأبلي #0071e3.
- خطوط واضحة: SF Pro Arabic أو النظام الافتراضي، دعم كامل للغة العربية واتجاه RTL.
2. تفاعل الـ HTML5 Canvas:
- رسم العناصر بدقة ونعومة عالية، مع أسهم موجهة، وأطوال بمقياس رسم مدروس، وشبكة بيانية إرشادية.
- تفاعل سلس عبر سلايدرات تحكم (Sliders) مع أزرار تشغيل/إيقاف/إعادة ضبط إن كانت تجربة حركية.
- قراءات وحسابات فيزيائية/رياضية لحظية تظهر أسفل الـ Canvas توضح القوانين والنتاجات.
- ويدجت "💡 فحص سقراطي مباشر" يطرح تحدياً تفكيرياً على الطالب لتجربته بالسلايدرات.
3. العلامة المائية والحماية من النسخ (Anti-Copy Protection):
- رسم علامة مائية مضمنة غير قابلة للإزالة داخل الـ Canvas نفسه:
ctx.fillText('منصة صَقِل التعليمية الذكية © Saqel Lab - محمي', 14, 24);
- منع النقر بالزر الأيمن وحظر اختصارات النسخ لمنع استخراج الكود.
4. الاستقلالية الكاملة (Self-contained):
- ملف HTML واحد متكامل يحتوي على كل CSS و JS دون أي اعتمادات خارجية غير مدعومة.
- لا تضع أي شروحات أو مقدمات خارج كود الـ HTML، أرجع كود HTML فقط.
محتوى الدرس:
```markdown
{$lessonMarkdown}
```
PROMPT;
}
/**
* Execute Gemini API Call.
*/
private static function callGemini(string $apiKey, string $prompt): string
{
$model = 'gemini-2.0-flash';
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt]
]
]
],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => 8192
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 90
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
// Fallback to gemini-1.5-flash
return self::callGeminiFallback($apiKey, $prompt);
}
$resData = json_decode($response, true);
$text = $resData['candidates'][0]['content']['parts'][0]['text'] ?? '';
if (empty($text)) {
throw new \RuntimeException("استجابة غير صالحة من Gemini.");
}
return $text;
}
private static function callGeminiFallback(string $apiKey, string $prompt): string
{
$model = 'gemini-1.5-flash';
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt]
]
]
],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => 8192
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 90
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
throw new \RuntimeException("فشل استدعاء Gemini API (HTTP {$httpCode})");
}
$resData = json_decode($response, true);
return $resData['candidates'][0]['content']['parts'][0]['text'] ?? '';
}
private static function resolveGeminiKey(): ?string
{
if (!empty($_ENV['GEMINI_API_KEY'])) return $_ENV['GEMINI_API_KEY'];
if (!empty(getenv('GEMINI_API_KEY'))) return getenv('GEMINI_API_KEY');
// Check backend/.env
$envPath = __DIR__ . '/../../.env';
if (file_exists($envPath)) {
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (str_starts_with(trim($line), 'GEMINI_API_KEY=')) {
return trim(substr(trim($line), 15), '"\' ');
}
}
}
return null;
}
}