Download src/lib/tuition/revisionLoop.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 18 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/tuition/revisionLoop.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/tuition/revisionLoop.ts
-
curl -L -o revisionLoop.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/tuition/revisionLoop.ts
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)); | |
| } | |