DocDoeAI / src /lib /phase3-learning.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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" },
],
};
}