diff --git a/app/(app)/past_papers/[code]/[exam]/page.tsx b/app/(app)/past_papers/[code]/[exam]/page.tsx index fd9fb2cf..993b5369 100644 --- a/app/(app)/past_papers/[code]/[exam]/page.tsx +++ b/app/(app)/past_papers/[code]/[exam]/page.tsx @@ -21,6 +21,7 @@ import { getCourseSyllabusPath, } from "@/lib/seo"; import CourseHeader from "@/app/components/past_papers/course-header"; +import StudyPlanCta from "@/app/components/study-plan/plan-cta"; import CoursePaperGrid from "@/app/components/past_papers/course-paper-grid"; import { DESKTOP_SELECT_ALL_HOST_ID, @@ -191,7 +192,9 @@ async function CourseExamContent({ ]} /> -
+ + +

Open the dedicated {label} collection for {course.code} when you want a focused set of papers for one exam pattern instead of the full course list. diff --git a/app/(app)/past_papers/[code]/page.tsx b/app/(app)/past_papers/[code]/page.tsx index 7d488393..7f55099e 100644 --- a/app/(app)/past_papers/[code]/page.tsx +++ b/app/(app)/past_papers/[code]/page.tsx @@ -34,6 +34,7 @@ import { } from "@/app/components/past_papers/course-paper-grid-controls"; import CoursePagination from "@/app/components/past_papers/course-pagination"; import CourseVisitTracker from "@/app/components/past_papers/course-visit-tracker"; +import StudyPlanCta from "@/app/components/study-plan/plan-cta"; import { campusValues, course as courseTable, @@ -602,6 +603,7 @@ async function CoursePastPapersPageContent({ noteCount={course.noteCount} syllabusId={null} > + + + }> ; +}) { + const [params, courses, upcoming] = await Promise.all([ + searchParams ?? Promise.resolve({} as SearchParams), + getCoursePickerRecords(), + getUpcomingExamsCourseGrid(), + ]); + + // Resolve the curated upcoming-exam courses to full search records (keeping + // the curated order) so they can seed the planner as one-tap entry points. + const byCode = new Map(courses.map((course) => [course.code, course])); + const suggested: CoursePick[] = upcoming + .map((item) => byCode.get(item.code)) + .filter((course): course is CourseSearchRecord => Boolean(course)); + + // A second band of real entry points: the courses with the most material that + // aren't already shown as upcoming exams. Always full, always useful. + const upcomingCodes = new Set(upcoming.map((item) => item.code)); + const popularPicks: CoursePick[] = courses + .filter((course) => !upcomingCodes.has(course.code) && course.paperCount > 0) + .sort((a, b) => b.paperCount - a.paperCount) + .slice(0, 12) + .map((course) => ({ + id: course.id, + code: course.code, + title: course.title, + paperCount: course.paperCount, + })); + + return ( + +

+ +
+
+ +
+
+ + ); +} diff --git a/app/(app)/syllabus/course/[code]/page.tsx b/app/(app)/syllabus/course/[code]/page.tsx index 4c9fc42d..30b91a31 100644 --- a/app/(app)/syllabus/course/[code]/page.tsx +++ b/app/(app)/syllabus/course/[code]/page.tsx @@ -9,6 +9,7 @@ import ViewTracker from "@/app/components/view-tracker"; import { LazySyllabusInlineEditor } from "@/app/components/moderation/lazy-editors"; import StructuredData from "@/app/components/seo/structured-data"; import DirectionalTransition from "@/app/components/common/directional-transition"; +import StudyPlanCta from "@/app/components/study-plan/plan-cta"; import { getCourseByCodeAny } from "@/lib/data/courses"; import { getCourseDetailByCode } from "@/lib/data/course-catalog"; import { getSubjectByCourseCode } from "@/lib/data/resources"; @@ -189,6 +190,8 @@ async function CourseSyllabusContent({ + +
void; + onBuild: (config: ComposerConfig) => void; +}; + +const PRIMARY_EXAMS: ExamType[] = ["CAT_1", "CAT_2", "FAT", "MID", "QUIZ"]; +const MORE_EXAMS: ExamType[] = examTypeValues.filter( + (value) => !PRIMARY_EXAMS.includes(value), +); + +const PREFERENCES: Array<{ id: StudyPreferenceMode; label: string }> = [ + { id: "past_papers", label: "Past papers" }, + { id: "videos", label: "Videos" }, + { id: "notes", label: "Notes" }, + { id: "solved_examples", label: "Solved examples" }, + { id: "quick_summaries", label: "Summaries" }, + { id: "mixed", label: "A bit of everything" }, +]; + +const TOPIC_SUGGESTIONS = ["Numericals", "Derivations", "Definitions", "Diagrams"]; + +const chipBase = + "ec-press inline-flex h-9 items-center gap-1.5 border px-3.5 text-sm font-semibold transition-colors"; +const chipActive = + "border-black bg-[#5FC4E7] text-black dark:border-[#5FC4E7]/60 dark:bg-[#5FC4E7]/15 dark:text-[#D5D5D5]"; +const chipIdle = + "border-black/15 bg-white/60 text-black/70 hover:border-black/40 hover:bg-white dark:border-[#D5D5D5]/15 dark:bg-transparent dark:text-[#D5D5D5]/70 dark:hover:border-[#D5D5D5]/40 dark:hover:bg-white/[0.04]"; + +function normalize(value: string) { + return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); +} + +function Label({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +export default function Composer({ + courses, + initialCourseCode = "", + initialExam = "", + initialSlot = "", + initialPreferences, + initialTopics, + onCourseChange, + onBuild, +}: Props) { + const initialCourse = useMemo( + () => + courses.find( + (course) => + course.code.toUpperCase() === initialCourseCode.toUpperCase(), + ) ?? null, + [courses, initialCourseCode], + ); + + const [courseQuery, setCourseQuery] = useState( + initialCourse ? `${initialCourse.code} ${initialCourse.title}` : "", + ); + const [selectedCourse, setSelectedCourse] = useState( + initialCourse, + ); + const [examType, setExamType] = useState(initialExam); + const [slot, setSlot] = useState(initialSlot.toUpperCase()); + const [preferences, setPreferences] = useState( + initialPreferences ?? ["past_papers", "mixed"], + ); + const [topics, setTopics] = useState(initialTopics ?? []); + const [topicDraft, setTopicDraft] = useState(""); + const [showMoreExams, setShowMoreExams] = useState( + Boolean(initialExam) && MORE_EXAMS.includes(initialExam as ExamType), + ); + + const buildVerb = pickFrom(BUILD_VERBS, `${selectedCourse?.code ?? ""}:${examType}`); + + useEffect(() => { + onCourseChange?.(selectedCourse); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedCourse]); + + const matches = useMemo(() => { + const query = normalize(courseQuery); + if (!query) return []; + return courses + .filter((course) => + normalize( + `${course.code} ${course.title} ${course.aliases.join(" ")}`, + ).includes(query), + ) + .slice(0, 6); + }, [courseQuery, courses]); + + const togglePreference = (id: StudyPreferenceMode) => { + setPreferences((current) => + current.includes(id) + ? current.filter((item) => item !== id) + : [...current, id], + ); + }; + + const addTopic = (raw: string) => { + const value = raw.trim(); + if (!value) return; + setTopics((current) => + current.some((topic) => topic.toLowerCase() === value.toLowerCase()) || + current.length >= 12 + ? current + : [...current, value], + ); + setTopicDraft(""); + }; + + const ready = Boolean(selectedCourse && examType); + + const handleBuild = () => { + if (!selectedCourse || !examType) return; + onBuild({ course: selectedCourse, examType, slot, preferences, topics }); + }; + + return ( +
+
+
+

+ Make a study plan. +

+

+ Pick your course, exam, and slot. ExamCooker orders what to study using + the syllabus, past papers, and what earlier slots reported. +

+
+ + {/* Course */} +
+ + {selectedCourse ? ( +
+
+

+ {selectedCourse.code} +

+

+ {selectedCourse.title} +

+

+ {selectedCourse.paperCount} paper + {selectedCourse.paperCount === 1 ? "" : "s"}, {selectedCourse.noteCount}{" "} + note{selectedCourse.noteCount === 1 ? "" : "s"} +

+
+ +
+ ) : ( + <> +
+ + setCourseQuery(event.target.value)} + placeholder="Search a course or code" + className="h-full min-w-0 flex-1 bg-transparent px-3 text-base text-black focus:outline-none placeholder:text-black/35 dark:text-[#D5D5D5] dark:placeholder:text-[#D5D5D5]/40" + /> + {courseQuery ? ( + + ) : null} +
+ {!courseQuery.trim() ? ( +

+ Search for a course, or pick an upcoming exam below. +

+ ) : matches.length > 0 ? ( +
+ {matches.map((course, index) => ( + + ))} +
+ ) : ( +

+ No match. Try the course code, like BCSE202L. +

+ )} + + )} +
+ + {selectedCourse ? ( + <> + {/* Exam */} +
+ +
+ {PRIMARY_EXAMS.map((value) => ( + + ))} + {showMoreExams ? ( + MORE_EXAMS.map((value) => ( + + )) + ) : ( + + )} +
+
+ + Slot + + setSlot(event.target.value.toUpperCase())} + placeholder="optional" + aria-label="Exam slot" + className="h-full min-w-0 flex-1 bg-transparent px-3 text-sm font-semibold text-black focus:outline-none placeholder:font-normal placeholder:text-black/35 dark:text-[#D5D5D5] dark:placeholder:text-[#D5D5D5]/35" + /> +
+
+ + {/* Topics */} +
+ +
+ setTopicDraft(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + addTopic(topicDraft); + } + }} + placeholder="Add a topic and press enter" + aria-label="Add a topic" + className="h-full min-w-0 flex-1 bg-transparent text-sm text-black focus:outline-none placeholder:text-black/40 dark:text-[#D5D5D5] dark:placeholder:text-[#D5D5D5]/40" + /> + +
+ {topics.length > 0 ? ( +
+ {topics.map((topic) => ( + + {topic} + + + ))} +
+ ) : ( +
+ + Try + + {TOPIC_SUGGESTIONS.map((suggestion) => ( + + ))} +
+ )} +
+ + {/* Style */} +
+ +
+ {PREFERENCES.map((option) => ( + + ))} +
+
+ + {/* Build */} +
+ + {!ready ? ( +

+ Choose an exam to build your plan. +

+ ) : null} +
+ + ) : null} +
+ ); +} diff --git a/app/components/study-plan/copy.ts b/app/components/study-plan/copy.ts new file mode 100644 index 00000000..6c7a2c4a --- /dev/null +++ b/app/components/study-plan/copy.ts @@ -0,0 +1,28 @@ +// Light, deterministic copy variation so the feature isn't one rigid branded +// label repeated everywhere. Kept plain and literal on purpose — no slogans, +// no em dashes, no exaggerated lines. + +function hashSeed(seed: string): number { + let h = 2166136261; + for (let i = 0; i < seed.length; i += 1) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return Math.abs(h); +} + +/** Deterministic pick so SSR and client agree (no hydration drift). */ +export function pickFrom(pool: readonly T[], seed: string): T { + return pool[hashSeed(seed) % pool.length] as T; +} + +export const PLAN_TITLES = ["Study plan", "Your plan", "Prep plan"] as const; + +export const BUILD_VERBS = ["Build plan", "Make plan", "Build the plan"] as const; + +export const CTA_HOOKS = [ + "Build a study plan for this exam.", + "Plan your prep for this course.", + "Turn this course into a study plan.", + "Make a plan before the exam.", +] as const; diff --git a/app/components/study-plan/generating.tsx b/app/components/study-plan/generating.tsx new file mode 100644 index 00000000..16a4828d --- /dev/null +++ b/app/components/study-plan/generating.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Check, Loader2 } from "lucide-react"; +import { examTypeLabel } from "@/lib/exam-slug"; +import type { ComposerConfig } from "./sample-plan"; + +type Props = { + config: ComposerConfig; + onDone: () => void; +}; + +export default function Generating({ config, onDone }: Props) { + const examLabel = config.examType ? examTypeLabel(config.examType) : ""; + + const stages = useMemo(() => { + const list: string[] = ["Matching the syllabus"]; + list.push( + config.course.paperCount > 0 + ? `Reading past papers${examLabel ? ` for ${examLabel}` : ""}` + : "Reading the syllabus modules", + ); + list.push("Checking earlier slots"); + list.push("Lining up resources"); + list.push("Ordering by priority"); + return list; + }, [config.course.paperCount, examLabel]); + + const [active, setActive] = useState(0); + + useEffect(() => { + const reduce = + typeof window !== "undefined" && + window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + const step = reduce ? 130 : 720; + let index = 0; + let timer: ReturnType; + + const tick = () => { + index += 1; + setActive(index); + if (index < stages.length) { + timer = setTimeout(tick, step); + } else { + timer = setTimeout(onDone, reduce ? 120 : 540); + } + }; + + timer = setTimeout(tick, step); + return () => clearTimeout(timer); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const progress = Math.min(100, Math.round((active / stages.length) * 100)); + + return ( +
+
+
+

+ Building your plan +

+

+ {config.course.code} + {examLabel ? ` · ${examLabel}` : ""} + {config.slot ? ` · slot ${config.slot}` : ""} +

+
+ +
+
+
+ +
    + {stages.map((label, index) => { + const done = index < active; + const isActive = index === active; + return ( +
  1. + + {done ? ( + + ) : isActive ? ( + + ) : null} + + + {label} + +
  2. + ); + })} +
+
+ ); +} diff --git a/app/components/study-plan/plan-cta.tsx b/app/components/study-plan/plan-cta.tsx new file mode 100644 index 00000000..c3b7e1e4 --- /dev/null +++ b/app/components/study-plan/plan-cta.tsx @@ -0,0 +1,64 @@ +import Link from "next/link"; +import { CTA_HOOKS, pickFrom } from "./copy"; + +type Props = { + courseCode?: string; + examType?: string; + slot?: string; + variant?: "compact" | "panel"; +}; + +function buildHref({ courseCode, examType, slot }: Props) { + const params = new URLSearchParams(); + if (courseCode) params.set("course", courseCode); + if (examType) params.set("exam", examType); + if (slot) params.set("slot", slot); + const query = params.toString(); + return `/study-plan${query ? `?${query}` : ""}`; +} + +export default function PlanCta({ + courseCode, + examType, + slot, + variant = "panel", +}: Props) { + const href = buildHref({ courseCode, examType, slot }); + const seed = courseCode ?? "exam-cooker"; + + if (variant === "compact") { + return ( + + Make a plan + + ); + } + + return ( + +
+

+ {pickFrom(CTA_HOOKS, seed)} +

+

+ Say what is coming and how you study. ExamCooker orders the high-yield + work first, using the syllabus, past papers, and earlier slots. +

+
+ + Make a plan + + → + + + + ); +} diff --git a/app/components/study-plan/plan-view.tsx b/app/components/study-plan/plan-view.tsx new file mode 100644 index 00000000..f3c728e3 --- /dev/null +++ b/app/components/study-plan/plan-view.tsx @@ -0,0 +1,295 @@ +"use client"; + +import type { ComponentType } from "react"; +import Link from "next/link"; +import { + ArrowLeft, + BookOpen, + ExternalLink, + FastForward, + PenLine, + Play, + RotateCcw, +} from "lucide-react"; +import { examTypeLabel } from "@/lib/exam-slug"; +import { getCoursePastPapersPath } from "@/lib/seo"; +import { GradientText } from "@/app/components/landing/landing"; +import type { StudyPlan, StudyPlanSection } from "@/lib/study-brain/schemas"; +import { formatMinutes } from "./sample-plan"; +import { PLAN_TITLES, pickFrom } from "./copy"; + +type Priority = StudyPlanSection["priority"]; +type TaskKind = StudyPlanSection["tasks"][number]["kind"]; + +type Props = { + plan: StudyPlan; + onReset: () => void; +}; + +const PRIORITY_META: Record = { + critical: { + label: "Start here", + card: "border-2 border-black/10 bg-white dark:border-[#D5D5D5]/12 dark:bg-white/[0.04]", + }, + high: { + label: "High yield", + card: "border-2 border-[#5FC4E7]/70 bg-white dark:border-[#5FC4E7]/25 dark:bg-white/[0.03]", + }, + medium: { + label: "Worth a pass", + card: "border border-black/12 bg-white dark:border-[#D5D5D5]/12 dark:bg-white/[0.02]", + }, + low: { + label: "Optional", + card: "border border-dashed border-black/20 bg-transparent dark:border-[#D5D5D5]/15", + }, +}; + +const KIND_META: Record; label: string }> = { + read: { Icon: BookOpen, label: "Read" }, + watch: { Icon: Play, label: "Watch" }, + practice: { Icon: PenLine, label: "Practice" }, + revise: { Icon: RotateCcw, label: "Revise" }, + skim: { Icon: FastForward, label: "Skim" }, +}; + +function humanJoin(parts: string[]): string { + if (parts.length === 0) return "the syllabus"; + if (parts.length === 1) return parts[0] as string; + if (parts.length === 2) return `${parts[0]} and ${parts[1]}`; + return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`; +} + +function Stat({ value, label }: { value: string | number; label: string }) { + return ( +
+ + {value} + + + {label} + +
+ ); +} + +export default function PlanView({ plan, onReset }: Props) { + const { context, evidenceSummary } = plan; + const examLabel = context.examType ? examTypeLabel(context.examType) : ""; + const title = pickFrom(PLAN_TITLES, context.courseCode); + + const builtFromParts: string[] = ["the syllabus"]; + if (evidenceSummary.pastPapersUsed > 0) { + builtFromParts.push( + `${evidenceSummary.pastPapersUsed} past paper${evidenceSummary.pastPapersUsed === 1 ? "" : "s"}`, + ); + } + if (evidenceSummary.earlierSlotSignalsUsed > 0) { + builtFromParts.push( + `${evidenceSummary.earlierSlotSignalsUsed} earlier-slot report${evidenceSummary.earlierSlotSignalsUsed === 1 ? "" : "s"}`, + ); + } + if (evidenceSummary.webResourcesUsed > 0) { + builtFromParts.push( + `${evidenceSummary.webResourcesUsed} resource${evidenceSummary.webResourcesUsed === 1 ? "" : "s"}`, + ); + } + + return ( +
+ {/* Top bar */} +
+ + + Preview + +
+ + {/* Header */} +
+
+

+ {title} +

+

+ {context.courseTitle} +

+

+ {context.courseCode} + {examLabel ? ` · ${examLabel}` : ""} + {context.slot ? ` · slot ${context.slot}` : ""} +

+
+ + {/* Stat strip */} +
+
+ + + + {evidenceSummary.earlierSlotSignalsUsed > 0 ? ( + + ) : null} + +
+

+ Built from {humanJoin(builtFromParts)}. Highest priority first. +

+
+ + {/* Sections */} +
+ {plan.sections.map((section, index) => { + const meta = PRIORITY_META[section.priority]; + const isCritical = section.priority === "critical"; + return ( +
+ {isCritical ? ( + + ) : null} +
+
+

+ {isCritical ? ( + {meta.label} + ) : ( + + {meta.label} + + )} +

+

+ {section.topicTitle} +

+
+ + {formatMinutes(section.estimatedMinutes)} + +
+ +

+ {section.reason} +

+ + {section.evidence.length > 0 ? ( +
+ {section.evidence.map((item, evidenceIndex) => ( + + {item.label} + + ))} +
+ ) : null} + +
    + {section.tasks.map((task, taskIndex) => { + const kind = KIND_META[task.kind]; + const Icon = kind.Icon; + return ( +
  • + + + +
    + {task.url ? ( + + {task.title} + + + ) : ( + + {task.title} + + )} +

    + {kind.label} + {task.source ? `, ${task.source}` : ""} + {task.skipIfShortOnTime ? ", skippable" : ""} +

    +
    + + {task.estimatedMinutes}m + +
  • + ); + })} +
+
+ ); + })} +
+ + {/* Warnings */} + {plan.warnings.length > 0 ? ( +
+ {plan.warnings.map((warning) => ( +

+ {warning} +

+ ))} +
+ ) : null} + + {/* After the exam */} +
+

+ After the exam +

+

+ You can mark what actually came up from a list of topics. It takes a + few seconds and helps the plan for students in later slots. +

+
+ + {/* Footer */} +
+ + Open {context.courseCode} past papers + + + +
+
+ ); +} diff --git a/app/components/study-plan/sample-plan.ts b/app/components/study-plan/sample-plan.ts new file mode 100644 index 00000000..04c56e7e --- /dev/null +++ b/app/components/study-plan/sample-plan.ts @@ -0,0 +1,318 @@ +import type { ExamType } from "@/db/enums"; +import type { CourseSearchRecord } from "@/lib/data/course-catalog"; +import { examTypeLabel } from "@/lib/exam-slug"; +import type { + StudyPlan, + StudyPlanSection, + StudyPreferenceMode, +} from "@/lib/study-brain/schemas"; + +type StudyPlanTask = StudyPlanSection["tasks"][number]; +type StudyPlanEvidence = StudyPlanSection["evidence"][number]; +type Priority = StudyPlanSection["priority"]; + +export type ComposerConfig = { + course: CourseSearchRecord; + examType: ExamType | ""; + slot: string; + preferences: StudyPreferenceMode[]; + topics: string[]; +}; + +// --- deterministic helpers ---------------------------------------------------- + +function seedNumber(seed: string): number { + let h = 2166136261; + for (let i = 0; i < seed.length; i += 1) { + h ^= seed.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return Math.abs(h); +} + +function pick(pool: readonly T[], seed: string): T { + return pool[seedNumber(seed) % pool.length] as T; +} + +export function formatMinutes(total: number): string { + if (total <= 0) return "0 min"; + const hours = Math.floor(total / 60); + const mins = total % 60; + if (hours === 0) return `${mins} min`; + if (mins === 0) return `${hours}h`; + return `${hours}h ${mins}m`; +} + +// --- content pools ------------------------------------------------------------ + +const DEFAULT_TOPICS = [ + "Core theory", + "Common numericals", + "Definitions and short answers", + "Derivations and proofs", + "Repeated paper questions", +]; + +const REASONS: Record = { + critical: [ + "Appeared in your last two {exam} papers.", + "High weightage on the syllabus, and it repeats most years.", + ], + high: [ + "Comes up in most {exam} cycles.", + "Overlaps with what earlier slots reported.", + ], + medium: [ + "Shows up occasionally. A quick pass is enough.", + "Good marks for the time if the basics feel shaky.", + ], + low: [ + "Lower priority this cycle.", + "Optional if you have time left at the end.", + ], +}; + +const PRIORITY_BY_INDEX: Priority[] = [ + "critical", + "high", + "high", + "medium", + "low", +]; + +function youtubeSearch(query: string): string { + return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`; +} + +function prefersAny( + prefs: StudyPreferenceMode[], + target: StudyPreferenceMode, +): boolean { + return prefs.includes(target) || prefs.includes("mixed"); +} + +// --- section + task assembly -------------------------------------------------- + +function buildTasks( + topic: string, + priority: Priority, + config: ComposerConfig, + examLabel: string, +): StudyPlanTask[] { + const { preferences, course } = config; + const lower = topic.toLowerCase(); + const prefTasks: StudyPlanTask[] = []; + + if (course.paperCount > 0 && prefersAny(preferences, "past_papers")) { + prefTasks.push({ + kind: "practice", + title: `Work the ${examLabel} questions on ${lower}`, + source: "Past papers", + estimatedMinutes: 22, + }); + } + if (prefersAny(preferences, "videos")) { + prefTasks.push({ + kind: "watch", + title: `Watch a walkthrough of ${lower}`, + source: "YouTube", + url: youtubeSearch(`${course.code} ${topic}`), + estimatedMinutes: 15, + }); + } + if (prefersAny(preferences, "notes") && course.noteCount > 0) { + prefTasks.push({ + kind: "read", + title: `Skim the clearest notes on ${lower}`, + source: "Notes", + estimatedMinutes: 12, + }); + } + if (prefersAny(preferences, "solved_examples")) { + prefTasks.push({ + kind: "practice", + title: "Redo three solved examples without peeking", + source: "Solved sets", + estimatedMinutes: 18, + }); + } + if (prefersAny(preferences, "quick_summaries")) { + prefTasks.push({ + kind: "revise", + title: "Run the one-page summary out loud", + estimatedMinutes: 8, + skipIfShortOnTime: true, + }); + } + + if (prefTasks.length === 0) { + prefTasks.push({ + kind: "read", + title: `Read through ${lower} once`, + source: "Syllabus", + estimatedMinutes: 20, + }); + } + + // Keep the core work, then close with a short recall pass that is the first + // thing to drop when time runs out (except for the must-do topic). + const tasks = prefTasks.slice(0, 3); + tasks.push({ + kind: "revise", + title: "Quick recall pass", + estimatedMinutes: 8, + skipIfShortOnTime: priority !== "critical", + }); + + return tasks; +} + +function buildEvidence( + index: number, + priority: Priority, + config: ComposerConfig, + examLabel: string, +): { evidence: StudyPlanEvidence[]; usedWebResource: boolean } { + const { course, slot, preferences } = config; + const evidence: StudyPlanEvidence[] = [ + { + type: "syllabus", + label: `Module ${index + 1} on the syllabus`, + confidence: "high", + }, + ]; + + if (course.paperCount > 0) { + const seen = Math.max(1, Math.min(course.paperCount, 2 + (index % 3))); + evidence.push({ + type: "past_paper", + label: `Seen in ${seen} past ${examLabel} paper${seen === 1 ? "" : "s"}`, + confidence: course.paperCount >= 4 ? "high" : "medium", + }); + } + + if (slot && index < 2) { + const family = slot.charAt(0).toUpperCase(); + evidence.push({ + type: "slot_report", + label: `Reported by ${family}-slot students`, + confidence: "medium", + }); + } + + let usedWebResource = false; + if ( + (prefersAny(preferences, "videos") || prefersAny(preferences, "notes")) && + priority !== "low" + ) { + usedWebResource = true; + evidence.push({ + type: index % 2 === 0 ? "resource" : "web", + label: index % 2 === 0 ? "Matched note + lecture" : "Vetted web resource", + confidence: "medium", + }); + } + + return { evidence, usedWebResource }; +} + +function buildSection( + topic: string, + index: number, + config: ComposerConfig, + examLabel: string, +): { section: StudyPlanSection; usedWebResource: boolean } { + const priority = PRIORITY_BY_INDEX[index] ?? "low"; + const tasks = buildTasks(topic, priority, config, examLabel); + const estimatedMinutes = Math.min( + 600, + tasks.reduce((sum, task) => sum + task.estimatedMinutes, 0), + ); + const { evidence, usedWebResource } = buildEvidence( + index, + priority, + config, + examLabel, + ); + const reason = pick(REASONS[priority], `${config.course.code}:${topic}`).replace( + "{exam}", + examLabel, + ); + + return { + section: { + topicId: undefined, + topicTitle: topic, + priority, + estimatedMinutes, + reason, + evidence, + tasks, + }, + usedWebResource, + }; +} + +/** + * Builds a realistic, schema-valid plan from the composer choices. This stands + * in for the worker's generated plan until the research agent is wired up — + * it reads from real course counts where we have them and is clearly labelled + * as a preview in the UI so nothing is presented as confirmed fact. + */ +export function buildSamplePlan(config: ComposerConfig): StudyPlan { + const { course, examType, slot } = config; + const examLabel = examType ? examTypeLabel(examType) : "exam"; + const sourceTopics = + config.topics.length > 0 ? config.topics.slice(0, 5) : DEFAULT_TOPICS; + + let webResourcesUsed = 0; + const sections = sourceTopics.map((topic, index) => { + const { section, usedWebResource } = buildSection( + topic, + index, + config, + examLabel, + ); + if (usedWebResource) webResourcesUsed += 1; + return section; + }); + + const totalEstimatedMinutes = sections.reduce( + (sum, section) => sum + section.estimatedMinutes, + 0, + ); + + const warnings: string[] = []; + if (!slot) { + warnings.push( + "Add your slot and ExamCooker can fold in earlier-slot reports too.", + ); + } + if (course.paperCount === 0) { + warnings.push( + "No past papers for this course yet, so a few calls lean on the syllabus alone.", + ); + } + + return { + title: `${course.code} ${examLabel}`, + context: { + courseCode: course.code, + courseTitle: course.title, + examType: examType || null, + slot: slot || null, + syllabusName: null, + }, + evidenceSummary: { + syllabusTopicsUsed: sections.length, + pastPapersUsed: Math.min(course.paperCount, 6), + earlierSlotSignalsUsed: slot + ? 3 + (seedNumber(course.code + slot) % 5) + : 0, + webResourcesUsed, + }, + totalEstimatedMinutes, + sections, + warnings, + }; +} diff --git a/app/components/study-plan/study-plan-experience.tsx b/app/components/study-plan/study-plan-experience.tsx new file mode 100644 index 00000000..56977305 --- /dev/null +++ b/app/components/study-plan/study-plan-experience.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { CourseSearchRecord } from "@/lib/data/course-catalog"; +import { parseExamTypeInput } from "@/lib/exam-slug"; +import type { StudyPlan } from "@/lib/study-brain/schemas"; +import Composer from "./composer"; +import Generating from "./generating"; +import PlanView from "./plan-view"; +import UpcomingExams from "./upcoming-exams"; +import { buildSamplePlan, type ComposerConfig } from "./sample-plan"; + +type CoursePick = { + id: string; + code: string; + title: string; + paperCount: number; +}; + +type Props = { + courses: CourseSearchRecord[]; + suggested: CoursePick[]; + popular: CoursePick[]; + initialCourse?: string; + initialExam?: string; + initialSlot?: string; +}; + +type Phase = "compose" | "building" | "plan"; + +function scrollToTop() { + if (typeof window !== "undefined") { + window.scrollTo({ top: 0, behavior: "smooth" }); + } +} + +export default function StudyPlanExperience({ + courses, + suggested, + popular, + initialCourse = "", + initialExam = "", + initialSlot = "", +}: Props) { + const [phase, setPhase] = useState("compose"); + const [config, setConfig] = useState(null); + const [plan, setPlan] = useState(null); + const [quickCourse, setQuickCourse] = useState(null); + const [hasCourse, setHasCourse] = useState(false); + + useEffect(() => { + scrollToTop(); + }, [phase]); + + const handleBuild = (next: ComposerConfig) => { + setConfig(next); + setPlan(buildSamplePlan(next)); + setPhase("building"); + }; + + const handlePick = (code: string) => { + setQuickCourse(code); + scrollToTop(); + }; + + if (phase === "building" && config) { + return setPhase("plan")} />; + } + + if (phase === "plan" && plan) { + return setPhase("compose")} />; + } + + const seedCourse = quickCourse ?? config?.course.code ?? initialCourse; + + return ( +
+ setHasCourse(Boolean(course))} + onBuild={handleBuild} + /> + {!hasCourse ? ( + <> + + + + ) : null} +
+ ); +} diff --git a/app/components/study-plan/upcoming-exams.tsx b/app/components/study-plan/upcoming-exams.tsx new file mode 100644 index 00000000..0b95b93f --- /dev/null +++ b/app/components/study-plan/upcoming-exams.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { ArrowRight } from "lucide-react"; + +type CourseCard = { + id: string; + code: string; + title: string; + paperCount: number; +}; + +type Props = { + courses: CourseCard[]; + onPick: (code: string) => void; + title?: string; + hint?: string; +}; + +// Same card grammar as the rest of the site (course-grid-card.tsx): filled cyan +// in light, dark surface in dark, gray mono code, big paper count. These double +// as one-tap entry points into the planner. +export default function CoursePickGrid({ + courses, + onPick, + title = "Upcoming exams", + hint = "Pick one to start", +}: Props) { + if (courses.length === 0) return null; + + return ( +
+
+

+ {title} +

+ + {hint} + +
+
+ {courses.map((course, index) => ( + + ))} +
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index c3c60adc..5cf4da1c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -224,6 +224,101 @@ overflow: hidden; } +/* ── Study plan surface ──────────────────────────────────────────────── */ +@keyframes sp-rise { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Staggered page-load reveal. Drive cascade with `--sp-i` per element. */ +.sp-rise { + animation: sp-rise 560ms var(--ec-ease-out-soft) both; + animation-delay: calc(var(--sp-i, 0) * 70ms); +} + +@keyframes sp-fade { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Phase transition (compose → building → plan). */ +.sp-fade { + animation: sp-fade 440ms var(--ec-ease-out-soft) both; +} + +/* Brand-blue focus ring — keeps the accent off the mint used app-wide. */ +.sp-focus { + transition: box-shadow 220ms var(--ec-ease-out-soft), border-color 220ms ease; +} + +.sp-focus:focus-within { + box-shadow: 0 0 0 2px rgb(39 186 236 / 0.42); + border-color: rgb(39 186 236 / 0.60); +} + +/* Soft brand-blue aura for atmosphere behind the hero block. */ +.sp-aura { + position: relative; + isolation: isolate; +} + +.sp-aura::before { + content: ""; + position: absolute; + z-index: -1; + left: -6%; + top: -60%; + width: min(540px, 96%); + height: 320px; + background: radial-gradient(56% 56% at 26% 44%, rgb(39 186 236 / 0.16), transparent 72%); + filter: blur(22px); + pointer-events: none; +} + +.dark .sp-aura::before { + background: radial-gradient(56% 56% at 26% 44%, rgb(39 186 236 / 0.20), transparent 72%); +} + +/* Signature gradient hairline. */ +.sp-rule { + height: 2px; + width: 56px; + border-radius: 2px; + background: linear-gradient(to right, #253ee0, #27baec); +} + +/* Gradient sweep that wipes across the build CTA on hover. */ +.sp-cta-sheen { + position: relative; + overflow: hidden; +} + +.sp-cta-sheen::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(110deg, transparent 20%, rgb(255 255 255 / 0.35) 50%, transparent 80%); + transform: translateX(-120%); + transition: transform 620ms var(--ec-ease-out-soft); + pointer-events: none; +} + +@media (hover: hover) { + .sp-cta-sheen:hover::after { + transform: translateX(120%); + } +} + +@media (prefers-reduced-motion: reduce) { + .sp-rise, + .sp-fade { + animation: none; + } + .sp-cta-sheen::after { + display: none; + } +} + @media (prefers-reduced-motion: reduce) { .ec-press, .ec-card-lift, diff --git a/db/schema.ts b/db/schema.ts index 8aa8f10c..7fa2c77b 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -614,6 +614,227 @@ export const upcomingExam = cockroachTable( table.scheduledAt.asc(), ), ], + ); + +export const examInstance = cockroachTable( + "ExamInstance", + { + id: string().$defaultFn(createId).primaryKey(), + courseId: string() + .notNull() + .references(() => course.id, { onDelete: "cascade", onUpdate: "cascade" }), + courseCode: string().notNull(), + examType: examType(), + semester: semester().default("UNKNOWN").notNull(), + campus: campus().default("VELLORE").notNull(), + slot: string(), + scheduledAt: timestamp({ mode: "date", precision: 3 }), + status: string().default("upcoming").notNull(), + createdAt: createdAtColumn(), + updatedAt: updatedAtColumn(), + }, + (table) => [ + index("ExamInstance_courseId_idx").using("btree", table.courseId.asc()), + index("ExamInstance_course_exam_slot_idx").using( + "btree", + table.courseCode.asc(), + table.examType.asc(), + table.semester.asc(), + table.campus.asc(), + table.slot.asc(), + ), + index("ExamInstance_scheduledAt_idx").using("btree", table.scheduledAt.asc()), + ], +); + +export const syllabusTopicExtraction = cockroachTable( + "SyllabusTopicExtraction", + { + id: string().$defaultFn(createId).primaryKey(), + syllabusId: string() + .notNull() + .references(() => syllabi.id, { onDelete: "cascade", onUpdate: "cascade" }), + sourceHash: string().notNull(), + model: string().notNull(), + promptVersion: string().notNull(), + status: string().default("queued").notNull(), + topics: jsonb().$type(), + error: string(), + createdAt: createdAtColumn(), + updatedAt: updatedAtColumn(), + }, + (table) => [ + index("SyllabusTopicExtraction_syllabusId_idx").using("btree", table.syllabusId.asc()), + uniqueIndex("SyllabusTopicExtraction_source_key").using( + "btree", + table.syllabusId.asc(), + table.sourceHash.asc(), + table.model.asc(), + table.promptVersion.asc(), + ), + ], +); + +export const canonicalTopic = cockroachTable( + "CanonicalTopic", + { + id: string().$defaultFn(createId).primaryKey(), + courseId: string() + .notNull() + .references(() => course.id, { onDelete: "cascade", onUpdate: "cascade" }), + syllabusId: string().references(() => syllabi.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + moduleLabel: string(), + title: string().notNull(), + aliases: string().array().$defaultFn(() => []), + embedding: jsonb().$type(), + createdAt: createdAtColumn(), + updatedAt: updatedAtColumn(), + }, + (table) => [ + index("CanonicalTopic_courseId_idx").using("btree", table.courseId.asc()), + index("CanonicalTopic_title_idx").using("btree", table.title.asc()), + ], +); + +export const examSignal = cockroachTable( + "ExamSignal", + { + id: string().$defaultFn(createId).primaryKey(), + examInstanceId: string().references(() => examInstance.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + userId: string().references(() => user.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + topicId: string().references(() => canonicalTopic.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + signalType: string().notNull(), + source: string().notNull(), + rawText: string(), + confidence: int4().default(1).notNull(), + metadata: jsonb().$type>(), + createdAt: createdAtColumn(), + updatedAt: updatedAtColumn(), + }, + (table) => [ + index("ExamSignal_examInstanceId_idx").using("btree", table.examInstanceId.asc()), + index("ExamSignal_topicId_idx").using("btree", table.topicId.asc()), + index("ExamSignal_userId_idx").using("btree", table.userId.asc()), + index("ExamSignal_type_createdAt_idx").using( + "btree", + table.signalType.asc(), + table.createdAt.asc(), + ), + ], +); + +export const studyPlanRun = cockroachTable( + "StudyPlanRun", + { + id: string().$defaultFn(createId).primaryKey(), + userId: string() + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + courseId: string().references(() => course.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + examInstanceId: string().references(() => examInstance.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + courseCode: string().notNull(), + examType: examType(), + syllabusId: string().references(() => syllabi.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + selectedTopics: jsonb().$type().notNull(), + studyPreferences: jsonb().$type().notNull(), + status: string().default("queued").notNull(), + plan: jsonb().$type(), + error: string(), + cost: jsonb().$type>(), + createdAt: createdAtColumn(), + updatedAt: updatedAtColumn(), + }, + (table) => [ + index("StudyPlanRun_userId_updatedAt_idx").using( + "btree", + table.userId.asc(), + table.updatedAt.asc(), + ), + index("StudyPlanRun_course_exam_idx").using( + "btree", + table.courseCode.asc(), + table.examType.asc(), + ), + index("StudyPlanRun_status_idx").using("btree", table.status.asc()), + ], +); + +export const slotIntelligenceSummary = cockroachTable( + "SlotIntelligenceSummary", + { + id: string().$defaultFn(createId).primaryKey(), + courseId: string() + .notNull() + .references(() => course.id, { onDelete: "cascade", onUpdate: "cascade" }), + examType: examType(), + semester: semester().default("UNKNOWN").notNull(), + campus: campus().default("VELLORE").notNull(), + slot: string(), + summary: jsonb().$type().notNull(), + confidence: int4().default(1).notNull(), + generatedAt: timestamp({ mode: "date", precision: 3 }).$defaultFn(now).notNull(), + createdAt: createdAtColumn(), + updatedAt: updatedAtColumn(), + }, + (table) => [ + uniqueIndex("SlotIntelligenceSummary_scope_key").using( + "btree", + table.courseId.asc(), + table.examType.asc(), + table.semester.asc(), + table.campus.asc(), + table.slot.asc(), + ), + ], +); + +export const contributionLedger = cockroachTable( + "ContributionLedger", + { + id: string().$defaultFn(createId).primaryKey(), + userId: string() + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + signalId: string().references(() => examSignal.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + creditDelta: int4().default(0).notNull(), + reputationDelta: int4().default(0).notNull(), + reason: string().notNull(), + status: string().default("pending").notNull(), + createdAt: createdAtColumn(), + updatedAt: updatedAtColumn(), + }, + (table) => [ + index("ContributionLedger_userId_createdAt_idx").using( + "btree", + table.userId.asc(), + table.createdAt.asc(), + ), + index("ContributionLedger_signalId_idx").using("btree", table.signalId.asc()), + ], ); export const viewHistory = cockroachTable( diff --git a/db/types.ts b/db/types.ts index 02b1360c..e57ed085 100644 --- a/db/types.ts +++ b/db/types.ts @@ -11,6 +11,10 @@ import { accounts, comment, course, + canonicalTopic, + contributionLedger, + examInstance, + examSignal, forum, forumPost, module, @@ -19,8 +23,11 @@ import { sessions, studyChat, studyMessage, + studyPlanRun, subject, syllabi, + slotIntelligenceSummary, + syllabusTopicExtraction, tag, upcomingExam, user, @@ -31,6 +38,10 @@ import { export type Account = InferSelectModel; export type Comment = InferSelectModel; export type Course = InferSelectModel; +export type CanonicalTopic = InferSelectModel; +export type ContributionLedger = InferSelectModel; +export type ExamInstance = InferSelectModel; +export type ExamSignal = InferSelectModel; export type Forum = InferSelectModel; export type ForumPost = InferSelectModel; export type Module = InferSelectModel; @@ -39,8 +50,11 @@ export type PastPaper = InferSelectModel; export type Session = InferSelectModel; export type StudyChat = InferSelectModel; export type StudyMessage = InferSelectModel; +export type StudyPlanRun = InferSelectModel; export type Subject = InferSelectModel; export type Syllabus = InferSelectModel; +export type SlotIntelligenceSummary = InferSelectModel; +export type SyllabusTopicExtraction = InferSelectModel; export type Tag = InferSelectModel; export type UpcomingExam = InferSelectModel; export type User = InferSelectModel; @@ -50,6 +64,10 @@ export type Vote = InferSelectModel; export type NewAccount = InferInsertModel; export type NewComment = InferInsertModel; export type NewCourse = InferInsertModel; +export type NewCanonicalTopic = InferInsertModel; +export type NewContributionLedger = InferInsertModel; +export type NewExamInstance = InferInsertModel; +export type NewExamSignal = InferInsertModel; export type NewForum = InferInsertModel; export type NewForumPost = InferInsertModel; export type NewModule = InferInsertModel; @@ -58,8 +76,11 @@ export type NewPastPaper = InferInsertModel; export type NewSession = InferInsertModel; export type NewStudyChat = InferInsertModel; export type NewStudyMessage = InferInsertModel; +export type NewStudyPlanRun = InferInsertModel; export type NewSubject = InferInsertModel; export type NewSyllabus = InferInsertModel; +export type NewSlotIntelligenceSummary = InferInsertModel; +export type NewSyllabusTopicExtraction = InferInsertModel; export type NewTag = InferInsertModel; export type NewUpcomingExam = InferInsertModel; export type NewUser = InferInsertModel; diff --git a/drizzle/20260619120000_study_brain_foundation/migration.sql b/drizzle/20260619120000_study_brain_foundation/migration.sql new file mode 100644 index 00000000..56164aab --- /dev/null +++ b/drizzle/20260619120000_study_brain_foundation/migration.sql @@ -0,0 +1,215 @@ +CREATE TABLE IF NOT EXISTS "ExamInstance" ( + "id" STRING PRIMARY KEY, + "courseId" STRING NOT NULL, + "courseCode" STRING NOT NULL, + "examType" "ExamType", + "semester" "Semester" DEFAULT 'UNKNOWN' NOT NULL, + "campus" "Campus" DEFAULT 'VELLORE' NOT NULL, + "slot" STRING, + "scheduledAt" timestamp(3), + "status" STRING DEFAULT 'upcoming' NOT NULL, + "createdAt" timestamp(3) DEFAULT current_timestamp() NOT NULL, + "updatedAt" timestamp(3) NOT NULL, + CONSTRAINT "ExamInstance_courseId_fkey" + FOREIGN KEY ("courseId") + REFERENCES "Course"("id") + ON DELETE CASCADE + ON UPDATE CASCADE +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ExamInstance_courseId_idx" + ON "ExamInstance" ("courseId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ExamInstance_course_exam_slot_idx" + ON "ExamInstance" ("courseCode", "examType", "semester", "campus", "slot"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ExamInstance_scheduledAt_idx" + ON "ExamInstance" ("scheduledAt"); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "SyllabusTopicExtraction" ( + "id" STRING PRIMARY KEY, + "syllabusId" STRING NOT NULL, + "sourceHash" STRING NOT NULL, + "model" STRING NOT NULL, + "promptVersion" STRING NOT NULL, + "status" STRING DEFAULT 'queued' NOT NULL, + "topics" jsonb, + "error" STRING, + "createdAt" timestamp(3) DEFAULT current_timestamp() NOT NULL, + "updatedAt" timestamp(3) NOT NULL, + CONSTRAINT "SyllabusTopicExtraction_syllabusId_fkey" + FOREIGN KEY ("syllabusId") + REFERENCES "syllabi"("id") + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT "SyllabusTopicExtraction_source_key" + UNIQUE ("syllabusId", "sourceHash", "model", "promptVersion") +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "SyllabusTopicExtraction_syllabusId_idx" + ON "SyllabusTopicExtraction" ("syllabusId"); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "CanonicalTopic" ( + "id" STRING PRIMARY KEY, + "courseId" STRING NOT NULL, + "syllabusId" STRING, + "moduleLabel" STRING, + "title" STRING NOT NULL, + "aliases" STRING[] DEFAULT ARRAY[]:::STRING[], + "embedding" jsonb, + "createdAt" timestamp(3) DEFAULT current_timestamp() NOT NULL, + "updatedAt" timestamp(3) NOT NULL, + CONSTRAINT "CanonicalTopic_courseId_fkey" + FOREIGN KEY ("courseId") + REFERENCES "Course"("id") + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT "CanonicalTopic_syllabusId_fkey" + FOREIGN KEY ("syllabusId") + REFERENCES "syllabi"("id") + ON DELETE SET NULL + ON UPDATE CASCADE +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "CanonicalTopic_courseId_idx" + ON "CanonicalTopic" ("courseId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "CanonicalTopic_title_idx" + ON "CanonicalTopic" ("title"); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "ExamSignal" ( + "id" STRING PRIMARY KEY, + "examInstanceId" STRING, + "userId" STRING, + "topicId" STRING, + "signalType" STRING NOT NULL, + "source" STRING NOT NULL, + "rawText" STRING, + "confidence" INT4 DEFAULT 1 NOT NULL, + "metadata" jsonb, + "createdAt" timestamp(3) DEFAULT current_timestamp() NOT NULL, + "updatedAt" timestamp(3) NOT NULL, + CONSTRAINT "ExamSignal_examInstanceId_fkey" + FOREIGN KEY ("examInstanceId") + REFERENCES "ExamInstance"("id") + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT "ExamSignal_userId_fkey" + FOREIGN KEY ("userId") + REFERENCES "User"("id") + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT "ExamSignal_topicId_fkey" + FOREIGN KEY ("topicId") + REFERENCES "CanonicalTopic"("id") + ON DELETE SET NULL + ON UPDATE CASCADE +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ExamSignal_examInstanceId_idx" + ON "ExamSignal" ("examInstanceId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ExamSignal_topicId_idx" + ON "ExamSignal" ("topicId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ExamSignal_userId_idx" + ON "ExamSignal" ("userId"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ExamSignal_type_createdAt_idx" + ON "ExamSignal" ("signalType", "createdAt"); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "StudyPlanRun" ( + "id" STRING PRIMARY KEY, + "userId" STRING NOT NULL, + "courseId" STRING, + "examInstanceId" STRING, + "courseCode" STRING NOT NULL, + "examType" "ExamType", + "syllabusId" STRING, + "selectedTopics" jsonb NOT NULL, + "studyPreferences" jsonb NOT NULL, + "status" STRING DEFAULT 'queued' NOT NULL, + "plan" jsonb, + "error" STRING, + "cost" jsonb, + "createdAt" timestamp(3) DEFAULT current_timestamp() NOT NULL, + "updatedAt" timestamp(3) NOT NULL, + CONSTRAINT "StudyPlanRun_userId_fkey" + FOREIGN KEY ("userId") + REFERENCES "User"("id") + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT "StudyPlanRun_courseId_fkey" + FOREIGN KEY ("courseId") + REFERENCES "Course"("id") + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT "StudyPlanRun_examInstanceId_fkey" + FOREIGN KEY ("examInstanceId") + REFERENCES "ExamInstance"("id") + ON DELETE SET NULL + ON UPDATE CASCADE, + CONSTRAINT "StudyPlanRun_syllabusId_fkey" + FOREIGN KEY ("syllabusId") + REFERENCES "syllabi"("id") + ON DELETE SET NULL + ON UPDATE CASCADE +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "StudyPlanRun_userId_updatedAt_idx" + ON "StudyPlanRun" ("userId", "updatedAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "StudyPlanRun_course_exam_idx" + ON "StudyPlanRun" ("courseCode", "examType"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "StudyPlanRun_status_idx" + ON "StudyPlanRun" ("status"); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "SlotIntelligenceSummary" ( + "id" STRING PRIMARY KEY, + "courseId" STRING NOT NULL, + "examType" "ExamType", + "semester" "Semester" DEFAULT 'UNKNOWN' NOT NULL, + "campus" "Campus" DEFAULT 'VELLORE' NOT NULL, + "slot" STRING, + "summary" jsonb NOT NULL, + "confidence" INT4 DEFAULT 1 NOT NULL, + "generatedAt" timestamp(3) DEFAULT current_timestamp() NOT NULL, + "createdAt" timestamp(3) DEFAULT current_timestamp() NOT NULL, + "updatedAt" timestamp(3) NOT NULL, + CONSTRAINT "SlotIntelligenceSummary_courseId_fkey" + FOREIGN KEY ("courseId") + REFERENCES "Course"("id") + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT "SlotIntelligenceSummary_scope_key" + UNIQUE ("courseId", "examType", "semester", "campus", "slot") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "ContributionLedger" ( + "id" STRING PRIMARY KEY, + "userId" STRING NOT NULL, + "signalId" STRING, + "creditDelta" INT4 DEFAULT 0 NOT NULL, + "reputationDelta" INT4 DEFAULT 0 NOT NULL, + "reason" STRING NOT NULL, + "status" STRING DEFAULT 'pending' NOT NULL, + "createdAt" timestamp(3) DEFAULT current_timestamp() NOT NULL, + "updatedAt" timestamp(3) NOT NULL, + CONSTRAINT "ContributionLedger_userId_fkey" + FOREIGN KEY ("userId") + REFERENCES "User"("id") + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT "ContributionLedger_signalId_fkey" + FOREIGN KEY ("signalId") + REFERENCES "ExamSignal"("id") + ON DELETE SET NULL + ON UPDATE CASCADE +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ContributionLedger_userId_createdAt_idx" + ON "ContributionLedger" ("userId", "createdAt"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "ContributionLedger_signalId_idx" + ON "ContributionLedger" ("signalId"); diff --git a/lib/study-brain/research/providers.ts b/lib/study-brain/research/providers.ts new file mode 100644 index 00000000..14680cd8 --- /dev/null +++ b/lib/study-brain/research/providers.ts @@ -0,0 +1,45 @@ +export type ResearchSearchProviderId = "parallel" | "exa"; + +export type ResearchSourceType = + | "official" + | "university" + | "docs" + | "tutorial" + | "video" + | "forum" + | "unknown"; + +export type ResearchSearchInput = { + query: string; + topicTitle: string; + courseCode: string; + courseTitle: string; + maxResults: number; +}; + +export type ResearchSearchResult = { + title: string; + url: string; + snippet: string | null; + sourceType: ResearchSourceType; + provider: ResearchSearchProviderId; + providerScore?: number; + publishedAt?: string | null; +}; + +export type ResearchSearchProvider = { + id: ResearchSearchProviderId; + search(input: ResearchSearchInput): Promise; +}; + +export type ResearchBudget = { + maxSearches: number; + maxFetchedPages: number; + maxSynthesisCalls: number; +}; + +export const DEFAULT_RESEARCH_BUDGET: ResearchBudget = { + maxSearches: 8, + maxFetchedPages: 12, + maxSynthesisCalls: 3, +}; diff --git a/lib/study-brain/research/queries.ts b/lib/study-brain/research/queries.ts new file mode 100644 index 00000000..32f44669 --- /dev/null +++ b/lib/study-brain/research/queries.ts @@ -0,0 +1,32 @@ +import type { StudyPreferenceMode } from "@/lib/study-brain/schemas"; + +type BuildResearchQueriesInput = { + courseCode: string; + courseTitle: string; + topicTitle: string; + preferences: StudyPreferenceMode[]; +}; + +export function buildResearchQueries(input: BuildResearchQueriesInput) { + const baseTopic = `${input.courseTitle} ${input.topicTitle}`.replace(/\s+/g, " ").trim(); + const queries = new Set([ + `${baseTopic} explained for engineering students`, + `${baseTopic} solved problems examples`, + `${input.topicTitle} ${input.courseTitle} university notes`, + ]); + + if (input.preferences.includes("videos")) { + queries.add(`${baseTopic} lecture video`); + } + + if (input.preferences.includes("past_papers")) { + queries.add(`${input.courseCode} ${input.topicTitle} previous year questions`); + queries.add(`${baseTopic} exam questions solved`); + } + + if (input.preferences.includes("quick_summaries")) { + queries.add(`${baseTopic} quick revision summary`); + } + + return Array.from(queries).slice(0, 6); +} diff --git a/lib/study-brain/schemas.ts b/lib/study-brain/schemas.ts new file mode 100644 index 00000000..e9d90aaa --- /dev/null +++ b/lib/study-brain/schemas.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; +import { campusValues, examTypeValues, semesterValues } from "@/db/enums"; + +export const StudyPreferenceModeSchema = z.enum([ + "videos", + "past_papers", + "notes", + "solved_examples", + "quick_summaries", + "mixed", +]); + +export const StudyPlanPrioritySchema = z.enum([ + "critical", + "high", + "medium", + "low", +]); + +export const StudyPlanEvidenceSchema = z.object({ + type: z.enum(["syllabus", "past_paper", "slot_report", "web", "resource"]), + label: z.string().min(1), + confidence: z.enum(["high", "medium", "low"]), +}); + +export const StudyPlanTaskSchema = z.object({ + kind: z.enum(["read", "watch", "practice", "revise", "skim"]), + title: z.string().min(1), + url: z.string().url().optional(), + source: z.string().optional(), + estimatedMinutes: z.number().int().min(1).max(240), + skipIfShortOnTime: z.boolean().optional(), +}); + +export const StudyPlanSectionSchema = z.object({ + topicId: z.string().optional(), + topicTitle: z.string().min(1), + priority: StudyPlanPrioritySchema, + estimatedMinutes: z.number().int().min(1).max(600), + reason: z.string().min(1), + evidence: z.array(StudyPlanEvidenceSchema), + tasks: z.array(StudyPlanTaskSchema), +}); + +export const StudyPlanSchema = z.object({ + title: z.string().min(1), + context: z.object({ + courseCode: z.string().min(1), + courseTitle: z.string().min(1), + examType: z.enum(examTypeValues).nullable(), + slot: z.string().nullable().optional(), + syllabusName: z.string().nullable().optional(), + }), + evidenceSummary: z.object({ + syllabusTopicsUsed: z.number().int().min(0), + pastPapersUsed: z.number().int().min(0), + earlierSlotSignalsUsed: z.number().int().min(0), + webResourcesUsed: z.number().int().min(0), + }), + totalEstimatedMinutes: z.number().int().min(0), + sections: z.array(StudyPlanSectionSchema), + warnings: z.array(z.string()), +}); + +export const StudyBrainPlanRequestSchema = z.object({ + courseCode: z.string().min(1), + examType: z.enum(examTypeValues).nullable(), + semester: z.enum(semesterValues).default("UNKNOWN"), + campus: z.enum(campusValues).default("VELLORE"), + slot: z.string().trim().max(20).nullable().optional(), + syllabusId: z.string().nullable().optional(), + selectedTopics: z.array( + z.object({ + topicId: z.string().nullable().optional(), + title: z.string().min(1), + rawText: z.string().optional(), + }), + ), + preferences: z.array(StudyPreferenceModeSchema).min(1), +}); + +export type StudyPreferenceMode = z.infer; +export type StudyPlan = z.infer; +export type StudyPlanSection = z.infer; +export type StudyBrainPlanRequest = z.infer; diff --git a/worker/src/command-agent.ts b/worker/src/command-agent.ts index a46af815..d0bc7b18 100644 --- a/worker/src/command-agent.ts +++ b/worker/src/command-agent.ts @@ -170,7 +170,7 @@ function getTrustedUserContext( }; } -async function getTrustedUserFromCommandToken(input: { +export async function getTrustedUserFromCommandToken(input: { userKey: string; userToken: string; secret: string; diff --git a/worker/src/http.ts b/worker/src/http.ts index 21eebc91..b2d60b7a 100644 --- a/worker/src/http.ts +++ b/worker/src/http.ts @@ -46,7 +46,7 @@ export async function readJsonPayload(request: Request) { return (await request.json().catch(() => null)) as Record | null; } -function readBearerToken(value: string | null) { +export function readBearerToken(value: string | null) { const match = value?.match(/^\s*Bearer\s+(.+?)\s*$/i); return match?.[1]?.trim() ?? ""; } diff --git a/worker/src/index.ts b/worker/src/index.ts index 2ad60248..c0204956 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,10 +1,12 @@ /// import { ExamCookerCommandAgent } from "./command-agent"; +import { ExamCookerStudyBrainAgent } from "./study-brain-agent"; import { routeWorkerRequest } from "./router"; import type { Env } from "./types"; export { ExamCookerCommandAgent }; +export { ExamCookerStudyBrainAgent }; export default { async fetch(request: Request, env: Env) { diff --git a/worker/src/router.ts b/worker/src/router.ts index ea574eba..0db0b9cb 100644 --- a/worker/src/router.ts +++ b/worker/src/router.ts @@ -14,9 +14,10 @@ export async function routeWorkerRequest(request: Request, env: Env) { if (pathname === "/health") { return jsonResponse({ ok: true, - service: "examcooker-command-agent", + service: "examcooker-agent-worker", routes: [ "/agents/ExamCookerCommandAgent/global", + "/agents/ExamCookerStudyBrainAgent/global", ], }); } diff --git a/worker/src/study-brain-agent.ts b/worker/src/study-brain-agent.ts new file mode 100644 index 00000000..269bfe20 --- /dev/null +++ b/worker/src/study-brain-agent.ts @@ -0,0 +1,347 @@ +import { Agent, callable } from "agents"; +import { getTrustedUserFromCommandToken } from "./command-agent"; +import { + emptyResponse, + jsonResponse, + readBearerToken, + readJsonPayload, +} from "./http"; +import type { + Env, + StudyBrainAgentState, + StudyBrainPlanRunInput, +} from "./types"; + +type StudyBrainRunRow = { + id: string; + status: string; + inputJson: string; + resultJson: string | null; + error: string | null; + createdAt: number; + updatedAt: number; +}; + +const STUDY_BRAIN_RUN_LIMIT = 2000; + +class UnauthorizedStudyBrainAgentRequestError extends Error { + constructor() { + super("Valid signed study brain user token required."); + this.name = "UnauthorizedStudyBrainAgentRequestError"; + } +} + +function ensureStudyBrainSchema(agent: Agent) { + agent.sql` + CREATE TABLE IF NOT EXISTS study_brain_runs ( + id TEXT PRIMARY KEY, + user_key TEXT, + course_code TEXT NOT NULL, + exam_type TEXT, + slot TEXT, + status TEXT NOT NULL, + input_json TEXT NOT NULL, + result_json TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `; + + agent.sql` + CREATE INDEX IF NOT EXISTS study_brain_runs_lookup_idx + ON study_brain_runs (user_key, updated_at DESC) + `; + + agent.sql` + CREATE INDEX IF NOT EXISTS study_brain_runs_course_idx + ON study_brain_runs (course_code, exam_type, slot, updated_at DESC) + `; +} + +function normalizeString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeNullableString(value: unknown) { + const normalized = normalizeString(value); + return normalized || null; +} + +function normalizePlanInput(input: StudyBrainPlanRunInput) { + const courseCode = normalizeString(input.courseCode).toUpperCase(); + if (!courseCode) { + throw new Error("courseCode is required."); + } + + const selectedTopics = Array.isArray(input.selectedTopics) + ? input.selectedTopics + .map((topic) => ({ + topicId: normalizeNullableString(topic.topicId), + title: normalizeString(topic.title), + rawText: normalizeString(topic.rawText), + })) + .filter((topic) => topic.title) + .slice(0, 24) + : []; + + return { + userKey: normalizeNullableString(input.userKey), + courseCode, + examType: normalizeNullableString(input.examType), + semester: normalizeString(input.semester) || "UNKNOWN", + campus: normalizeString(input.campus) || "VELLORE", + slot: normalizeNullableString(input.slot)?.toUpperCase() ?? null, + syllabusId: normalizeNullableString(input.syllabusId), + selectedTopics, + preferences: Array.isArray(input.preferences) + ? input.preferences.map(normalizeString).filter(Boolean).slice(0, 8) + : [], + }; +} + +function pruneStudyBrainRuns(agent: Agent) { + agent.sql` + DELETE FROM study_brain_runs + WHERE id NOT IN ( + SELECT id + FROM study_brain_runs + ORDER BY updated_at DESC + LIMIT ${STUDY_BRAIN_RUN_LIMIT} + ) + `; +} + +export class ExamCookerStudyBrainAgent extends Agent { + initialState: StudyBrainAgentState = { + runsCreated: 0, + lastRunId: null, + lastStatus: null, + updatedAt: null, + }; + + async onStart() { + ensureStudyBrainSchema(this); + await this.scheduleEvery(86_400, "pruneRuns", undefined, { + _idempotent: true, + }); + } + + async onRequest(request: Request) { + if (request.method === "OPTIONS") { + return emptyResponse({ status: 204 }); + } + + const { pathname } = new URL(request.url); + const action = pathname.split("/").filter(Boolean).at(-1); + + if (action === "runs" && request.method === "POST") { + return this.handleCreateRun(request); + } + + if (action === "runs" && request.method === "GET") { + return this.handleListRuns(request); + } + + return jsonResponse({ error: "Unknown study brain action." }, { status: 404 }); + } + + async pruneRuns() { + ensureStudyBrainSchema(this); + pruneStudyBrainRuns(this); + } + + private async getTrustedUser(input: { + userKey?: unknown; + userToken?: unknown; + }) { + const userKey = typeof input.userKey === "string" ? input.userKey.trim() : ""; + const userToken = + typeof input.userToken === "string" ? input.userToken.trim() : ""; + const secret = this.env.CLOUDFLARE_COMMAND_AGENT_ADMIN_TOKEN?.trim(); + + if (!userKey || !userToken || !secret) return null; + + return getTrustedUserFromCommandToken({ userKey, userToken, secret }); + } + + @callable() + async createPlanRun(input: StudyBrainPlanRunInput) { + ensureStudyBrainSchema(this); + const trustedUser = await this.getTrustedUser(input); + if (!trustedUser) { + throw new UnauthorizedStudyBrainAgentRequestError(); + } + + const normalized = normalizePlanInput({ + ...input, + userKey: trustedUser.userKey, + }); + const timestamp = Date.now(); + const id = crypto.randomUUID(); + + this.sql` + INSERT INTO study_brain_runs ( + id, + user_key, + course_code, + exam_type, + slot, + status, + input_json, + result_json, + error, + created_at, + updated_at + ) + VALUES ( + ${id}, + ${normalized.userKey}, + ${normalized.courseCode}, + ${normalized.examType}, + ${normalized.slot}, + ${"queued"}, + ${JSON.stringify(normalized)}, + ${null}, + ${null}, + ${timestamp}, + ${timestamp} + ) + `; + + const nextState = { + runsCreated: this.state.runsCreated + 1, + lastRunId: id, + lastStatus: "queued", + updatedAt: new Date(timestamp).toISOString(), + }; + this.setState(nextState); + pruneStudyBrainRuns(this); + + return { + run: { + id, + status: "queued", + input: normalized, + createdAt: timestamp, + updatedAt: timestamp, + }, + state: nextState, + source: "cloudflare-study-brain-agent", + }; + } + + @callable() + async getPlanRun(input: { + id?: string; + userKey?: string; + userToken?: string | null; + }) { + ensureStudyBrainSchema(this); + const trustedUser = await this.getTrustedUser(input); + if (!trustedUser) { + throw new UnauthorizedStudyBrainAgentRequestError(); + } + + const id = normalizeString(input.id); + if (!id) return null; + + const rows = this.sql` + SELECT + id, + status, + input_json AS inputJson, + result_json AS resultJson, + error, + created_at AS createdAt, + updated_at AS updatedAt + FROM study_brain_runs + WHERE id = ${id} AND user_key = ${trustedUser.userKey} + LIMIT 1 + `; + + const row = rows[0]; + if (!row) return null; + + return { + id: row.id, + status: row.status, + input: JSON.parse(row.inputJson) as unknown, + result: row.resultJson ? (JSON.parse(row.resultJson) as unknown) : null, + error: row.error, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + private async handleCreateRun(request: Request) { + const payload = (await readJsonPayload(request)) as StudyBrainPlanRunInput | null; + const bearerToken = readBearerToken(request.headers.get("Authorization")); + const payloadToken = + typeof payload?.userToken === "string" ? payload.userToken.trim() : ""; + const input = { + ...(payload ?? {}), + userToken: payloadToken || bearerToken, + }; + + try { + return jsonResponse(await this.createPlanRun(input)); + } catch (error) { + if (error instanceof UnauthorizedStudyBrainAgentRequestError) { + return jsonResponse({ error: error.message }, { status: 401 }); + } + + return jsonResponse( + { error: error instanceof Error ? error.message : "Invalid study brain run." }, + { status: 400 }, + ); + } + } + + private async handleListRuns(request: Request) { + ensureStudyBrainSchema(this); + const userKey = new URL(request.url).searchParams.get("userKey")?.trim() || ""; + if (!userKey) { + return jsonResponse({ error: "userKey is required." }, { status: 400 }); + } + + const trustedUser = await this.getTrustedUser({ + userKey, + userToken: readBearerToken(request.headers.get("Authorization")), + }); + if (!trustedUser) { + return jsonResponse( + { error: "Valid signed study brain user token required." }, + { status: 401 }, + ); + } + + const rows = this.sql` + SELECT + id, + status, + input_json AS inputJson, + result_json AS resultJson, + error, + created_at AS createdAt, + updated_at AS updatedAt + FROM study_brain_runs + WHERE user_key = ${trustedUser.userKey} + ORDER BY updated_at DESC + LIMIT 20 + `; + + return jsonResponse({ + runs: rows.map((row) => ({ + id: row.id, + status: row.status, + input: JSON.parse(row.inputJson) as unknown, + result: row.resultJson ? (JSON.parse(row.resultJson) as unknown) : null, + error: row.error, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })), + source: "cloudflare-study-brain-agent", + }); + } +} diff --git a/worker/src/types.ts b/worker/src/types.ts index 9845a927..470733e7 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -8,6 +8,7 @@ export type CommandResolver = "openai" | "local" | "semantic-cache"; export type Env = { ExamCookerCommandAgent: DurableObjectNamespace; + ExamCookerStudyBrainAgent: DurableObjectNamespace; CLOUDFLARE_COMMAND_AGENT_ADMIN_TOKEN?: string; OPENAI_API_KEY?: string; OPENAI_COMMAND_MODEL?: string; @@ -91,6 +92,30 @@ export type TrustedCommandUserContext = { role: "user" | "moderator" | null; }; +export type StudyBrainAgentState = { + runsCreated: number; + lastRunId: string | null; + lastStatus: string | null; + updatedAt: string | null; +}; + +export type StudyBrainPlanRunInput = { + userKey?: string; + userToken?: string | null; + courseCode?: string; + examType?: string | null; + semester?: string; + campus?: string; + slot?: string | null; + syllabusId?: string | null; + selectedTopics?: Array<{ + topicId?: string | null; + title: string; + rawText?: string; + }>; + preferences?: string[]; +}; + export type CommandPreferenceRequestInput = { userKey?: string; userToken?: string | null; diff --git a/worker/wrangler.jsonc b/worker/wrangler.jsonc index 930376de..749f62ef 100644 --- a/worker/wrangler.jsonc +++ b/worker/wrangler.jsonc @@ -9,6 +9,10 @@ { "name": "ExamCookerCommandAgent", "class_name": "ExamCookerCommandAgent" + }, + { + "name": "ExamCookerStudyBrainAgent", + "class_name": "ExamCookerStudyBrainAgent" } ] }, @@ -16,6 +20,10 @@ { "tag": "v1", "new_sqlite_classes": ["ExamCookerCommandAgent"] + }, + { + "tag": "v2", + "new_sqlite_classes": ["ExamCookerStudyBrainAgent"] } ], "vars": {