638 lines
35 KiB
PHP
638 lines
35 KiB
PHP
<?php
|
|
/**
|
|
* ==============================================================================
|
|
* SAQEL ENTERPRISE (EDTECH 2.0) - AI VIDEO & SOCRATIC CHECKPOINT ANALYZER
|
|
* ==============================================================================
|
|
*
|
|
* ملف: AiVideoAnalyzerService.php
|
|
* الهدف المعماري:
|
|
* التحليل الذكي المستقل للفيديوهات التعليمية وتوليد الفحوصات السقراطية والفصول الزمنية:
|
|
* 1. فهرسة الفصول الزمنية الذكية للدرس (Timeline Chapters) بدقة الثواني والدقائق.
|
|
* 2. توليد نقاط الفحص السقراطي الزمنية (Chronological Socratic Checkpoints) التي تقطع المشاهدة
|
|
* وتلزم الطالب بالإجابة للتأكد من اليقظة والفهم، وتحدد زمن الإرجاع العلاجي (rewind_seconds) عند الخطأ.
|
|
* 3. توليد بنك أسئلة إتقان الدرس الختامي مستنداً حرفياً إلى كتاب المنهاج الوزاري المعتمد.
|
|
* 4. ربط كل فحص سقراطي بنتاجات التعلم المحددة في شجرة المنهاج.
|
|
*/
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Core\Database;
|
|
use App\Core\Security;
|
|
use App\Core\RedisClient;
|
|
|
|
class AiVideoAnalyzerService
|
|
{
|
|
/**
|
|
* Select a Gemini key from the configured comma-separated pool. Redis
|
|
* provides a process-safe round robin counter across PHP-FPM workers.
|
|
* No key material is logged or returned to callers.
|
|
*/
|
|
private static function nextGeminiApiKey(): string
|
|
{
|
|
$raw = trim((string)(getenv('GEMINI_API_KEYS') ?: getenv('GEMINI_KEY') ?: ''));
|
|
$keys = array_values(array_filter(array_map('trim', explode(',', $raw))));
|
|
if (empty($keys)) {
|
|
return '';
|
|
}
|
|
if (count($keys) === 1) {
|
|
return $keys[0];
|
|
}
|
|
|
|
try {
|
|
$turn = (int)RedisClient::getInstance()->incr('saqel:gemini:round_robin');
|
|
return $keys[($turn - 1) % count($keys)];
|
|
} catch (\Throwable $e) {
|
|
// The analysis can still proceed during a Redis outage. The time
|
|
// bucket alternates keys without exposing either value.
|
|
return $keys[(int)(floor(time() / 60) % count($keys))];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fail-closed admission gate for teacher uploads. The media stays in PHP's
|
|
* temporary upload area until Gemini and technical checks approve it.
|
|
*/
|
|
public static function auditUploadBeforeStorage(array $file, string $title, string $subject = ''): array
|
|
{
|
|
$path = (string)($file['tmp_name'] ?? '');
|
|
if ($path === '' || !is_file($path)) {
|
|
return ['decision' => 'rejected', 'reason' => 'ملف الفيديو غير متاح للتدقيق'];
|
|
}
|
|
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path) ?: '';
|
|
if (!str_starts_with($mime, 'video/')) {
|
|
return ['decision' => 'rejected', 'reason' => 'نوع الملف ليس فيديو صالحاً'];
|
|
}
|
|
$ffprobe = trim((string)@shell_exec('command -v ffprobe 2>/dev/null'));
|
|
if ($ffprobe === '') {
|
|
return ['decision' => 'needs_manual_review', 'reason' => 'خدمة فحص الوسائط غير متاحة؛ لم يتم رفع الفيديو'];
|
|
}
|
|
$raw = @shell_exec(escapeshellarg($ffprobe) . ' -v error -show_entries format=duration -show_streams -of json ' . escapeshellarg($path));
|
|
$probe = json_decode((string)$raw, true);
|
|
$duration = (float)($probe['format']['duration'] ?? 0);
|
|
$hasVideo = false; $hasAudio = false;
|
|
foreach (($probe['streams'] ?? []) as $stream) {
|
|
$hasVideo = $hasVideo || (($stream['codec_type'] ?? '') === 'video');
|
|
$hasAudio = $hasAudio || (($stream['codec_type'] ?? '') === 'audio');
|
|
}
|
|
if (!$hasVideo || !$hasAudio || $duration <= 0 || $duration > 1500) {
|
|
return ['decision' => 'rejected', 'reason' => 'الفيديو يجب أن يحتوي صورة وصوتاً وأن لا يتجاوز 25 دقيقة', 'duration_seconds' => $duration];
|
|
}
|
|
$key = self::nextGeminiApiKey();
|
|
if ($key === '') return ['decision' => 'needs_manual_review', 'reason' => 'Gemini غير مهيأ؛ لم يتم رفع الفيديو', 'duration_seconds' => $duration];
|
|
|
|
// Extract a representative frame; Gemini receives real media evidence,
|
|
// not only the title or metadata supplied by the teacher.
|
|
$tempBase = tempnam(sys_get_temp_dir(), 'saqel_audit_');
|
|
@unlink($tempBase);
|
|
$frame = $tempBase . '.jpg';
|
|
|
|
$ffmpeg = trim((string)@shell_exec('command -v ffmpeg 2>/dev/null'));
|
|
if ($ffmpeg === '') {
|
|
foreach (['/usr/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/bin/ffmpeg'] as $cand) {
|
|
if (@is_executable($cand)) {
|
|
$ffmpeg = $cand;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
if ($ffmpeg === '') {
|
|
return ['decision' => 'needs_manual_review', 'reason' => 'خدمة فحص الوسائط (ffmpeg) غير متوفرة على الخادم', 'duration_seconds' => $duration];
|
|
}
|
|
|
|
$ssTime = max(1, (int)($duration / 3));
|
|
$cmd = escapeshellarg($ffmpeg) . ' -y -ss ' . escapeshellarg((string)$ssTime) . ' -i ' . escapeshellarg($path) . ' -frames:v 1 -q:v 3 ' . escapeshellarg($frame) . ' 2>&1';
|
|
$output = [];
|
|
$returnCode = 1;
|
|
@exec($cmd, $output, $returnCode);
|
|
|
|
if ($returnCode !== 0 || !is_file($frame) || filesize($frame) === 0) {
|
|
error_log("AiVideoAnalyzerService ffmpeg extraction failed (code {$returnCode}): " . implode("\n", array_slice($output, -5)));
|
|
return ['decision' => 'needs_manual_review', 'reason' => 'تعذر استخراج لقطة للتقييم؛ لم يتم رفع الفيديو', 'duration_seconds' => $duration];
|
|
}
|
|
try {
|
|
$prompt = "أنت مدقق جودة تربوية لمنصة صقل. قيّم اللقطة الفعلية من فيديو تعليمي بعنوان: {$title}. المادة: {$subject}. مدة الفيديو: {$duration} ثانية. أخرج JSON فقط: {\"decision\":\"approved|needs_manual_review|rejected\",\"visual_clarity_score\":0-100,\"pedagogical_readiness_score\":0-100,\"report\":\"سبب عربي مختصر\"}. ارفض المحتوى غير التعليمي أو غير الواضح. لا تمنح الموافقة إن لم تظهر أدلة كافية.";
|
|
$payload = [
|
|
'contents' => [[
|
|
'parts' => [
|
|
['text' => $prompt],
|
|
['inlineData' => [
|
|
'mimeType' => 'image/jpeg',
|
|
'data' => base64_encode((string)file_get_contents($frame)),
|
|
]],
|
|
],
|
|
]],
|
|
'generationConfig' => ['responseMimeType' => 'application/json', 'temperature' => 0.1],
|
|
];
|
|
$geminiModel = getenv('GEMINI_MODEL') ?: 'gemini-flash-lite-latest';
|
|
$ch = curl_init('https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($geminiModel) . ':generateContent?key=' . rawurlencode($key));
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_POSTFIELDS => json_encode($payload),
|
|
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_TIMEOUT => 30,
|
|
]);
|
|
$body = curl_exec($ch);
|
|
$curlErr = curl_error($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($body === false || !empty($curlErr)) {
|
|
error_log("AiVideoAnalyzerService cURL error: " . $curlErr);
|
|
throw new \RuntimeException("تعذر الاتصال بخدمة التحليل: {$curlErr}");
|
|
}
|
|
|
|
if ($code !== 200) {
|
|
error_log("AiVideoAnalyzerService Gemini HTTP {$code}: " . $body);
|
|
$errJson = json_decode((string)$body, true);
|
|
$errMsg = $errJson['error']['message'] ?? "رمز الاستجابة {$code}";
|
|
throw new \RuntimeException("استجابة Gemini: {$errMsg}");
|
|
}
|
|
|
|
$rawText = json_decode((string)$body, true)['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
|
$text = trim($rawText);
|
|
if (str_starts_with($text, '```json')) {
|
|
$text = trim(substr($text, 7));
|
|
if (str_ends_with($text, '```')) {
|
|
$text = trim(substr($text, 0, -3));
|
|
}
|
|
} elseif (str_starts_with($text, '```')) {
|
|
$text = trim(substr($text, 3));
|
|
if (str_ends_with($text, '```')) {
|
|
$text = trim(substr($text, 0, -3));
|
|
}
|
|
}
|
|
|
|
$report = json_decode($text, true);
|
|
if (!is_array($report) || !in_array($report['decision'] ?? '', ['approved','needs_manual_review','rejected'], true)) {
|
|
error_log("AiVideoAnalyzerService invalid report format: " . $text);
|
|
throw new \RuntimeException('تنسيق تقرير الجودة غير صالح');
|
|
}
|
|
$report['duration_seconds'] = $duration;
|
|
if (($report['visual_clarity_score'] ?? 0) < 85 || ($report['pedagogical_readiness_score'] ?? 0) < 85) {
|
|
$report['decision'] = 'needs_manual_review';
|
|
}
|
|
return $report;
|
|
} catch (\Throwable $e) {
|
|
error_log("AiVideoAnalyzerService audit exception: " . $e->getMessage());
|
|
return ['decision' => 'needs_manual_review', 'reason' => 'تعذر إكمال تحليل Gemini: ' . $e->getMessage(), 'duration_seconds' => $duration];
|
|
} finally { @unlink($frame); }
|
|
}
|
|
/**
|
|
* التحليل الآلي المستقل للفيديو وإنشاء الفصول ونقاط الفحص السقراطي في قاعدة البيانات
|
|
*
|
|
* @param int $lessonId معرّف الدرس
|
|
* @return array مصفوفة تحتوي على الفصول ونقاط الفحص وحالة التنفيذ
|
|
*/
|
|
public static function processLessonAutonomously(int $lessonId): array
|
|
{
|
|
CurriculumService::ensureSchema();
|
|
|
|
$lesson = Database::selectOne("SELECT * FROM lessons WHERE id = ? LIMIT 1", [$lessonId]);
|
|
if (!$lesson) {
|
|
return ['status' => 'error', 'message' => 'الدرس غير موجود'];
|
|
}
|
|
|
|
$courseId = (int)$lesson['course_id'];
|
|
$lessonTitle = $lesson['title'];
|
|
$duration = (int)($lesson['duration_seconds'] ?: 600);
|
|
$curriculum = CurriculumService::getCurriculumContext($courseId, $lessonTitle);
|
|
|
|
// Perform Gemini AI or Curriculum-grounded Analysis
|
|
$analysisResult = self::generateAnalysis($lessonTitle, $duration, $curriculum);
|
|
|
|
if (($analysisResult['status'] ?? '') === 'needs_evidence_review') {
|
|
return [
|
|
'status' => 'needs_evidence_review',
|
|
'lesson_id' => $lessonId,
|
|
'message' => 'لا يمكن نشر فصول أو أسئلة للفيديو من دون تفريغ زمني ومراجعة تربوية مرتبطة بالدرس.',
|
|
'timeline_chapters' => [],
|
|
'checkpoints_count' => 0,
|
|
];
|
|
}
|
|
|
|
// 1. Save Timeline Chapters to lessons table
|
|
if (!empty($analysisResult['timeline_chapters'])) {
|
|
Database::query(
|
|
"UPDATE lessons SET timeline_chapters_json = ? WHERE id = ?",
|
|
[json_encode($analysisResult['timeline_chapters'], JSON_UNESCAPED_UNICODE), $lessonId]
|
|
);
|
|
}
|
|
|
|
// 2. Clear old checkpoints and their questions for this lesson
|
|
$oldExams = Database::select("SELECT id FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]);
|
|
foreach ($oldExams as $oe) {
|
|
Database::query("DELETE FROM exams WHERE id = ?", [$oe['id']]);
|
|
}
|
|
|
|
// 3. Save Socratic Checkpoints into exams, questions, question_options
|
|
if (!empty($analysisResult['socratic_checkpoints'])) {
|
|
foreach ($analysisResult['socratic_checkpoints'] as $cp) {
|
|
try {
|
|
$examUuid = 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)
|
|
);
|
|
|
|
$examId = (int)Database::insert(
|
|
"INSERT INTO exams (uuid, course_id, lesson_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published)
|
|
VALUES (?, ?, ?, 'ai_adaptive', 'in_video_checkpoint', ?, ?, ?, 100.00, 10, 1, 1)",
|
|
[
|
|
$examUuid,
|
|
$courseId,
|
|
$lessonId,
|
|
$cp['question_text'],
|
|
(int)$cp['timestamp_seconds'],
|
|
(int)($cp['rewind_seconds'] ?? 45)
|
|
]
|
|
);
|
|
|
|
$qUuid = 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)
|
|
);
|
|
|
|
$qId = (int)Database::insert(
|
|
"INSERT INTO questions (uuid, exam_id, question_text, question_type, bloom_taxonomy, explanation_text, points)
|
|
VALUES (?, ?, ?, 'multiple_choice', 'comprehension', ?, 10)",
|
|
[
|
|
$qUuid,
|
|
$examId,
|
|
$cp['question_text'],
|
|
$cp['explanation'] ?? 'تطبيق مباشر لقواعد المنهاج المعتمد'
|
|
]
|
|
);
|
|
|
|
$correctIdx = (int)($cp['correct_index'] ?? 0);
|
|
if (!empty($cp['options']) && is_array($cp['options'])) {
|
|
foreach ($cp['options'] as $idx => $optText) {
|
|
Database::insert(
|
|
"INSERT INTO question_options (question_id, option_text, is_correct) VALUES (?, ?, ?)",
|
|
[$qId, (string)$optText, ($idx === $correctIdx) ? 1 : 0]
|
|
);
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log("Checkpoint insert error: " . $e->getMessage());
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. Perform AI Pedagogical Quality & Ministry Alignment Assessment
|
|
$qualityAssessment = self::evaluatePedagogicalQuality($lessonId, $lessonTitle, $duration, $curriculum);
|
|
|
|
return [
|
|
'status' => 'success',
|
|
'lesson_id' => $lessonId,
|
|
'timeline_chapters' => $analysisResult['timeline_chapters'],
|
|
'checkpoints_count' => count($analysisResult['socratic_checkpoints'] ?? []),
|
|
'quality_assessment' => $qualityAssessment
|
|
];
|
|
}
|
|
|
|
|
|
/**
|
|
* AI Pedagogical & Curriculum Alignment Evaluation Engine
|
|
* Evaluates video clarity, Bloom taxonomy coverage, and target learning outcomes
|
|
*/
|
|
public static function evaluatePedagogicalQuality(int $lessonId, string $lessonTitle, int $duration, array $curriculum): array
|
|
{
|
|
try {
|
|
$geminiKey = self::nextGeminiApiKey();
|
|
$alignmentScore = 0.0;
|
|
$clarityScore = 0.0;
|
|
$outcomes = [
|
|
"استيعاب المفهوم الرياضي/العلمي لدرس {$lessonTitle}",
|
|
"تطبيق القواعد والقوانين المعتمدة في كتاب الوزارة",
|
|
"حل المسائل والتمارين النموذجية بدقة وبناء استراتيجية التفكير السليم"
|
|
];
|
|
$bloom = [
|
|
'recall' => 20,
|
|
'comprehension' => 40,
|
|
'application' => 30,
|
|
'analysis' => 10
|
|
];
|
|
$critique = "لم يكتمل تدقيق Gemini؛ تتطلب الحصة مراجعة بشرية.";
|
|
$status = 'needs_manual_review';
|
|
|
|
if (!empty($geminiKey)) {
|
|
$prompt = "أنت كبير المشرفين التربويين في وزارة التربية والتعليم الأردنية لمنصة صَقِل.
|
|
قم بتقييم جودة الحصة التعليمية وتحديد نتاجات التعلم:
|
|
الدرس: '{$lessonTitle}'
|
|
المادة: {$curriculum['subject']}
|
|
المواضيع: " . implode(' | ', $curriculum['core_topics']) . "
|
|
مدة الحصة بالثواني: {$duration}
|
|
|
|
أخرج JSON حصري بالهيكل التالي:
|
|
{
|
|
\"curriculum_alignment_score\": 97.0,
|
|
\"pedagogical_clarity_score\": 95.0,
|
|
\"learning_outcomes\": [\"نتاج التعلم 1\", \"نتاج التعلم 2\", \"نتاج التعلم 3\"],
|
|
\"bloom_coverage\": {\"recall\": 15, \"comprehension\": 35, \"application\": 40, \"analysis\": 10},
|
|
\"ai_critique\": \"تقرير الجودة التربوي المعتمد\",
|
|
\"approval_status\": \"approved_official\"
|
|
}";
|
|
|
|
$geminiModel = getenv('GEMINI_MODEL') ?: 'gemini-flash-lite-latest';
|
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/" . rawurlencode($geminiModel) . ":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 => 15
|
|
]);
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode === 200 && !empty($response)) {
|
|
$json = json_decode($response, true);
|
|
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
|
$p = json_decode($text, true);
|
|
if (!empty($p['curriculum_alignment_score'])) {
|
|
$alignmentScore = (float)$p['curriculum_alignment_score'];
|
|
$clarityScore = (float)($p['pedagogical_clarity_score'] ?? 95.0);
|
|
if (!empty($p['learning_outcomes'])) $outcomes = $p['learning_outcomes'];
|
|
if (!empty($p['bloom_coverage'])) $bloom = $p['bloom_coverage'];
|
|
if (!empty($p['ai_critique'])) $critique = $p['ai_critique'];
|
|
if (!empty($p['approval_status'])) $status = $p['approval_status'];
|
|
}
|
|
}
|
|
}
|
|
|
|
// Save to video_quality_assessments table
|
|
Database::query(
|
|
"INSERT INTO video_quality_assessments
|
|
(lesson_id, curriculum_alignment_score, pedagogical_clarity_score, learning_outcomes_json, bloom_coverage_json, ai_critique_text, approval_status, evaluated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
|
|
ON DUPLICATE KEY UPDATE
|
|
curriculum_alignment_score = VALUES(curriculum_alignment_score),
|
|
pedagogical_clarity_score = VALUES(pedagogical_clarity_score),
|
|
learning_outcomes_json = VALUES(learning_outcomes_json),
|
|
bloom_coverage_json = VALUES(bloom_coverage_json),
|
|
ai_critique_text = VALUES(ai_critique_text),
|
|
approval_status = VALUES(approval_status),
|
|
evaluated_at = NOW()",
|
|
[
|
|
$lessonId,
|
|
$alignmentScore,
|
|
$clarityScore,
|
|
json_encode($outcomes, JSON_UNESCAPED_UNICODE),
|
|
json_encode($bloom, JSON_UNESCAPED_UNICODE),
|
|
$critique,
|
|
$status
|
|
]
|
|
);
|
|
|
|
return [
|
|
'alignment_score' => $alignmentScore,
|
|
'clarity_score' => $clarityScore,
|
|
'learning_outcomes' => $outcomes,
|
|
'bloom_coverage' => $bloom,
|
|
'critique' => $critique,
|
|
'status' => $status
|
|
];
|
|
} catch (\Throwable $e) {
|
|
error_log("Pedagogical quality assessment error: " . $e->getMessage());
|
|
}
|
|
|
|
return ['alignment_score' => 0.0, 'clarity_score' => 0.0, 'status' => 'needs_manual_review'];
|
|
}
|
|
|
|
/**
|
|
* Generate structured analysis with strict curriculum grounding
|
|
*/
|
|
private static function generateAnalysis(string $lessonTitle, int $duration, array $curriculum): array
|
|
{
|
|
// This legacy signature receives neither a timestamped transcript nor
|
|
// the approved Markdown version. It must never create publishable
|
|
// video claims, even when an AI key happens to be configured.
|
|
return [
|
|
'timeline_chapters' => [],
|
|
'socratic_checkpoints' => [],
|
|
'status' => 'needs_evidence_review',
|
|
];
|
|
|
|
$geminiKey = self::nextGeminiApiKey();
|
|
|
|
if (!empty($geminiKey)) {
|
|
try {
|
|
$prompt = "أنت خبير تربوي ومحلل جنائي لمناهج وزارة التربية والتعليم الأردنية لمنصة صَقِل.
|
|
حلل درس: '{$lessonTitle}'
|
|
المنهاج الرسمي: {$curriculum['subject']} - {$curriculum['unit']}
|
|
المواضيع المعتمدة: " . implode(' | ', $curriculum['core_topics']) . "
|
|
مدة الدرس بالثواني: {$duration}
|
|
|
|
المطلوب إخراج JSON حصري بالهيكل التالي:
|
|
{
|
|
\"timeline_chapters\": [
|
|
{\"start_seconds\": 0, \"end_seconds\": 180, \"title\": \"المفهوم العام والتمهيد\", \"summary\": \"توضيح الفكرة الأساسية\"},
|
|
{\"start_seconds\": 180, \"end_seconds\": 360, \"title\": \"عرض القانون والقواعد الأساسية\", \"summary\": \"شرح خطوات القاعدة\"},
|
|
{\"start_seconds\": 360, \"end_seconds\": {$duration}, \"title\": \"تطبيقات وأمثلة نموذجية\", \"summary\": \"حل مسائل وزارية معيارية\"}
|
|
],
|
|
\"socratic_checkpoints\": [
|
|
{
|
|
\"timestamp_seconds\": 180,
|
|
\"question_text\": \"سؤال فحص فهم حول ما تم شرحه في أول 3 دقائق فقط\",
|
|
\"options\": [\"الخيار الصحيح\", \"خيار خطأ 1\", \"خيار خطأ 2\", \"خيار خطأ 3\"],
|
|
\"correct_index\": 0,
|
|
\"rewind_seconds\": 45,
|
|
\"explanation\": \"توضيح القاعدة الوزارية\"
|
|
},
|
|
{
|
|
\"timestamp_seconds\": 360,
|
|
\"question_text\": \"سؤال حول تطبيق القانون المشروح حتى الدقيقة السادسة\",
|
|
\"options\": [\"خيار خطأ 1\", \"الخيار الصحيح\", \"خيار خطأ 2\", \"خيار خطأ 3\"],
|
|
\"correct_index\": 1,
|
|
\"rewind_seconds\": 45,
|
|
\"explanation\": \"توضيح خطوات الحل\"
|
|
}
|
|
]
|
|
}
|
|
قاعدة صارمة: السؤال عند أي دقيقة يسأل فقط عما تم شرحه قبل ذلك التوقيت، وممنوع نهائياً الاستعانة بأي معلومة خارج المنهاج.";
|
|
|
|
$geminiModel = getenv('GEMINI_MODEL') ?: 'gemini-flash-lite-latest';
|
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/" . rawurlencode($geminiModel) . ":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 => 20
|
|
]);
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
if ($httpCode === 200 && !empty($response)) {
|
|
$json = json_decode($response, true);
|
|
$text = $json['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
|
$parsed = json_decode($text, true);
|
|
if (!empty($parsed['timeline_chapters']) && !empty($parsed['socratic_checkpoints'])) {
|
|
return $parsed;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
error_log("Gemini API call notice: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
// A title and curriculum outline do not prove what appears at a given
|
|
// moment in the video. Do not publish chapters or questions without
|
|
// transcript evidence and a successful review workflow.
|
|
return [
|
|
'timeline_chapters' => [],
|
|
'socratic_checkpoints' => [],
|
|
'status' => 'needs_evidence_review',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* High-Precision Curriculum Grounded Generator
|
|
*/
|
|
private static function buildGroundedCurriculumAnalysis(string $title, int $duration, array $curriculum): array
|
|
{
|
|
$isMilitary = ($curriculum['subject'] === 'التربية الوطنية والثقافة العسكرية');
|
|
$duration = max(5, $duration);
|
|
|
|
// Dynamic Time Segmentation based on real duration
|
|
if ($duration <= 45) {
|
|
// Short Video (e.g. 10s AI generated video)
|
|
$c1 = max(1, (int)round($duration * 0.3));
|
|
$c2 = max(2, (int)round($duration * 0.7));
|
|
$cp1 = max(1, (int)round($duration * 0.3));
|
|
$cp2 = max(2, (int)round($duration * 0.7));
|
|
$rewind = 3;
|
|
} else {
|
|
// Full Length Standard Lesson (e.g. 10 - 45 minutes)
|
|
$c1 = max(60, (int)round($duration * 0.25));
|
|
$c2 = max(120, (int)round($duration * 0.60));
|
|
$cp1 = max(60, (int)round($duration * 0.25));
|
|
$cp2 = max(120, (int)round($duration * 0.60));
|
|
$rewind = 45;
|
|
}
|
|
|
|
if ($isMilitary) {
|
|
return [
|
|
'timeline_chapters' => [
|
|
['start_seconds' => 0, 'end_seconds' => $c1, 'title' => 'النشأة والتأسيس التاريخي', 'summary' => 'مراحل تشكيل القوات المسلحة الأردنية — الجيش العربي منذ عام 1921.'],
|
|
['start_seconds' => $c1, 'end_seconds' => $c2, 'title' => 'القرار التاريخي لتعريب القيادة (1956)', 'summary' => 'الرؤية الوطنية للملك الحسين بن طلال وإنهاء الانتداب البريطاني.'],
|
|
['start_seconds' => $c2, 'end_seconds' => $duration, 'title' => 'معركة الكرامة (1968) والأدوار التنموية', 'summary' => 'تحطيم أسطورة العدو، والمستشفيات الميدانية الإنسانية.']
|
|
],
|
|
'socratic_checkpoints' => [
|
|
[
|
|
'timestamp_seconds' => $cp1,
|
|
'question_text' => 'في أي عام تم تأسيس الجيش العربي الأردني؟',
|
|
'options' => ['عام 1921 في عهد الملك المؤسس عبدالله الأول', 'عام 1956', 'عام 1968', 'عام 1946'],
|
|
'correct_index' => 0,
|
|
'rewind_seconds' => $rewind,
|
|
'explanation' => 'تأسس الجيش العربي عام 1921 مع تأسيس إمارة شرق الأردن.'
|
|
],
|
|
[
|
|
'timestamp_seconds' => $cp2,
|
|
'question_text' => 'متى تم اتخاذ القرار التاريخي بتعريب قيادة الجيش العربي؟',
|
|
'options' => ['1 آذار 1956 بقيادة الملك الحسين بن طلال', '21 آذار 1968', '11 نيسان 1921', '25 أيار 1946'],
|
|
'correct_index' => 0,
|
|
'rewind_seconds' => $rewind,
|
|
'explanation' => 'صدر قرار تعريب القيادة التاريخي في 1 آذار 1956.'
|
|
]
|
|
]
|
|
];
|
|
}
|
|
|
|
// Grade 10 Mathematics: Systems of Equations (أنظمة المعادلات)
|
|
$isEquations = (str_contains($title, 'معادلات') || str_contains($title, 'أنظمة') || str_contains($curriculum['unit'] ?? '', 'معادلات'));
|
|
if ($isEquations) {
|
|
return [
|
|
'timeline_chapters' => [
|
|
['start_seconds' => 0, 'end_seconds' => $c1, 'title' => 'مفهوم أنظمة المعادلات ونماذجها الحياتية', 'summary' => 'التعريف بنظام المعادلات واستخدام النماذج الحياتية مثل الأرصاد والفيزياء.'],
|
|
['start_seconds' => $c1, 'end_seconds' => $c2, 'title' => 'طرائق الحل الجبري والبياني وبرمجية جيوجبرا', 'summary' => 'حل أنظمة المعادلات بالتعويض والتحليل وتعيين نقاط التقاطع عبر برمجية GeoGebra.'],
|
|
['start_seconds' => $c2, 'end_seconds' => $duration, 'title' => 'تطبيقات عملية والتحقق من صحة الحل', 'summary' => 'حل مسائل المنهاج والتأكد من تحقيق قيم x و y لكافة معادلات النظام.']
|
|
],
|
|
'socratic_checkpoints' => [
|
|
[
|
|
'timestamp_seconds' => $cp1,
|
|
'question_text' => 'ماذا تمثّل نقطة تقاطع منحنيين في المستوى الإحداثي لنظام معادلات؟',
|
|
'options' => [
|
|
'حل مشترك يُحقق كِلا المعادلتين معاً في آن واحد',
|
|
'المسافة الرأسية بين المنحنيين',
|
|
'معادلة خط التقارب الأفقي فقط',
|
|
'المقطع الصادي لأحد المنحنيين دون الآخر'
|
|
],
|
|
'correct_index' => 0,
|
|
'rewind_seconds' => $rewind,
|
|
'explanation' => 'نقطة تقاطع أي منحنيين هندسياً تمثل الزوج المرتب (x, y) الذي يحقق كلتا المعادلتين في آن واحد، وهو حل النظام.'
|
|
],
|
|
[
|
|
'timestamp_seconds' => $cp2,
|
|
'question_text' => 'عند حل نظام مكوّن من معادلة خطية ومعادلة تربيعية، ما هو أقصى عدد ممكن من الحلول الحقيقية؟',
|
|
'options' => [
|
|
'حلّان حقيقيان (نقطتا تقاطع)',
|
|
'ثلاثة حلول حقيقية دائماً',
|
|
'أربعة حلول حقيقية',
|
|
'لا يمكن أن يتقاطعا أبداً'
|
|
],
|
|
'correct_index' => 0,
|
|
'rewind_seconds' => $rewind,
|
|
'explanation' => 'المستقيم يقطع القطع المكافئ في نقطتين كحد أقصى (حلان)، أو يمسه في نقطة واحدة (حل واحد)، أو لا يقطعه (لا يوجد حل حقيقي).'
|
|
]
|
|
]
|
|
];
|
|
}
|
|
|
|
// Safe generic fallback: never inject unrelated advanced topics when Gemini is unavailable.
|
|
$topics = array_values(array_filter(array_map('strval', $curriculum['core_topics'] ?? [])));
|
|
$topic = $topics[0] ?? $title;
|
|
$topicTwo = $topics[1] ?? $topic;
|
|
return [
|
|
'timeline_chapters' => [
|
|
['start_seconds' => 0, 'end_seconds' => $c1, 'title' => 'تمهيد الدرس', 'summary' => "التعريف بموضوع {$topic} وأهداف درس {$title}."],
|
|
['start_seconds' => $c1, 'end_seconds' => $c2, 'title' => 'المفهوم الأساسي', 'summary' => "شرح الفكرة والقواعد المرتبطة بـ {$topic}."],
|
|
['start_seconds' => $c2, 'end_seconds' => $duration, 'title' => 'التطبيق والمراجعة', 'summary' => "تطبيق ما ورد في الدرس ومراجعة {$topicTwo}."]
|
|
],
|
|
'socratic_checkpoints' => [
|
|
[
|
|
'timestamp_seconds' => $cp1,
|
|
'question_text' => "ما الموضوع الذي يركّز عليه درس {$title}؟",
|
|
'options' => [$topic, $topicTwo, 'موضوع خارج نطاق هذا الدرس', 'لا شيء مما سبق'],
|
|
'correct_index' => 0,
|
|
'rewind_seconds' => $rewind,
|
|
'explanation' => "يركّز الدرس على {$topic} وفق سياق المنهاج المعتمد."
|
|
],
|
|
[
|
|
'timestamp_seconds' => $cp2,
|
|
'question_text' => "أي عبارة أقرب إلى الفكرة التي يجب مراجعتها في هذا الدرس؟",
|
|
'options' => [$topicTwo, $topic, 'قاعدة من مادة أخرى', 'معلومة غير مرتبطة بالدرس'],
|
|
'correct_index' => 0,
|
|
'rewind_seconds' => $rewind,
|
|
'explanation' => "هذه النقطة مرتبطة بنتائج درس {$title} وليست بمادة أخرى."
|
|
]
|
|
]
|
|
];
|
|
}
|
|
}
|