Download src/lib/phase3-learning.ts from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 51.1 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/phase3-learning.ts
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/lib/phase3-learning.ts
-
curl -L -o phase3-learning.ts https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/lib/phase3-learning.ts
51.1 kB
| import type { StudyIntent } from "@/lib/study-intents"; | |
| import { getPhase3ActivityRemote, savePhase3ActivityRemote } from "@/lib/api/phase3-activity"; | |
| import { getRoadmapRemote, listRoadmapsRemote, saveRoadmapRemote } from "@/lib/api/learn-anything-roadmap"; | |
| export type StructuredStudyAction = { | |
| label: string; | |
| intent: StudyIntent; | |
| prompt: string; | |
| }; | |
| export type StructuredStudySection = { | |
| title: string; | |
| summary: string; | |
| points: string[]; | |
| action?: StructuredStudyAction; | |
| }; | |
| export type StructuredStudyResponse = { | |
| topic: string; | |
| contextNote: string; | |
| sections: StructuredStudySection[]; | |
| footerActions: StructuredStudyAction[]; | |
| }; | |
| export type LearnAnythingRoadmapInput = { | |
| topic: string; | |
| currentLevel: string; | |
| timeLimit: string; | |
| weeklyCommitment: string; | |
| goal: string; | |
| learningStyle: string; | |
| }; | |
| /** | |
| * How the roadmap was produced. "starter" = the deterministic offline outline; | |
| * "ai" = the personalised course plan generated by the learning backend. | |
| */ | |
| export type RoadmapGenerationSource = "starter" | "ai"; | |
| /** Provenance and honesty metadata carried by AI-generated roadmaps. */ | |
| export type RoadmapAiMeta = { | |
| planTitle: string; | |
| dataSourceLabel: string; | |
| confidence: number; | |
| prerequisites: string[]; | |
| finalOutcome: string; | |
| estimatedTotalMinutes: number; | |
| trustNotes: string[]; | |
| }; | |
| export type RoadmapLessonCompletion = { | |
| /** Stable roadmap topic key; legacy rows may contain "today". */ | |
| lessonId: string; | |
| title: string; | |
| completedAt: string; | |
| }; | |
| export type LearnAnythingRoadmap = { | |
| id: string; | |
| /** How this roadmap was generated, preserved through local and remote saves. */ | |
| generationSource: RoadmapGenerationSource; | |
| topic: string; | |
| currentLevel: string; | |
| timeLimit: string; | |
| weeklyCommitment: string; | |
| goal: string; | |
| learningStyle: string; | |
| overview: string; | |
| todayLesson: { | |
| title: string; | |
| minutes: number; | |
| tasks: string[]; | |
| }; | |
| weeklyModules: Array<{ | |
| week: string; | |
| title: string; | |
| outcome: string; | |
| tasks: string[]; | |
| }>; | |
| videoNotes: string[]; | |
| smartNotes: string[]; | |
| flashcards: Array<{ front: string; back: string }>; | |
| quiz: Array<{ question: string; answer: string }>; | |
| practiceTask: string; | |
| progress: { | |
| completed: number; | |
| total: number; | |
| nextCheckpoint: string; | |
| }; | |
| revision: string[]; | |
| /** Weeks the learner marked done (module `week` labels). Progress derives from this. */ | |
| completedWeeks?: string[]; | |
| /** Per-topic learning state, keyed by roadmapTopicKey(). Absent = not started. */ | |
| topicStatus?: Record<string, RoadmapTopicStatus>; | |
| /** True once the "see what you already know" diagnostic has run. */ | |
| diagnosticDone?: boolean; | |
| /** Spaced-repetition state per flashcard, keyed by the card front. */ | |
| srState?: Record<string, FlashcardReviewState>; | |
| /** Last progressive-quiz run on this roadmap. */ | |
| quizResult?: RoadmapQuizResult; | |
| /** Explicit lesson-complete actions. Opening Study Chat never writes these. */ | |
| completedLessons?: RoadmapLessonCompletion[]; | |
| /** Present only when generationSource === "ai". */ | |
| aiMeta?: RoadmapAiMeta; | |
| }; | |
| export type FlashcardReviewState = { | |
| /** Leitner box 0-3; box 3 answered correctly again = retired. */ | |
| box: number; | |
| /** ISO timestamp the card becomes due again; absent = due now. */ | |
| dueAt?: string; | |
| retired?: boolean; | |
| }; | |
| export type RoadmapQuizResult = { | |
| answeredAt: string; | |
| correct: number; | |
| total: number; | |
| missedQuestions: string[]; | |
| }; | |
| export type RoadmapTopicStatus = "covered" | "mastered"; | |
| export type RoadmapTopic = { | |
| key: string; | |
| week: string; | |
| moduleTitle: string; | |
| title: string; | |
| }; | |
| export type RoadmapModuleNote = { | |
| week: string; | |
| moduleTitle: string; | |
| summary: string; | |
| keyPoints: string[]; | |
| selfCheck: string[]; | |
| }; | |
| export type TuitionSupportInput = { | |
| subject: string; | |
| chapter: string; | |
| missionTitle: string; | |
| goal: string; | |
| estimatedMinutes: number; | |
| keyNotes: string[]; | |
| keywords: string[]; | |
| weakAreas: string[]; | |
| examQuestion: string; | |
| modelAnswer: string[]; | |
| memoryLine: string; | |
| }; | |
| export type WeekendPyqQuestion = { | |
| id: string; | |
| section: "A" | "B"; | |
| label: string; | |
| question: string; | |
| marks: number; | |
| expectedAnswer: string; | |
| keywords: string[]; | |
| weakArea: string; | |
| }; | |
| export type WeekendPyqTest = { | |
| id: string; | |
| title: string; | |
| subject: string; | |
| chapter: string; | |
| weekLabel: string; | |
| estimatedMinutes: number; | |
| safePatternNote: string; | |
| questions: WeekendPyqQuestion[]; | |
| }; | |
| export type WeekendPyqResult = { | |
| score: number; | |
| maxScore: number; | |
| correctAnswer: string; | |
| missingKeywords: string[]; | |
| weakAreas: string[]; | |
| suggestedRevision: string; | |
| }; | |
| export type RevisionQueueItem = { | |
| id: string; | |
| title: string; | |
| source: string; | |
| dueLabel: string; | |
| minutes: number; | |
| action: string; | |
| }; | |
| export type RevisionQueue = { | |
| dueToday: RevisionQueueItem[]; | |
| savedWeakAreas: string[]; | |
| flashcards: Array<{ front: string; back: string }>; | |
| quickRecall: { question: string; answer: string }; | |
| upcomingReviews: RevisionQueueItem[]; | |
| examReadiness: string[]; | |
| }; | |
| export type WeakAreaRecovery = { | |
| topic: string; | |
| severity: "High" | "Medium" | "Review"; | |
| whyWeak: string; | |
| lastMistakeSource: string; | |
| recommendedFix: string; | |
| actionPrompt: string; | |
| }; | |
| export type Phase3Activity = { | |
| pyqSubmittedAt?: string; | |
| pyqScore?: number; | |
| pyqMaxScore?: number; | |
| weakTopicsAdded?: string[]; | |
| revisionSavedTopics?: string[]; | |
| recoveryActions?: string[]; | |
| /** Explicit completed lessons from Learn Anything. */ | |
| roadmapLessonCompletions?: Array<RoadmapLessonCompletion & { roadmapId: string; topic: string }>; | |
| /** Explicit completed quiz runs from Learn Anything. */ | |
| roadmapQuizAttempts?: Array<RoadmapQuizResult & { roadmapId: string; topic: string }>; | |
| }; | |
| export type ProgressSnapshot = { | |
| metrics: Array<{ label: string; value: string; note: string }>; | |
| weakAreas: string[]; | |
| weakAreasIsSample: boolean; | |
| nextActions: Array<{ label: string; href: string }>; | |
| empty: boolean; | |
| }; | |
| export const PHASE3_ACTIVITY_KEY = "docdoe:phase3-support-activity"; | |
| export const LEARN_ANYTHING_ROADMAP_PREFIX = "docdoe:learn-anything-roadmap:"; | |
| export const LEARN_ANYTHING_LAST_ROADMAP_KEY = "docdoe:learn-anything-last-roadmap"; | |
| function slugify(value: string) { | |
| return value | |
| .toLowerCase() | |
| .normalize("NFKD") | |
| .replace(/[^a-z0-9]+/g, "-") | |
| .replace(/(^-|-$)/g, ""); | |
| } | |
| function titleCase(value: string) { | |
| return value | |
| .split(/\s+/) | |
| .filter(Boolean) | |
| .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) | |
| .join(" "); | |
| } | |
| export function extractStudyTopic(prompt: string): string { | |
| const cleaned = prompt | |
| .replace(/\b(please|teach|explain|me|chapter|full|fully|from|basics|zero|with|notes|pyq|video|plan|make|give)\b/gi, " ") | |
| .replace(/[?.!,]+/g, " ") | |
| .replace(/\s+/g, " ") | |
| .trim(); | |
| return titleCase(cleaned || "This Topic"); | |
| } | |
| export function shouldUseStructuredStudyResponse(prompt: string, intent: StudyIntent): boolean { | |
| if (intent !== "teach_topic" && intent !== "explain_source") return false; | |
| const value = prompt.toLowerCase(); | |
| return ( | |
| /\bchapter\b/.test(value) || | |
| /\bteach\b.*\bfully\b/.test(value) || | |
| /\bfrom\s+(basics|zero|scratch)\b/.test(value) || | |
| /\bvideo\s+plan\b.*\bnotes\b.*\bpyq\b/.test(value) | |
| ); | |
| } | |
| export function buildStructuredStudyResponse(prompt: string): StructuredStudyResponse { | |
| const topic = extractStudyTopic(prompt); | |
| const topicLower = topic.toLowerCase(); | |
| const isRenaissance = /renaissance|italy|humanism/.test(topicLower); | |
| const overview = isRenaissance | |
| ? "Renaissance means rebirth or revival. The exam focus is why Italy became the starting point." | |
| : `${topic} is best learned by first understanding the idea, then turning it into practice, notes, and exam-style answers.`; | |
| const corePoints = isRenaissance | |
| ? ["City-states became wealthy.", "Trade brought new ideas.", "Greek and Roman knowledge was revived.", "Patrons supported artists and scholars."] | |
| : [ | |
| `Start with the meaning of ${topic}.`, | |
| "Identify the key terms that repeat in questions.", | |
| "Practise one short answer before reading the model answer.", | |
| "Save the weak point for revision.", | |
| ]; | |
| return { | |
| topic, | |
| contextNote: "This is a Study Chat plan. It does not replace Today's Tuition unless a future backend handoff explicitly supports that.", | |
| sections: [ | |
| { | |
| title: "Chapter Overview", | |
| summary: overview, | |
| points: corePoints.slice(0, 3), | |
| action: { label: "Practice separately", intent: "generate_quiz", prompt: `Make a compact practice quiz on ${topic}` }, | |
| }, | |
| { | |
| title: "Video Plan", | |
| summary: "Turn the topic into a short visual lesson with clear scenes.", | |
| points: ["Scene 1: meaning and context", "Scene 2: main concept flow", "Scene 3: exam recap"], | |
| action: { label: "Create video plan", intent: "create_video_plan", prompt: `Create a study video plan for ${topic}` }, | |
| }, | |
| { | |
| title: "Concept Breakdown", | |
| summary: "Break the chapter into small ideas before solving questions.", | |
| points: corePoints, | |
| action: { label: "Make it simpler", intent: "teach_topic", prompt: `Explain ${topic} in simpler words` }, | |
| }, | |
| { | |
| title: "Smart Notes", | |
| summary: "Short notes for recall and answer writing.", | |
| points: corePoints.map((point) => `Note: ${point}`), | |
| action: { label: "Generate notes", intent: "generate_notes", prompt: `Make smart notes on ${topic}` }, | |
| }, | |
| { | |
| title: "Flashcards", | |
| summary: "Use active recall instead of rereading.", | |
| points: [`Front: What is ${topic}?`, "Back: Definition plus one example.", "Front: Which keyword gives marks?"], | |
| action: { label: "Generate flashcards", intent: "generate_flashcards", prompt: `Make flashcards for ${topic}` }, | |
| }, | |
| { | |
| title: "PYQ Practice", | |
| summary: "Use exam-relevant and frequently asked question types without promising exact exam repeats.", | |
| points: ["One 2-mark question", "One 4-mark answer-writing question", "Check missing keywords after writing"], | |
| action: { label: "Give PYQ questions", intent: "analyze_pyq", prompt: `Give exam-relevant PYQ practice for ${topic}` }, | |
| }, | |
| { | |
| title: "Answer Correction", | |
| summary: "The student should write first, then compare.", | |
| points: ["Write the answer.", "Check keywords.", "Improve the final wording."], | |
| action: { label: "Correct my answer", intent: "generate_exam_answer", prompt: `Correct my answer on ${topic}` }, | |
| }, | |
| { | |
| title: "Ask Doubt", | |
| summary: "Ask another doubt here without changing the official tuition class.", | |
| points: ["Ask a smaller question.", "Request Malayalam explanation.", "Ask for one example."], | |
| action: { label: "Ask another doubt", intent: "teach_topic", prompt: `I have another doubt in ${topic}: ` }, | |
| }, | |
| ], | |
| footerActions: [ | |
| { label: "Practice separately", intent: "generate_quiz", prompt: `Make a 5-question practice quiz on ${topic}` }, | |
| { label: "Add to Tuition Plan", intent: "continue_learning", prompt: `TODO backend handoff: add ${topic} to tuition plan if supported` }, | |
| { label: "Ask another doubt", intent: "teach_topic", prompt: `I have another doubt in ${topic}: ` }, | |
| ], | |
| }; | |
| } | |
| export function createRoadmapId(topic: string) { | |
| const base = slugify(topic) || "roadmap"; | |
| return `${base}-${Date.now().toString(36)}`; | |
| } | |
| export function buildLearnAnythingRoadmap( | |
| input: LearnAnythingRoadmapInput, | |
| id = createRoadmapId(input.topic), | |
| ): LearnAnythingRoadmap { | |
| const topic = titleCase(input.topic.trim() || "Deep Learning"); | |
| const timeLimit = input.timeLimit || "3 months"; | |
| const weeklyCommitment = input.weeklyCommitment || "5 hours per week"; | |
| const style = input.learningStyle || "Video plus practice"; | |
| const modules = [ | |
| { | |
| week: "Week 1", | |
| title: "Orientation and prerequisites", | |
| outcome: `Know what ${topic} means and what basics you need.`, | |
| tasks: ["Map prerequisites", "Learn the core vocabulary", "Do a small diagnostic quiz"], | |
| }, | |
| { | |
| week: "Week 2", | |
| title: "Foundations", | |
| outcome: "Build the beginner base without rushing.", | |
| tasks: ["Study the first concepts", "Make smart notes", "Complete one guided practice task"], | |
| }, | |
| { | |
| week: "Weeks 3-4", | |
| title: "Core concepts", | |
| outcome: "Understand the main ideas well enough to explain them.", | |
| tasks: ["Watch or read one lesson block", "Create flashcards", "Answer short checks"], | |
| }, | |
| { | |
| week: "Weeks 5-8", | |
| title: "Projects and application", | |
| outcome: "Use the topic in practical tasks.", | |
| tasks: ["Build one mini project", "Review mistakes", "Repeat the hard concepts"], | |
| }, | |
| { | |
| week: "Weeks 9-10", | |
| title: "Intermediate depth", | |
| outcome: "Move from knowing terms to solving real problems.", | |
| tasks: ["Study deeper examples", "Compare methods", "Write a personal cheat sheet"], | |
| }, | |
| { | |
| week: "Weeks 11-12", | |
| title: "Final project and revision", | |
| outcome: "Finish with evidence that you can use the skill.", | |
| tasks: ["Complete final task", "Revise flashcards", "Plan the next roadmap"], | |
| }, | |
| ]; | |
| return { | |
| id, | |
| generationSource: "starter", | |
| topic, | |
| currentLevel: input.currentLevel || "Beginner", | |
| timeLimit, | |
| weeklyCommitment, | |
| goal: input.goal || `Learn ${topic} with practical confidence`, | |
| learningStyle: style, | |
| overview: `${topic} roadmap for ${timeLimit}. Built for ${input.currentLevel || "a beginner"} with ${weeklyCommitment}.`, | |
| todayLesson: { | |
| title: `Start ${topic}: map the field`, | |
| minutes: 35, | |
| tasks: ["Write what you already know", "Learn the first five terms", "Finish a 5-question check"], | |
| }, | |
| weeklyModules: modules, | |
| videoNotes: [ | |
| "Use short lessons before long lectures.", | |
| "Pause after every major idea and write one example.", | |
| `Prefer ${style.toLowerCase()} for the first two weeks.`, | |
| ], | |
| smartNotes: [ | |
| `${topic} needs vocabulary, examples, practice, and review.`, | |
| "Do not skip prerequisites if the first quiz feels confusing.", | |
| "Every week ends with one proof-of-learning task.", | |
| ], | |
| flashcards: [ | |
| { front: `What is the goal of this ${topic} roadmap?`, back: input.goal || "Build practical understanding step by step." }, | |
| { front: "When do I move to the next module?", back: "After a lesson, a practice task, and a short recall check." }, | |
| { front: "What if I get stuck?", back: "Return to prerequisites and ask in Study Chat." }, | |
| ], | |
| quiz: [ | |
| { question: `What is the first outcome for ${topic}?`, answer: "Understand the field and map prerequisites." }, | |
| { question: "What should happen every week?", answer: "A lesson, practice task, and revision checkpoint." }, | |
| { question: "Why avoid jumping to advanced topics first?", answer: "Weak prerequisites make later topics feel random." }, | |
| ], | |
| practiceTask: `Build a one-page learner map for ${topic}: prerequisites, key terms, first project, and one doubt to ask.`, | |
| progress: { | |
| completed: 0, | |
| total: modules.length, | |
| nextCheckpoint: "Finish today's lesson and first recall quiz.", | |
| }, | |
| revision: ["Review today's five terms tomorrow.", "Revise Week 1 cards after three days.", "Do a weekly checkpoint every Sunday."], | |
| }; | |
| } | |
| /** Minimal structural slice of the backend CoursePlanResult this mapper needs. */ | |
| export type CoursePlanForRoadmap = { | |
| title: string; | |
| final_outcome: string; | |
| next_action: string; | |
| data_source_label: string; | |
| confidence: number; | |
| trust_notes: string[]; | |
| prerequisites: string[]; | |
| key_concepts: string[]; | |
| lesson_outline: string[]; | |
| lessons_total: number; | |
| estimated_total_minutes: number; | |
| modules: Array<{ | |
| title: string; | |
| estimated_minutes: number; | |
| lessons: Array<{ title: string; duration_minutes: number; task: string }>; | |
| }>; | |
| practice_tasks: Array<{ title: string; description: string }>; | |
| practice_questions: Array<{ question: string; answer_hint: string }>; | |
| revision_plan: Array<{ timing: string; task: string }>; | |
| revision_checkpoints: Array<{ title: string }>; | |
| }; | |
| /** Spread backend modules across the learner's chosen timeline. */ | |
| export function roadmapWeekLabel( | |
| moduleIndex: number, | |
| moduleCount: number, | |
| timeLimit: string, | |
| ): string { | |
| const monthMatch = timeLimit.match(/(\d+)\s*months?/i); | |
| if (!monthMatch || moduleCount <= 0) return `Week ${moduleIndex + 1}`; | |
| const totalWeeks = Math.max(moduleCount, Number(monthMatch[1]) * 4); | |
| const start = Math.floor((moduleIndex * totalWeeks) / moduleCount) + 1; | |
| const end = Math.max( | |
| start, | |
| Math.floor(((moduleIndex + 1) * totalWeeks) / moduleCount), | |
| ); | |
| return start === end ? `Week ${start}` : `Weeks ${start}-${end}`; | |
| } | |
| /** | |
| * Map a validated backend course plan into the roadmap contract every Learn | |
| * Anything surface already understands. Additive: all practice/quiz/topic | |
| * state helpers keep working; `aiMeta` carries provenance for honest display. | |
| */ | |
| export function coursePlanToRoadmap( | |
| plan: CoursePlanForRoadmap, | |
| input: LearnAnythingRoadmapInput, | |
| id = createRoadmapId(input.topic), | |
| ): LearnAnythingRoadmap { | |
| const topic = titleCase(input.topic.trim() || plan.title || "New Skill"); | |
| const weeklyModules = plan.modules.map((module, index) => ({ | |
| week: roadmapWeekLabel( | |
| index, | |
| plan.modules.length, | |
| input.timeLimit || "3 months", | |
| ), | |
| title: module.title, | |
| outcome: | |
| module.lessons[0]?.task || | |
| `Finish the ${module.lessons.length} lessons in ${module.title}.`, | |
| tasks: module.lessons.map((lesson) => lesson.title), | |
| })); | |
| const firstModule = plan.modules[0]; | |
| const firstLesson = firstModule?.lessons[0]; | |
| const quiz = plan.practice_questions | |
| .filter((item) => item.question && item.answer_hint) | |
| .slice(0, 8) | |
| .map((item) => ({ question: item.question, answer: item.answer_hint })); | |
| const revision = plan.revision_plan | |
| .slice(0, 5) | |
| .map((item) => `${item.timing}: ${item.task}`); | |
| return { | |
| id, | |
| generationSource: "ai", | |
| topic, | |
| currentLevel: input.currentLevel || "Complete beginner", | |
| timeLimit: input.timeLimit || "3 months", | |
| weeklyCommitment: input.weeklyCommitment || "5 hours per week", | |
| goal: input.goal || plan.final_outcome, | |
| learningStyle: input.learningStyle || "Balanced theory and practice", | |
| overview: plan.final_outcome || plan.title, | |
| todayLesson: { | |
| title: firstLesson?.title ?? weeklyModules[0]?.title ?? `Start ${topic}`, | |
| minutes: Math.max(10, Math.min(90, firstLesson?.duration_minutes ?? 25)), | |
| tasks: (firstModule?.lessons ?? []) | |
| .slice(0, 3) | |
| .map((lesson) => lesson.task || lesson.title) | |
| .filter(Boolean), | |
| }, | |
| weeklyModules, | |
| videoNotes: plan.lesson_outline.slice(0, 8), | |
| smartNotes: plan.key_concepts.slice(0, 10), | |
| flashcards: plan.key_concepts.slice(0, 8).map((concept) => ({ | |
| front: concept, | |
| back: `Explain "${concept}" in your own words, with one concrete example.`, | |
| })), | |
| quiz, | |
| practiceTask: | |
| plan.practice_tasks[0]?.description || | |
| plan.next_action || | |
| `Apply today's ${topic} lesson in one small hands-on exercise.`, | |
| progress: { | |
| completed: 0, | |
| total: Math.max(plan.lessons_total, weeklyModules.length), | |
| nextCheckpoint: | |
| plan.revision_checkpoints[0]?.title ?? | |
| "Finish today's lesson and first recall quiz.", | |
| }, | |
| revision: revision.length | |
| ? revision | |
| : ["Review today's key terms tomorrow.", "Revise Week 1 after three days."], | |
| aiMeta: { | |
| planTitle: plan.title, | |
| dataSourceLabel: plan.data_source_label, | |
| confidence: plan.confidence, | |
| prerequisites: plan.prerequisites, | |
| finalOutcome: plan.final_outcome, | |
| estimatedTotalMinutes: plan.estimated_total_minutes, | |
| trustNotes: plan.trust_notes, | |
| }, | |
| }; | |
| } | |
| /** | |
| * Flip one weekly module between done and not-done, recomputing progress from | |
| * the set of completed weeks. Pure — the caller persists the returned roadmap. | |
| */ | |
| export function toggleRoadmapModuleDone(roadmap: LearnAnythingRoadmap, week: string): LearnAnythingRoadmap { | |
| const done = new Set(roadmap.completedWeeks ?? []); | |
| if (done.has(week)) { | |
| done.delete(week); | |
| } else { | |
| done.add(week); | |
| } | |
| const completedWeeks = roadmap.weeklyModules.map((module) => module.week).filter((label) => done.has(label)); | |
| return { | |
| ...roadmap, | |
| completedWeeks, | |
| progress: { | |
| ...roadmap.progress, | |
| completed: completedWeeks.length, | |
| total: roadmap.weeklyModules.length, | |
| }, | |
| }; | |
| } | |
| export function roadmapTopicKey(week: string, index: number): string { | |
| return `${week}::${index}`; | |
| } | |
| /** Every module task is a learnable topic — the clickable unit of the plan. */ | |
| export function roadmapTopics(roadmap: LearnAnythingRoadmap): RoadmapTopic[] { | |
| return roadmap.weeklyModules.flatMap((module) => | |
| module.tasks.map((task, index) => ({ | |
| key: roadmapTopicKey(module.week, index), | |
| week: module.week, | |
| moduleTitle: module.title, | |
| title: task, | |
| })), | |
| ); | |
| } | |
| /** | |
| * Set one topic's status (or clear it with null). Also syncs module-level | |
| * completedWeeks: a module counts done once every topic in it is at least | |
| * covered, so the existing progress bar keeps meaning something. | |
| */ | |
| export function setRoadmapTopicStatus( | |
| roadmap: LearnAnythingRoadmap, | |
| topicKey: string, | |
| status: RoadmapTopicStatus | null, | |
| ): LearnAnythingRoadmap { | |
| const topicStatus: Record<string, RoadmapTopicStatus> = { ...(roadmap.topicStatus ?? {}) }; | |
| if (status === null) { | |
| delete topicStatus[topicKey]; | |
| } else { | |
| topicStatus[topicKey] = status; | |
| } | |
| const completedWeeks = roadmap.weeklyModules | |
| .filter((module) => module.tasks.every((_, i) => topicStatus[roadmapTopicKey(module.week, i)])) | |
| .map((module) => module.week); | |
| return { | |
| ...roadmap, | |
| topicStatus, | |
| completedWeeks, | |
| progress: { | |
| ...roadmap.progress, | |
| completed: completedWeeks.length, | |
| total: roadmap.weeklyModules.length, | |
| }, | |
| }; | |
| } | |
| /** | |
| * Index of the module the learner is currently on: the first not-yet-completed | |
| * one (matches the "Now" marker in the roadmap list). Equals the module count | |
| * when every module is done. | |
| */ | |
| export function activeRoadmapModuleIndex(roadmap: LearnAnythingRoadmap): number { | |
| const completedWeeks = new Set(roadmap.completedWeeks ?? []); | |
| const statusMap = roadmap.topicStatus ?? {}; | |
| const index = roadmap.weeklyModules.findIndex( | |
| (module) => | |
| !completedWeeks.has(module.week) && | |
| module.tasks.some( | |
| (_task, taskIndex) => | |
| !statusMap[roadmapTopicKey(module.week, taskIndex)], | |
| ), | |
| ); | |
| return index === -1 ? roadmap.weeklyModules.length : index; | |
| } | |
| /** The exact next unfinished lesson/task, including its stable persistence key. */ | |
| export function activeRoadmapTopic( | |
| roadmap: LearnAnythingRoadmap, | |
| ): RoadmapTopic | null { | |
| const completedWeeks = new Set(roadmap.completedWeeks ?? []); | |
| const statusMap = roadmap.topicStatus ?? {}; | |
| return ( | |
| roadmapTopics(roadmap).find( | |
| (topic) => | |
| !completedWeeks.has(topic.week) && !statusMap[topic.key], | |
| ) ?? null | |
| ); | |
| } | |
| /** Complete only the lesson the learner actually finished, never a whole module. */ | |
| export function completeActiveRoadmapTopic( | |
| roadmap: LearnAnythingRoadmap, | |
| ): LearnAnythingRoadmap { | |
| const active = activeRoadmapTopic(roadmap); | |
| return active | |
| ? setRoadmapTopicStatus(roadmap, active.key, "covered") | |
| : roadmap; | |
| } | |
| /** | |
| * Mark the active module's topics covered — advancing today's session to the | |
| * next module and moving both the progress bar (topicStatus) and the week list | |
| * (completedWeeks) forward together. No-op once the whole plan is complete. | |
| * Pure — the caller persists the returned roadmap. | |
| */ | |
| export function completeActiveRoadmapModule( | |
| roadmap: LearnAnythingRoadmap, | |
| ): LearnAnythingRoadmap { | |
| const active = roadmap.weeklyModules[activeRoadmapModuleIndex(roadmap)]; | |
| if (!active) return roadmap; | |
| return active.tasks.reduce<LearnAnythingRoadmap>( | |
| (next, _task, index) => | |
| setRoadmapTopicStatus(next, roadmapTopicKey(active.week, index), "covered"), | |
| roadmap, | |
| ); | |
| } | |
| /** Diagnostic result: everything the learner already knows becomes covered. */ | |
| export function applyRoadmapDiagnostic(roadmap: LearnAnythingRoadmap, knownTopicKeys: string[]): LearnAnythingRoadmap { | |
| let next: LearnAnythingRoadmap = { ...roadmap, diagnosticDone: true }; | |
| for (const key of knownTopicKeys) { | |
| next = setRoadmapTopicStatus(next, key, "covered"); | |
| } | |
| return next; | |
| } | |
| export function roadmapTopicStats(roadmap: LearnAnythingRoadmap): { total: number; covered: number; mastered: number } { | |
| const topics = roadmapTopics(roadmap); | |
| const statusMap = roadmap.topicStatus ?? {}; | |
| let covered = 0; | |
| let mastered = 0; | |
| for (const topic of topics) { | |
| const status = statusMap[topic.key]; | |
| if (status === "mastered") mastered += 1; | |
| else if (status === "covered") covered += 1; | |
| } | |
| return { total: topics.length, covered, mastered }; | |
| } | |
| /** | |
| * Structured study notes, one per weekly module, derived deterministically | |
| * from the roadmap itself (never stored, so they can't drift out of sync | |
| * with the plan). The "turn into" actions in the UI convert these into | |
| * real flashcards / quizzes / exam notes through Study Chat. | |
| */ | |
| export function roadmapModuleNotes(roadmap: LearnAnythingRoadmap): RoadmapModuleNote[] { | |
| return roadmap.weeklyModules.map((module) => ({ | |
| week: module.week, | |
| moduleTitle: module.title, | |
| summary: `${module.outcome} This is the ${module.week} focus of your ${roadmap.topic} plan at ${roadmap.currentLevel.toLowerCase()} level.`, | |
| keyPoints: module.tasks.map((task) => `${task} — capture one example or takeaway in your own words.`), | |
| selfCheck: [ | |
| `Explain "${module.title}" for ${roadmap.topic} out loud without looking at these notes.`, | |
| "Write down the one idea that still feels fuzzy and take it to Study Chat.", | |
| ], | |
| })); | |
| } | |
| const SR_BOX_INTERVAL_DAYS = [1, 3, 7]; | |
| /** | |
| * Leitner review: remembering promotes the card one box (1d -> 3d -> 7d, | |
| * then retired); forgetting sends it back to box 0, due immediately. | |
| */ | |
| export function reviewFlashcard( | |
| roadmap: LearnAnythingRoadmap, | |
| cardFront: string, | |
| remembered: boolean, | |
| now: Date = new Date(), | |
| ): LearnAnythingRoadmap { | |
| const previous = roadmap.srState?.[cardFront]; | |
| let next: FlashcardReviewState; | |
| if (!remembered) { | |
| next = { box: 0 }; | |
| } else { | |
| const box = Math.min((previous?.box ?? 0) + 1, SR_BOX_INTERVAL_DAYS.length); | |
| if ((previous?.box ?? 0) >= SR_BOX_INTERVAL_DAYS.length) { | |
| next = { box: SR_BOX_INTERVAL_DAYS.length, retired: true }; | |
| } else { | |
| const days = SR_BOX_INTERVAL_DAYS[box - 1]; | |
| next = { box, dueAt: new Date(now.getTime() + days * 24 * 60 * 60 * 1000).toISOString() }; | |
| } | |
| } | |
| return { | |
| ...roadmap, | |
| srState: { ...(roadmap.srState ?? {}), [cardFront]: next }, | |
| }; | |
| } | |
| /** Cards due for review now: never seen, sent back to box 0, or past dueAt. */ | |
| export function dueFlashcards( | |
| roadmap: LearnAnythingRoadmap, | |
| now: Date = new Date(), | |
| ): Array<{ front: string; back: string }> { | |
| return roadmap.flashcards.filter((card) => { | |
| const state = roadmap.srState?.[card.front]; | |
| if (!state) return true; | |
| if (state.retired) return false; | |
| if (!state.dueAt) return true; | |
| return new Date(state.dueAt).getTime() <= now.getTime(); | |
| }); | |
| } | |
| export function recordRoadmapQuizResult( | |
| roadmap: LearnAnythingRoadmap, | |
| result: { correct: number; total: number; missedQuestions: string[] }, | |
| now: Date = new Date(), | |
| ): LearnAnythingRoadmap { | |
| return { | |
| ...roadmap, | |
| quizResult: { ...result, answeredAt: now.toISOString() }, | |
| }; | |
| } | |
| /** | |
| * A lesson is recorded only when the learner explicitly marks it complete. | |
| * This intentionally does not infer module or topic progress from opening | |
| * Study Chat: those states require their own observed actions. | |
| */ | |
| export function recordRoadmapLessonCompletion( | |
| roadmap: LearnAnythingRoadmap, | |
| topicKey: string = "today", | |
| title: string = roadmap.todayLesson.title, | |
| now: Date = new Date(), | |
| ): LearnAnythingRoadmap { | |
| if (roadmap.completedLessons?.some((lesson) => lesson.lessonId === topicKey)) return roadmap; | |
| return { | |
| ...roadmap, | |
| completedLessons: [ | |
| ...(roadmap.completedLessons ?? []), | |
| { | |
| lessonId: topicKey, | |
| title, | |
| completedAt: now.toISOString(), | |
| }, | |
| ], | |
| }; | |
| } | |
| type StoredLearnAnythingRoadmap = Omit<LearnAnythingRoadmap, "generationSource"> & { | |
| generationSource?: RoadmapGenerationSource; | |
| }; | |
| function isRecord(value: unknown): value is Record<string, unknown> { | |
| return typeof value === "object" && value !== null && !Array.isArray(value); | |
| } | |
| function isStringArray(value: unknown): value is string[] { | |
| return Array.isArray(value) && value.every((item) => typeof item === "string"); | |
| } | |
| function isFiniteNumber(value: unknown): value is number { | |
| return typeof value === "number" && Number.isFinite(value); | |
| } | |
| function isRoadmapModule(value: unknown): boolean { | |
| return ( | |
| isRecord(value) && | |
| typeof value.week === "string" && | |
| typeof value.title === "string" && | |
| typeof value.outcome === "string" && | |
| isStringArray(value.tasks) | |
| ); | |
| } | |
| function isFlashcard(value: unknown): boolean { | |
| return isRecord(value) && typeof value.front === "string" && typeof value.back === "string"; | |
| } | |
| function isQuizQuestion(value: unknown): boolean { | |
| return isRecord(value) && typeof value.question === "string" && typeof value.answer === "string"; | |
| } | |
| function isTopicStatus(value: unknown): boolean { | |
| return value === undefined || ( | |
| isRecord(value) && | |
| Object.values(value).every((status) => status === "covered" || status === "mastered") | |
| ); | |
| } | |
| function isFlashcardState(value: unknown): boolean { | |
| if (value === undefined) return true; | |
| if (!isRecord(value)) return false; | |
| return Object.values(value).every((state) => ( | |
| isRecord(state) && | |
| isFiniteNumber(state.box) && | |
| (state.dueAt === undefined || typeof state.dueAt === "string") && | |
| (state.retired === undefined || typeof state.retired === "boolean") | |
| )); | |
| } | |
| function isQuizResult(value: unknown): boolean { | |
| return value === undefined || ( | |
| isRecord(value) && | |
| typeof value.answeredAt === "string" && | |
| isFiniteNumber(value.correct) && | |
| isFiniteNumber(value.total) && | |
| isStringArray(value.missedQuestions) | |
| ); | |
| } | |
| function isLessonCompletions(value: unknown): boolean { | |
| return value === undefined || ( | |
| Array.isArray(value) && | |
| value.every((lesson) => ( | |
| isRecord(lesson) && | |
| typeof lesson.lessonId === "string" && | |
| lesson.lessonId.length > 0 && | |
| typeof lesson.title === "string" && | |
| typeof lesson.completedAt === "string" | |
| )) | |
| ); | |
| } | |
| function isStoredLearnAnythingRoadmap(value: unknown): value is StoredLearnAnythingRoadmap { | |
| return ( | |
| isRecord(value) && | |
| typeof value.id === "string" && | |
| value.id.length > 0 && | |
| (value.generationSource === undefined || | |
| value.generationSource === "starter" || | |
| value.generationSource === "ai") && | |
| typeof value.topic === "string" && | |
| value.topic.length > 0 && | |
| typeof value.currentLevel === "string" && | |
| typeof value.timeLimit === "string" && | |
| typeof value.weeklyCommitment === "string" && | |
| typeof value.goal === "string" && | |
| typeof value.learningStyle === "string" && | |
| typeof value.overview === "string" && | |
| isRecord(value.todayLesson) && | |
| typeof value.todayLesson.title === "string" && | |
| isFiniteNumber(value.todayLesson.minutes) && | |
| isStringArray(value.todayLesson.tasks) && | |
| Array.isArray(value.weeklyModules) && | |
| value.weeklyModules.every(isRoadmapModule) && | |
| isStringArray(value.videoNotes) && | |
| isStringArray(value.smartNotes) && | |
| Array.isArray(value.flashcards) && | |
| value.flashcards.every(isFlashcard) && | |
| Array.isArray(value.quiz) && | |
| value.quiz.every(isQuizQuestion) && | |
| typeof value.practiceTask === "string" && | |
| isRecord(value.progress) && | |
| isFiniteNumber(value.progress.completed) && | |
| isFiniteNumber(value.progress.total) && | |
| typeof value.progress.nextCheckpoint === "string" && | |
| isStringArray(value.revision) && | |
| (value.completedWeeks === undefined || isStringArray(value.completedWeeks)) && | |
| isTopicStatus(value.topicStatus) && | |
| (value.diagnosticDone === undefined || typeof value.diagnosticDone === "boolean") && | |
| isFlashcardState(value.srState) && | |
| isQuizResult(value.quizResult) && | |
| isLessonCompletions(value.completedLessons) | |
| ); | |
| } | |
| function normalizeLearnAnythingRoadmap( | |
| value: unknown, | |
| ): { roadmap: LearnAnythingRoadmap; migrated: boolean } | null { | |
| if (!isStoredLearnAnythingRoadmap(value)) return null; | |
| const source: RoadmapGenerationSource = | |
| value.generationSource === "ai" ? "ai" : "starter"; | |
| return { | |
| roadmap: { ...value, generationSource: source }, | |
| migrated: value.generationSource === undefined, | |
| }; | |
| } | |
| /** | |
| * The signed-in learner's id, so device caches are account-scoped. A shared | |
| * computer must never show (or upload) one student's roadmap to another. | |
| */ | |
| export function learnerScopeId(): string | null { | |
| if (typeof window === "undefined") return null; | |
| try { | |
| const rawUser = | |
| window.localStorage.getItem("exam_success_user") ?? | |
| window.sessionStorage.getItem("exam_success_user"); | |
| if (rawUser) { | |
| const parsed = JSON.parse(rawUser) as { id?: unknown }; | |
| if (typeof parsed.id === "string" && parsed.id) return parsed.id; | |
| } | |
| const token = | |
| window.localStorage.getItem("exam_success_access_token") ?? | |
| window.sessionStorage.getItem("exam_success_access_token"); | |
| if (!token) return null; | |
| const payload = JSON.parse( | |
| atob((token.split(".")[1] ?? "").replace(/-/g, "+").replace(/_/g, "/")), | |
| ) as { sub?: unknown }; | |
| return typeof payload.sub === "string" ? payload.sub : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function roadmapStorageKey(id: string): string { | |
| const uid = learnerScopeId(); | |
| return uid | |
| ? `${LEARN_ANYTHING_ROADMAP_PREFIX}${uid}:${id}` | |
| : `${LEARN_ANYTHING_ROADMAP_PREFIX}${id}`; | |
| } | |
| function lastRoadmapStorageKey(): string { | |
| const uid = learnerScopeId(); | |
| return uid ? `${LEARN_ANYTHING_LAST_ROADMAP_KEY}:${uid}` : LEARN_ANYTHING_LAST_ROADMAP_KEY; | |
| } | |
| function writeLearnAnythingRoadmapLocal(roadmap: LearnAnythingRoadmap, markAsLast = true): void { | |
| if (typeof window === "undefined") return; | |
| try { | |
| window.localStorage.setItem(roadmapStorageKey(roadmap.id), JSON.stringify(roadmap)); | |
| if (markAsLast) window.localStorage.setItem(lastRoadmapStorageKey(), roadmap.id); | |
| } catch { | |
| // Local fallback only; ignore storage failure. | |
| } | |
| } | |
| export function saveLearnAnythingRoadmap(roadmap: LearnAnythingRoadmap): Promise<boolean> { | |
| writeLearnAnythingRoadmapLocal(roadmap); | |
| // Callers that need to make a saved/resume claim can await the boolean. The | |
| // local copy is only an offline cache; authenticated progress is confirmed | |
| // by the typed server command path above. | |
| return saveRoadmapRemote(roadmap); | |
| } | |
| export function readLearnAnythingRoadmap(id: string): LearnAnythingRoadmap | null { | |
| if (typeof window === "undefined") return null; | |
| try { | |
| // Strict account scoping: a signed-in learner only reads their own scoped | |
| // key. Legacy unscoped entries could belong to anyone on this device — | |
| // the learner's own roadmaps come back via backend hydration instead. | |
| const raw = window.localStorage.getItem(roadmapStorageKey(id)); | |
| if (!raw) return null; | |
| const normalized = normalizeLearnAnythingRoadmap(JSON.parse(raw)); | |
| if (!normalized || normalized.roadmap.id !== id) return null; | |
| if (normalized.migrated) writeLearnAnythingRoadmapLocal(normalized.roadmap, false); | |
| return normalized.roadmap; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| /** | |
| * All roadmaps saved on this device, most recently saved first (the id | |
| * stored under LEARN_ANYTHING_LAST_ROADMAP_KEY leads). This is what lets | |
| * /learn-anything greet a returning learner with "continue where you left | |
| * off" instead of asking the six intake questions again. | |
| */ | |
| export function listLearnAnythingRoadmaps(): LearnAnythingRoadmap[] { | |
| if (typeof window === "undefined") return []; | |
| try { | |
| const uid = learnerScopeId(); | |
| const scopedPrefix = uid ? `${LEARN_ANYTHING_ROADMAP_PREFIX}${uid}:` : null; | |
| const roadmaps: LearnAnythingRoadmap[] = []; | |
| for (let i = 0; i < window.localStorage.length; i++) { | |
| const key = window.localStorage.key(i); | |
| if (!key || !key.startsWith(LEARN_ANYTHING_ROADMAP_PREFIX)) continue; | |
| const remainder = key.slice(LEARN_ANYTHING_ROADMAP_PREFIX.length); | |
| let storedId: string; | |
| if (scopedPrefix) { | |
| // Signed in: only this account's scoped entries. | |
| if (!key.startsWith(scopedPrefix)) continue; | |
| storedId = key.slice(scopedPrefix.length); | |
| } else { | |
| // Logged out: only unscoped device entries — never another account's cache. | |
| if (/^usr_[a-z0-9]+:/i.test(remainder)) continue; | |
| storedId = remainder; | |
| } | |
| const raw = window.localStorage.getItem(key); | |
| if (!raw) continue; | |
| try { | |
| const normalized = normalizeLearnAnythingRoadmap(JSON.parse(raw)); | |
| if (!normalized || normalized.roadmap.id !== storedId) continue; | |
| if (normalized.migrated) writeLearnAnythingRoadmapLocal(normalized.roadmap, false); | |
| roadmaps.push(normalized.roadmap); | |
| } catch { | |
| // One corrupt entry should not hide the rest. | |
| } | |
| } | |
| const lastId = window.localStorage.getItem(lastRoadmapStorageKey()); | |
| roadmaps.sort((a, b) => (a.id === lastId ? -1 : b.id === lastId ? 1 : a.topic.localeCompare(b.topic))); | |
| return roadmaps; | |
| } catch { | |
| return []; | |
| } | |
| } | |
| /** | |
| * Merge a signed-in learner's remote tracks into the local list by id. Normal | |
| * collisions are remote-wins because remote records are the cross-device saved | |
| * value; local-only tracks are retained. Bulk hydration separately reapplies a | |
| * local record created or changed while the remote request was pending, so | |
| * in-flight learner work wins that collision. The helper is pure so callers | |
| * never create a fictional remote record while logged out. | |
| */ | |
| export function mergeLearnAnythingRoadmaps( | |
| local: LearnAnythingRoadmap[], | |
| remote: LearnAnythingRoadmap[], | |
| ): LearnAnythingRoadmap[] { | |
| const merged = new Map(local.map((roadmap) => [roadmap.id, roadmap])); | |
| for (const roadmap of remote) merged.set(roadmap.id, roadmap); | |
| return Array.from(merged.values()); | |
| } | |
| function roadmapSnapshotsMatch( | |
| first: LearnAnythingRoadmap | undefined, | |
| second: LearnAnythingRoadmap, | |
| ): boolean { | |
| return first !== undefined && JSON.stringify(first) === JSON.stringify(second); | |
| } | |
| /** | |
| * Pull a saved roadmap from the backend into localStorage so /learn-anything/ | |
| * [roadmapId] loads it on a fresh device. Returns the roadmap (or null). | |
| */ | |
| export async function hydrateLearnAnythingRoadmap(id: string): Promise<LearnAnythingRoadmap | null> { | |
| const remote = await getRoadmapRemote(id); | |
| const normalized = normalizeLearnAnythingRoadmap(remote); | |
| if (!normalized || normalized.roadmap.id !== id) { | |
| // A remote miss (offline, logged out, failed earlier write) must never | |
| // hide a roadmap that exists on this device. | |
| return readLearnAnythingRoadmap(id); | |
| } | |
| writeLearnAnythingRoadmapLocal(normalized.roadmap); | |
| if (normalized.migrated) void saveRoadmapRemote(normalized.roadmap); | |
| return normalized.roadmap; | |
| } | |
| /** | |
| * Bring every remotely saved track onto this device after mount. This only | |
| * reads the remote API when there is a live session (the API helper otherwise | |
| * returns an empty list), and never changes the current local "last" track. | |
| */ | |
| export async function hydrateLearnAnythingRoadmaps(): Promise<LearnAnythingRoadmap[]> { | |
| const localAtRequestStart = new Map( | |
| listLearnAnythingRoadmaps().map((roadmap) => [roadmap.id, roadmap]), | |
| ); | |
| const remoteRecords = (await listRoadmapsRemote()) | |
| .map(normalizeLearnAnythingRoadmap) | |
| .filter((record): record is NonNullable<typeof record> => record !== null); | |
| const remote = remoteRecords.map((record) => record.roadmap); | |
| // The remote request may stay pending while the learner generates a new | |
| // roadmap. Read storage only after it settles so that in-flight local work | |
| // remains part of the merged result. | |
| const latestLocal = listLearnAnythingRoadmaps(); | |
| const locallyChanged = new Map( | |
| latestLocal | |
| .filter((roadmap) => !roadmapSnapshotsMatch(localAtRequestStart.get(roadmap.id), roadmap)) | |
| .map((roadmap) => [roadmap.id, roadmap]), | |
| ); | |
| const merged = mergeLearnAnythingRoadmaps(latestLocal, remote).map( | |
| (roadmap) => locallyChanged.get(roadmap.id) ?? roadmap, | |
| ); | |
| for (const roadmap of remote) { | |
| writeLearnAnythingRoadmapLocal(locallyChanged.get(roadmap.id) ?? roadmap, false); | |
| } | |
| for (const record of remoteRecords) { | |
| if (record.migrated) void saveRoadmapRemote(record.roadmap); | |
| } | |
| return merged; | |
| } | |
| export function buildWeekendPyqTest(input: TuitionSupportInput): WeekendPyqTest { | |
| const keywords = input.keywords.length > 0 ? input.keywords : ["direct answer", "keywords", "clear points"]; | |
| const weak = input.weakAreas.length > 0 ? input.weakAreas : [keywords[0]]; | |
| return { | |
| id: `weekend-${slugify(input.subject)}-${slugify(input.chapter)}`, | |
| title: "Weekend Exam Practice", | |
| subject: input.subject, | |
| chapter: input.chapter, | |
| weekLabel: "Sample practice", | |
| estimatedMinutes: Math.max(25, input.estimatedMinutes), | |
| safePatternNote: "This is sample exam practice, not full previous-question coverage. It uses a fixed practice set for this beta, not necessarily your most recent lesson. More previous-question coverage will be added as the beta improves.", | |
| questions: [ | |
| { | |
| id: "a1", | |
| section: "A", | |
| label: "Section A - Board-style answer", | |
| question: input.examQuestion, | |
| marks: 2, | |
| expectedAnswer: input.modelAnswer.slice(0, 2).join(" "), | |
| keywords: keywords.slice(0, 3), | |
| weakArea: weak[0], | |
| }, | |
| { | |
| id: "a2", | |
| section: "A", | |
| label: "Section A - Previous-question style", | |
| question: `Write the meaning or main idea of ${input.missionTitle}.`, | |
| marks: 2, | |
| expectedAnswer: input.goal, | |
| keywords: keywords.slice(0, 2), | |
| weakArea: weak[0], | |
| }, | |
| { | |
| id: "b1", | |
| section: "B", | |
| label: "Section B - Exam-relevant practice", | |
| question: `Explain ${input.missionTitle} in four clear points.`, | |
| marks: 4, | |
| expectedAnswer: input.modelAnswer.join(" "), | |
| keywords: keywords.slice(0, 5), | |
| weakArea: weak[1] ?? weak[0], | |
| }, | |
| { | |
| id: "b2", | |
| section: "B", | |
| label: "Section B - Exam-relevant practice", | |
| question: `Use this memory line in an answer: ${input.memoryLine}`, | |
| marks: 4, | |
| expectedAnswer: input.modelAnswer.join(" "), | |
| keywords: keywords.slice(0, 4), | |
| weakArea: weak[2] ?? weak[0], | |
| }, | |
| ], | |
| }; | |
| } | |
| export function evaluatePyqAnswers(test: WeekendPyqTest, answers: Record<string, string>): WeekendPyqResult { | |
| let score = 0; | |
| const missing = new Set<string>(); | |
| const weak = new Set<string>(); | |
| const firstQuestion = test.questions[0]; | |
| for (const question of test.questions) { | |
| const answer = (answers[question.id] ?? "").toLowerCase(); | |
| const matched = question.keywords.filter((keyword) => answer.includes(keyword.toLowerCase())); | |
| score += Math.min(question.marks, matched.length); | |
| if (matched.length < Math.min(question.marks, question.keywords.length)) { | |
| weak.add(question.weakArea); | |
| question.keywords.filter((keyword) => !answer.includes(keyword.toLowerCase())).forEach((keyword) => missing.add(keyword)); | |
| } | |
| } | |
| return { | |
| score, | |
| maxScore: test.questions.reduce((total, question) => total + question.marks, 0), | |
| correctAnswer: firstQuestion.expectedAnswer, | |
| missingKeywords: Array.from(missing).slice(0, 6), | |
| weakAreas: Array.from(weak).slice(0, 4), | |
| suggestedRevision: "Revise the missing keywords, then write one 4-mark answer before the next exam practice.", | |
| }; | |
| } | |
| export function buildRevisionQueue(input: TuitionSupportInput): RevisionQueue { | |
| const weakAreas = input.weakAreas.length > 0 ? input.weakAreas : input.keywords.slice(0, 2); | |
| const firstWeak = weakAreas[0] ?? input.missionTitle; | |
| return { | |
| dueToday: [ | |
| { | |
| id: "recall", | |
| title: `Recall: ${input.missionTitle}`, | |
| source: "Saved from today's class", | |
| dueLabel: "Due today", | |
| minutes: 8, | |
| action: "Quick recall", | |
| }, | |
| { | |
| id: "weak-keyword", | |
| title: `Fix keyword: ${firstWeak}`, | |
| source: "Answer correction", | |
| dueLabel: "Due today", | |
| minutes: 10, | |
| action: "Revise notes", | |
| }, | |
| ], | |
| savedWeakAreas: weakAreas, | |
| flashcards: input.keywords.slice(0, 4).map((keyword) => ({ | |
| front: `What should you remember about ${keyword}?`, | |
| back: `Use ${keyword} in the answer and explain it in one sentence.`, | |
| })), | |
| quickRecall: { | |
| question: input.examQuestion, | |
| answer: input.modelAnswer.slice(0, 2).join(" "), | |
| }, | |
| upcomingReviews: [ | |
| { | |
| id: "weekend-pyq", | |
| title: "Weekend exam practice", | |
| source: "Revision schedule", | |
| dueLabel: "Weekend", | |
| minutes: 25, | |
| action: "Practice questions", | |
| }, | |
| ], | |
| examReadiness: ["2-mark answer: ready after recall", "4-mark answer: needs one written practice", "Exam practice: include weak keywords"], | |
| }; | |
| } | |
| export function buildWeakAreaRecovery(input: TuitionSupportInput): WeakAreaRecovery[] { | |
| const weakAreas = input.weakAreas.length > 0 ? input.weakAreas : input.keywords.slice(0, 3); | |
| return weakAreas.map((topic, index) => ({ | |
| topic, | |
| severity: index === 0 ? "High" : index === 1 ? "Medium" : "Review", | |
| whyWeak: index === 0 ? "Missed in answer writing or quiz feedback." : "Needs one more recall and exam-use check.", | |
| lastMistakeSource: index === 0 ? "Today's class correction" : "Quiz or saved note", | |
| recommendedFix: `Spend 10 minutes on ${topic}, then write one answer using the keyword.`, | |
| actionPrompt: `Explain ${topic} from today's tuition class and give one practice question.`, | |
| })); | |
| } | |
| /** Account-scoped phase3 cache key so a shared device never shows one student's | |
| * weak topics / revision history in another account's Progress. */ | |
| function phase3ActivityStorageKey(): string { | |
| const uid = learnerScopeId(); | |
| return uid ? `${PHASE3_ACTIVITY_KEY}:${uid}` : PHASE3_ACTIVITY_KEY; | |
| } | |
| export function readPhase3Activity(): Phase3Activity { | |
| if (typeof window === "undefined") return {}; | |
| try { | |
| const raw = window.localStorage.getItem(phase3ActivityStorageKey()); | |
| return raw ? (JSON.parse(raw) as Phase3Activity) : {}; | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| export function updatePhase3Activity(patch: Phase3Activity): Phase3Activity { | |
| const current = readPhase3Activity(); | |
| const next: Phase3Activity = { | |
| ...current, | |
| ...patch, | |
| weakTopicsAdded: Array.from(new Set([...(current.weakTopicsAdded ?? []), ...(patch.weakTopicsAdded ?? [])])), | |
| revisionSavedTopics: Array.from(new Set([...(current.revisionSavedTopics ?? []), ...(patch.revisionSavedTopics ?? [])])), | |
| recoveryActions: Array.from(new Set([...(current.recoveryActions ?? []), ...(patch.recoveryActions ?? [])])), | |
| roadmapLessonCompletions: mergeRoadmapActivity( | |
| current.roadmapLessonCompletions, | |
| patch.roadmapLessonCompletions, | |
| (event) => `${event.roadmapId}:${event.lessonId}`, | |
| ), | |
| roadmapQuizAttempts: mergeRoadmapActivity( | |
| current.roadmapQuizAttempts, | |
| patch.roadmapQuizAttempts, | |
| (event) => `${event.roadmapId}:${event.answeredAt}`, | |
| ), | |
| }; | |
| if (typeof window !== "undefined") { | |
| try { | |
| window.localStorage.setItem(phase3ActivityStorageKey(), JSON.stringify(next)); | |
| } catch { | |
| // Local fallback only; ignore storage failure. | |
| } | |
| } | |
| // Write-through to the backend (best-effort, no-op when logged out). | |
| void savePhase3ActivityRemote(next); | |
| return next; | |
| } | |
| function mergeRoadmapActivity<T>( | |
| current: T[] | undefined, | |
| patch: T[] | undefined, | |
| key: (event: T) => string, | |
| ): T[] | undefined { | |
| if (!current && !patch) return undefined; | |
| const merged = new Map<string, T>(); | |
| for (const event of [...(current ?? []), ...(patch ?? [])]) merged.set(key(event), event); | |
| return Array.from(merged.values()); | |
| } | |
| /** | |
| * Pull the saved activity aggregate from the backend into localStorage so the | |
| * progress / revision / weak-area pages reflect real history on a fresh device. | |
| */ | |
| export async function hydratePhase3Activity(): Promise<Phase3Activity | null> { | |
| const remote = await getPhase3ActivityRemote(); | |
| if (remote && typeof window !== "undefined") { | |
| try { | |
| window.localStorage.setItem(phase3ActivityStorageKey(), JSON.stringify(remote)); | |
| } catch { | |
| // Local fallback only; ignore storage failure. | |
| } | |
| } | |
| return remote; | |
| } | |
| export function buildProgressSnapshot(input: TuitionSupportInput, activity: Phase3Activity): ProgressSnapshot { | |
| const hasActivity = Boolean( | |
| activity.pyqSubmittedAt || | |
| activity.weakTopicsAdded?.length || | |
| activity.revisionSavedTopics?.length || | |
| activity.recoveryActions?.length, | |
| ); | |
| return { | |
| empty: !hasActivity, | |
| metrics: [ | |
| { | |
| label: "Today's class", | |
| value: input.missionTitle, | |
| note: "Sample mission — open Today's Tuition to see your real one", | |
| }, | |
| { | |
| label: "Exam practice", | |
| value: activity.pyqSubmittedAt && typeof activity.pyqScore === "number" && typeof activity.pyqMaxScore === "number" | |
| ? `${activity.pyqScore}/${activity.pyqMaxScore}` | |
| : "Not submitted", | |
| note: activity.pyqSubmittedAt ? "Stored locally from exam practice" : "No local practice result yet", | |
| }, | |
| { | |
| label: "Weak topics saved", | |
| value: String(activity.weakTopicsAdded?.length ?? input.weakAreas.length), | |
| note: hasActivity ? "Local recovery list" : "From today's tuition fallback", | |
| }, | |
| { | |
| label: "Revision queue", | |
| value: String(activity.revisionSavedTopics?.length ?? 0), | |
| note: "Local queue until backend contract is wired", | |
| }, | |
| ], | |
| weakAreas: activity.weakTopicsAdded?.length ? activity.weakTopicsAdded : input.weakAreas, | |
| weakAreasIsSample: !activity.weakTopicsAdded?.length, | |
| nextActions: [ | |
| { label: "Start revision", href: "/tuition/revision" }, | |
| { label: "Practice questions", href: "/tuition/pyq-test" }, | |
| { label: "Ask in Study Chat", href: "/study-chat" }, | |
| ], | |
| }; | |
| } | |