mirror of
https://github.com/nihonbuzz/nihonbuzz-academy.git
synced 2026-01-27 02:41:58 +07:00
130 lines
3.9 KiB
PHP
130 lines
3.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\SrsReview;
|
|
use App\Models\User;
|
|
use App\Models\Vocabulary;
|
|
use Carbon\Carbon;
|
|
|
|
class SrsService
|
|
{
|
|
/**
|
|
* Process a review using SuperMemo-2 Algorithm.
|
|
*
|
|
* @param User $user
|
|
* @param Vocabulary $vocab
|
|
* @param int $grade (1=Again, 2=Hard, 3=Good, 4=Easy)
|
|
* Mapped to SM-2 Quality (q):
|
|
* 1 (Again) -> q=1
|
|
* 2 (Hard) -> q=3
|
|
* 3 (Good) -> q=4
|
|
* 4 (Easy) -> q=5
|
|
*/
|
|
public function processReview(User $user, Vocabulary $vocab, int $grade)
|
|
{
|
|
// Map UI Grade to SM-2 Quality
|
|
// Mapping: 1->1, 2->3, 3->4, 4->5
|
|
$qualityMap = [1 => 1, 2 => 3, 3 => 4, 4 => 5];
|
|
$q = $qualityMap[$grade] ?? 3;
|
|
|
|
// Find existing review or create new
|
|
$review = SrsReview::firstOrNew([
|
|
'user_id' => $user->id,
|
|
'vocabulary_id' => $vocab->id
|
|
]);
|
|
|
|
if (!$review->exists) {
|
|
// Defaults
|
|
$review->ease_factor = 2.5;
|
|
$review->interval = 0;
|
|
$review->repetitions = 0;
|
|
$review->status = 'new';
|
|
}
|
|
|
|
// SM-2 Algorithm Implementation
|
|
if ($q >= 3) {
|
|
// Correct response
|
|
if ($review->repetitions == 0) {
|
|
$review->interval = 1;
|
|
} elseif ($review->repetitions == 1) {
|
|
$review->interval = 6;
|
|
} else {
|
|
$review->interval = round($review->interval * $review->ease_factor);
|
|
}
|
|
$review->repetitions++;
|
|
$review->status = $review->interval > 21 ? 'graduated' : 'review';
|
|
} else {
|
|
// Incorrect response
|
|
$review->repetitions = 0;
|
|
$review->interval = 1;
|
|
$review->status = 'learning';
|
|
}
|
|
|
|
// Update Ease Factor
|
|
// EF' = EF + (0.1 - (5 - q) * (0.08 + (5 - q) * 0.02))
|
|
$review->ease_factor = $review->ease_factor + (0.1 - (5 - $q) * (0.08 + (5 - $q) * 0.02));
|
|
if ($review->ease_factor < 1.3) {
|
|
$review->ease_factor = 1.3;
|
|
}
|
|
|
|
$review->last_review_at = Carbon::now();
|
|
$review->next_review_at = Carbon::now()->addDays($review->interval);
|
|
|
|
$review->save();
|
|
|
|
// Track Activity (XP and Streak)
|
|
$this->trackActivity($user, $grade);
|
|
|
|
return $review;
|
|
}
|
|
|
|
public function trackActivity(User $user, int $grade)
|
|
{
|
|
$today = Carbon::today();
|
|
$lastActivity = $user->last_activity_at ? Carbon::parse($user->last_activity_at)->startOfDay() : null;
|
|
|
|
// Award XP (Easy gives slightly more?)
|
|
// Let's just do 5 XP per card for now.
|
|
$user->xp_points += 5;
|
|
|
|
if (!$lastActivity) {
|
|
// First activity ever
|
|
$user->current_streak = 1;
|
|
} elseif ($today->isSameDay($lastActivity)) {
|
|
// Already active today, do nothing to streak
|
|
} elseif ($today->diffInDays($lastActivity) === 1) {
|
|
// Consecutive day
|
|
$user->current_streak += 1;
|
|
} else {
|
|
// Streak broken
|
|
$user->current_streak = 1;
|
|
}
|
|
|
|
$user->last_activity_at = Carbon::now();
|
|
$user->save();
|
|
}
|
|
|
|
public function getDueReviews(User $user, int $limit = 20)
|
|
{
|
|
return SrsReview::where('user_id', $user->id)
|
|
->where('next_review_at', '<=', Carbon::now())
|
|
->with(['vocabulary'])
|
|
->orderBy('next_review_at', 'asc')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
|
|
public function getNewCards(User $user, int $limit = 5)
|
|
{
|
|
// Get vocabularies NOT in srs_reviews
|
|
$reviewedIds = SrsReview::where('user_id', $user->id)->pluck('vocabulary_id');
|
|
|
|
return Vocabulary::whereNotIn('id', $reviewedIds)
|
|
// Ideally filter by User's Level (e.g. N5)
|
|
// ->where('level', $user->level ?? 'N5')
|
|
->limit($limit)
|
|
->get();
|
|
}
|
|
}
|