Fix student_question_answers table creation, clean manifest duplicate units, and calibrate Socratic timeline seeking

This commit is contained in:
Hamza-Ayed
2026-09-03 14:04:39 +03:00
parent b41db82537
commit 7e0aae023a
5 changed files with 141 additions and 128 deletions
@@ -97,14 +97,18 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
void updatePosition(int seconds) { void updatePosition(int seconds) {
final currentState = state; final currentState = state;
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) { if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
// Check if this second triggers a Socratic checkpoint // Check if natural forward playback reaches a Socratic checkpoint
for (var cp in currentState.playbackData.checkpoints) { for (var cp in currentState.playbackData.checkpoints) {
final crossedCheckpoint = seconds >= cp.timestampSeconds && final hitCheckpoint = !currentState.passedCheckpointIds.contains(cp.id) &&
(_lastObservedPosition < cp.timestampSeconds || _lastObservedPosition > seconds); _lastObservedPosition < cp.timestampSeconds &&
if (!currentState.passedCheckpointIds.contains(cp.id) && crossedCheckpoint) { seconds >= cp.timestampSeconds &&
AppLogger.log('🚨 Socratic Checkpoint Triggered! (${cp.questionText})', tag: 'SOCRATIC_ENGINE'); (seconds - _lastObservedPosition).abs() <= 3; // Natural sequential playback
if (hitCheckpoint) {
AppLogger.log('🚨 Socratic Checkpoint Triggered at ${cp.timestampSeconds}s! (${cp.questionText})', tag: 'SOCRATIC_ENGINE');
_lastObservedPosition = cp.timestampSeconds;
emit(currentState.copyWith( emit(currentState.copyWith(
currentPositionSeconds: seconds, currentPositionSeconds: cp.timestampSeconds,
isPlaying: false, // Freeze video playback isPlaying: false, // Freeze video playback
activeCheckpoint: cp, activeCheckpoint: cp,
)); ));
@@ -127,7 +131,25 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
void seekTo(int seconds) { void seekTo(int seconds) {
final currentState = state; final currentState = state;
if (currentState is VideoPlaybackReady) { if (currentState is VideoPlaybackReady) {
emit(currentState.copyWith(currentPositionSeconds: seconds)); // Find the earliest unpassed checkpoint before or at this target
SocraticCheckpointModel? blockingCheckpoint;
for (var cp in currentState.playbackData.checkpoints) {
if (!currentState.passedCheckpointIds.contains(cp.id) && seconds > cp.timestampSeconds) {
if (blockingCheckpoint == null || cp.timestampSeconds < blockingCheckpoint.timestampSeconds) {
blockingCheckpoint = cp;
}
}
}
// If user tries to skip past an unpassed checkpoint, snap precisely to that checkpoint
final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds;
_lastObservedPosition = actualSeek;
emit(currentState.copyWith(
currentPositionSeconds: actualSeek,
activeCheckpoint: blockingCheckpoint,
isPlaying: blockingCheckpoint == null ? currentState.isPlaying : false,
));
} }
} }
@@ -155,6 +177,7 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
// Correct Answer -> Reward readiness score (+0.5%) & resume video // Correct Answer -> Reward readiness score (+0.5%) & resume video
AppLogger.log('✅ Correct answer! Rewarding +0.5% readiness.', tag: 'SOCRATIC_ENGINE'); AppLogger.log('✅ Correct answer! Rewarding +0.5% readiness.', tag: 'SOCRATIC_ENGINE');
final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)..add(cp.id); final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)..add(cp.id);
_lastObservedPosition = cp.timestampSeconds;
emit(currentState.copyWith( emit(currentState.copyWith(
clearActiveCheckpoint: true, clearActiveCheckpoint: true,
isPlaying: true, isPlaying: true,
@@ -169,8 +192,9 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
} }
return true; return true;
} else { } else {
// Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds // Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint
final rewindTo = (currentState.currentPositionSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds); final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds);
_lastObservedPosition = rewindTo;
AppLogger.log('❌ Incorrect answer. Socratic remediation: Rewinding to ${rewindTo}s.', tag: 'SOCRATIC_ENGINE'); AppLogger.log('❌ Incorrect answer. Socratic remediation: Rewinding to ${rewindTo}s.', tag: 'SOCRATIC_ENGINE');
emit(currentState.copyWith( emit(currentState.copyWith(
clearActiveCheckpoint: true, clearActiveCheckpoint: true,
@@ -435,52 +435,63 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Understanding checkpoints on the timeline LayoutBuilder(
Stack( builder: (context, constraints) {
alignment: Alignment.centerLeft, const double horizontalPadding = 24.0;
children: [ final double trackWidth = (constraints.maxWidth - (horizontalPadding * 2)).clamp(10.0, 4000.0);
SliderTheme(
data: SliderTheme.of(context).copyWith( return Stack(
trackHeight: 5, alignment: Alignment.centerLeft,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), children: [
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), SliderTheme(
activeTrackColor: AppColors.saqelCyan, data: SliderTheme.of(context).copyWith(
inactiveTrackColor: Colors.white24, trackHeight: 5,
thumbColor: AppColors.saqelCyan, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
), overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
child: Slider( activeTrackColor: AppColors.saqelCyan,
value: progress, inactiveTrackColor: Colors.white24,
onChanged: (val) { thumbColor: AppColors.saqelCyan,
final targetSec = (val * duration).toInt(); ),
context.read<VideoPlaybackCubit>().seekTo(targetSec); child: Slider(
}, value: progress,
), onChanged: (val) {
), final targetSec = (val * duration).toInt();
// Checkpoint markers on the progress track context.read<VideoPlaybackCubit>().seekTo(targetSec);
...state.playbackData.checkpoints.map((cp) { },
final posFraction = (cp.timestampSeconds / duration).clamp(0.0, 1.0); ),
final isPassed = state.passedCheckpointIds.contains(cp.id); ),
return Positioned( // Checkpoint markers on the progress track
left: (MediaQuery.of(context).size.width * 0.85 * posFraction).clamp(20.0, 700.0), if (duration > 0)
child: Container( ...state.playbackData.checkpoints.map((cp) {
width: 10, final posFraction = (cp.timestampSeconds / duration).clamp(0.0, 1.0);
height: 10, final isPassed = state.passedCheckpointIds.contains(cp.id);
decoration: BoxDecoration( final dotLeft = horizontalPadding + (trackWidth * posFraction) - 5.0;
color: isPassed ? AppColors.emeraldGreen : AppColors.guardianAmber,
shape: BoxShape.circle, return Positioned(
border: Border.all(color: Colors.white, width: 1.5), left: dotLeft,
boxShadow: [ child: IgnorePointer(
BoxShadow( child: Container(
color: (isPassed ? AppColors.emeraldGreen : AppColors.guardianAmber).withAlpha(120), width: 10,
blurRadius: 6, height: 10,
), decoration: BoxDecoration(
], color: isPassed ? AppColors.emeraldGreen : AppColors.guardianAmber,
), shape: BoxShape.circle,
), border: Border.all(color: Colors.white, width: 1.5),
); boxShadow: [
}), BoxShadow(
], color: (isPassed ? AppColors.emeraldGreen : AppColors.guardianAmber).withAlpha(120),
), blurRadius: 6,
),
],
),
),
),
);
}),
],
);
},
),
Row( Row(
children: [ children: [
IconButton( IconButton(
+6 -6
View File
@@ -332,10 +332,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -348,10 +348,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -577,10 +577,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+45 -6
View File
@@ -157,6 +157,8 @@ class ExamController
: "تحتاج لمراجعة المفاهيم المتعلقة بـ: " . implode('، ', array_keys($weakTopics)) . ". يُنصح بمشاهدة مقطع الشرح الموصى به."; : "تحتاج لمراجعة المفاهيم المتعلقة بـ: " . implode('، ', array_keys($weakTopics)) . ". يُنصح بمشاهدة مقطع الشرح الموصى به.";
// Insert Attempt Record // Insert Attempt Record
self::ensureSchema();
$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
@@ -171,13 +173,17 @@ class ExamController
[$uuid, $studentId, $examId, $earnedScore, $totalPoints, $percentage, $status, $timeSpent, json_encode(array_keys($weakTopics), JSON_UNESCAPED_UNICODE), $aiReport] [$uuid, $studentId, $examId, $earnedScore, $totalPoints, $percentage, $status, $timeSpent, json_encode(array_keys($weakTopics), JSON_UNESCAPED_UNICODE), $aiReport]
); );
// Save detailed question answers // Save detailed question answers safely
foreach ($detailedAnswers as $dAns) { foreach ($detailedAnswers as $dAns) {
Database::insert( try {
"INSERT INTO student_question_answers (attempt_id, student_id, question_id, selected_option_id, is_correct, points_awarded, time_spent_seconds) Database::insert(
VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO student_question_answers (attempt_id, student_id, question_id, selected_option_id, is_correct, points_awarded, time_spent_seconds)
[$attemptId, $studentId, $dAns['question_id'], $dAns['selected_option_id'], $dAns['is_correct'], $dAns['points_awarded'], 0] VALUES (?, ?, ?, ?, ?, ?, ?)",
); [$attemptId, $studentId, $dAns['question_id'], $dAns['selected_option_id'], $dAns['is_correct'], $dAns['points_awarded'], 0]
);
} catch (\Throwable $e) {
error_log("student_question_answers insert notice: " . $e->getMessage());
}
} }
// Update Student Cumulative Mastery & Tawjihi Readiness Score // Update Student Cumulative Mastery & Tawjihi Readiness Score
@@ -318,4 +324,37 @@ class ExamController
] ]
]); ]);
} }
public static function ensureSchema(): void
{
try {
Database::query(
"CREATE TABLE IF NOT EXISTS `student_question_answers` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`attempt_id` BIGINT UNSIGNED NOT NULL,
`student_id` BIGINT UNSIGNED NOT NULL,
`question_id` BIGINT UNSIGNED NOT NULL,
`selected_option_id` BIGINT UNSIGNED NULL,
`is_correct` TINYINT(1) NOT NULL DEFAULT 0,
`points_awarded` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
`time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY `idx_sqa_attempt` (`attempt_id`),
KEY `idx_sqa_student` (`student_id`),
KEY `idx_sqa_question` (`question_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
// Ensure completed_at in exam_attempts
$colCheck = Database::selectOne(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts' AND COLUMN_NAME = 'completed_at' LIMIT 1"
);
if (!$colCheck) {
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER ai_diagnostic_report");
}
} catch (\Throwable $e) {
error_log("ExamController ensureSchema notice: " . $e->getMessage());
}
}
} }
-61
View File
@@ -8,67 +8,6 @@
"semester_1": { "semester_1": {
"name": "الفصل الدراسي الأول", "name": "الفصل الدراسي الأول",
"units": { "units": {
"unit_01_exponents_equations": {
"name": "الوحدة الأولى: الأسس والمعادلات",
"lessons": [
{
"id": "u1_l1_solving_equations",
"title": "الدرس 1: حل نظام مكون من معادلة خطية ومعادلة تربيعية",
"outcomes": [
"حل نظام معادلات",
"التمثيل البياني"
],
"file": "grade_10\/math_10\/semester_1\/unit_01\/lesson_1.md"
},
{
"id": "u1_l2_solving_quadratic_system",
"title": "الدرس 2: حل نظام مكون من معادلتين تربيعيتين",
"outcomes": [
"استخدام التعويض",
"استخدام الحذف"
],
"file": "grade_10\/math_10\/semester_1\/unit_01\/lesson_2.md"
}
]
},
"unit_02_circle": {
"name": "الوحدة الثانية: الدائرة",
"lessons": [
{
"id": "u2_l1_circle_properties",
"title": "الدرس 1: أوتار الدائرة وأقطارها ومماساتها",
"outcomes": [
"نظريات الدائرة",
"تطبيقات المماسات"
],
"file": "grade_10\/math_10\/semester_1\/unit_02\/lesson_1.md"
}
]
},
"unit_03_trigonometry": {
"name": "الوحدة الثالثة: حساب المثلثات",
"lessons": []
},
"unit_04_trig_apps": {
"name": "الوحدة الرابعة: تطبيقات المثلثات",
"lessons": []
},
"unit_05_functions": {
"name": "الوحدة الخامسة: الاقترانات",
"lessons": []
},
"unit_06_derivatives": {
"name": "الوحدة السادسة: المشتقات",
"lessons": []
},
"unit_07_vectors": {
"name": "الوحدة السابعة: المتجهات",
"lessons": []
},
"unit_08_stats": {
"name": "الوحدة الثامنة: الإحصاء والاحتمالات",
"lessons": []
},
"unit_01": { "unit_01": {
"name": "الوحدة الأولى: المعادلات (Equations)", "name": "الوحدة الأولى: المعادلات (Equations)",
"lessons": [ "lessons": [