Files
saqel/backend/app/Services/ProgressTrackingService.php
T

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(),
];
}
}