DB, Collections, Search

This commit is contained in:
Stefan Hardegger
2025-11-21 07:53:37 +01:00
parent c8eb6237c4
commit 8a03edbb88
67 changed files with 17703 additions and 103 deletions

275
src/lib/learning/sm2.ts Normal file
View File

@@ -0,0 +1,275 @@
/**
* SM-2 Algorithm Implementation
*
* Implements the SuperMemo SM-2 spaced repetition algorithm
* as specified in the MemoHanzi specification.
*
* Reference: https://www.supermemo.com/en/archives1990-2015/english/ol/sm2
*/
/**
* Progress data for a single card
*/
export interface CardProgress {
easeFactor: number
interval: number // in days
consecutiveCorrect: number
incorrectCount: number
lastReviewDate: Date | null
nextReviewDate: Date | null
}
/**
* Initial values for a new card
*/
export const INITIAL_PROGRESS: CardProgress = {
easeFactor: 2.5,
interval: 1,
consecutiveCorrect: 0,
incorrectCount: 0,
lastReviewDate: null,
nextReviewDate: null,
}
/**
* Result of calculating the next review
*/
export interface ReviewResult {
easeFactor: number
interval: number
consecutiveCorrect: number
incorrectCount: number
nextReviewDate: Date
}
/**
* Calculate the next review for a correct answer
*
* @param progress Current card progress
* @param reviewDate Date of the review (defaults to now)
* @returns Updated progress values
*/
export function calculateCorrectAnswer(
progress: CardProgress,
reviewDate: Date = new Date()
): ReviewResult {
let newInterval: number
let newEaseFactor: number
let newConsecutiveCorrect: number
// Calculate new interval based on consecutive correct count
if (progress.consecutiveCorrect === 0) {
newInterval = 1
} else if (progress.consecutiveCorrect === 1) {
newInterval = 6
} else {
newInterval = Math.round(progress.interval * progress.easeFactor)
}
// Increase ease factor (making future intervals longer)
newEaseFactor = progress.easeFactor + 0.1
// Increment consecutive correct count
newConsecutiveCorrect = progress.consecutiveCorrect + 1
// Calculate next review date
const nextReviewDate = new Date(reviewDate)
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
return {
easeFactor: newEaseFactor,
interval: newInterval,
consecutiveCorrect: newConsecutiveCorrect,
incorrectCount: progress.incorrectCount,
nextReviewDate,
}
}
/**
* Calculate the next review for an incorrect answer
*
* @param progress Current card progress
* @param reviewDate Date of the review (defaults to now)
* @returns Updated progress values
*/
export function calculateIncorrectAnswer(
progress: CardProgress,
reviewDate: Date = new Date()
): ReviewResult {
// Reset interval to 1 day
const newInterval = 1
// Reset consecutive correct count
const newConsecutiveCorrect = 0
// Decrease ease factor (but not below 1.3)
const newEaseFactor = Math.max(1.3, progress.easeFactor - 0.2)
// Increment incorrect count
const newIncorrectCount = progress.incorrectCount + 1
// Calculate next review date (1 day from now)
const nextReviewDate = new Date(reviewDate)
nextReviewDate.setDate(nextReviewDate.getDate() + newInterval)
return {
easeFactor: newEaseFactor,
interval: newInterval,
consecutiveCorrect: newConsecutiveCorrect,
incorrectCount: newIncorrectCount,
nextReviewDate,
}
}
/**
* Difficulty enum matching the Prisma schema
*/
export enum Difficulty {
EASY = "EASY",
NORMAL = "NORMAL",
HARD = "HARD",
SUSPENDED = "SUSPENDED",
}
/**
* Card for selection with progress and metadata
*/
export interface SelectableCard {
id: string
nextReviewDate: Date | null
incorrectCount: number
consecutiveCorrect: number
manualDifficulty: Difficulty
}
/**
* Select cards for a learning session
*
* Algorithm:
* 1. Filter out SUSPENDED cards
* 2. Filter cards that are due (nextReviewDate <= now)
* 3. Apply priority: HARD cards first, NORMAL, then EASY
* 4. Sort by: nextReviewDate ASC, incorrectCount DESC, consecutiveCorrect ASC
* 5. Limit to cardsPerSession
*
* @param cards Available cards
* @param cardsPerSession Maximum number of cards to select
* @param now Current date (defaults to now)
* @returns Selected cards for the session
*/
export function selectCardsForSession(
cards: SelectableCard[],
cardsPerSession: number,
now: Date = new Date()
): SelectableCard[] {
// Filter out suspended cards
const activeCards = cards.filter(
(card) => card.manualDifficulty !== Difficulty.SUSPENDED
)
// Filter cards that are due (nextReviewDate <= now or null for new cards)
const dueCards = activeCards.filter(
(card) => card.nextReviewDate === null || card.nextReviewDate <= now
)
// Apply difficulty priority and sort
const sortedCards = dueCards.sort((a, b) => {
// Priority by difficulty: HARD > NORMAL > EASY
const difficultyPriority = {
[Difficulty.HARD]: 0,
[Difficulty.NORMAL]: 1,
[Difficulty.EASY]: 2,
[Difficulty.SUSPENDED]: 3, // Should not appear due to filter
}
const aPriority = difficultyPriority[a.manualDifficulty]
const bPriority = difficultyPriority[b.manualDifficulty]
if (aPriority !== bPriority) {
return aPriority - bPriority
}
// Sort by nextReviewDate (null = new cards, should come first)
if (a.nextReviewDate === null && b.nextReviewDate !== null) return -1
if (a.nextReviewDate !== null && b.nextReviewDate === null) return 1
if (a.nextReviewDate !== null && b.nextReviewDate !== null) {
const dateCompare = a.nextReviewDate.getTime() - b.nextReviewDate.getTime()
if (dateCompare !== 0) return dateCompare
}
// Sort by incorrectCount DESC (more incorrect = higher priority)
if (a.incorrectCount !== b.incorrectCount) {
return b.incorrectCount - a.incorrectCount
}
// Sort by consecutiveCorrect ASC (fewer correct = higher priority)
return a.consecutiveCorrect - b.consecutiveCorrect
})
// Limit to cardsPerSession
return sortedCards.slice(0, cardsPerSession)
}
/**
* Hanzi option for wrong answer generation
*/
export interface HanziOption {
id: string
simplified: string
pinyin: string
hskLevel: string
}
/**
* Generate wrong answers for a multiple choice question
*
* Selects 3 random incorrect pinyin from the same HSK level,
* ensuring no duplicates.
*
* @param correctAnswer The correct hanzi
* @param sameHskOptions Available hanzi from the same HSK level
* @returns Array of 3 wrong pinyin options
*/
export function generateWrongAnswers(
correctAnswer: HanziOption,
sameHskOptions: HanziOption[]
): string[] {
// Filter out the correct answer and any with duplicate pinyin
const candidates = sameHskOptions.filter(
(option) =>
option.id !== correctAnswer.id && option.pinyin !== correctAnswer.pinyin
)
// If not enough candidates, throw error
if (candidates.length < 3) {
throw new Error(
`Not enough wrong answers available. Need 3, found ${candidates.length}`
)
}
// Fisher-Yates shuffle
const shuffled = [...candidates]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
// Take first 3
return shuffled.slice(0, 3).map((option) => option.pinyin)
}
/**
* Shuffle an array of options (for randomizing answer positions)
* Uses Fisher-Yates shuffle algorithm
*
* @param options Array to shuffle
* @returns Shuffled array
*/
export function shuffleOptions<T>(options: T[]): T[] {
const shuffled = [...options]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}