feat: STEM Virtual Lab, English TTS companion, dynamic textbooks and zero-demo video fallback
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user