diff options
Diffstat (limited to 'src/lib')
-rw-r--r-- | src/lib/services/srs_study.ts | 552 |
1 files changed, 552 insertions, 0 deletions
diff --git a/src/lib/services/srs_study.ts b/src/lib/services/srs_study.ts new file mode 100644 index 0000000..6e8f6f5 --- /dev/null +++ b/src/lib/services/srs_study.ts @@ -0,0 +1,552 @@ +import { DatabaseHandler } from "../db/db"; +import { CardResponse, DeckResponse } from "../types/cards"; + +export interface SRSConfiguration { + maxInterval: number; + difficultyDecay: number; + easyBonus: number; + newCardsPerDay: number; + maxNewCards: number; +} + +export const DEFAULT_CONFIG: SRSConfiguration = { + maxInterval: 365, + difficultyDecay: -0.5, + easyBonus: 1.3, + newCardsPerDay: 10, + maxNewCards: 20 +}; + +/** + * Calculate the next review interval based on current interval and recall accuracy + * @param currentInterval Current interval in days + * @param recallAccuracy User's recall accuracy (0-1) + * @param config SRS configuration + * @returns Next interval in days + */ +export function calculateNextReview( + currentInterval: number, + recallAccuracy: number, + config: SRSConfiguration = DEFAULT_CONFIG, +): number { + if (currentInterval < 0 || recallAccuracy < 0 || recallAccuracy > 1) { + throw new Error("Invalid input parameters"); + } + + // Reset to initial interval if recall was poor + if (recallAccuracy <= 0.6) return 1; + + // Adjusted tiered multiplier based on accuracy + const multiplier = + 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) { + 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)); + nextInterval = Math.max(Math.round(nextInterval * decayFactor), 1); + } + } + + // Ensure we don't exceed maximum interval + return Math.min(nextInterval, config.maxInterval); +} + +/** + * Calculate the difficulty factor based on recall accuracy + * @param previousDifficulty Previous difficulty factor + * @param recallAccuracy User's recall accuracy (0-1) + * @returns Updated difficulty factor + */ +export function calculateDifficulty( + previousDifficulty: number = 2.5, + 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); + + // 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; + return newDifficulty; +} + +/** + * Determine if a review is due based on scheduled date + * @param scheduledDate The date when review is scheduled + * @returns Boolean indicating if the review is due + */ +export function isReviewDue(scheduledDate: number): boolean { + const now = Date.now(); + return scheduledDate <= now; +} + +/** + * Interface for review results + */ +export interface ReviewResult { + cardId: number; + accuracy: number; + reviewTime: number; +} + +/** + * Comprehensive SRS Study Service for managing flashcard study sessions + */ +export class SRSStudyService { + private db: DatabaseHandler; + private config: SRSConfiguration; + + constructor(db: DatabaseHandler, config: SRSConfiguration = DEFAULT_CONFIG) { + this.db = db; + this.config = config; + } + + /** + * Start a study session for a lesson + * @param userId User ID + * @param lessonId Lesson ID + * @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 } { + // Fetch the lesson with its due cards + const deckResponse = this.db.fetchLesson({ + userId, + lessonId, + random + }); + + if ('error' in deckResponse) { + return { error: deckResponse.error }; + } + + 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)) { + return newCardsResponse; + } + + return { error: "No cards due for review and no new cards available" }; + } + + return deckResponse.ok; + } + + /** + * Fetch new cards that haven't been studied yet + * @param userId User ID + * @param lessonId Lesson ID + * @param random Whether to randomize card order + * @returns Deck response with new cards + */ + fetchNewCards(userId: number, lessonId: number, random: boolean = true): DeckResponse | { error: string } { + // Get new cards that don't have progress records + const query = this.db.db.query(` + SELECT cards.id + FROM cards_lessons cl + JOIN cards ON cards.id = cl.card_id + LEFT JOIN user_progress up ON up.card_id = cards.id AND up.user_id = ? + WHERE cl.lesson_id = ? AND up.id IS NULL + ${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(','); + + // Fetch full card data for these IDs + const deckResponse = this.db.fetchLesson({ + userId, + lessonId, + // 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 + }); + + if ('error' in deckResponse) { + return { error: deckResponse.error }; + } + + return deckResponse.ok; + } + + /** + * Process a review result and update SRS parameters + * @param userId User ID + * @param reviewResult Review result data + * @returns Updated card data + */ + 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); + const newProgressQuery = this.db.db.query(` + SELECT * FROM user_progress + WHERE user_id = ? AND card_id = ? + `); + progress = newProgressQuery.get(userId, cardId); + if (!progress) { + return { error: "Failed to initialize progress" }; + } + } 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); + + // Calculate next review date + 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 + SET + repetition_count = repetition_count + 1, + ease_factor = ?, + interval = ?, + next_review_date = ?, + last_reviewed = ?, + is_mastered = ? + WHERE user_id = ? AND card_id = ? + `); + + updateQuery.run( + newEaseFactor, + newInterval, + nextReviewDate, + now, + isMastered ? 1 : 0, + userId, + 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 + }); + + 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); + if (!updatedCard) { + return { error: "Failed to retrieve updated card" }; + } + + return updatedCard; + } + + /** + * Get a card by ID + * @param cardId Card ID + * @returns Card data or null if not found + */ + getCard(cardId: number): any | null { + const query = this.db.db.query(` + SELECT * FROM cards WHERE id = ? + `); + + return query.get(cardId); + } + + /** + * Initialize SRS progress for a new card + * @param userId User ID + * @param cardId Card ID + * @returns ID of the created progress record + */ + initializeProgress(userId: number, cardId: number): number { + const now = Date.now(); + 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); + } + + /** + * Record an attempt for a card + * @param userId User ID + * @param cardId Card ID + * @param good Whether the attempt was successful (1) or not (0) + * @param reviewTime Time taken to review in milliseconds + * @returns ID of the created attempt record + */ + recordAttempt( + userId: number, + cardId: number, + good: 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); + } + + /** + * Reset progress for a specific card + * @param userId User ID + * @param cardId Card ID + */ + resetProgress(userId: number, cardId: number): void { + const now = Date.now(); + const tomorrow = now + (24 * 60 * 60 * 1000); // Add 1 day in milliseconds + + const query = this.db.db.query(` + UPDATE user_progress + SET + repetition_count = 0, + ease_factor = 2.5, + interval = 1, + next_review_date = ?, + is_mastered = 0 + WHERE user_id = ? AND card_id = ? + `); + + query.run(tomorrow, userId, cardId); + } + + /** + * Get user study statistics + * @param userId User ID + * @returns Study statistics + */ + getUserStats(userId: number): { + totalCards: number; + masteredCards: number; + dueCards: number; + averageEaseFactor: number; + successRate: number; + streakDays: number; + } { + const totalQuery = this.db.db.query(` + 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 ( + SELECT DISTINCT date(timestamp, 'unixepoch') as activity_date + FROM attempts + WHERE user_id = ? + ORDER BY activity_date DESC + ), + streak_calculation AS ( + SELECT + activity_date, + julianday(activity_date) - julianday(LAG(activity_date) OVER (ORDER BY activity_date DESC)) as day_diff + FROM daily_activity + ) + SELECT COUNT(*) as streak_days + FROM streak_calculation + 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 + }; + } + + /** + * Get lesson progress statistics + * @param userId User ID + * @param lessonId Lesson ID + * @returns Lesson progress statistics + */ + getLessonProgress(userId: number, lessonId: number): { + totalCards: number; + masteredCards: number; + dueCards: number; + progress: number; + } { + const query = this.db.db.query(` + SELECT + COUNT(cl.card_id) as total_cards, + SUM(CASE WHEN up.is_mastered = 1 THEN 1 ELSE 0 END) as mastered_cards, + SUM(CASE WHEN up.next_review_date <= ? AND up.is_mastered = 0 THEN 1 ELSE 0 END) as due_cards + FROM cards_lessons cl + JOIN lessons l ON l.id = cl.lesson_id + 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 + }; + } + + /** + * Get all lessons with progress information for a user + * @param userId User ID + * @returns Array of lessons with progress information + */ + getUserLessons(userId: number): Array<{ + id: number; + name: string; + description: string; + totalCards: number; + masteredCards: number; + dueCards: number; + progress: number; + }> { + const query = this.db.db.query(` + SELECT + l.id, + l.name, + l.description, + COUNT(cl.card_id) as total_cards, + SUM(CASE WHEN up.is_mastered = 1 THEN 1 ELSE 0 END) as mastered_cards, + SUM(CASE WHEN up.next_review_date <= ? AND up.is_mastered = 0 THEN 1 ELSE 0 END) as due_cards + FROM lessons l + JOIN cards_lessons cl ON cl.lesson_id = l.id + LEFT JOIN user_progress up ON up.card_id = cl.card_id AND up.user_id = ? + 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, + description: row.description || "", + totalCards, + masteredCards, + dueCards: row.due_cards || 0, + progress + }; + }); + } +}
\ No newline at end of file |