Update Saqel Platform: 2026-09-08 17:30:46

This commit is contained in:
Hamza-Ayed
2026-09-08 17:30:46 +03:00
parent 5fd1f32d4c
commit 902cf00152
12 changed files with 333 additions and 9 deletions
@@ -6,9 +6,59 @@ use App\Core\Database;
use App\Core\Request;
use App\Core\Response;
use App\Services\CliqPaymentService;
use App\Core\Security;
class SuperAdminController
{
public function directorates(Request $request, Response $response): void
{ $response->json(['status'=>'success','data'=>Database::select('SELECT * FROM directorates ORDER BY id DESC')]); }
public function saveDirectorate(Request $request, Response $response): void
{ $b=$request->getBody(); $id=(int)($b['id']??0); $name=trim((string)($b['name']??'')); $code=trim((string)($b['code']??'')); if($name===''||$code===''){ $response->status(422)->json(['status'=>'error','message'=>'اسم المديرية والرمز مطلوبان']); return; } if($id>0) Database::query('UPDATE directorates SET name=?,code=?,type=?,commander_name=?,phone=?,is_active=? WHERE id=?',[$name,$code,$b['type']??'ministry',$b['commander_name']??null,$b['phone']??null,(int)($b['is_active']??1),$id]); else Database::query('INSERT INTO directorates(uuid,code,name,type,commander_name,phone) VALUES(UUID(),?,?,?,?,?)',[$code,$name,$b['type']??'ministry',$b['commander_name']??null,$b['phone']??null]); $response->json(['status'=>'success']); }
public function toggleDirectorate(Request $request, Response $response): void
{ $b=$request->getBody(); Database::query("UPDATE directorates SET is_active=IF(is_active=1,0,1) WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); }
public function schools(Request $request, Response $response): void
{
$response->json(['status' => 'success', 'data' => Database::select(
"SELECT s.*, d.name AS directorate_name FROM schools s LEFT JOIN directorates d ON d.id=s.directorate_id ORDER BY s.id DESC"
)]);
}
public function saveSchool(Request $request, Response $response): void
{
$b = $request->getBody(); $id = (int)($b['id'] ?? 0);
$name = trim((string)($b['name'] ?? '')); $code = trim((string)($b['code'] ?? ''));
if ($name === '' || $code === '') { $response->status(422)->json(['status'=>'error','message'=>'اسم المدرسة والرمز مطلوبان']); return; }
if ($id > 0) {
Database::query("UPDATE schools SET name=?, code=?, type=?, directorate_id=?, director_name=?, phone=?, city=?, governorate=?, latitude=?, longitude=? WHERE id=?", [$name,$code,$b['type']??'center',$b['directorate_id']?:null,$b['director_name']??null,$b['phone']??null,$b['city']??'Amman',$b['governorate']??'العاصمة',$b['latitude']??null,$b['longitude']??null,$id]);
} else {
Database::query("INSERT INTO schools (uuid,code,name,type,directorate_id,director_name,phone,city,governorate,latitude,longitude) VALUES (UUID(),?,?,?,?,?,?,?,?,?,?)", [$code,$name,$b['type']??'center',$b['directorate_id']?:null,$b['director_name']??null,$b['phone']??null,$b['city']??'Amman',$b['governorate']??'العاصمة',$b['latitude']??null,$b['longitude']??null]);
$id=(int)Database::getInstance()->lastInsertId();
}
$response->json(['status'=>'success','data'=>['id'=>$id]]);
}
public function toggleSchool(Request $request, Response $response): void
{ $b=$request->getBody(); Database::query("UPDATE schools SET is_active=IF(is_active=1,0,1) WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); }
public function staff(Request $request, Response $response): void
{ $response->json(['status'=>'success','data'=>Database::select("SELECT sa.id,sa.uuid,sa.identity_id,sa.full_name,sa.role,sa.school_id,sa.directorate_id,sa.status,ai.phone_hash FROM staff_accounts sa JOIN auth_identities ai ON ai.id=sa.identity_id ORDER BY sa.id DESC")]); }
public function toggleStaff(Request $request, Response $response): void
{ $b=$request->getBody(); Database::query("UPDATE staff_accounts SET status=IF(status='active','suspended','active') WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); }
public function saveStaff(Request $request, Response $response): void
{
$b=$request->getBody(); $id=(int)($b['id']??0); $role=(string)($b['role']??''); $name=trim((string)($b['full_name']??'')); $phone=preg_replace('/\D+/','',(string)($b['phone']??''));
if($name===''||!in_array($role,['super_admin','school_admin','directorate_admin','supervisor'],true)){ $response->status(422)->json(['status'=>'error','message'=>'الاسم والدور مطلوبان']); return; }
if(str_starts_with($phone,'07'))$phone='962'.substr($phone,1); $hash=Security::blindIndex($phone);
$identity=Database::selectOne('SELECT id FROM auth_identities WHERE phone_hash=? LIMIT 1',[$hash]);
if(!$identity){ Database::query("INSERT INTO auth_identities(uuid,phone_number,phone_hash,status) VALUES(UUID(),?,?, 'active')",[Security::encrypt($phone),$hash]); $identity=['id'=>(int)Database::getInstance()->lastInsertId()]; }
if($id>0) Database::query('UPDATE staff_accounts SET full_name=?,role=?,school_id=?,directorate_id=?,status=\'active\' WHERE id=?',[$name,$role,$b['school_id']?:null,$b['directorate_id']?:null,$id]);
else Database::query("INSERT INTO staff_accounts(uuid,identity_id,full_name,role,school_id,directorate_id) VALUES(UUID(),?,?,?,?,?)",[$identity['id'],$name,$role,$b['school_id']?:null,$b['directorate_id']?:null]);
$response->json(['status'=>'success']);
}
public function overview(Request $request, Response $response): void
{
CliqPaymentService::ensureSchema();
@@ -98,6 +98,24 @@ class VideoController
return;
}
// No Cloudflare R2 write occurs before this fail-closed media gate.
// The report is generated from the real uploaded file while it remains
// in PHP's temporary storage.
$preflight = AiVideoAnalyzerService::auditUploadBeforeStorage(
$_FILES['video'],
$title,
trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? ''))
);
$auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight);
if (($preflight['decision'] ?? '') !== 'approved') {
$response->status(422)->json([
'status' => 'needs_review',
'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.',
'data' => ['audit_id' => $auditId, 'preflight_report' => $preflight],
]);
return;
}
try {
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title);
@@ -116,6 +134,9 @@ class VideoController
$uploadResult['duration'] ?? 0
]
);
if ($auditId) {
Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]);
}
// Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation (Silent Background Execution)
$aiReport = AiVideoAnalyzerService::processLessonAutonomously($lessonId);
@@ -128,6 +149,8 @@ class VideoController
'title' => $title,
'course_id' => $courseId,
'ai_analysis' => $aiReport
,'preflight_report' => $preflight,
'audit_id' => $auditId,
])
]);
} catch (\Throwable $e) {
@@ -138,6 +161,21 @@ class VideoController
}
}
private function recordUploadAudit(Request $request, int $courseId, array $file, array $report): ?int
{
try {
$hex = bin2hex(random_bytes(16));
$uuid = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) . '-a' . substr($hex, 17, 3) . '-' . substr($hex, 20);
return (int)Database::insert(
'INSERT INTO video_upload_audits (uuid, teacher_id, course_id, file_sha256, original_filename, decision, report_json) VALUES (?, ?, ?, ?, ?, ?, ?)',
[$uuid, (int)$request->user_id, $courseId ?: null, hash_file('sha256', (string)($file['tmp_name'] ?? '')), (string)($file['name'] ?? 'video'), (string)($report['decision'] ?? 'needs_manual_review'), json_encode($report, JSON_UNESCAPED_UNICODE)]
);
} catch (\Throwable $e) {
error_log('Video upload audit persistence failed: ' . $e->getMessage());
return null;
}
}
/**
* Create video entity on Bunny Stream
* POST /api/teacher/videos/bunny-create
@@ -21,6 +21,69 @@ use App\Core\Security;
class AiVideoAnalyzerService
{
/**
* 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 = trim((string)getenv('GEMINI_API_KEY'));
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.
$frame = tempnam(sys_get_temp_dir(), 'saqel_audit_') . '.jpg';
$ffmpeg = trim((string)@shell_exec('command -v ffmpeg 2>/dev/null'));
if ($ffmpeg === '' || @shell_exec(escapeshellarg($ffmpeg) . ' -y -ss ' . escapeshellarg((string)max(1, (int)($duration / 3))) . ' -i ' . escapeshellarg($path) . ' -frames:v 1 -q:v 3 ' . escapeshellarg($frame) . ' 2>/dev/null') === null || !is_file($frame)) {
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],
['inline_data' => ['mime_type' => 'image/jpeg', 'data' => base64_encode((string)file_get_contents($frame))],],
],
]],
'generationConfig' => ['responseMimeType' => 'application/json', 'temperature' => 0.1],
];
$ch = curl_init('https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest: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); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
$text = json_decode((string)$body, true)['candidates'][0]['content']['parts'][0]['text'] ?? '';
$report = json_decode($text, true);
if ($code !== 200 || !is_array($report) || !in_array($report['decision'] ?? '', ['approved','needs_manual_review','rejected'], true)) throw new \RuntimeException('استجابة Gemini غير صالحة');
$report['duration_seconds'] = $duration;
if (($report['visual_clarity_score'] ?? 0) < 70 || ($report['pedagogical_readiness_score'] ?? 0) < 70) $report['decision'] = 'needs_manual_review';
return $report;
} catch (\Throwable $e) {
return ['decision' => 'needs_manual_review', 'reason' => 'تعذر إكمال تحليل Gemini؛ لم يتم رفع الفيديو'];
} finally { @unlink($frame); }
}
/**
* التحليل الآلي المستقل للفيديو وإنشاء الفصول ونقاط الفحص السقراطي في قاعدة البيانات
*
@@ -138,8 +201,8 @@ class AiVideoAnalyzerService
{
try {
$geminiKey = getenv('GEMINI_API_KEY');
$alignmentScore = 96.50;
$clarityScore = 94.00;
$alignmentScore = 0.0;
$clarityScore = 0.0;
$outcomes = [
"استيعاب المفهوم الرياضي/العلمي لدرس {$lessonTitle}",
"تطبيق القواعد والقوانين المعتمدة في كتاب الوزارة",
@@ -151,8 +214,8 @@ class AiVideoAnalyzerService
'application' => 30,
'analysis' => 10
];
$critique = "الحصة التعليمية مطابقة لمعايير المنهاج الوزاري المعتمد وتغطي نتاجات التعلم الأساسية بكفاءة عالية.";
$status = 'approved_official';
$critique = "لم يكتمل تدقيق Gemini؛ تتطلب الحصة مراجعة بشرية.";
$status = 'needs_manual_review';
if (!empty($geminiKey)) {
$prompt = "أنت كبير المشرفين التربويين في وزارة التربية والتعليم الأردنية لمنصة صَقِل.
@@ -241,11 +304,7 @@ class AiVideoAnalyzerService
error_log("Pedagogical quality assessment error: " . $e->getMessage());
}
return [
'alignment_score' => 96.0,
'clarity_score' => 94.0,
'status' => 'approved_official'
];
return ['alignment_score' => 0.0, 'clarity_score' => 0.0, 'status' => 'needs_manual_review'];
}
/**