diff --git a/README.md b/README.md index 5118916..18a7bf2 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ ## 🏛️ الهيكل التقني للمشروع (Tech Stack) -- **Backend:** PHP 8.3+ / Laravel 11 / RESTful APIs +- **Backend:** PHP 8.3+ / Native PHP REST API with PDO and a lightweight internal Router - **Database:** SQLite (Local Development/Testing) / MySQL 8 (Production) - **AI Engine:** Google Gemini Flash API (Multimodal / Socratic Tutor / Concept Diagnosis) - **Video & DRM:** Bunny Stream (HLS + Dynamic Watermark + DRM) -- **Deployment:** Docker & Docker Compose (`saqel_app`, `saqel_nginx`, `saqel_mysql`, `saqel_queue`) +- **Deployment:** PHP-FPM/Nginx on the production server; Cloudflare R2 for video and static media --- @@ -22,11 +22,14 @@ cd backend php artisan test ``` -### 2. تشغيل السيرفر عبر Docker: +### 2. تشغيل الـAPI محليًا: ```bash -docker compose up -d --build +cd backend +php -S 127.0.0.1:8000 -t public ``` +الإنتاج يعمل عبر PHP-FPM وNginx مباشرة، ولا يعتمد على Laravel أو Docker. + --- ## 📂 مسار التوثيق والتحليلات diff --git a/apps/student_app/lib/data/models/lesson_model.dart b/apps/student_app/lib/data/models/lesson_model.dart index 12f38ce..5b984fc 100644 --- a/apps/student_app/lib/data/models/lesson_model.dart +++ b/apps/student_app/lib/data/models/lesson_model.dart @@ -86,19 +86,26 @@ class GuardianChildModel { }); factory GuardianChildModel.fromJson(Map json) { + final student = json['student'] is Map + ? Map.from(json['student']) + : {}; + final metrics = json['metrics'] is Map + ? Map.from(json['metrics']) + : {}; + final source = {...student, ...metrics, ...json}; return GuardianChildModel( - id: json['id'] is int ? json['id'] : int.tryParse(json['id']?.toString() ?? '0') ?? 0, - uuid: json['uuid']?.toString() ?? '', - name: json['full_name']?.toString() ?? json['name']?.toString() ?? 'الطالب', - nationalId: json['national_id']?.toString(), - gradeLevel: json['grade_level']?.toString(), - stream: json['stream']?.toString(), - schoolName: json['school_name']?.toString() ?? 'مدرسة معتمدة', - readinessScore: json['tawjihi_readiness_score'] != null - ? double.tryParse(json['tawjihi_readiness_score'].toString()) ?? 0.0 - : (json['readiness_score'] != null ? double.tryParse(json['readiness_score'].toString()) ?? 0.0 : 0.0), - examsPassed: json['exams_passed_count'] is int ? json['exams_passed_count'] : int.tryParse(json['exams_passed_count']?.toString() ?? '0') ?? 0, - examsTotal: json['exams_total_count'] is int ? json['exams_total_count'] : int.tryParse(json['exams_total_count']?.toString() ?? '0') ?? 0, + id: source['id'] is int ? source['id'] : int.tryParse(source['id']?.toString() ?? '0') ?? 0, + uuid: source['uuid']?.toString() ?? '', + name: source['full_name']?.toString() ?? source['name']?.toString() ?? 'الطالب', + nationalId: source['national_id']?.toString(), + gradeLevel: source['grade_level']?.toString(), + stream: source['stream']?.toString(), + schoolName: source['school_name']?.toString() ?? 'مدرسة معتمدة', + readinessScore: source['readiness_score'] != null + ? double.tryParse(source['readiness_score'].toString()) ?? 0.0 + : (source['tawjihi_readiness_score'] != null ? double.tryParse(source['tawjihi_readiness_score'].toString()) ?? 0.0 : 0.0), + examsPassed: source['exams_passed_count'] is int ? source['exams_passed_count'] : int.tryParse(source['exams_passed_count']?.toString() ?? '0') ?? 0, + examsTotal: source['exams_total_count'] is int ? source['exams_total_count'] : int.tryParse(source['exams_total_count']?.toString() ?? '0') ?? 0, ); } } diff --git a/apps/student_app/lib/data/models/socratic_checkpoint_model.dart b/apps/student_app/lib/data/models/socratic_checkpoint_model.dart index 0705add..05cc2f0 100644 --- a/apps/student_app/lib/data/models/socratic_checkpoint_model.dart +++ b/apps/student_app/lib/data/models/socratic_checkpoint_model.dart @@ -1,6 +1,15 @@ +import '../../core/config/app_config.dart'; + +String _absolutePlaybackUrl(String value) { + if (value.isEmpty || value.startsWith('http://') || value.startsWith('https://')) return value; + final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), ''); + return '$base/${value.replaceFirst(RegExp(r'^/+'), '')}'; +} + /// Model representing an In-Video Socratic Gatekeeping Checkpoint (فحص الفهم السقراطي) class SocraticCheckpointModel { final int id; + final int questionId; final String questionText; final int timestampSeconds; final int rewindSecondsOnFail; @@ -10,6 +19,7 @@ class SocraticCheckpointModel { const SocraticCheckpointModel({ required this.id, + this.questionId = 0, required this.questionText, required this.timestampSeconds, this.rewindSecondsOnFail = 45, @@ -29,7 +39,12 @@ class SocraticCheckpointModel { } return SocraticCheckpointModel( - id: (json['id'] as num?)?.toInt() ?? 0, + id: json['id'] is num + ? (json['id'] as num).toInt() + : int.tryParse((json['id'] ?? json['exam_id'] ?? '0').toString()) ?? 0, + questionId: json['question_id'] is num + ? (json['question_id'] as num).toInt() + : int.tryParse(json['question_id']?.toString() ?? '0') ?? 0, questionText: json['question']?.toString() ?? json['question_text']?.toString() ?? 'سؤال فحص الفهم السقراطي', timestampSeconds: (json['timestamp_seconds'] as num?)?.toInt() ?? 120, rewindSecondsOnFail: (json['rewind_seconds'] as num?)?.toInt() ?? 45, @@ -105,9 +120,9 @@ class LessonPlaybackData { } } - final vidUrl = playback['hls_url']?.toString() ?? - playback['stream_url']?.toString() ?? - playback['video_url']?.toString() ?? ''; + final vidUrl = _absolutePlaybackUrl( + playback['hls_url']?.toString() ?? playback['stream_url']?.toString() ?? playback['video_url']?.toString() ?? '', + ); return LessonPlaybackData( lessonId: (lesson['id'] as num?)?.toInt() ?? 0, @@ -143,9 +158,9 @@ class LessonVersionModel { factory LessonVersionModel.fromJson(Map json) { final playback = json['playback'] is Map ? json['playback'] as Map : {}; - final vidUrl = playback['hls_url']?.toString() ?? - playback['stream_url']?.toString() ?? - playback['video_url']?.toString() ?? ''; + final vidUrl = _absolutePlaybackUrl( + playback['hls_url']?.toString() ?? playback['stream_url']?.toString() ?? playback['video_url']?.toString() ?? '', + ); return LessonVersionModel( lessonId: (json['lesson_id'] as num?)?.toInt() ?? 0, diff --git a/apps/student_app/lib/data/repositories/app_repositories.dart b/apps/student_app/lib/data/repositories/app_repositories.dart index b91aa1c..db6177e 100644 --- a/apps/student_app/lib/data/repositories/app_repositories.dart +++ b/apps/student_app/lib/data/repositories/app_repositories.dart @@ -168,8 +168,8 @@ class GuardianRepository { Future> getDashboardChildren() async { final res = await _api.get(AppConfig.guardianDashboardEndpoint); - if (res is Map && res['data'] is List) { - return (res['data'] as List) + if (res is Map && res['data'] is Map && (res['data']['children'] is List)) { + return (res['data']['children'] as List) .map((item) => GuardianChildModel.fromJson(Map.from(item))) .toList(); } diff --git a/apps/student_app/lib/data/repositories/curriculum_repository.dart b/apps/student_app/lib/data/repositories/curriculum_repository.dart index 27dd4d3..730673e 100644 --- a/apps/student_app/lib/data/repositories/curriculum_repository.dart +++ b/apps/student_app/lib/data/repositories/curriculum_repository.dart @@ -137,4 +137,19 @@ class CurriculumRepository { throw ApiException('فشل جلب بيانات تشغيل الدرس من الخادم'); } + + Future saveProgress({required int lessonId, required int positionSeconds, required int watchedSeconds}) async { + await _api.post('/api/student/lessons/$lessonId/progress', body: { + 'position_seconds': positionSeconds, + 'watched_seconds': watchedSeconds, + }); + } + + Future submitCheckpoint({required int examId, required int questionId, required int optionId}) async { + await _api.post('/api/exams/$examId/submit', body: { + 'answers': [ + {'question_id': questionId, 'selected_option_id': optionId} + ], + }); + } } diff --git a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart index 27014f9..4f29d71 100644 --- a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart +++ b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart @@ -87,50 +87,8 @@ class VideoPlaybackCubit extends Cubit { isPlaying: true, )); } catch (e) { - AppLogger.log('Playback API notice for ${lesson.title}: $e (Generating dynamic Socratic session)', tag: 'VIDEO_CUBIT'); - // Create dynamic fallback Socratic session with lesson checkpoints - final fallbackPlayback = LessonPlaybackData( - lessonId: 1, - title: lesson.title, - durationSeconds: lesson.durationSeconds > 0 ? lesson.durationSeconds : 600, - videoUrl: 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson', - availableVersions: const [ - LessonVersionModel( - lessonId: 1, - isAi: true, - teacherName: 'المعلم الافتراضي بالذكاء الاصطناعي', - schoolName: 'وزارة التربية والتعليم', - label: 'شرح المنهاج المعتمد 🤖', - isRecommended: true, - videoUrl: '', - ), - ], - checkpoints: [ - SocraticCheckpointModel( - id: 1, - questionText: lesson.outcomes.isNotEmpty - ? 'وفقاً لنتاجات هذا الدرس (${lesson.outcomes.first}): ما هو المفهوم الجوهري الواجب إتقانه هنا؟' - : 'ما هو المبدأ الأساسي المشروح في هذا المبحث؟', - timestampSeconds: 15, - rewindSecondsOnFail: 20, - hint: 'راجع نتاجات التعلم الموضحة أسفل الفيديو بدقة.', - pedagogicalExplanation: 'الإتقان السقراطي يتطلب الفهم المفاهيمي العميق قبل الانتقال للمسائل الحسابية.', - options: const [ - SocraticOptionModel(id: 1, text: 'الفهم المنهجي والتطبيق المباشر للقواعد', isCorrect: true), - SocraticOptionModel(id: 2, text: 'الحفظ المجرد دون فهم', isCorrect: false), - SocraticOptionModel(id: 3, text: 'تخطي المفهوم', isCorrect: false), - ], - ), - ], - ); - - emit(VideoPlaybackReady( - playbackData: fallbackPlayback, - lessonItem: lesson, - subject: subject, - currentPositionSeconds: 0, - isPlaying: true, - )); + AppLogger.log('Playback API failed for ${lesson.title}: $e', tag: 'VIDEO_CUBIT'); + emit(VideoPlaybackError('لا يوجد فيديو منشور لهذا الدرس حاليًا. يرجى المحاولة لاحقًا.')); } } @@ -170,6 +128,20 @@ class VideoPlaybackCubit extends Cubit { } } + Future saveProgress({required int positionSeconds, required int watchedSeconds}) async { + final currentState = state; + if (currentState is! VideoPlaybackReady || currentState.playbackData.lessonId <= 0) return; + try { + await _repo.saveProgress( + lessonId: currentState.playbackData.lessonId, + positionSeconds: positionSeconds, + watchedSeconds: watchedSeconds, + ); + } catch (e) { + AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT'); + } + } + /// Submit Socratic Checkpoint Answer bool submitCheckpointAnswer(SocraticOptionModel selectedOption) { final currentState = state; @@ -187,6 +159,11 @@ class VideoPlaybackCubit extends Cubit { passedCheckpointIds: updatedPassed, remediationNotice: 'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀', )); + if (cp.id > 0) { + _repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) { + AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT'); + }); + } return true; } else { // Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds @@ -198,6 +175,11 @@ class VideoPlaybackCubit extends Cubit { isPlaying: true, remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄', )); + if (cp.id > 0 && cp.questionId > 0) { + _repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) { + AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT'); + }); + } return false; } } diff --git a/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart b/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart index 327a618..7d8f811 100644 --- a/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart +++ b/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart @@ -10,6 +10,7 @@ import '../../../logic/cubits/auth_cubit.dart'; import '../../../logic/cubits/video_playback_cubit.dart'; import '../../widgets/luxury_widgets.dart'; import '../../widgets/socratic_dialog.dart'; +import 'package:video_player/video_player.dart'; class SocraticVideoPlayerScreen extends StatefulWidget { final CurriculumLessonItemModel lesson; @@ -28,6 +29,8 @@ class SocraticVideoPlayerScreen extends StatefulWidget { class _SocraticVideoPlayerScreenState extends State with SingleTickerProviderStateMixin { Timer? _playbackTicker; late AnimationController _watermarkController; + VideoPlayerController? _videoController; + bool _isVideoInitialized = false; @override void initState() { @@ -40,14 +43,14 @@ class _SocraticVideoPlayerScreenState extends State w duration: const Duration(seconds: 18), )..repeat(reverse: true); - // Ticker simulating video playback time progression and Socratic checkpoint gatekeeping + // Read the real player clock and periodically persist progress. _playbackTicker = Timer.periodic(const Duration(seconds: 1), (timer) { final cubit = context.read(); final state = cubit.state; - if (state is VideoPlaybackReady && state.isPlaying && state.activeCheckpoint == null) { - if (state.currentPositionSeconds < state.playbackData.durationSeconds) { - cubit.updatePosition(state.currentPositionSeconds + 1); - } + if (state is VideoPlaybackReady && _videoController?.value.isInitialized == true) { + final position = _videoController!.value.position.inSeconds; + if (state.activeCheckpoint == null) cubit.updatePosition(position); + cubit.saveProgress(positionSeconds: position, watchedSeconds: position); } }); } @@ -56,6 +59,7 @@ class _SocraticVideoPlayerScreenState extends State w void dispose() { _playbackTicker?.cancel(); _watermarkController.dispose(); + _videoController?.dispose(); super.dispose(); } @@ -111,6 +115,31 @@ class _SocraticVideoPlayerScreenState extends State w body: BlocConsumer( listener: (context, state) { if (state is VideoPlaybackReady) { + // Initialize Video Player if not yet initialized + if (_videoController == null) { + _videoController = VideoPlayerController.networkUrl(Uri.parse(state.playbackData.videoUrl)) + ..initialize().then((_) { + if (mounted) { + setState(() { + _isVideoInitialized = true; + }); + if (state.isPlaying && state.activeCheckpoint == null) { + _videoController!.play(); + } + } + }); + } else if (_isVideoInitialized) { + // Sync play/pause state + if (state.isPlaying && state.activeCheckpoint == null) { + _videoController!.play(); + } else { + _videoController!.pause(); + } + if (state.currentPositionSeconds != _videoController!.value.position.inSeconds) { + _videoController!.seekTo(Duration(seconds: state.currentPositionSeconds)); + } + } + // Trigger Socratic Checkpoint Modal automatically when active if (state.activeCheckpoint != null) { showDialog( @@ -187,20 +216,35 @@ class _SocraticVideoPlayerScreenState extends State w borderRadius: BorderRadius.circular(24), child: Stack( children: [ - // Video Background Scene Simulation & Visualizer - Container( - decoration: const BoxDecoration( - gradient: RadialGradient( - center: Alignment(0.0, -0.3), - radius: 1.3, - colors: [ - Color(0xFF0F2342), - Color(0xFF060B14), - ], - ), - ), - child: Stack( - children: [ + // Video Background Scene + Stack( + children: [ + if (_videoController != null && _isVideoInitialized) + Positioned.fill( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: _videoController!.value.size.width, + height: _videoController!.value.size.height, + child: VideoPlayer(_videoController!), + ), + ), + ) + else + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + gradient: RadialGradient( + center: Alignment(0.0, -0.3), + radius: 1.3, + colors: [ + Color(0xFF0F2342), + Color(0xFF060B14), + ], + ), + ), + ), + ), // Grid Overlay Effect Positioned.fill( child: Opacity( @@ -315,7 +359,6 @@ class _SocraticVideoPlayerScreenState extends State w ), ], ), - ), // Dynamic Forensic Anti-Piracy Watermark (Moves continuously) AnimatedBuilder( diff --git a/apps/student_app/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/student_app/macos/Flutter/GeneratedPluginRegistrant.swift index 5a0a476..55ab879 100644 --- a/apps/student_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/student_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,9 +8,11 @@ import Foundation import device_info_plus import flutter_secure_storage_macos import shared_preferences_foundation +import video_player_avfoundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) } diff --git a/apps/student_app/pubspec.lock b/apps/student_app/pubspec.lock index cf9fe61..bf689f5 100644 --- a/apps/student_app/pubspec.lock +++ b/apps/student_app/pubspec.lock @@ -73,6 +73,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -216,6 +224,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + html: + dependency: transitive + description: + name: html + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.dev" + source: hosted + version: "0.15.7" http: dependency: "direct main" description: @@ -316,10 +332,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -332,10 +348,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" nested: dependency: transitive description: @@ -561,10 +577,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" typed_data: dependency: transitive description: @@ -581,6 +597,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0" + url: "https://pub.dev" + source: hosted + version: "2.9.5" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "436fd029bd1c1e303b2d95ebd76948893f3c28dab286e7235ba9dd7b22533bf0" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce" + url: "https://pub.dev" + source: hosted + version: "6.9.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: diff --git a/apps/student_app/pubspec.yaml b/apps/student_app/pubspec.yaml index 78fe860..4b1220b 100644 --- a/apps/student_app/pubspec.yaml +++ b/apps/student_app/pubspec.yaml @@ -34,6 +34,7 @@ dependencies: device_info_plus: ^10.1.0 google_fonts: ^6.2.1 intl: ^0.19.0 + video_player: ^2.11.1 dev_dependencies: flutter_test: diff --git a/backend/app/Controllers/GuardianController.php b/backend/app/Controllers/GuardianController.php index 0f573d2..017e1ce 100644 --- a/backend/app/Controllers/GuardianController.php +++ b/backend/app/Controllers/GuardianController.php @@ -25,27 +25,6 @@ class GuardianController [$guardianId] ); - // If no children linked (for demo purposes, link up to 3 students so the multi-child UI can be tested) - if (empty($children)) { - $demoStudents = Database::select("SELECT id FROM students LIMIT 3"); - if (!empty($demoStudents)) { - foreach ($demoStudents as $demoStudent) { - Database::insert( - "INSERT IGNORE INTO guardian_students (guardian_id, student_id, relationship_type) VALUES (?, ?, 'father')", - [$guardianId, $demoStudent['id']] - ); - } - // Re-fetch - $children = Database::select( - "SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream - FROM students s - JOIN guardian_students gs ON gs.student_id = s.id - WHERE gs.guardian_id = ?", - [$guardianId] - ); - } - } - $dashboardData = []; foreach ($children as $child) { diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index b3aeb04..fa8e32d 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -400,67 +400,9 @@ class VideoController } if (!$lesson) { - // Construct a complete, standalone live playback payload directly from the requested lesson - $cleanTitle = str_replace(['_', '-'], ' ', $rawId); - - $response->json([ - 'status' => 'success', - 'data' => [ - 'lesson' => [ - 'id' => 1, - 'course_id' => 0, - 'title' => !empty($rawId) ? $cleanTitle : 'الدرس التفاعلي المعتمد', - 'duration_seconds' => 900, - 'is_free_preview' => true, - 'storage_type' => 'api_upload' - ], - 'playback' => [ - 'storage_type' => 'api_upload', - 'stream_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson', - 'hls_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson', - 'video_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson', - 'is_direct' => true - ], - 'available_versions' => [ - [ - 'lesson_id' => 1, - 'is_ai' => true, - 'teacher_name' => 'الذكاء الاصطناعي الوزاري', - 'school_name' => 'وزارة التربية والتعليم', - 'label' => 'شرح الذكاء الاصطناعي المعتمد 🤖', - 'is_recommended' => true, - 'playback' => [ - 'video_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson' - ] - ] - ], - 'chapters' => [], - 'checkpoints' => [ - [ - 'exam_id' => 1, - 'timestamp_seconds' => 15, - 'rewind_on_fail_seconds' => 30, - 'question_text' => 'فحص الفهم السقراطي: ما هو المفهوم الأساسي المشروح في هذا الدرس؟', - 'explanation' => 'التركيز على نتاجات التعلم يضمن استيعاب المتطلبات الوزارية بدقة.', - 'options' => [ - ['id' => 1, 'text' => 'تطبيق القواعد والمفاهيم العلمية/الرياضية بدقة', 'is_correct' => true], - ['id' => 2, 'text' => 'تجاهل الخطوات التحليلية', 'is_correct' => false], - ['id' => 3, 'text' => 'الحفظ المجرد دون فهم سياقي', 'is_correct' => false], - ] - ], - [ - 'exam_id' => 2, - 'timestamp_seconds' => 45, - 'rewind_on_fail_seconds' => 25, - 'question_text' => 'فحص الفهم التعمقي: كيف يتم التحقق من صحة الحل وتجنب الأخطاء الشائعة؟', - 'explanation' => 'التعويض المباشر والتحليل المنطقي يثبتان صحة النتائج.', - 'options' => [ - ['id' => 4, 'text' => 'بالتعويض المباشر في المسألة الأصلية والتحقق من التكافؤ', 'is_correct' => true], - ['id' => 5, 'text' => 'بالاعتماد على التخمين', 'is_correct' => false], - ] - ] - ] - ] + $response->status(404)->json([ + 'status' => 'error', + 'message' => 'الدرس غير موجود أو لم يتم نشر الفيديو الخاص به بعد' ]); return; } @@ -497,7 +439,8 @@ class VideoController } $checkpoints[] = [ - 'exam_id' => (int)$ex['exam_id'], + 'exam_id' => (int)$ex['exam_id'], + 'question_id' => $q ? (int)$q['id'] : 0, 'timestamp_seconds' => (int)$ex['timestamp_seconds'], 'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'], 'question_text' => $q['question_text'] ?? 'سؤال فحص فهم الفكرة:', @@ -512,34 +455,6 @@ class VideoController ]; } - if (empty($checkpoints)) { - $checkpoints = [ - [ - 'exam_id' => 1, - 'timestamp_seconds' => 15, - 'rewind_on_fail_seconds' => 30, - 'question_text' => 'فحص الفهم الأولي: ما هو المفهوم الأساسي المشروح في هذه الفقرة من الدرس؟', - 'explanation' => 'التركيز على نتاجات التعلم يضمن استيعاب المتطلبات الوزارية بدقة.', - 'options' => [ - ['id' => 1, 'text' => 'تطبيق القواعد والمفاهيم العلمية/الرياضية بدقة', 'is_correct' => true], - ['id' => 2, 'text' => 'تجاهل الخطوات التحليلية', 'is_correct' => false], - ['id' => 3, 'text' => 'الحفظ المجرد دون فهم سياقي', 'is_correct' => false], - ] - ], - [ - 'exam_id' => 2, - 'timestamp_seconds' => 45, - 'rewind_on_fail_seconds' => 25, - 'question_text' => 'فحص الفهم التعمقي: كيف يتم التحقق من صحة الحل وتجنب الأخطاء الشائعة؟', - 'explanation' => 'التعويض المباشر والتحليل المنطقي يثبتان صحة النتائج.', - 'options' => [ - ['id' => 4, 'text' => 'بالتعويض المباشر في المسألة الأصلية والتحقق من التكافؤ', 'is_correct' => true], - ['id' => 5, 'text' => 'بالاعتماد على التخمين', 'is_correct' => false], - ] - ] - ]; - } - $storageType = $lesson['storage_type'] ?? 'bunny_stream'; $playbackInfo = []; @@ -553,7 +468,11 @@ class VideoController ]; } else { // Bunny Stream Signed Playback - $bunnyId = $lesson['bunny_video_id'] ?: 'mock-bunny-guid-2026'; + $bunnyId = $lesson['bunny_video_id'] ?: ''; + if ($bunnyId === '') { + $response->status(409)->json(['status' => 'error', 'message' => 'الفيديو لم يجهز للبث بعد']); + return; + } $signedData = VideoService::generateBunnySignedPlayback($bunnyId, 10800); // 3-hour token $playbackInfo = array_merge(['storage_type' => 'bunny_stream'], $signedData); } @@ -587,7 +506,8 @@ class VideoController 'video_url' => $ver['ai_video_url'] ?: ($ver['hls_url'] ?: ('/api/videos/stream/' . $ver['video_uuid'])) ]; } else { - $bId = $ver['bunny_video_id'] ?: 'mock-bunny-guid-2026'; + $bId = $ver['bunny_video_id'] ?: ''; + if ($bId === '') continue; $signed = VideoService::generateBunnySignedPlayback($bId, 10800); $vPlayback = array_merge(['storage_type' => 'bunny_stream', 'video_url' => $signed['hls_url']], $signed); } @@ -637,6 +557,38 @@ class VideoController ]); } + /** Save real playback progress; the client never owns the readiness score. */ + public function saveProgress(Request $request, Response $response): void + { + VideoService::ensureSchema(); + $lessonId = (int)$request->getParam('id'); + $body = $request->getBody(); + $position = max(0, (int)($body['position_seconds'] ?? 0)); + $watched = max(0, (int)($body['watched_seconds'] ?? $position)); + $lesson = Database::selectOne('SELECT id, duration_seconds FROM lessons WHERE id = ? LIMIT 1', [$lessonId]); + if (!$lesson) { + $response->status(404)->json(['status' => 'error', 'message' => 'الدرس غير موجود']); + return; + } + + $duration = max(1, (int)$lesson['duration_seconds']); + $percentage = min(100, round(($position / $duration) * 100, 2)); + $completed = $percentage >= 90 ? 1 : 0; + Database::query( + "INSERT INTO lesson_progress (student_id, lesson_id, position_seconds, watched_seconds, completion_percentage, is_completed, last_seen_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, NOW(), CASE WHEN ? = 1 THEN NOW() ELSE NULL END) + ON DUPLICATE KEY UPDATE + position_seconds = VALUES(position_seconds), + watched_seconds = GREATEST(watched_seconds, VALUES(watched_seconds)), + completion_percentage = VALUES(completion_percentage), + is_completed = GREATEST(is_completed, VALUES(is_completed)), + last_seen_at = NOW(), + completed_at = CASE WHEN is_completed = 1 OR VALUES(is_completed) = 1 THEN COALESCE(completed_at, NOW()) ELSE completed_at END", + [$request->user_id, $lessonId, $position, $watched, $percentage, $completed, $completed] + ); + $response->json(['status' => 'success', 'data' => ['completion_percentage' => $percentage, 'is_completed' => (bool)$completed]]); + } + /** * Webhook listener for Bunny Stream encoding notifications * POST /api/webhooks/bunny diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php index 729a7e5..f81effb 100644 --- a/backend/app/Services/VideoService.php +++ b/backend/app/Services/VideoService.php @@ -158,6 +158,17 @@ class VideoService $r2Key = "videos/{$courseId}/{$targetFileName}"; $r2VideoUrl = self::uploadToR2($targetPath, $r2Key, $mime); + // The player must receive a complete HLS package, not only the source MP4. + // Upload the playlist and every segment using the same relative layout so + // relative segment references in index.m3u8 continue to resolve on R2. + if (!empty($hlsResult['hls_url'])) { + $hlsDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid; + $r2HlsPlaylist = self::uploadHlsDirectoryToR2($hlsDir, $courseId, $videoUuid); + if ($r2HlsPlaylist) { + $hlsResult['hls_url'] = $r2HlsPlaylist; + } + } + $thumbnailPath = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid . '/thumbnail.jpg'; if (file_exists($thumbnailPath)) { $r2ThumbKey = "hls/{$courseId}/{$videoUuid}/thumbnail.jpg"; @@ -393,6 +404,8 @@ class VideoService { $config = self::getBunnyConfig(); if (empty($config['api_key'])) { + throw new \RuntimeException('Bunny Stream غير مهيأ: أضف مفاتيح الوصول قبل إنشاء فيديو سحابي.'); + /* $mockGuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), @@ -409,6 +422,7 @@ class VideoService 'direct_embed' => "https://{$config['embed_host']}/embed/{$config['library_id']}/{$mockGuid}", 'hls_playlist' => "https://{$config['pull_zone_host']}/{$mockGuid}/playlist.m3u8" ]; + */ } $url = "https://video.bunnycdn.com/library/{$config['library_id']}/videos"; @@ -581,10 +595,33 @@ class VideoService $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); - if ($httpCode >= 200 && $httpCode < 300) { + if ($httpCode >= 200 && $httpCode < 300 && !empty($config['public_url'])) { return rtrim($config['public_url'], '/') . '/' . ltrim($r2Key, '/'); } return null; } + + /** Upload a complete HLS directory and return the public playlist URL. */ + private static function uploadHlsDirectoryToR2(string $directory, int $courseId, string $videoUuid): ?string + { + if (!is_dir($directory)) return null; + + $playlistUrl = null; + foreach (glob($directory . '/*') ?: [] as $filePath) { + if (!is_file($filePath)) continue; + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $contentType = match ($extension) { + 'm3u8' => 'application/vnd.apple.mpegurl', + 'ts' => 'video/mp2t', + 'jpg', 'jpeg' => 'image/jpeg', + default => 'application/octet-stream', + }; + $key = "hls/{$courseId}/{$videoUuid}/" . basename($filePath); + $url = self::uploadToR2($filePath, $key, $contentType); + if ($extension === 'm3u8' && $url) $playlistUrl = $url; + } + + return $playlistUrl; + } } diff --git a/backend/database_schema.sql b/backend/database_schema.sql index df5089d..c840574 100644 --- a/backend/database_schema.sql +++ b/backend/database_schema.sql @@ -226,7 +226,11 @@ CREATE TABLE IF NOT EXISTS `lessons` ( -- المعلم الحقيقي (Teacher Video) `video_uuid` VARCHAR(64) DEFAULT NULL, + `storage_type` ENUM('bunny_stream', 'api_upload', 'external_url') NOT NULL DEFAULT 'bunny_stream', + `local_path` VARCHAR(500) DEFAULT NULL, `hls_url` VARCHAR(500) DEFAULT NULL, + `thumbnail_url` VARCHAR(500) DEFAULT NULL, + `encoding_status` ENUM('pending', 'processing', 'ready', 'failed') NOT NULL DEFAULT 'ready', `timeline_chapters_json` JSON DEFAULT NULL, `bunny_video_id` VARCHAR(100) DEFAULT NULL, `duration_seconds` INT UNSIGNED NOT NULL DEFAULT 0, @@ -249,6 +253,46 @@ CREATE TABLE IF NOT EXISTS `lessons` ( CONSTRAINT `fk_lessons_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ------------------------------------------------------------------------------ +-- 10.6. Table: lesson_progress (تقدم المشاهدة الفعلي لكل طالب) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `lesson_progress` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `student_id` BIGINT UNSIGNED NOT NULL, + `lesson_id` BIGINT UNSIGNED NOT NULL, + `position_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `watched_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `completion_percentage` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `is_completed` TINYINT(1) NOT NULL DEFAULT 0, + `last_seen_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, + `completed_at` TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_progress_student_lesson` (`student_id`, `lesson_id`), + KEY `idx_progress_lesson` (`lesson_id`), + CONSTRAINT `fk_progress_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_progress_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- ------------------------------------------------------------------------------ +-- 10.7. Table: student_mastery_analytics (المؤشر التراكمي للطالب) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `student_mastery_analytics` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `student_id` BIGINT UNSIGNED NOT NULL, + `course_id` BIGINT UNSIGNED NOT NULL, + `subject_id` BIGINT UNSIGNED NOT NULL, + `mastery_percentage` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `tawjihi_readiness_score` DECIMAL(5,2) NOT NULL DEFAULT 0.00, + `exams_passed_count` INT UNSIGNED NOT NULL DEFAULT 0, + `exams_total_count` INT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_mastery_student_course` (`student_id`, `course_id`), + CONSTRAINT `fk_mastery_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mastery_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_mastery_subject` FOREIGN KEY (`subject_id`) REFERENCES `subjects` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ------------------------------------------------------------------------------ -- 10.5. Table: lesson_resources (المصادر المرفقة للدرس: كتب، ملخصات، أوراق عمل PDF) -- ------------------------------------------------------------------------------ @@ -361,6 +405,7 @@ CREATE TABLE IF NOT EXISTS `exam_attempts` ( `time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0, `weak_topics_json` JSON DEFAULT NULL, `ai_diagnostic_report` TEXT DEFAULT NULL, + `completed_at` TIMESTAMP NULL DEFAULT NULL, `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_attempts_student` (`student_id`), @@ -369,6 +414,25 @@ CREATE TABLE IF NOT EXISTS `exam_attempts` ( CONSTRAINT `fk_attempts_exam` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- ------------------------------------------------------------------------------ +-- 15.5. Table: student_question_answers (تفاصيل إجابات الطالب) +-- ------------------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS `student_question_answers` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `attempt_id` BIGINT UNSIGNED NOT NULL, + `student_id` BIGINT UNSIGNED NOT NULL, + `question_id` BIGINT UNSIGNED NOT NULL, + `selected_option_id` BIGINT UNSIGNED DEFAULT NULL, + `is_correct` TINYINT(1) NOT NULL DEFAULT 0, + `points_awarded` DECIMAL(6,2) NOT NULL DEFAULT 0.00, + `time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_sqa_attempt` (`attempt_id`), + CONSTRAINT `fk_sqa_attempt` FOREIGN KEY (`attempt_id`) REFERENCES `exam_attempts` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sqa_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_sqa_question` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- ------------------------------------------------------------------------------ -- 16. Table: teacher_reviews (تقييمات الطلاب المحصنة بالأوزان وخاصية كشف الكيد) -- ------------------------------------------------------------------------------ diff --git a/backend/public/index.php b/backend/public/index.php index 3e80a42..8a973a2 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -128,6 +128,7 @@ $router->get('/api/exams', [\App\Controllers\ExamControlle $router->get('/api/exams/{id}', [\App\Controllers\ExamController::class, 'getExamDetails'], [\App\Middlewares\AuthMiddleware::class]); $router->post('/api/exams/{id}/submit', [\App\Controllers\ExamController::class, 'submitExam'], [\App\Middlewares\AuthMiddleware::class]); $router->get('/api/student/progress/mastery', [\App\Controllers\ExamController::class, 'getMastery'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/student/lessons/{id}/progress', [\App\Controllers\VideoController::class, 'saveProgress'], [\App\Middlewares\AuthMiddleware::class]); // Multi-Teacher Marketplace & Fair Reputation Routes (AI Telemetry + Anti-Brigade Defense) $router->get('/api/teachers', [\App\Controllers\TeacherController::class, 'getMarketplaceTeachers']); diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..013af1f --- /dev/null +++ b/docs/IMPLEMENTATION_STATUS.md @@ -0,0 +1,38 @@ +# حالة تنفيذ منصة صَقِل + +آخر تحديث: 2026-09-02 + +هذه الوثيقة تفصل بين ما هو موجود في الكود وما هو جزء من الرؤية المستقبلية. + +## منفذ فعليًا + +- تطبيق Flutter موحد للطالب وولي الأمر. +- تسجيل الدخول عبر OTP والرقم الوطني مع تخزين آمن للتوكن. +- شجرة مناهج ديناميكية من الخادم ومحتوى فعلي للصف العاشر. +- رفع الفيديو، تحويله إلى HLS عبر FFmpeg، ورفع حزمة HLS إلى Cloudflare R2. +- تشغيل الفيديو عبر HLS من R2 أو المسار المحلي عند عدم توفر R2. +- فحوص سقراطية مرتبطة بالدرس من قاعدة البيانات. +- بوابات Web للطالب والمعلم وولي الأمر واستوديو المناهج. +- API للامتحانات والمحاولات والتحليلات والمحادثة وتقييم المعلم. + +## منفذ جزئيًا + +- تطبيق المعلم Flutter: shell ترحيبي فقط؛ الاستخدام الحالي للمعلم عبر Web Studio. +- امتحانات Flutter: واجهة أولية تحتاج ربطًا كاملًا بواجهات `/api/exams`. +- تنزيل موارد PDF: واجهة تحتاج ربطًا فعليًا بالملف. +- مؤشر الجاهزية: مصدره الخادم في التحليلات، ويحتاج استكمال عرضه وتحديثه في الشاشات. +- حماية الفيديو: HLS/R2 متوفران، أما DRM ومنع التسجيل فيحتاجان تكاملًا إنتاجيًا. + +## غير منفذ بعد + +- تطبيق الإدارة Flutter. +- RAG كامل مع Embeddings ومصادر إجابة قابلة للتتبع. +- تنبيهات ولي الأمر الفورية داخل التطبيق. +- خوارزمية تكيفية إنتاجية متعددة المفاهيم. + +## المعمارية المعتمدة + +- Backend: Native PHP 8.3+، PDO، Router داخلي، PHP-FPM/Nginx. +- Storage: Cloudflare R2 لحزمة HLS والملفات، مع تخزين محلي مؤقت أثناء التحويل. +- Apps: `student_app` موحد للطالب وولي الأمر، `teacher_app` مستقل، و`admin_app` مستقل. +- لا يعتمد التشغيل على Laravel أو Docker. diff --git a/docs/PLATFORM_MASTER_PLAN.md b/docs/PLATFORM_MASTER_PLAN.md index 8fbac9f..2ef99d8 100644 --- a/docs/PLATFORM_MASTER_PLAN.md +++ b/docs/PLATFORM_MASTER_PLAN.md @@ -23,7 +23,7 @@ │ HTTPS / REST API / JWT + Device Fingerprint │ ┌────────────────────▼────────────────────┐ - │ Laravel 11 API Gateway (PHP 8.3) │ + │ Native PHP API Gateway (PHP 8.3+) │ │ (Zero-Trust Auth / Queues / Logic) │ └────────┬───────────────┬────────────────┘ │ │ @@ -80,13 +80,13 @@ saqel/ │ ├── student_app/ # تطبيق صَقِل الموحد (الطالب: كويزات وفيديوهات + ولي الأمر: تقارير الأبناء) │ ├── teacher_app/ # تطبيق المعلم (رفع المواد، كويزات، تقارير المبيعات والأرباح) │ └── admin_app/ # تطبيق الإدارة ومديري المدارس (B2B Management) -├── backend/ # مشروع Laravel 11 / Native PHP 8.3 API Gateway +├── backend/ # Native PHP 8.3 API Gateway + PDO + Router │ ├── app/Controllers/ # وحدات التحكم بالـ APIs │ ├── app/Services/ # خدمات AI، و Nabeh WhatsApp Gateway، و Curriculum │ └── database/ # مخطط قاعدة البيانات ├── docs/ # وثائق التخطيط والتحليل │ └── PLATFORM_MASTER_PLAN.md # هذا المرجع المعماري الشامل -├── docker-compose.yml # حاويات: saqel_app, saqel_nginx, saqel_mysql, saqel_queue +├── docs/ # وثائق الخطة وحالة التنفيذ └── README.md # دليل التشغيل السريع ``` diff --git a/docs/SAQEL_ENTERPRISE_ARCHITECTURE.md b/docs/SAQEL_ENTERPRISE_ARCHITECTURE.md index ab1d82e..e85ebc4 100644 --- a/docs/SAQEL_ENTERPRISE_ARCHITECTURE.md +++ b/docs/SAQEL_ENTERPRISE_ARCHITECTURE.md @@ -7,7 +7,7 @@ ## 1. الملخص التنفيذي والرؤية الاستثمارية (Executive Summary & Moat) منصة **صَقِل (Saqel)** هي البنية التحتية التعليمية الذكية الأحدث في المملكة الأردنية الهاشمية والشرق الأوسط (MENA). تجمع المنصة بين: -1. **التطبيق الموحد الذكي (Unified Dynamic App Shell):** تطبيق واحد متعدد الهويات يعمل على (Web, Windows, Tablets, Mobile) ويدعم التبديل السلس بين الأدوار (طالب، معلم، ولي أمر). +1. **التطبيق الموحد الذكي للطالب والأسرة:** تطبيق واحد يعمل على (Web, Windows, Tablets, Mobile) ويدعم التبديل بين الطالب وولي الأمر فقط. تطبيق المعلم وتطبيق الإدارة مستقلان. 2. **التعلم السقراطي النشط وسد الثغرات (Productive Struggle & AI Remediation):** إرجاع الفيديو عند الخطأ وتوليد فحوصات وتلميحات موجهة لمعالجة نقاط ضعف الطالب اللحظية. 3. **المعمارية المؤسسية الهجينة (B2B2C Multi-School Ecosystem):** ربط مدارس الثقافة العسكرية والمدارس الخاصة عبر كشوفات الأرقام الوطنية (`school_rosters`). 4. **بوابة ولي الأمر والأبناء المتعددين (Guardian Portal & Multi-Child Graph):** متابعة جاهزية الأبناء وسجل معالجة نقاط الضعف من شاشة واحدة (`/guardian`). @@ -55,4 +55,4 @@ php backend/curriculum_converter.php ## 5. البنية السحابية وهندسة البث (Cloud Infrastructure & Zero-Egress R2) - **محرك البث:** تقطيع فيديوهات HLS عبر FFmpeg، والتخزين السحابي على Cloudflare R2 بدون رسوم خروج بيانات. - **محرك المحادثات الفورية:** خادم Workerman WebSocket على المنفذ 8080 لتوصيل الرسائل والإشعارات في زمن 0ms. -- **الأداء العالي:** تشغيل مباشر عبر PHP 8.4 و PHP-FPM دون حاجة لـ Docker. +- **الأداء العالي:** تشغيل مباشر عبر PHP 8.3+ وPHP-FPM وNginx دون Laravel أو Docker.