183 lines
6.2 KiB
PHP
183 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Answer;
|
|
use App\Models\ConceptMastery;
|
|
use App\Models\Lesson;
|
|
use App\Models\LessonProgress;
|
|
use App\Models\Question;
|
|
use App\Models\QuestionOption;
|
|
use App\Models\Quiz;
|
|
use App\Models\QuizAttempt;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Collection;
|
|
|
|
class ProgressTrackingService
|
|
{
|
|
private const COMPLETION_THRESHOLD = 95.0;
|
|
|
|
private const MASTERY_DECAY = 0.7;
|
|
|
|
public function heartbeat(User $student, Lesson $lesson, int $positionSeconds): LessonProgress
|
|
{
|
|
$duration = max(1, $lesson->duration_seconds);
|
|
$position = max(0, min($positionSeconds, $lesson->duration_seconds));
|
|
$watched = round(($position / $duration) * 100, 2);
|
|
|
|
/** @var LessonProgress $progress */
|
|
$progress = LessonProgress::updateOrCreate(
|
|
['user_id' => $student->id, 'lesson_id' => $lesson->id],
|
|
[
|
|
'last_position_seconds' => $position,
|
|
'watched_percentage' => (int) min(100, $watched),
|
|
'completed_at' => $watched >= self::COMPLETION_THRESHOLD ? now() : null,
|
|
]
|
|
);
|
|
|
|
return $progress;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{question_id: int, selected_option_id?: ?int, time_spent_seconds?: int}> $submittedAnswers
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function recordQuizAttempt(User $student, Quiz $quiz, array $submittedAnswers): array
|
|
{
|
|
/** @var Collection<int, Question> $questions */
|
|
$questions = Question::with('options')
|
|
->where('quiz_id', $quiz->id)
|
|
->whereIn('id', collect($submittedAnswers)->pluck('question_id'))
|
|
->get()
|
|
->keyBy('id');
|
|
|
|
/** @var QuizAttempt $attempt */
|
|
$attempt = $quiz->attempts()->create([
|
|
'user_id' => $student->id,
|
|
'score' => 0,
|
|
]);
|
|
|
|
$correctCount = 0;
|
|
$results = [];
|
|
|
|
foreach ($submittedAnswers as $entry) {
|
|
/** @var Question|null $question */
|
|
$question = $questions->get($entry['question_id']);
|
|
|
|
if ($question === null) {
|
|
continue;
|
|
}
|
|
|
|
$selectedOptionId = $entry['selected_option_id'] ?? null;
|
|
|
|
/** @var QuestionOption|null $option */
|
|
$option = $selectedOptionId !== null
|
|
? $question->options->firstWhere('id', $selectedOptionId)
|
|
: null;
|
|
|
|
$isCorrect = $option !== null && $option->is_correct;
|
|
|
|
if ($isCorrect) {
|
|
$correctCount++;
|
|
}
|
|
|
|
$attempt->answers()->create([
|
|
'question_id' => $question->id,
|
|
'selected_option_id' => $option?->id,
|
|
'is_correct' => $isCorrect,
|
|
'time_spent_seconds' => $entry['time_spent_seconds'] ?? 0,
|
|
]);
|
|
|
|
if ($question->concept_id !== null) {
|
|
$this->updateMastery($student->id, (int) $question->concept_id, $isCorrect);
|
|
}
|
|
|
|
$results[] = [
|
|
'question_id' => $question->id,
|
|
'is_correct' => $isCorrect,
|
|
'explanation' => $question->explanation,
|
|
];
|
|
}
|
|
|
|
$total = count($results);
|
|
$score = $total > 0 ? (int) round(($correctCount / $total) * 100) : 0;
|
|
$attempt->update(['score' => $score]);
|
|
|
|
return [
|
|
'attempt_id' => $attempt->id,
|
|
'score' => $score,
|
|
'total_questions' => $total,
|
|
'correct_answers' => $correctCount,
|
|
'results' => $results,
|
|
];
|
|
}
|
|
|
|
private function updateMastery(int $userId, int $conceptId, bool $isCorrect): ConceptMastery
|
|
{
|
|
/** @var ConceptMastery $mastery */
|
|
$mastery = ConceptMastery::firstOrNew(['user_id' => $userId, 'concept_id' => $conceptId]);
|
|
|
|
if (! $mastery->exists) {
|
|
$mastery->correct_count = 0;
|
|
$mastery->wrong_count = 0;
|
|
$mastery->mastery_score = 0;
|
|
}
|
|
|
|
if ($mastery->correct_count + $mastery->wrong_count === 0) {
|
|
$mastery->mastery_score = $isCorrect ? 100 : 0;
|
|
} else {
|
|
$previous = (float) $mastery->mastery_score / 100;
|
|
$outcome = $isCorrect ? 1.0 : 0.0;
|
|
$mastery->mastery_score = round((($previous * self::MASTERY_DECAY) + ($outcome * (1 - self::MASTERY_DECAY))) * 100, 2);
|
|
}
|
|
|
|
$isCorrect ? $mastery->correct_count++ : $mastery->wrong_count++;
|
|
$mastery->save();
|
|
|
|
return $mastery;
|
|
}
|
|
|
|
/**
|
|
* Collect the student's aggregated performance snapshot for AI analysis.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function buildPerformanceSnapshot(User $student): array
|
|
{
|
|
$masteryRows = $student->conceptMastery()
|
|
->with('concept:id,name,remedial_lesson_id')
|
|
->orderBy('mastery_score')
|
|
->limit(15)
|
|
->get();
|
|
|
|
$recentWrongAnswers = Answer::query()
|
|
->where('is_correct', false)
|
|
->whereHas('attempt', fn ($query) => $query->where('user_id', $student->id))
|
|
->with('question:id,body,concept_id')
|
|
->latest()
|
|
->limit(10)
|
|
->get();
|
|
|
|
$progressStats = $student->lessonProgress()
|
|
->selectRaw('COUNT(*) as lessons_started, COALESCE(AVG(watched_percentage), 0) as avg_watched')
|
|
->first();
|
|
|
|
return [
|
|
'student_grade' => $student->grade,
|
|
'track' => $student->track,
|
|
'lessons_started' => (int) ($progressStats?->lessons_started ?? 0),
|
|
'average_watch_percentage' => round((float) ($progressStats?->avg_watched ?? 0), 1),
|
|
'concept_mastery' => $masteryRows->map(fn (ConceptMastery $row): array => [
|
|
'concept_id' => $row->concept_id,
|
|
'name' => $row->concept?->name,
|
|
'mastery_score' => (float) $row->mastery_score,
|
|
'correct' => $row->correct_count,
|
|
'wrong' => $row->wrong_count,
|
|
])->all(),
|
|
'recent_wrong_answers' => $recentWrongAnswers->map(fn (Answer $answer): array => [
|
|
'question_body' => $answer->question?->body,
|
|
])->all(),
|
|
];
|
|
}
|
|
}
|