integratedhub/utils/BrainDataClient.ts
2026-01-20 09:02:26 -06:00

426 lines
14 KiB
TypeScript

// utils/BrainDataClient.ts
// Client for managing Brain study progress data with mode progression
import { kvStore } from "../modules/KvCacheStore.ts";
import {
BrainQuestionProgress,
BrainQueueItem,
KvObj,
QuizConfig,
} from "./types.ts";
import { calculateNextReview, isDue } from "./spacedRepetition.ts";
import {
DEFAULT_QUIZ_MODE,
getModeIndex,
getNextMode,
QUIZ_MODES,
} from "./quizModes.ts";
// How many days to push out review-only cards
const REVIEW_ONLY_INTERVAL_DAYS = 30;
const REVIEW_ONLY_MIN_DAYS_SINCE_LAST_REVIEW = 30;
function shuffleInPlace<T>(arr: T[]) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
/**
* Generate composite ID for brain progress record (mode-agnostic)
*/
export function generateBrainProgressId(
userId: string,
questionId: string,
): string {
return `${userId}_${questionId}`;
}
/**
* Client for managing Brain study progress with mode progression
*/
export class BrainDataClient {
/**
* Get all progress records for a user
*/
async getUserProgress(userId: string): Promise<BrainQuestionProgress[]> {
const progress: BrainQuestionProgress[] = [];
for await (
const entry of kvStore.list<BrainQuestionProgress>({
prefix: ["brain_question_progress", userId],
})
) {
if (entry.value) {
progress.push(entry.value);
}
}
return progress;
}
/**
* Get a specific progress record for a question (mode-agnostic)
*/
async getProgress(
userId: string,
questionId: string,
): Promise<BrainQuestionProgress | null> {
const id = generateBrainProgressId(userId, questionId);
const key = ["brain_question_progress", userId, id];
return await kvStore.get<BrainQuestionProgress>(key);
}
/**
* Get all questions due for review
*/
async getDueQuestions(userId: string): Promise<BrainQuestionProgress[]> {
const allProgress = await this.getUserProgress(userId);
return allProgress.filter((p) => isDue(p.next_due_at));
}
/**
* Record an answer and update progress with mode progression logic
*
* Mode progression rules:
* - When wasCorrectFirstTry is true and not in final mode: advance to next mode
* - When wasCorrectFirstTry is true and in final mode: mark as review-only
* - When wasCorrectFirstTry is false: stay in current mode, reset consecutive streak
*/
async recordAnswer(
userId: string,
questionId: string,
quizId: string,
wasCorrectFirstTry: boolean,
): Promise<BrainQuestionProgress> {
const id = generateBrainProgressId(userId, questionId);
const key = ["brain_question_progress", userId, id];
const now = Date.now();
// Get existing progress or create new
const existing = await kvStore.get<BrainQuestionProgress>(key);
// Determine current mode and index
let currentMode = existing?.current_mode ?? DEFAULT_QUIZ_MODE;
let modeIndex = existing?.mode_index ?? 0;
let consecutiveFirstTry = existing?.consecutive_first_try ?? 0;
let isReviewOnly = existing?.is_review_only ?? false;
let currentInterval = existing?.interval_days ?? 1;
const currentEase = existing?.ease_factor ?? 2.5;
// Mode progression logic
if (wasCorrectFirstTry) {
consecutiveFirstTry++;
// Advance to next mode if correct on first try
const nextMode = getNextMode(currentMode);
if (nextMode !== null) {
// Move to next harder mode
currentMode = nextMode;
modeIndex = getModeIndex(nextMode);
// Reset interval for the new mode (card needs to be reviewed soon in new mode)
currentInterval = 1;
} else {
// Already at final mode and correct on first try - mark as review-only
isReviewOnly = true;
// Push due date far out for review-only cards
currentInterval = REVIEW_ONLY_INTERVAL_DAYS;
}
} else {
// Incorrect: reset consecutive streak, stay in current mode
consecutiveFirstTry = 0;
// Don't reset mode - keep in current mode until they get it right
}
// Calculate new review schedule
const { nextDueAt, newInterval, newEaseFactor } = calculateNextReview(
wasCorrectFirstTry,
currentInterval,
currentEase,
);
// For review-only cards, use longer interval
const finalInterval = isReviewOnly
? Math.max(newInterval, REVIEW_ONLY_INTERVAL_DAYS)
: newInterval;
const finalNextDueAt = isReviewOnly
? now + (finalInterval * 24 * 60 * 60 * 1000)
: nextDueAt;
const progress: BrainQuestionProgress = {
id,
user_id: userId,
quiz_id: quizId,
question_id: questionId,
current_mode: currentMode,
mode_index: modeIndex,
attempts: (existing?.attempts ?? 0) + 1,
first_try_successes: (existing?.first_try_successes ?? 0) +
(wasCorrectFirstTry ? 1 : 0),
consecutive_first_try: consecutiveFirstTry,
last_result: wasCorrectFirstTry,
next_due_at: finalNextDueAt,
interval_days: finalInterval,
ease_factor: newEaseFactor,
is_review_only: isReviewOnly,
created_on: existing?.created_on ?? now,
updated_on: now,
};
await kvStore.set(key, progress);
return progress;
}
/**
* Build study queue from all user's quizzes
* Returns cards sorted by mode (easier modes first), then by due status
* Review-only cards are deprioritized unless there are too few active cards
*/
async buildStudyQueue(
userId: string,
quizConfigs: KvObj<QuizConfig>[],
_mode: string, // Deprecated - mode is now per-card based on progression
newCardsLimit: number = 10,
totalLimit: number = 50,
): Promise<BrainQueueItem[]> {
const now = Date.now();
// Get all existing progress for this user
const allProgress = await this.getUserProgress(userId);
const progressMap = new Map<string, BrainQuestionProgress>();
for (const p of allProgress) {
progressMap.set(p.question_id, p);
}
// Build queue items from all questions
const allItems: {
item: BrainQueueItem;
nextDueAt: number | null;
isNew: boolean; // Truly new (never seen)
isInProgression: boolean; // Has started but not completed all modes
modeIndex: number;
isReviewOnly: boolean;
isDue: boolean;
isReviewLongEnough: boolean;
}[] = [];
for (const kvObj of quizConfigs) {
if (!kvObj.value?.questions) continue;
for (const question of kvObj.value.questions) {
const progress = progressMap.get(question.id) ?? null;
// "Truly new" = never seen before (no progress record)
const isTrulyNew = progress === null;
// "In progression" = has started but hasn't completed all 6 modes yet
const isInProgression = progress !== null && !progress.is_review_only;
// For UI: "new" means either truly new or still in active progression
const isNew = isTrulyNew || isInProgression;
// Determine the mode for this card
const displayMode = progress?.current_mode ?? DEFAULT_QUIZ_MODE;
const modeIndex = progress?.mode_index ?? 0;
const isReviewOnly = progress?.is_review_only ?? false;
const nextDueAt = progress?.next_due_at ?? null;
const cardIsDue = isDue(nextDueAt);
const lastReviewedAt = progress?.updated_on ?? progress?.created_on ??
null;
const isReviewLongEnough = !isReviewOnly || lastReviewedAt === null
? true
: (now - lastReviewedAt) >=
(REVIEW_ONLY_MIN_DAYS_SINCE_LAST_REVIEW * 24 * 60 * 60 * 1000);
allItems.push({
item: {
question_id: question.id,
quiz_id: kvObj.value.quiz_config_id,
question_data: question,
progress,
is_new: isNew,
display_mode: displayMode,
mode_index: modeIndex,
is_review_only: isReviewOnly,
},
nextDueAt,
isNew: isTrulyNew, // For queue building, only count truly new cards toward newLimit
isInProgression,
modeIndex,
isReviewOnly,
isDue: cardIsDue,
isReviewLongEnough,
});
}
}
// Separate cards into categories for the locked-set study behavior:
// 1. Cards currently in progression (started but not finished all 6 modes) - highest priority
// IMPORTANT: Do NOT gate on isDue - active-learning cards should remain eligible
// even if next_due_at is in the future, so the user can complete all 6 modes in one session
// 2. Truly new cards (never seen) - these fill the active set up to newLimit
// 3. Review-only cards (completed all modes, now in spaced repetition) - only if due
const inProgressionCards = allItems.filter(
(item) => item.isInProgression, // No isDue check - always include in-progression cards
);
const trulyNewCards = allItems.filter((item) => item.isNew);
const reviewOnlyCards = allItems.filter(
(item) => item.isReviewOnly && item.isDue && item.isReviewLongEnough, // Review-only respects due and minimum time since last review
);
// Sort each category by mode index (easier modes first for learning progression)
const sortByModeAndDue = (
a: typeof allItems[0],
b: typeof allItems[0],
) => {
// Primary: mode index (lower = easier = first)
if (a.modeIndex !== b.modeIndex) {
return a.modeIndex - b.modeIndex;
}
// Secondary: due status (more overdue first)
const aDue = a.nextDueAt ?? Date.now();
const bDue = b.nextDueAt ?? Date.now();
return aDue - bDue;
};
inProgressionCards.sort(sortByModeAndDue);
trulyNewCards.sort(sortByModeAndDue);
reviewOnlyCards.sort(sortByModeAndDue);
// Randomize the pool of new cards so large queue requests don't return the same order
// Keep in-progression cards sorted/stable so users can reliably finish what they've started.
shuffleInPlace(trulyNewCards);
shuffleInPlace(reviewOnlyCards);
// Build final queue using locked-set behavior:
// - First, include all cards currently in progression (they're part of the active set)
// - Then, fill up to newCardsLimit with truly new cards
// - Review-only cards are only added if specifically requested (not mixed into active set)
const queue: BrainQueueItem[] = [];
// 1. Add all cards currently in progression (these are the "active set" that must be completed)
for (const { item } of inProgressionCards) {
if (queue.length >= totalLimit) break;
queue.push(item);
}
// 2. Calculate how many slots are available for new cards
// The active set should be up to newCardsLimit (default 20)
const activeSetCount = queue.length;
const slotsForNew = Math.max(0, newCardsLimit - activeSetCount);
// 3. Add truly new cards to fill the active set up to the limit
let newCardsAdded = 0;
for (const { item } of trulyNewCards) {
if (queue.length >= totalLimit || newCardsAdded >= slotsForNew) break;
queue.push(item);
newCardsAdded++;
}
// 4. Only add review-only cards if there are no active new/progression cards
// This ensures the user focuses on completing the active set before reviews
if (queue.length === 0) {
for (const { item } of reviewOnlyCards) {
if (queue.length >= totalLimit) break;
queue.push(item);
}
}
return queue;
}
/**
* Get count of questions due for review
*/
async getDueCount(userId: string, _mode?: string): Promise<number> {
const allProgress = await this.getUserProgress(userId);
// Mode parameter is deprecated - count all due cards
return allProgress.filter((p) => isDue(p.next_due_at)).length;
}
/**
* Get statistics for a user
*/
async getStats(userId: string, _mode?: string): Promise<{
totalReviewed: number;
totalCorrect: number;
dueNow: number;
accuracy: number;
reviewOnlyCount: number;
modeDistribution: Record<string, number>;
}> {
const allProgress = await this.getUserProgress(userId);
const totalReviewed = allProgress.reduce(
(sum, p) => sum + (p.attempts ?? 0),
0,
);
const totalCorrect = allProgress.reduce(
(sum, p) => sum + (p.first_try_successes ?? 0),
0,
);
const dueNow = allProgress.filter((p) => isDue(p.next_due_at)).length;
const accuracy = totalReviewed > 0
? (totalCorrect / totalReviewed) * 100
: 0;
const reviewOnlyCount = allProgress.filter((p) => p.is_review_only).length;
// Count cards by current mode
const modeDistribution: Record<string, number> = {};
for (const mode of QUIZ_MODES) {
modeDistribution[mode] = 0;
}
for (const p of allProgress) {
const mode = p.current_mode ?? DEFAULT_QUIZ_MODE;
modeDistribution[mode] = (modeDistribution[mode] ?? 0) + 1;
}
return {
totalReviewed,
totalCorrect,
dueNow,
accuracy: Math.round(accuracy * 10) / 10,
reviewOnlyCount,
modeDistribution,
};
}
/**
* Reset progress for a specific question
*/
async resetQuestionProgress(
userId: string,
questionId: string,
): Promise<void> {
const id = generateBrainProgressId(userId, questionId);
const key = ["brain_question_progress", userId, id];
await kvStore.delete(key);
}
/**
* Reset all progress for a quiz
*/
async resetQuizProgress(userId: string, quizId: string): Promise<void> {
const allProgress = await this.getUserProgress(userId);
for (const p of allProgress) {
if (p.quiz_id === quizId) {
const key = ["brain_question_progress", userId, p.id];
await kvStore.delete(key);
}
}
}
/**
* Reset all progress for a user
*/
async resetAllProgress(userId: string): Promise<void> {
const allProgress = await this.getUserProgress(userId);
for (const p of allProgress) {
const key = ["brain_question_progress", userId, p.id];
await kvStore.delete(key);
}
}
}
// Export singleton instance
export const brainDataClient = new BrainDataClient();