summaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-05-29 15:16:41 +0700
committerpolwex <polwex@sortug.com>2025-05-29 15:16:41 +0700
commit8e0965f5274635f609972ef85802675af64df0f4 (patch)
treecc82db5928d49bede5c162cd22ab2a4e36cbdc6b /src/lib
parent490388360a0852bcf8ee054e96fa90e166df5792 (diff)
this is mostly me
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/db/index.ts26
-rw-r--r--src/lib/services/srs_study.ts211
2 files changed, 140 insertions, 97 deletions
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index 6bd417c..a767f70 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -348,11 +348,13 @@ class DatabaseHandler {
page?: number;
random?: boolean;
}): Result<DeckResponse> {
+ console.time("fetchLesson-total");
const p = page ? page : 1;
const size = count ? count : PAGE_SIZE;
const offset = getDBOffset(p, size);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
+ console.time("fetchLesson-query");
const queryString = `
SELECT
l.name, l.description, ll.lang as llang, cards.text, cards.note, cards.id as cid,
@@ -403,10 +405,18 @@ class DatabaseHandler {
// LIMIT 10;
const query = this.db.query(queryString);
const res = query.all(userId, lessonId, tomorrow.getTime(), size, offset);
+ console.timeEnd("fetchLesson-query");
+
// console.log("cards", res.length);
- if (res.length === 0) return { error: "Lesson not found" };
+ if (res.length === 0) {
+ console.timeEnd("fetchLesson-total");
+ return { error: "Lesson not found" };
+ }
+
const row: any = res[0];
- console.log({ row });
+ // console.log({ row });
+
+ console.time("fetchLesson-process");
const lesson = {
id: lessonId,
name: row.name,
@@ -414,10 +424,11 @@ class DatabaseHandler {
language: row.llang,
cardCount: row.total_card_count,
};
- // TODO IPA, prosody, senses... should we unify the format on the wikisource standard?
+
+ // Process the cards
+ console.time("fetchLesson-json-processing");
const cards = res.map((row: any) => {
- // TODO parse here...?
- // console.log({ row });
+ // JSON parsing is often expensive
const sense_array = JSON.parse(row.senses_array);
const senses = sense_array.map((s: any) => {
const senses = JSON.parse(s.senses);
@@ -457,6 +468,10 @@ class DatabaseHandler {
};
return card;
});
+ console.timeEnd("fetchLesson-json-processing");
+
+ console.timeEnd("fetchLesson-process");
+ console.timeEnd("fetchLesson-total");
return { ok: { lesson, cards } };
}
fetchCard(cid: number, userid: number) {
@@ -794,4 +809,5 @@ type ExpressionSearchParams = {
type ExpressionType = "syllable" | "word" | "expression";
const db = new DatabaseHandler();
+export type { DatabaseHandler };
export default db;
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<DeckResponse> {
// 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<DeckResponse> {
// 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
+}