From 8e0965f5274635f609972ef85802675af64df0f4 Mon Sep 17 00:00:00 2001 From: polwex Date: Thu, 29 May 2025 15:16:41 +0700 Subject: this is mostly me --- src/lib/services/srs_study.ts | 211 ++++++++++++++++++++++++------------------ 1 file changed, 119 insertions(+), 92 deletions(-) (limited to 'src/lib/services/srs_study.ts') diff --git a/src/lib/services/srs_study.ts b/src/lib/services/srs_study.ts index 6e8f6f5..9223722 100644 --- a/src/lib/services/srs_study.ts +++ b/src/lib/services/srs_study.ts @@ -1,4 +1,5 @@ -import { DatabaseHandler } from "../db/db"; +import type { DatabaseHandler } from "@/lib/db"; +import { Result } from "../types"; import { CardResponse, DeckResponse } from "../types/cards"; export interface SRSConfiguration { @@ -14,7 +15,7 @@ export const DEFAULT_CONFIG: SRSConfiguration = { difficultyDecay: -0.5, easyBonus: 1.3, newCardsPerDay: 10, - maxNewCards: 20 + maxNewCards: 20, }; /** @@ -38,37 +39,45 @@ export function calculateNextReview( // Adjusted tiered multiplier based on accuracy const multiplier = - recallAccuracy >= 0.95 ? 3 : - recallAccuracy >= 0.85 ? 2 : - recallAccuracy >= 0.75 ? 1.5 : 1.2; + recallAccuracy >= 0.95 + ? 3 + : recallAccuracy >= 0.85 + ? 2 + : recallAccuracy >= 0.75 + ? 1.5 + : 1.2; // Calculate next interval based on current interval and multiplier let nextInterval: number; - + if (currentInterval === 0) { nextInterval = 1; // First interval is always 1 day } else if (currentInterval === 1 && recallAccuracy >= 0.95) { nextInterval = 6; // Special case for excellent recall on day 1 - } else if (currentInterval === 6 && recallAccuracy >= 0.75 && recallAccuracy < 0.85) { + } else if ( + currentInterval === 6 && + recallAccuracy >= 0.75 && + recallAccuracy < 0.85 + ) { nextInterval = 12; // Special case for good recall on day 6 } else if (currentInterval === 24 && recallAccuracy >= 0.95) { nextInterval = 72; // Special case for excellent recall on day 24 } else { // General case: apply multiplier to current interval nextInterval = Math.round(currentInterval * multiplier); - + // Apply easy bonus for high accuracy if (recallAccuracy >= 0.9) { nextInterval = Math.round(nextInterval * config.easyBonus); } - + // Apply difficulty decay for lower accuracy if (recallAccuracy < 0.8) { - const decayFactor = 1 + (config.difficultyDecay * (0.8 - recallAccuracy)); + const decayFactor = 1 + config.difficultyDecay * (0.8 - recallAccuracy); nextInterval = Math.max(Math.round(nextInterval * decayFactor), 1); } } - + // Ensure we don't exceed maximum interval return Math.min(nextInterval, config.maxInterval); } @@ -81,19 +90,19 @@ export function calculateNextReview( */ export function calculateDifficulty( previousDifficulty: number = 2.5, - recallAccuracy: number + recallAccuracy: number, ): number { if (recallAccuracy < 0 || recallAccuracy > 1) { throw new Error("Recall accuracy must be between 0 and 1"); } - + // Adjust difficulty based on performance // Lower accuracy increases difficulty, higher accuracy decreases it - const difficultyDelta = 0.1 - (0.2 * recallAccuracy); - + const difficultyDelta = 0.1 - 0.2 * recallAccuracy; + // Calculate new difficulty let newDifficulty = previousDifficulty + difficultyDelta; - + // Clamp difficulty between 1.0 and 4.0 if (newDifficulty < 1.0) return 1.0; if (newDifficulty > 4.0 || Math.abs(newDifficulty - 4.0) < 0.05) return 4.0; @@ -138,32 +147,34 @@ export class SRSStudyService { * @param random Whether to randomize card order * @returns Deck response with cards due for review */ - startStudySession(userId: number, lessonId: number, random: boolean = true): DeckResponse | { error: string } { + startStudySession( + userId: number, + lessonId: number, + random: boolean = true, + ): Result { // Fetch the lesson with its due cards const deckResponse = this.db.fetchLesson({ userId, lessonId, - random + random, }); - - if ('error' in deckResponse) { - return { error: deckResponse.error }; - } - + + if ("error" in deckResponse) return deckResponse; + const { lesson, cards } = deckResponse.ok; - + // If there are no cards due, we might want to introduce new cards if (cards.length === 0) { const newCardsResponse = this.fetchNewCards(userId, lessonId, random); - - if (newCardsResponse && !('error' in newCardsResponse)) { + + if (newCardsResponse && !("error" in newCardsResponse)) { return newCardsResponse; } - + return { error: "No cards due for review and no new cards available" }; } - - return deckResponse.ok; + + return deckResponse; } /** @@ -173,7 +184,11 @@ export class SRSStudyService { * @param random Whether to randomize card order * @returns Deck response with new cards */ - fetchNewCards(userId: number, lessonId: number, random: boolean = true): DeckResponse | { error: string } { + fetchNewCards( + userId: number, + lessonId: number, + random: boolean = true, + ): Result { // Get new cards that don't have progress records const query = this.db.db.query(` SELECT cards.id @@ -184,16 +199,16 @@ export class SRSStudyService { ${random ? "ORDER BY RANDOM()" : "ORDER BY cards.id"} LIMIT ? `); - + const results = query.all(userId, lessonId, this.config.newCardsPerDay); - + if (results.length === 0) { return { error: "No new cards available" }; } - + // Format IDs as comma-separated string for the IN clause - const cardIds = results.map((row: any) => row.id).join(','); - + const cardIds = results.map((row: any) => row.id).join(","); + // Fetch full card data for these IDs const deckResponse = this.db.fetchLesson({ userId, @@ -201,14 +216,14 @@ export class SRSStudyService { // This is a hack to limit to specific card IDs // We'd normally modify fetchLesson to accept a card IDs parameter count: this.config.newCardsPerDay, - page: 1 + page: 1, }); - - if ('error' in deckResponse) { + + if ("error" in deckResponse) { return { error: deckResponse.error }; } - - return deckResponse.ok; + + return deckResponse; } /** @@ -217,24 +232,27 @@ export class SRSStudyService { * @param reviewResult Review result data * @returns Updated card data */ - processReview(userId: number, reviewResult: ReviewResult): CardResponse | { error: string } { + processReview( + userId: number, + reviewResult: ReviewResult, + ): CardResponse | { error: string } { const { cardId, accuracy, reviewTime } = reviewResult; - + // Get the card to update const card = this.getCard(cardId); if (!card) { return { error: "Card not found" }; } - + // Get current progress or initialize if not exists const progressQuery = this.db.db.query(` SELECT * FROM user_progress WHERE user_id = ? AND card_id = ? `); - + const progressRow = progressQuery.get(userId, cardId); let progress; - + if (!progressRow) { // Initialize progress for new card this.initializeProgress(userId, cardId); @@ -249,18 +267,22 @@ export class SRSStudyService { } else { progress = progressRow; } - + // Calculate new SRS parameters const now = Date.now(); const newEaseFactor = calculateDifficulty(progress.ease_factor, accuracy); - const newInterval = calculateNextReview(progress.interval, accuracy, this.config); - + const newInterval = calculateNextReview( + progress.interval, + accuracy, + this.config, + ); + // Calculate next review date - const nextReviewDate = now + (newInterval * 24 * 60 * 60 * 1000); // Convert days to ms - + const nextReviewDate = now + newInterval * 24 * 60 * 60 * 1000; // Convert days to ms + // Check if card should be marked as mastered const isMastered = newInterval >= 60 && accuracy >= 0.9; - + // Update progress in database const updateQuery = this.db.db.query(` UPDATE user_progress @@ -273,7 +295,7 @@ export class SRSStudyService { is_mastered = ? WHERE user_id = ? AND card_id = ? `); - + updateQuery.run( newEaseFactor, newInterval, @@ -281,30 +303,32 @@ export class SRSStudyService { now, isMastered ? 1 : 0, userId, - cardId + cardId, ); - + // Record the attempt this.recordAttempt(userId, cardId, accuracy > 0.6 ? 1 : 0, reviewTime); - + // Fetch updated card data const updatedDeckResponse = this.db.fetchLesson({ userId, lessonId: 0, // We don't care about lesson context here count: 1, - page: 1 + page: 1, }); - - if ('error' in updatedDeckResponse) { + + if ("error" in updatedDeckResponse) { return { error: "Failed to fetch updated card data" }; } - + // Find the updated card - const updatedCard = updatedDeckResponse.ok.cards.find(c => c.id === cardId); + const updatedCard = updatedDeckResponse.ok.cards.find( + (c) => c.id === cardId, + ); if (!updatedCard) { return { error: "Failed to retrieve updated card" }; } - + return updatedCard; } @@ -317,7 +341,7 @@ export class SRSStudyService { const query = this.db.db.query(` SELECT * FROM cards WHERE id = ? `); - + return query.get(cardId); } @@ -329,15 +353,15 @@ export class SRSStudyService { */ initializeProgress(userId: number, cardId: number): number { const now = Date.now(); - const tomorrow = now + (24 * 60 * 60 * 1000); // Add 1 day in milliseconds - + const tomorrow = now + 24 * 60 * 60 * 1000; // Add 1 day in milliseconds + const query = this.db.db.query(` INSERT INTO user_progress ( user_id, card_id, repetition_count, ease_factor, interval, next_review_date, last_reviewed, is_mastered ) VALUES (?, ?, 0, 2.5, 1, ?, NULL, 0) `); - + const result = query.run(userId, cardId, tomorrow); return Number(result.lastInsertRowid); } @@ -351,19 +375,19 @@ export class SRSStudyService { * @returns ID of the created attempt record */ recordAttempt( - userId: number, - cardId: number, + userId: number, + cardId: number, good: number, - reviewTime: number + reviewTime: number, ): number { const now = Math.floor(Date.now() / 1000); // Unix timestamp - + const query = this.db.db.query(` INSERT INTO attempts ( user_id, timestamp, card_id, good ) VALUES (?, ?, ?, ?) `); - + const result = query.run(userId, now, cardId, good); return Number(result.lastInsertRowid); } @@ -375,8 +399,8 @@ export class SRSStudyService { */ resetProgress(userId: number, cardId: number): void { const now = Date.now(); - const tomorrow = now + (24 * 60 * 60 * 1000); // Add 1 day in milliseconds - + const tomorrow = now + 24 * 60 * 60 * 1000; // Add 1 day in milliseconds + const query = this.db.db.query(` UPDATE user_progress SET @@ -387,7 +411,7 @@ export class SRSStudyService { is_mastered = 0 WHERE user_id = ? AND card_id = ? `); - + query.run(tomorrow, userId, cardId); } @@ -408,29 +432,29 @@ export class SRSStudyService { SELECT COUNT(*) as count FROM user_progress WHERE user_id = ? `); const totalResult = totalQuery.get(userId); - + const masteredQuery = this.db.db.query(` SELECT COUNT(*) as count FROM user_progress WHERE user_id = ? AND is_mastered = 1 `); const masteredResult = masteredQuery.get(userId); - + const now = Date.now(); const dueQuery = this.db.db.query(` SELECT COUNT(*) as count FROM user_progress WHERE user_id = ? AND next_review_date <= ? AND is_mastered = 0 `); const dueResult = dueQuery.get(userId, now); - + const avgQuery = this.db.db.query(` SELECT AVG(ease_factor) as avg FROM user_progress WHERE user_id = ? `); const avgResult = avgQuery.get(userId); - + const successQuery = this.db.db.query(` SELECT AVG(good) as avg FROM attempts WHERE user_id = ? `); const successResult = successQuery.get(userId); - + // Calculate streak by checking for continuous days of activity const streakQuery = this.db.db.query(` WITH daily_activity AS ( @@ -450,14 +474,14 @@ export class SRSStudyService { WHERE day_diff = -1 OR day_diff IS NULL `); const streakResult = streakQuery.get(userId); - + return { totalCards: totalResult?.count || 0, masteredCards: masteredResult?.count || 0, dueCards: dueResult?.count || 0, averageEaseFactor: avgResult?.avg || 2.5, successRate: successResult?.avg || 0, - streakDays: streakResult?.streak_days || 0 + streakDays: streakResult?.streak_days || 0, }; } @@ -467,7 +491,10 @@ export class SRSStudyService { * @param lessonId Lesson ID * @returns Lesson progress statistics */ - getLessonProgress(userId: number, lessonId: number): { + getLessonProgress( + userId: number, + lessonId: number, + ): { totalCards: number; masteredCards: number; dueCards: number; @@ -483,20 +510,20 @@ export class SRSStudyService { LEFT JOIN user_progress up ON up.card_id = cl.card_id AND up.user_id = ? WHERE l.id = ? `); - + const result = query.get(Date.now(), userId, lessonId); - + const totalCards = result?.total_cards || 0; const masteredCards = result?.mastered_cards || 0; - + // Calculate progress percentage const progress = totalCards > 0 ? (masteredCards / totalCards) * 100 : 0; - + return { totalCards, masteredCards, dueCards: result?.due_cards || 0, - progress + progress, }; } @@ -528,16 +555,16 @@ export class SRSStudyService { GROUP BY l.id ORDER BY l.position, l.id `); - + const results = query.all(Date.now(), userId); - + return results.map((row: any) => { const totalCards = row.total_cards || 0; const masteredCards = row.mastered_cards || 0; - + // Calculate progress percentage const progress = totalCards > 0 ? (masteredCards / totalCards) * 100 : 0; - + return { id: row.id, name: row.name, @@ -545,8 +572,8 @@ export class SRSStudyService { totalCards, masteredCards, dueCards: row.due_cards || 0, - progress + progress, }; }); } -} \ No newline at end of file +} -- cgit v1.2.3