DocDoeAI / src /lib /tuition /revisionLoop.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
18 kB
"use client";
export const REVISION_LOOP_STORAGE_KEY = "docdoe:tuition-revision-loop:v1";
const DAY_MS = 24 * 60 * 60 * 1000;
export type RevisionQueueItem = {
id: string;
sourceMissionId: string;
subject: string;
chapter: string;
missionTitle: string;
title: string;
reason: string;
dueAt: string;
status: "pending" | "done";
priority: "low" | "medium" | "high";
source: "save_revision" | "quiz_mistake" | "weak_area";
weakAreaTags: string[];
flashcards: Array<{ front: string; back: string }>;
notebookPoints: string[];
quizPrompts: Array<{ question: string; expectedAnswer: string; explanation?: string }>;
commonMistakes: string[];
completedAt?: string;
};
export type QuizHistoryItem = {
id: string;
sourceMissionId: string;
subject: string;
chapter: string;
missionTitle: string;
completedAt: string;
questions: Array<{ question: string; expectedAnswer: string; explanation?: string }>;
mistakes: Array<{ question: string; expectedAnswer: string; explanation: string; weakAreaTag?: string }>;
};
export type WeakAreaSummaryItem = {
tag: string;
count: number;
lastSeenAt: string;
subjects: string[];
chapters: string[];
sourceMissionIds: string[];
};
export type RevisionLoopState = {
queue: RevisionQueueItem[];
quizHistory: QuizHistoryItem[];
weakAreas: WeakAreaSummaryItem[];
lastSavedClass?: {
sourceMissionId: string;
subject: string;
chapter: string;
missionTitle: string;
savedAt: string;
};
};
export type RevisionRuntimeInput = {
sourceMissionId: string;
subject: string;
chapter: string;
missionTitle: string;
weakAreaTags: string[];
flashcards?: Array<{ front: string; back: string }>;
notebookPoints?: string[];
quizPrompts?: Array<{ question: string; expectedAnswer: string; explanation?: string }>;
commonMistakes?: string[];
reason?: string;
};
export type QuizMistakeInput = {
sourceMissionId: string;
subject: string;
chapter: string;
missionTitle: string;
question: string;
expectedAnswer: string;
explanation: string;
weakAreaTag?: string;
};
type WriteOptions = {
storage?: Storage | null;
now?: number;
};
function emptyState(): RevisionLoopState {
return {
queue: [],
quizHistory: [],
weakAreas: [],
};
}
function browserStorage(): Storage | null {
if (typeof window === "undefined") return null;
return window.localStorage;
}
function safeString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function compactStrings(values: unknown): string[] {
if (!Array.isArray(values)) return [];
return Array.from(new Set(values.map(safeString).filter(Boolean)));
}
function compactFlashcards(values: unknown): Array<{ front: string; back: string }> {
if (!Array.isArray(values)) return [];
return values
.map((value) => {
if (!value || typeof value !== "object") return null;
const card = value as Record<string, unknown>;
const front = safeString(card.front);
const back = safeString(card.back);
return front && back ? { front, back } : null;
})
.filter((card): card is { front: string; back: string } => Boolean(card));
}
function compactQuizPrompts(values: unknown): Array<{ question: string; expectedAnswer: string; explanation?: string }> {
if (!Array.isArray(values)) return [];
return values
.map((value) => {
if (!value || typeof value !== "object") return null;
const prompt = value as Record<string, unknown>;
const question = safeString(prompt.question);
const expectedAnswer = safeString(prompt.expectedAnswer);
const explanation = safeString(prompt.explanation);
if (!question || !expectedAnswer) return null;
return explanation ? { question, expectedAnswer, explanation } : { question, expectedAnswer };
})
.filter((prompt): prompt is { question: string; expectedAnswer: string; explanation?: string } => Boolean(prompt));
}
function normalizeQueueItem(value: unknown): RevisionQueueItem | null {
if (!value || typeof value !== "object") return null;
const item = value as Record<string, unknown>;
const id = safeString(item.id);
const sourceMissionId = safeString(item.sourceMissionId);
const subject = safeString(item.subject);
const chapter = safeString(item.chapter);
const missionTitle = safeString(item.missionTitle);
const title = safeString(item.title);
const dueAt = safeString(item.dueAt);
if (!id || !sourceMissionId || !subject || !chapter || !missionTitle || !title || !dueAt) return null;
const status = item.status === "done" ? "done" : "pending";
const priority = item.priority === "high" || item.priority === "low" ? item.priority : "medium";
const source =
item.source === "quiz_mistake" || item.source === "weak_area" ? item.source : "save_revision";
return {
id,
sourceMissionId,
subject,
chapter,
missionTitle,
title,
reason: safeString(item.reason) || "Saved from today's class.",
dueAt,
status,
priority,
source,
weakAreaTags: compactStrings(item.weakAreaTags),
flashcards: compactFlashcards(item.flashcards),
notebookPoints: compactStrings(item.notebookPoints),
quizPrompts: compactQuizPrompts(item.quizPrompts),
commonMistakes: compactStrings(item.commonMistakes),
completedAt: safeString(item.completedAt) || undefined,
};
}
function normalizeQuizHistoryItem(value: unknown): QuizHistoryItem | null {
if (!value || typeof value !== "object") return null;
const item = value as Record<string, unknown>;
const id = safeString(item.id);
const sourceMissionId = safeString(item.sourceMissionId);
const subject = safeString(item.subject);
const chapter = safeString(item.chapter);
const missionTitle = safeString(item.missionTitle);
const completedAt = safeString(item.completedAt);
if (!id || !sourceMissionId || !subject || !chapter || !missionTitle || !completedAt) return null;
const mistakes = Array.isArray(item.mistakes)
? item.mistakes
.map((value) => {
if (!value || typeof value !== "object") return null;
const mistake = value as Record<string, unknown>;
const question = safeString(mistake.question);
const expectedAnswer = safeString(mistake.expectedAnswer);
const explanation = safeString(mistake.explanation);
const weakAreaTag = safeString(mistake.weakAreaTag);
if (!question || !expectedAnswer || !explanation) return null;
return weakAreaTag ? { question, expectedAnswer, explanation, weakAreaTag } : { question, expectedAnswer, explanation };
})
.filter((mistake): mistake is { question: string; expectedAnswer: string; explanation: string; weakAreaTag?: string } =>
Boolean(mistake),
)
: [];
return {
id,
sourceMissionId,
subject,
chapter,
missionTitle,
completedAt,
questions: compactQuizPrompts(item.questions),
mistakes,
};
}
function normalizeWeakArea(value: unknown): WeakAreaSummaryItem | null {
if (!value || typeof value !== "object") return null;
const item = value as Record<string, unknown>;
const tag = safeString(item.tag);
const lastSeenAt = safeString(item.lastSeenAt);
if (!tag || !lastSeenAt) return null;
return {
tag,
count: typeof item.count === "number" && item.count > 0 ? item.count : 1,
lastSeenAt,
subjects: compactStrings(item.subjects),
chapters: compactStrings(item.chapters),
sourceMissionIds: compactStrings(item.sourceMissionIds),
};
}
function normalizeState(value: unknown): RevisionLoopState {
if (!value || typeof value !== "object") return emptyState();
const state = value as Record<string, unknown>;
const lastSavedClass =
state.lastSavedClass && typeof state.lastSavedClass === "object"
? (() => {
const last = state.lastSavedClass as Record<string, unknown>;
const sourceMissionId = safeString(last.sourceMissionId);
const subject = safeString(last.subject);
const chapter = safeString(last.chapter);
const missionTitle = safeString(last.missionTitle);
const savedAt = safeString(last.savedAt);
return sourceMissionId && subject && chapter && missionTitle && savedAt
? { sourceMissionId, subject, chapter, missionTitle, savedAt }
: undefined;
})()
: undefined;
return {
queue: Array.isArray(state.queue)
? state.queue.map(normalizeQueueItem).filter((item): item is RevisionQueueItem => Boolean(item))
: [],
quizHistory: Array.isArray(state.quizHistory)
? state.quizHistory.map(normalizeQuizHistoryItem).filter((item): item is QuizHistoryItem => Boolean(item))
: [],
weakAreas: Array.isArray(state.weakAreas)
? state.weakAreas.map(normalizeWeakArea).filter((item): item is WeakAreaSummaryItem => Boolean(item))
: [],
lastSavedClass,
};
}
function slug(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
}
function isoAfter(now: number, days: number): string {
return new Date(now + days * DAY_MS).toISOString();
}
function upsertQueueItem(queue: RevisionQueueItem[], item: RevisionQueueItem): RevisionQueueItem[] {
const index = queue.findIndex((existing) => existing.id === item.id);
if (index === -1) return [item, ...queue];
const next = [...queue];
next[index] = { ...next[index], ...item, status: next[index].status === "done" ? "pending" : item.status };
return next;
}
function updateWeakAreas(
weakAreas: WeakAreaSummaryItem[],
tags: string[],
context: { subject: string; chapter: string; sourceMissionId: string; seenAt: string },
): WeakAreaSummaryItem[] {
const next = [...weakAreas];
for (const tag of tags) {
const index = next.findIndex((item) => item.tag.toLowerCase() === tag.toLowerCase());
if (index === -1) {
next.push({
tag,
count: 1,
lastSeenAt: context.seenAt,
subjects: [context.subject],
chapters: [context.chapter],
sourceMissionIds: [context.sourceMissionId],
});
continue;
}
const current = next[index];
next[index] = {
...current,
count: current.count + 1,
lastSeenAt: context.seenAt,
subjects: Array.from(new Set([...current.subjects, context.subject])),
chapters: Array.from(new Set([...current.chapters, context.chapter])),
sourceMissionIds: Array.from(new Set([...current.sourceMissionIds, context.sourceMissionId])),
};
}
return getWeakAreaSummary({ queue: [], quizHistory: [], weakAreas: next });
}
function buildMistakeCards(input: RevisionRuntimeInput): QuizHistoryItem["mistakes"] {
const mistakes = compactStrings(input.commonMistakes);
const weakTags = compactStrings(input.weakAreaTags);
return mistakes.map((mistake, index) => {
const weakAreaTag = weakTags[index % Math.max(weakTags.length, 1)];
const card = {
question: `Review common mistake ${index + 1}`,
expectedAnswer: mistake,
explanation: "Use this as a mistake card before the next class.",
};
return weakAreaTag ? { ...card, weakAreaTag } : card;
});
}
export function loadRevisionLoopState(storage: Storage | null = browserStorage()): RevisionLoopState {
if (!storage) return emptyState();
try {
const raw = storage.getItem(REVISION_LOOP_STORAGE_KEY);
if (!raw) return emptyState();
return normalizeState(JSON.parse(raw));
} catch {
return emptyState();
}
}
export function saveRevisionLoopState(
state: RevisionLoopState,
storage: Storage | null = browserStorage(),
): RevisionLoopState {
const normalized = normalizeState(state);
if (!storage) return normalized;
try {
storage.setItem(REVISION_LOOP_STORAGE_KEY, JSON.stringify(normalized));
} catch {
// Keep the UI usable when storage is unavailable or full.
}
return normalized;
}
export function addRevisionFromRuntime(input: RevisionRuntimeInput, options: WriteOptions = {}): RevisionLoopState {
const now = options.now ?? Date.now();
const savedAt = new Date(now).toISOString();
const weakAreaTags = compactStrings(input.weakAreaTags);
const quizPrompts = compactQuizPrompts(input.quizPrompts);
const commonMistakes = compactStrings(input.commonMistakes);
const sourceMissionId = safeString(input.sourceMissionId) || "unknown-mission";
const subject = safeString(input.subject) || "Tuition";
const chapter = safeString(input.chapter) || "Today's class";
const missionTitle = safeString(input.missionTitle) || "Saved class";
const state = loadRevisionLoopState(options.storage);
const item: RevisionQueueItem = {
id: `save:${slug(sourceMissionId)}`,
sourceMissionId,
subject,
chapter,
missionTitle,
title: `${missionTitle} revision`,
reason: safeString(input.reason) || "Saved from today's class.",
dueAt: isoAfter(now, 1),
status: "pending",
priority: weakAreaTags.length > 0 || commonMistakes.length > 0 ? "high" : "medium",
source: "save_revision",
weakAreaTags,
flashcards: compactFlashcards(input.flashcards),
notebookPoints: compactStrings(input.notebookPoints),
quizPrompts,
commonMistakes,
};
const quizHistoryItem: QuizHistoryItem | null =
quizPrompts.length > 0 || commonMistakes.length > 0
? {
id: `quiz-review:${slug(sourceMissionId)}`,
sourceMissionId,
subject,
chapter,
missionTitle,
completedAt: savedAt,
questions: quizPrompts,
mistakes: buildMistakeCards({ ...input, weakAreaTags, commonMistakes }),
}
: null;
const nextState: RevisionLoopState = {
queue: upsertQueueItem(state.queue, item),
quizHistory: quizHistoryItem
? [quizHistoryItem, ...state.quizHistory.filter((history) => history.id !== quizHistoryItem.id)]
: state.quizHistory,
weakAreas: updateWeakAreas(state.weakAreas, weakAreaTags, { subject, chapter, sourceMissionId, seenAt: savedAt }),
lastSavedClass: {
sourceMissionId,
subject,
chapter,
missionTitle,
savedAt,
},
};
return saveRevisionLoopState(nextState, options.storage);
}
export function addQuizMistake(input: QuizMistakeInput, options: WriteOptions = {}): RevisionLoopState {
const now = options.now ?? Date.now();
const savedAt = new Date(now).toISOString();
const sourceMissionId = safeString(input.sourceMissionId) || "unknown-mission";
const subject = safeString(input.subject) || "Tuition";
const chapter = safeString(input.chapter) || "Today's class";
const missionTitle = safeString(input.missionTitle) || "Quiz practice";
const question = safeString(input.question) || "Review this quiz question.";
const expectedAnswer = safeString(input.expectedAnswer) || "Review the model answer.";
const explanation = safeString(input.explanation) || "Revise this before the next class.";
const weakAreaTags = compactStrings([input.weakAreaTag]);
const id = `mistake:${slug(sourceMissionId)}:${slug(question)}`;
const state = loadRevisionLoopState(options.storage);
const item: RevisionQueueItem = {
id,
sourceMissionId,
subject,
chapter,
missionTitle,
title: `Fix: ${question}`,
reason: "Saved from a quiz mistake.",
dueAt: isoAfter(now, 1),
status: "pending",
priority: "high",
source: "quiz_mistake",
weakAreaTags,
flashcards: [],
notebookPoints: [],
quizPrompts: [{ question, expectedAnswer, explanation }],
commonMistakes: [explanation],
};
const historyItem: QuizHistoryItem = {
id: `quiz-mistake:${slug(sourceMissionId)}:${slug(question)}`,
sourceMissionId,
subject,
chapter,
missionTitle,
completedAt: savedAt,
questions: [{ question, expectedAnswer, explanation }],
mistakes: [
weakAreaTags[0] ? { question, expectedAnswer, explanation, weakAreaTag: weakAreaTags[0] } : { question, expectedAnswer, explanation },
],
};
const nextState: RevisionLoopState = {
queue: upsertQueueItem(state.queue, item),
quizHistory: [historyItem, ...state.quizHistory.filter((history) => history.id !== historyItem.id)],
weakAreas: updateWeakAreas(state.weakAreas, weakAreaTags, { subject, chapter, sourceMissionId, seenAt: savedAt }),
lastSavedClass: state.lastSavedClass,
};
return saveRevisionLoopState(nextState, options.storage);
}
export function markRevisionDone(id: string, options: WriteOptions = {}): RevisionLoopState {
const now = options.now ?? Date.now();
const state = loadRevisionLoopState(options.storage);
const nextState: RevisionLoopState = {
...state,
queue: state.queue.map((item) =>
item.id === id ? { ...item, status: "done", completedAt: new Date(now).toISOString() } : item,
),
};
return saveRevisionLoopState(nextState, options.storage);
}
export function getTomorrowRevisionPlan(state: RevisionLoopState, now: number = Date.now()): RevisionQueueItem[] {
const tomorrowEnd = now + DAY_MS + 60 * 1000;
return state.queue
.filter((item) => item.status === "pending")
.filter((item) => Date.parse(item.dueAt) <= tomorrowEnd)
.sort((a, b) => {
const priority = { high: 0, medium: 1, low: 2 };
return priority[a.priority] - priority[b.priority] || Date.parse(a.dueAt) - Date.parse(b.dueAt);
})
.slice(0, 5);
}
export function getDueToday(state: RevisionLoopState, now: number = Date.now()): RevisionQueueItem[] {
return state.queue
.filter((item) => item.status === "pending")
.filter((item) => Date.parse(item.dueAt) <= now)
.sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt));
}
export function getWeakAreaSummary(state: RevisionLoopState): WeakAreaSummaryItem[] {
return [...state.weakAreas].sort((a, b) => b.count - a.count || b.lastSeenAt.localeCompare(a.lastSeenAt));
}