first commit

This commit is contained in:
2026-01-23 17:28:21 +07:00
commit 29ff8992b9
331 changed files with 30545 additions and 0 deletions

129
app/Services/SrsService.php Normal file
View File

@@ -0,0 +1,129 @@
<?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();
}
}