diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c6cee3a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ + +name: PR Validation Pipeline + +on: + pull_request: + branches: + - main + +jobs: + + client: + name: Validate Client + runs-on: ubuntu-latest + + defaults: + run: + working-directory: client + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install Dependencies + run: npm ci + + - name: Run Build + run: npm run build + + go-server: + name: Validate Go Server + runs-on: ubuntu-latest + + defaults: + run: + working-directory: server + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.0' + + - name: Run Tests + run: go test -v ./... + + - name: Run Build + run: go build -o cmd/server/main.go + + python-service: + name: Validate Python Service + runs-on: ubuntu-latest + + defaults: + run: + working-directory: python + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install Dependencies + run: pip install -r requirements.txt \ No newline at end of file diff --git a/.gitignore b/.gitignore index 785ffdc..ab137e9 100755 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ __pycache__/ .vercel +*/__pycache__/* \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore index a547bf3..1cac559 100755 --- a/client/.gitignore +++ b/client/.gitignore @@ -22,3 +22,4 @@ dist-ssr *.njsproj *.sln *.sw? +.env \ No newline at end of file diff --git a/client/src/apis/pathApi.ts b/client/src/apis/pathApi.ts index 9a27379..2d01124 100755 --- a/client/src/apis/pathApi.ts +++ b/client/src/apis/pathApi.ts @@ -11,6 +11,7 @@ export type Roadmap = { roadmapContent: string; responsePayload: string; dayProgress?: string; + taskProgress?: string; createdAt: string; authorId: number; }; @@ -59,6 +60,93 @@ export type GenerateQuizResponse = { quiz: string; }; +export type TaskProgressEntry = { + dayLabel: string; + taskIndex: number; + completed: boolean; +}; + +export type UpdateTaskProgressResponse = { + success: boolean; + taskProgress: TaskProgressEntry[]; +}; + +export type ResourceItem = { + type: "video" | "article"; + title: string; + url: string; + thumbnail?: string; + description?: string; +}; + +export type FetchResourcesResponse = { + success: boolean; + resources: Record; +}; + +export type PathStatItem = { + id: number; + name: string; + progress: number; + totalDays: number; + completedDays: number; +}; + +export type CompletedPathItem = { + id: number; + name: string; + totalDays: number; + createdAt: string; +}; + +export type DistributionEntry = { + name: string; + value: number; +}; + +export type WeeklyEntry = { + label: string; + completed: number; + created: number; +}; + +export type MonthlyEntry = { + month: string; + focus: number; + completion: number; +}; + +export type UserStats = { + totalPaths: number; + completedPaths: number; + inProgressPaths: number; + queuedPaths: number; + completionRate: number; + activePaths: PathStatItem[]; + completedList: CompletedPathItem[]; + distribution: DistributionEntry[]; + weeklyClosures: WeeklyEntry[]; + monthlyActivity: MonthlyEntry[]; + currentFocus: string; +}; + +export type GetUserStatsResponse = { + success: boolean; + stats: UserStats; +}; + +export async function getUserStats(): Promise { + try { + const response = await api.get("/path/stats"); + if (response.data.success) { + return response.data.stats; + } + return null; + } catch { + return null; + } +} + export function createPath(payload: FormData) { return api.post("/path/create", payload, { headers: { @@ -94,6 +182,38 @@ export async function updateDayProgress( }); } +export async function updateTaskProgress( + roadmapId: number, + dayLabel: string, + taskIndex: number, + completed: boolean, +) { + return api.patch("/path/task-progress", { + roadmapId, + dayLabel, + taskIndex, + completed, + }); +} + +export async function fetchDayResources( + topics: string[], + userGoal: string, +): Promise> { + try { + const response = await api.post("/path/resources", { + topics, + user_goal: userGoal, + }); + if (response.data.success) { + return response.data.resources; + } + return {}; + } catch { + return {}; + } +} + export async function generateQuiz( roadmapId: number, difficultyTiers: number, diff --git a/client/src/components/AddCourseModal.tsx b/client/src/components/AddCourseModal.tsx index f70f60b..7c4eded 100755 --- a/client/src/components/AddCourseModal.tsx +++ b/client/src/components/AddCourseModal.tsx @@ -437,12 +437,12 @@ const AddCourseModal: React.FC = ({ onClose, refreshData })
{ setYoutubeUrl(e.target.value); @@ -459,8 +459,7 @@ const AddCourseModal: React.FC = ({ onClose, refreshData })

Note:{" "} - We'll extract transcripts and structure them into a - day-by-day plan. + Paste a single video or playlist URL — we'll extract transcripts and structure them into a day-by-day plan.

diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 4e35698..bd7c07c 100755 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -7,7 +7,7 @@ import AddCourseModal from "../components/AddCourseModal"; import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { Plus } from "lucide-react"; -import { getAllPaths, type Roadmap } from "@/apis/pathApi"; +import { getAllPaths, getUserStats, type Roadmap, type UserStats } from "@/apis/pathApi"; gsap.registerPlugin(ScrollTrigger); @@ -16,12 +16,14 @@ const Dashboard: React.FC = () => { const gridRef = useRef(null); const [isModalOpen, setIsModalOpen] = useState(false); const [paths, setPaths] = useState>([]); + const [stats, setStats] = useState(null); const navigate = useNavigate(); const handleFetchPaths = async () => { - const response = await getAllPaths(); - setPaths(response as Array); - }; + const [roadmaps, userStats] = await Promise.all([getAllPaths(), getUserStats()]); + setPaths(roadmaps as Array); + setStats(userStats); + }; useEffect(() => { handleFetchPaths(); @@ -81,14 +83,14 @@ const Dashboard: React.FC = () => { Create New
- 12 + {stats?.completedPaths ?? 0} Completed
- 4 + {stats?.inProgressPaths ?? 0} In Progress diff --git a/client/src/pages/Profile.tsx b/client/src/pages/Profile.tsx index 588fb29..f19302b 100755 --- a/client/src/pages/Profile.tsx +++ b/client/src/pages/Profile.tsx @@ -10,10 +10,6 @@ import { Cell, Pie, PieChart, - PolarAngleAxis, - PolarGrid, - Radar, - RadarChart, ResponsiveContainer, Tooltip, XAxis, @@ -22,51 +18,13 @@ import { import Layout from "../components/Layout"; import Navigation from "../components/Navigation"; import { getCurrentUser, logout, type AuthUser } from "../apis/authApi"; +import { getUserStats, type UserStats } from "../apis/pathApi"; -const learningArc = [ - { month: "Jan", focus: 22, completion: 12 }, - { month: "Feb", focus: 31, completion: 18 }, - { month: "Mar", focus: 37, completion: 24 }, - { month: "Apr", focus: 45, completion: 29 }, - { month: "May", focus: 54, completion: 35 }, - { month: "Jun", focus: 61, completion: 42 }, -]; - -const activePaths = [ - { name: "Systems Thinking", progress: 82, hours: 14, cadence: "4 sessions/week" }, - { name: "Cryptography", progress: 57, hours: 11, cadence: "2 deep dives/week" }, - { name: "Game Theory", progress: 61, hours: 9, cadence: "3 sessions/week" }, -]; - -const completedPaths = [ - { name: "Mental Models", retention: 88, depth: 84 }, - { name: "Design Patterns", retention: 80, depth: 91 }, - { name: "Decision Science", retention: 77, depth: 79 }, -]; - -const distribution = [ - { name: "Active", value: 3, color: "#f1d6a8" }, - { name: "Completed", value: 3, color: "#8cb6ff" }, - { name: "Queued", value: 2, color: "#6d7785" }, -]; - -const capabilityMap = [ - { skill: "Strategy", score: 88 }, - { skill: "Systems", score: 92 }, - { skill: "Execution", score: 71 }, - { skill: "Research", score: 84 }, - { skill: "Retention", score: 76 }, - { skill: "Synthesis", score: 90 }, -]; - -const weeklyClosures = [ - { label: "W1", completed: 3 }, - { label: "W2", completed: 5 }, - { label: "W3", completed: 4 }, - { label: "W4", completed: 6 }, - { label: "W5", completed: 8 }, - { label: "W6", completed: 7 }, -]; +const DIST_COLORS: Record = { + Active: "#f1d6a8", + Completed: "#8cb6ff", + Queued: "#6d7785", +}; const tooltipStyle = { backgroundColor: "rgba(15, 15, 15, 0.92)", @@ -81,15 +39,20 @@ const Profile: React.FC = () => { const statsRef = useRef(null); const chartsRef = useRef(null); const [user, setUser] = useState(null); + const [stats, setStats] = useState(null); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(""); const [isLoggingOut, setIsLoggingOut] = useState(false); useEffect(() => { - const fetchUser = async () => { + const fetchData = async () => { try { - const response = await getCurrentUser(); - setUser(response.data.user); + const [userRes, userStats] = await Promise.all([ + getCurrentUser(), + getUserStats(), + ]); + setUser(userRes.data.user); + setStats(userStats); } catch { setErrorMessage("Profile data is unavailable until you sign in."); } finally { @@ -97,7 +60,7 @@ const Profile: React.FC = () => { } }; - fetchUser(); + fetchData(); }, []); useEffect(() => { @@ -142,6 +105,36 @@ const Profile: React.FC = () => { ? `${user.firstName} ${user.lastName}`.trim() : "Curriculum Architect"; + const completionRate = stats ? Math.round(stats.completionRate) : 0; + + const distributionWithColors = (stats?.distribution ?? []).map((d) => ({ + ...d, + color: DIST_COLORS[d.name] ?? "#6d7785", + })); + + const statCards = [ + { + label: "Active Paths", + value: String(stats?.inProgressPaths ?? 0), + note: "currently in progress", + }, + { + label: "Completed Paths", + value: String(stats?.completedPaths ?? 0), + note: "paths finished", + }, + { + label: "Total Paths", + value: String(stats?.totalPaths ?? 0), + note: "paths created", + }, + { + label: "Completion Rate", + value: `${completionRate}%`, + note: "paths completed vs created", + }, + ]; + return ( @@ -171,10 +164,10 @@ const Profile: React.FC = () => { Provider: {user?.provider ?? "guest"}
- Active streak: 27 days + Total paths: {stats?.totalPaths ?? 0}
- Completion rate: 86% + Completion rate: {completionRate}%
@@ -195,7 +188,9 @@ const Profile: React.FC = () => {
Current focus - Systems Thinking + + {stats?.currentFocus || "No active paths"} +
@@ -223,12 +218,7 @@ const Profile: React.FC = () => { ref={statsRef} className="mt-10 grid gap-4 md:grid-cols-2 xl:grid-cols-4" > - {[ - { label: "Active Paths", value: "03", note: "14 hrs logged this week" }, - { label: "Completed Paths", value: "11", note: "3 closed this quarter" }, - { label: "Deep Work Hours", value: "126", note: "up 18% month over month" }, - { label: "Retention Score", value: "84%", note: "measured by revision cadence" }, - ].map((item) => ( + {statCards.map((item) => (
{ ref={chartsRef} className="mt-10 grid gap-6 xl:grid-cols-[1.25fr_0.75fr]" > + {/* Learning Momentum — monthly paths created vs completed */}

Learning Momentum

-

Focus and completion arc

+

Paths created and completed

6 month view

- + @@ -283,15 +274,16 @@ const Profile: React.FC = () => { - + - - + +
+ {/* Path Distribution */}

Path Distribution @@ -301,14 +293,14 @@ const Profile: React.FC = () => { - {distribution.map((entry) => ( + {distributionWithColors.map((entry) => ( ))} @@ -317,7 +309,7 @@ const Profile: React.FC = () => {

- {distribution.map((entry) => ( + {distributionWithColors.map((entry) => (
@@ -329,6 +321,7 @@ const Profile: React.FC = () => {
+ {/* Active Paths */}
@@ -339,84 +332,88 @@ const Profile: React.FC = () => {

in progress now

-
- - - - - - - - - -
-
- {activePaths.map((path) => ( -
-

{path.name}

-

- {path.hours} hrs logged -

-

{path.cadence}

+ {(stats?.activePaths ?? []).length === 0 ? ( +
+

No active paths yet

+
+ ) : ( + <> +
+ + + + + + [`${v}%`, "Progress"]} /> + + +
- ))} -
+
+ {(stats?.activePaths ?? []).map((path) => ( +
+

{path.name}

+

+ {path.completedDays} of {path.totalDays} days done +

+

{path.progress}% complete

+
+ ))} +
+ + )}
+ {/* Completed Paths */}

Completed Paths

-

Capability map

-
- - - - - - - - -
-
- {completedPaths.map((path) => ( -
-
- {path.name} - {path.depth}% depth -
-
-
-
-

- {path.retention}% retention stability -

+

Finished arcs

+
+ {(stats?.completedList ?? []).length === 0 ? ( +
+

No completed paths yet

- ))} + ) : ( + (stats?.completedList ?? []).map((path) => ( +
+
+ {path.name} + {path.totalDays} days +
+
+
+
+

+ All {path.totalDays} days completed +

+
+ )) + )}
+ {/* Weekly Closures */}

Completion Rhythm

-

Recent weekly closures

+

Weekly path activity

last 6 weeks

- + - + - + +
diff --git a/client/src/pages/SpecificPathView.tsx b/client/src/pages/SpecificPathView.tsx index a2a6654..f592590 100755 --- a/client/src/pages/SpecificPathView.tsx +++ b/client/src/pages/SpecificPathView.tsx @@ -1,23 +1,44 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import gsap from "gsap"; +import { + ArrowLeft, + Check, + ChevronDown, + ChevronUp, + ExternalLink, + FileText, + Loader2, + Play, + Sparkles, +} from "lucide-react"; import Layout from "../components/Layout"; import Navigation from "../components/Navigation"; import QuizModal, { type QuizQuestion as QuizQuestionView } from "../components/QuizModal"; import { + fetchDayResources, generateQuiz, getAllPaths, updateDayProgress, + updateTaskProgress, type DayProgressEntry, + type ResourceItem, type Roadmap, + type TaskProgressEntry, } from "../apis/pathApi"; +// ─── Types ─────────────────────────────────────────────────────────────────── + type DayPlan = { dayLabel: string; topic: string; tasks: string[]; }; +type TaskMap = Record>; + +// ─── Parsing helpers (preserved from original) ──────────────────────────────── + function normalizeTaskText(task: string): string { return task.replace(/\s+/g, " ").trim(); } @@ -31,10 +52,7 @@ function safeJsonParse(value: string): T | null { } function parseDayObject(dayKey: string, value: unknown): DayPlan | null { - if (!value || typeof value !== "object") { - return null; - } - + if (!value || typeof value !== "object") return null; const record = value as Record; const numberFromRecord = typeof record.number === "number" @@ -43,217 +61,123 @@ function parseDayObject(dayKey: string, value: unknown): DayPlan | null { ? Number(record.number) : NaN; const numberFromKey = Number(dayKey.replace(/[^\d]/g, "")); - const dayNumber = Number.isFinite(numberFromRecord) - ? numberFromRecord - : numberFromKey; - + const dayNumber = Number.isFinite(numberFromRecord) ? numberFromRecord : numberFromKey; const topic = typeof record.topic === "string" && record.topic.trim() ? record.topic.trim() : "Learning Focus"; - const rawTasks = Array.isArray(record.tasks) ? record.tasks : []; const tasks = rawTasks .map((item) => (typeof item === "string" ? normalizeTaskText(item) : "")) .filter(Boolean); - - if (!Number.isFinite(dayNumber)) { - return null; - } - - return { - dayLabel: `Day ${dayNumber}`, - topic, - tasks, - }; + if (!Number.isFinite(dayNumber)) return null; + return { dayLabel: `Day ${dayNumber}`, topic, tasks }; } function extractPlansFromObject(data: unknown): DayPlan[] { - if (!data || typeof data !== "object") { - return []; - } - + if (!data || typeof data !== "object") return []; const record = data as Record; const daysArray = Array.isArray(record.days) ? record.days : []; if (daysArray.length > 0) { const fromArray = daysArray .map((value, index) => parseDayObject(`day${index + 1}`, value)) .filter((item): item is DayPlan => Boolean(item)) - .sort( - (a, b) => - Number(a.dayLabel.replace(/[^\d]/g, "")) - - Number(b.dayLabel.replace(/[^\d]/g, "")), - ); - if (fromArray.length > 0) { - return fromArray; - } + .sort((a, b) => Number(a.dayLabel.replace(/[^\d]/g, "")) - Number(b.dayLabel.replace(/[^\d]/g, ""))); + if (fromArray.length > 0) return fromArray; } - - const dayEntries = Object.entries(record).filter(([key]) => - /^day\s*\d*$/i.test(key), - ); - - const plans = dayEntries + const dayEntries = Object.entries(record).filter(([key]) => /^day\s*\d*$/i.test(key)); + return dayEntries .map(([key, value]) => parseDayObject(key, value)) .filter((item): item is DayPlan => Boolean(item)) - .sort( - (a, b) => - Number(a.dayLabel.replace(/[^\d]/g, "")) - - Number(b.dayLabel.replace(/[^\d]/g, "")), - ); - - return plans; + .sort((a, b) => Number(a.dayLabel.replace(/[^\d]/g, "")) - Number(b.dayLabel.replace(/[^\d]/g, ""))); } function parseEmbeddedJson(content: string): DayPlan[] { const cleaned = content.replace(/\\"/g, '"'); - const jsonAnchorIndex = cleaned.search(/json\s*\n/i); if (jsonAnchorIndex >= 0) { const possibleJson = cleaned.slice(jsonAnchorIndex).replace(/^json\s*\n/i, ""); const lastBrace = possibleJson.lastIndexOf("}"); if (lastBrace > 0) { - const candidate = possibleJson.slice(0, lastBrace + 1); - const parsed = safeJsonParse>(candidate); + const parsed = safeJsonParse>(possibleJson.slice(0, lastBrace + 1)); if (parsed) { const plans = extractPlansFromObject(parsed); - if (plans.length > 0) { - return plans; - } + if (plans.length > 0) return plans; } } } - const firstBrace = cleaned.indexOf("{"); const lastBrace = cleaned.lastIndexOf("}"); if (firstBrace >= 0 && lastBrace > firstBrace) { - const candidate = cleaned.slice(firstBrace, lastBrace + 1); - const parsed = safeJsonParse>(candidate); - if (parsed) { - return extractPlansFromObject(parsed); - } + const parsed = safeJsonParse>(cleaned.slice(firstBrace, lastBrace + 1)); + if (parsed) return extractPlansFromObject(parsed); } - return []; } function parseMalformedDayBlocks(content: string): DayPlan[] { - const normalized = content - .replace(/\\"/g, '"') - .replace(/\\n/g, " ") - .replace(/\s+/g, " "); - + const normalized = content.replace(/\\"/g, '"').replace(/\\n/g, " ").replace(/\s+/g, " "); const blockRegex = /"(day\d*)"\s*:\s*\{[\s\S]*?"topic"\s*:\s*"([^"]+)"[\s\S]*?"tasks"\s*:\s*\[([\s\S]*?)\][\s\S]*?\}/gi; - const plans: DayPlan[] = []; let match: RegExpExecArray | null; - while ((match = blockRegex.exec(normalized)) !== null) { const dayKey = match[1]; const topic = normalizeTaskText(match[2] ?? "Learning Focus"); - const tasksChunk = match[3] ?? ""; - - const tasks = Array.from(tasksChunk.matchAll(/"([^"]+)"/g)) + const tasks = Array.from((match[3] ?? "").matchAll(/"([^"]+)"/g)) .map((m) => normalizeTaskText(m[1] ?? "")) .filter(Boolean); - const dayNumber = Number(dayKey.replace(/[^\d]/g, "")) || 1; - plans.push({ - dayLabel: `Day ${dayNumber}`, - topic, - tasks, - }); + plans.push({ dayLabel: `Day ${dayNumber}`, topic, tasks }); } - - return plans.sort( - (a, b) => - Number(a.dayLabel.replace(/[^\d]/g, "")) - - Number(b.dayLabel.replace(/[^\d]/g, "")), - ); + return plans.sort((a, b) => Number(a.dayLabel.replace(/[^\d]/g, "")) - Number(b.dayLabel.replace(/[^\d]/g, ""))); } function parseRoadmapContent(content: string): DayPlan[] { - if (!content || !content.trim()) { - return []; - } - - const plansFromJson = parseEmbeddedJson(content); - if (plansFromJson.length > 0) { - return plansFromJson; - } - - const plansFromMalformedJson = parseMalformedDayBlocks(content); - if (plansFromMalformedJson.length > 0) { - return plansFromMalformedJson; - } - + if (!content?.trim()) return []; + const fromJson = parseEmbeddedJson(content); + if (fromJson.length > 0) return fromJson; + const fromMalformed = parseMalformedDayBlocks(content); + if (fromMalformed.length > 0) return fromMalformed; const dayRegex = /(?:^|\n)\s*(?:[#*\- ]*)Day\s*(\d+)\s*[:\-]?\s*([\s\S]*?)(?=\n\s*(?:[#*\- ]*)Day\s*\d+\s*[:\-]?|$)/gi; const plans: DayPlan[] = []; let match: RegExpExecArray | null; - while ((match = dayRegex.exec(content)) !== null) { - const dayNumber = match[1]; const block = match[2] ?? ""; - const topicMatch = block.match(/(?:\*\*)?\s*Topic(?:\*\*)?\s*:\s*(.*)/i); - const fallbackTopicLine = block + const fallbackLine = block .split("\n") - .map((line) => normalizeTaskText(line.replace(/[*#>-]/g, ""))) - .find( - (line) => - line && - !/^tasks?\s*:?$/i.test(line) && - !/^[-*]\s*/.test(line) && - !/^\d+[.)]\s+/.test(line), - ); - const topic = topicMatch?.[1]?.trim() || fallbackTopicLine || "Learning Focus"; - + .map((l) => normalizeTaskText(l.replace(/[*#>-]/g, ""))) + .find((l) => l && !/^tasks?\s*:?$/i.test(l) && !/^[-*]\s*/.test(l) && !/^\d+[.)]\s+/.test(l)); + const topic = topicMatch?.[1]?.trim() || fallbackLine || "Learning Focus"; const tasksSectionMatch = block.match(/(?:\*\*)?\s*Tasks?(?:\*\*)?\s*:\s*([\s\S]*)/i); - const candidateTaskText = tasksSectionMatch?.[1] ?? block; - const tasks = candidateTaskText + const tasks = (tasksSectionMatch?.[1] ?? block) .split("\n") - .map((line) => - normalizeTaskText( - line - .replace(/^\s*(?:[-*]|\d+[.)])\s+/, "") - .replace(/^\s*(?:tasks?|topic)\s*:\s*/i, "") - .replace(/\*\*/g, ""), - ), - ) - .filter( - (line) => - Boolean(line) && - !/^learning focus$/i.test(line) && - !/^tasks?$/i.test(line) && - !/^topic$/i.test(line), - ); - - plans.push({ - dayLabel: `Day ${dayNumber}`, - topic, - tasks, - }); + .map((l) => normalizeTaskText(l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, "").replace(/^\s*(?:tasks?|topic)\s*:\s*/i, "").replace(/\*\*/g, ""))) + .filter((l) => Boolean(l) && !/^learning focus$/i.test(l) && !/^tasks?$/i.test(l) && !/^topic$/i.test(l)); + plans.push({ dayLabel: `Day ${match[1]}`, topic, tasks }); } - return plans; } function parseDayProgress(raw?: string): Record { - if (!raw || !raw.trim()) { - return {}; - } - + if (!raw?.trim()) return {}; const parsed = safeJsonParse(raw); - if (!parsed || !Array.isArray(parsed)) { - return {}; - } + if (!parsed || !Array.isArray(parsed)) return {}; + return parsed.reduce>((acc, e) => { + if (e.dayLabel) acc[e.dayLabel] = Boolean(e.completed); + return acc; + }, {}); +} - return parsed.reduce>((acc, entry) => { - if (entry.dayLabel) { - acc[entry.dayLabel] = Boolean(entry.completed); - } +function parseTaskProgressJson(raw?: string): TaskMap { + if (!raw?.trim()) return {}; + const parsed = safeJsonParse(raw); + if (!parsed || !Array.isArray(parsed)) return {}; + return parsed.reduce((acc, e) => { + if (!acc[e.dayLabel]) acc[e.dayLabel] = {}; + acc[e.dayLabel][e.taskIndex] = Boolean(e.completed); return acc; }, {}); } @@ -262,155 +186,270 @@ function parseQuizFromResponse(raw: string): QuizQuestionView[] { const cleaned = raw.replace(/\\n/g, "\n").replace(/\\"/g, '"'); const firstBrace = cleaned.indexOf("{"); const lastBrace = cleaned.lastIndexOf("}"); + if (firstBrace < 0 || lastBrace <= firstBrace) return []; + const parsed = safeJsonParse<{ questions?: QuizQuestionView[] }>(cleaned.slice(firstBrace, lastBrace + 1)); + if (!parsed?.questions || !Array.isArray(parsed.questions)) return []; + return parsed.questions.filter( + (q) => typeof q.question === "string" && Array.isArray(q.options) && typeof q.answer === "string", + ); +} - if (firstBrace < 0 || lastBrace <= firstBrace) { - return []; - } - - const candidate = cleaned.slice(firstBrace, lastBrace + 1); - const parsed = safeJsonParse<{ questions?: QuizQuestionView[] }>(candidate); +function isPlaceholderTopic(topic: string): boolean { + const lower = topic.toLowerCase().trim(); + return lower.includes("unknown") || lower === "learning focus" || lower === ""; +} - if (!parsed?.questions || !Array.isArray(parsed.questions)) { - return []; - } +// ─── Sub-components ─────────────────────────────────────────────────────────── - return parsed.questions.filter( - (q) => - typeof q.question === "string" && - Array.isArray(q.options) && - typeof q.answer === "string", +const ProgressRing: React.FC<{ percent: number; completedDays: number; totalDays: number }> = ({ + percent, + completedDays, + totalDays, +}) => { + const r = 52; + const circ = 2 * Math.PI * r; + const offset = circ - (percent / 100) * circ; + return ( +
+ + + + + {percent}% + + + complete + + +

+ {completedDays} / {totalDays} days +

+
); -} +}; + +const TaskCheckbox: React.FC<{ + task: string; + completed: boolean; + onChange: (val: boolean) => void; +}> = ({ task, completed, onChange }) => ( + +); + +const ResourceLink: React.FC<{ resource: ResourceItem }> = ({ resource }) => ( + +
+ {resource.type === "video" ? ( + + ) : ( + + )} +
+
+

{resource.title}

+ {resource.description && ( +

{resource.description}

+ )} +
+ +
+); + +// ─── Main component ─────────────────────────────────────────────────────────── const SpecificPathView: React.FC = () => { const navigate = useNavigate(); const { id } = useParams(); + const heroRef = useRef(null); + const contentRef = useRef(null); + const [path, setPath] = useState(null); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(""); + const [completionMap, setCompletionMap] = useState>({}); + const [taskMap, setTaskMap] = useState({}); + + const [expandedDays, setExpandedDays] = useState>({}); + const [resourcesMap, setResourcesMap] = useState>({}); + const [loadingResources, setLoadingResources] = useState>({}); + const [isQuizModalOpen, setIsQuizModalOpen] = useState(false); const [isGeneratingQuiz, setIsGeneratingQuiz] = useState(false); const [quizError, setQuizError] = useState(""); const [quizQuestions, setQuizQuestions] = useState([]); const [difficultyTiers, setDifficultyTiers] = useState(2); const [questionsPerTier, setQuestionsPerTier] = useState(6); - const heroRef = useRef(null); - const timelineRef = useRef(null); useEffect(() => { const fetchPath = async () => { setIsLoading(true); - setErrorMessage(""); - const allPaths = await getAllPaths(); - const parsedId = Number(id); - const selectedPath = allPaths.find((item) => item.id === parsedId) ?? null; - - if (!selectedPath) { - setErrorMessage("Path not found. It may have been removed."); - } - - setPath(selectedPath); - setCompletionMap(parseDayProgress(selectedPath?.dayProgress)); + const selected = allPaths.find((p) => p.id === Number(id)) ?? null; + setPath(selected); + setCompletionMap(parseDayProgress(selected?.dayProgress)); + setTaskMap(parseTaskProgressJson(selected?.taskProgress)); setIsLoading(false); + if (!selected) setErrorMessage("Path not found. It may have been removed."); }; - fetchPath(); }, [id]); useEffect(() => { - const timeline = gsap.timeline({ defaults: { ease: "power3.out" } }); - - timeline - .fromTo( - heroRef.current, - { opacity: 0, y: 20 }, - { opacity: 1, y: 0, duration: 0.85 }, - ) - .fromTo( - timelineRef.current?.children || [], - { opacity: 0, y: 20 }, - { opacity: 1, y: 0, duration: 0.7, stagger: 0.08 }, - "-=0.45", - ); - - return () => { - timeline.kill(); - }; + const tl = gsap.timeline({ defaults: { ease: "power3.out" } }); + tl.fromTo(heroRef.current, { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.8 }) + .fromTo(contentRef.current, { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.7 }, "-=0.4"); + return () => { tl.kill(); }; }, []); const dayPlans = useMemo(() => { - if (!path?.roadmapContent) { - return []; - } + if (!path?.roadmapContent) return []; return parseRoadmapContent(path.roadmapContent); }, [path]); - const allDaysCompleted = useMemo(() => { - if (!dayPlans.length) { - return false; - } - return dayPlans.every((day) => completionMap[day.dayLabel]); - }, [completionMap, dayPlans]); + // Expand all days by default once plans are parsed + useEffect(() => { + if (dayPlans.length === 0) return; + setExpandedDays(dayPlans.reduce>((acc, d) => { acc[d.dayLabel] = true; return acc; }, {})); + }, [dayPlans]); + + const completedDays = useMemo(() => dayPlans.filter((d) => completionMap[d.dayLabel]).length, [dayPlans, completionMap]); + const totalDays = dayPlans.length; + const progressPercent = totalDays > 0 ? Math.round((completedDays / totalDays) * 100) : 0; + + const totalTasks = useMemo(() => dayPlans.reduce((s, d) => s + d.tasks.length, 0), [dayPlans]); + const completedTaskCount = useMemo( + () => Object.values(taskMap).reduce((s, tasks) => s + Object.values(tasks).filter(Boolean).length, 0), + [taskMap], + ); - const handleToggleDayCompletion = async (dayLabel: string) => { - if (!path) { - return; - } + const allDaysCompleted = totalDays > 0 && completedDays === totalDays; + + // ── Handlers ────────────────────────────────────────────────────────────── + const handleToggleDayCompletion = async (dayLabel: string) => { + if (!path) return; const current = Boolean(completionMap[dayLabel]); const next = !current; - - setCompletionMap((prev) => ({ - ...prev, - [dayLabel]: next, - })); + setCompletionMap((prev) => ({ ...prev, [dayLabel]: next })); + + // When marking day complete, also mark all its tasks complete + if (next) { + const day = dayPlans.find((d) => d.dayLabel === dayLabel); + if (day && day.tasks.length > 0) { + const allDone: Record = {}; + day.tasks.forEach((_, i) => { allDone[i] = true; }); + setTaskMap((prev) => ({ ...prev, [dayLabel]: allDone })); + day.tasks.forEach((_, i) => { + if (path) updateTaskProgress(path.id, dayLabel, i, true).catch(() => {}); + }); + } + } try { - const response = await updateDayProgress(path.id, dayLabel, next); - const updatedMap = response.data.dayProgress.reduce>( - (acc, item) => { + const res = await updateDayProgress(path.id, dayLabel, next); + setCompletionMap( + res.data.dayProgress.reduce>((acc, item) => { acc[item.dayLabel] = Boolean(item.completed); return acc; - }, - {}, + }, {}), ); - setCompletionMap(updatedMap); } catch { - setCompletionMap((prev) => ({ - ...prev, - [dayLabel]: current, - })); + setCompletionMap((prev) => ({ ...prev, [dayLabel]: current })); } }; - const openQuizModal = () => { - if (!path || !allDaysCompleted) { - return; + const handleToggleTask = async (dayLabel: string, taskIndex: number, completed: boolean) => { + setTaskMap((prev) => ({ + ...prev, + [dayLabel]: { ...(prev[dayLabel] || {}), [taskIndex]: completed }, + })); + + if (path) { + updateTaskProgress(path.id, dayLabel, taskIndex, completed).catch(() => {}); + } + + // Auto-unmark day if unchecking a task while day is complete + if (!completed && completionMap[dayLabel]) { + setCompletionMap((prev) => ({ ...prev, [dayLabel]: false })); + if (path) updateDayProgress(path.id, dayLabel, false).catch(() => {}); + } + + // Auto-mark day complete when all tasks are done + if (completed) { + const day = dayPlans.find((d) => d.dayLabel === dayLabel); + if (day && day.tasks.length > 0) { + const updated = { ...(taskMap[dayLabel] || {}), [taskIndex]: true }; + const allDone = day.tasks.every((_, i) => updated[i]); + if (allDone && !completionMap[dayLabel]) { + setCompletionMap((prev) => ({ ...prev, [dayLabel]: true })); + if (path) updateDayProgress(path.id, dayLabel, true).catch(() => {}); + } + } } - setIsQuizModalOpen(true); - setQuizError(""); - setQuizQuestions([]); }; - const handleGenerateQuiz = async () => { - if (!path || !allDaysCompleted) { - return; + const handleLoadResources = async (dayLabel: string, topic: string) => { + if (resourcesMap[dayLabel] !== undefined || loadingResources[dayLabel] || !path) return; + setLoadingResources((prev) => ({ ...prev, [dayLabel]: true })); + try { + const result = await fetchDayResources([topic], path.userGoal); + setResourcesMap((prev) => ({ ...prev, [dayLabel]: result[topic] ?? [] })); + } finally { + setLoadingResources((prev) => ({ ...prev, [dayLabel]: false })); } + }; + const handleGenerateQuiz = async () => { + if (!path || !allDaysCompleted) return; setIsGeneratingQuiz(true); setQuizError(""); setQuizQuestions([]); - try { - const response = await generateQuiz(path.id, difficultyTiers, questionsPerTier); - const parsedQuestions = parseQuizFromResponse(response.data.quiz); - if (!parsedQuestions.length) { - setQuizError("Quiz generated, but it could not be parsed into question format."); - } else { - setQuizQuestions(parsedQuestions); - } + const res = await generateQuiz(path.id, difficultyTiers, questionsPerTier); + const parsed = parseQuizFromResponse(res.data.quiz); + if (!parsed.length) setQuizError("Quiz generated but could not be parsed."); + else setQuizQuestions(parsed); } catch { setQuizError("Failed to generate quiz. Please try again."); } finally { @@ -418,6 +457,8 @@ const SpecificPathView: React.FC = () => { } }; + // ── Render ──────────────────────────────────────────────────────────────── + return ( @@ -433,147 +474,325 @@ const SpecificPathView: React.FC = () => { onQuestionsPerTierChange={setQuestionsPerTier} onGenerateQuiz={handleGenerateQuiz} /> -
-
+ +
+
+ + {/* ── Hero header ── */}

- Specific Path View + Learning Path

-

- {path?.name ?? "Path Detail"} -

+

{path?.name ?? "Path Detail"}

- {path?.description || - "A structured roadmap broken into clear daily milestones, focused topics, and actionable tasks."} + {path?.description || "A structured roadmap broken into clear daily milestones, focused topics, and actionable tasks."}

-
-
-

Time Query

-

{path?.timeQuery ?? "-"}

-
-
-

Documents

-

{path?.documentsCount ?? 0}

-
-
-

Processed Types

-

{path?.processedTypes || "-"}

-
-
-

Generated Days

-

{dayPlans.length || "-"}

-
+ {[ + { label: "Time Query", value: path?.timeQuery ?? "-" }, + { label: "Documents", value: String(path?.documentsCount ?? 0) }, + { label: "Source", value: path?.processedTypes || "-" }, + { label: "Days", value: String(totalDays || "-") }, + ].map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))}
- {isLoading ? ( + {isLoading && (
- Loading path roadmap... + Loading roadmap...
- ) : null} + )} - {!isLoading && errorMessage ? ( + {!isLoading && errorMessage && (
{errorMessage}
- ) : null} + )} {!isLoading && path && ( -
- {dayPlans.length > 0 ? ( - dayPlans.map((plan, index) => { - const completed = Boolean(completionMap[plan.dayLabel]); - - return ( -
-
-
-

- {plan.dayLabel} -

-

{plan.topic}

-
- -
+
+
+

+ {plan.dayLabel} +

+ {placeholder && ( + + Inferred topic + + )} + {dayCompleted && ( + + Done + + )} +
+

{plan.topic}

+

+ {completedTasksInDay} / {plan.tasks.length} tasks complete +

+
+
+ + {isExpanded ? ( + + ) : ( + + )} +
+
-
-

Tasks

- {plan.tasks.length > 0 ? ( -
    - {plan.tasks.map((task, taskIndex) => ( -
  • - {task} -
  • - ))} -
- ) : ( -

- No explicit tasks found in this day block. -

+ {/* Expandable body */} + {isExpanded && ( +
+ {/* Tasks */} + {plan.tasks.length > 0 ? ( +
+

+ Tasks +

+
+ {plan.tasks.map((task, taskIndex) => ( + handleToggleTask(plan.dayLabel, taskIndex, val)} + /> + ))} +
+
+ ) : ( +

No tasks defined for this day.

+ )} + + {/* Resources section */} +
+
+

+ Resources +

+ {dayResources === undefined && ( + + )} +
+ + {dayResources !== undefined && ( +
+ {dayResources.length === 0 ? ( +

+ No resources found for this topic. +

+ ) : ( + dayResources.map((res, i) => ( + + )) + )} +
+ )} +
+
)} +
+ ); + }) + ) : ( +
+

Raw Content

+
+                      {path.roadmapContent}
+                    
+
+ )} + + {/* Quiz section */} +
+
+
+

Assessment

+

Generate Final Quiz

+

+ {allDaysCompleted + ? "All days complete — quiz generation unlocked." + : `Complete all ${totalDays} days to unlock quiz generation.`} +

+
+ +
+
+
+ + {/* ── Right: Sticky sidebar ── */} + +
)} diff --git a/python/app/__pycache__/app.cpython-310.pyc b/python/app/__pycache__/app.cpython-310.pyc deleted file mode 100755 index ae8c266..0000000 Binary files a/python/app/__pycache__/app.cpython-310.pyc and /dev/null differ diff --git a/python/app/__pycache__/app.cpython-314.pyc b/python/app/__pycache__/app.cpython-314.pyc deleted file mode 100755 index 56d9024..0000000 Binary files a/python/app/__pycache__/app.cpython-314.pyc and /dev/null differ diff --git a/python/app/__pycache__/database.cpython-314.pyc b/python/app/__pycache__/database.cpython-314.pyc deleted file mode 100755 index f8613a3..0000000 Binary files a/python/app/__pycache__/database.cpython-314.pyc and /dev/null differ diff --git a/python/app/app.py b/python/app/app.py index bfced6a..8db5029 100755 --- a/python/app/app.py +++ b/python/app/app.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from app.ml_models import ml_models +from app.routes.enrich import enrich_router from app.routes.query import query_router from app.routes.quiz import quiz_router from app.routes.upload import upload_router @@ -63,6 +64,7 @@ async def lifespan(app: FastAPI): app.include_router(upload_router, prefix="/upload") app.include_router(query_router, prefix="/query") app.include_router(quiz_router, prefix="/quiz") +app.include_router(enrich_router, prefix="/enrich") @app.get("/health") diff --git a/python/app/models/__pycache__/__init__.cpython-314.pyc b/python/app/models/__pycache__/__init__.cpython-314.pyc deleted file mode 100755 index d82464e..0000000 Binary files a/python/app/models/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/python/app/models/__pycache__/roadmap.cpython-314.pyc b/python/app/models/__pycache__/roadmap.cpython-314.pyc deleted file mode 100755 index 4c53360..0000000 Binary files a/python/app/models/__pycache__/roadmap.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/__pycache__/llm.cpython-314.pyc b/python/app/rag/__pycache__/llm.cpython-314.pyc deleted file mode 100755 index 7bd5ded..0000000 Binary files a/python/app/rag/__pycache__/llm.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/__pycache__/pipeline.cpython-314.pyc b/python/app/rag/__pycache__/pipeline.cpython-314.pyc deleted file mode 100755 index 3307455..0000000 Binary files a/python/app/rag/__pycache__/pipeline.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/embeddings/__pycache__/embeddor.cpython-314.pyc b/python/app/rag/embeddings/__pycache__/embeddor.cpython-314.pyc deleted file mode 100755 index 1ce9554..0000000 Binary files a/python/app/rag/embeddings/__pycache__/embeddor.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/embeddings/__pycache__/vector_db.cpython-314.pyc b/python/app/rag/embeddings/__pycache__/vector_db.cpython-314.pyc deleted file mode 100755 index 0ee94b4..0000000 Binary files a/python/app/rag/embeddings/__pycache__/vector_db.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/loaders/__pycache__/pdf.cpython-314.pyc b/python/app/rag/loaders/__pycache__/pdf.cpython-314.pyc deleted file mode 100755 index 8f0f08f..0000000 Binary files a/python/app/rag/loaders/__pycache__/pdf.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/loaders/__pycache__/text.cpython-310.pyc b/python/app/rag/loaders/__pycache__/text.cpython-310.pyc deleted file mode 100755 index a0c292b..0000000 Binary files a/python/app/rag/loaders/__pycache__/text.cpython-310.pyc and /dev/null differ diff --git a/python/app/rag/loaders/__pycache__/text.cpython-314.pyc b/python/app/rag/loaders/__pycache__/text.cpython-314.pyc deleted file mode 100755 index e601684..0000000 Binary files a/python/app/rag/loaders/__pycache__/text.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/loaders/__pycache__/youtube.cpython-310.pyc b/python/app/rag/loaders/__pycache__/youtube.cpython-310.pyc deleted file mode 100755 index d9231c3..0000000 Binary files a/python/app/rag/loaders/__pycache__/youtube.cpython-310.pyc and /dev/null differ diff --git a/python/app/rag/loaders/__pycache__/youtube.cpython-314.pyc b/python/app/rag/loaders/__pycache__/youtube.cpython-314.pyc deleted file mode 100755 index e1cf097..0000000 Binary files a/python/app/rag/loaders/__pycache__/youtube.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/pipeline.py b/python/app/rag/pipeline.py index 3f6986e..8697f3e 100755 --- a/python/app/rag/pipeline.py +++ b/python/app/rag/pipeline.py @@ -1,5 +1,6 @@ import json import os +from concurrent.futures import ThreadPoolExecutor, as_completed from langchain_core.documents import Document from pydantic import BaseModel, Field @@ -9,6 +10,8 @@ from app.rag.processors.chunker import chunk_documents +# ── Output schemas ──────────────────────────────────────────────────────────── + class RoadmapDay(BaseModel): number: int = Field(..., ge=1, description="1-indexed day number") topic: str = Field(..., min_length=1) @@ -19,21 +22,200 @@ class StructuredRoadmap(BaseModel): days: list[RoadmapDay] = Field(default_factory=list) +class CurriculumAnalysis(BaseModel): + core_topics: list[str] = Field( + default_factory=list, + description=( + "Key learning topics extracted from the materials, " + "ordered by logical learning progression" + ), + ) + + +# ── Retrieval helpers ───────────────────────────────────────────────────────── + def build_retrieval_query(user_goal: str, time_query: str) -> str: - return f""" -Learning goal: {user_goal} -Time available: {time_query} + return ( + f"Learning goal: {user_goal}\n" + f"Time available: {time_query}\n\n" + "Return the most relevant curriculum concepts, prerequisites, " + "practice tasks, and progression milestones." + ) -Return the most relevant curriculum concepts, prerequisites, -practice tasks, and progression milestones. -""".strip() +def build_context(docs: list[Document]) -> str: + sections: list[str] = [] + max_doc_chars = int(os.getenv("RAG_CONTEXT_DOC_CHARS", "1200")) + max_total_chars = int(os.getenv("RAG_CONTEXT_TOTAL_CHARS", "8000")) + current_total = 0 + + for i, doc in enumerate(docs, start=1): + meta = doc.metadata or {} + label = ( + meta.get("title") + or meta.get("filename") + or meta.get("source") + or "document" + ) + content = doc.page_content.strip() + if max_doc_chars > 0: + content = content[:max_doc_chars] + + entry = f"[Source {i}: {label}]\n{content}" + if max_total_chars > 0 and current_total + len(entry) > max_total_chars: + break + sections.append(entry) + current_total += len(entry) + + return "\n\n".join(sections) -def build_time_constrained_prompt(user_goal: str, time_query: str, context: str) -> str: - return f""" -You are an expert AI learning planner. -Your task is to generate a structured, time-constrained learning roadmap. +# ── Agent 1: Understanding ──────────────────────────────────────────────────── + +def extract_curriculum_topics( + context: str, user_goal: str, time_query: str, llm +) -> list[str]: + """ + First agent: reads the retrieved docs and extracts the key curriculum + topics the learner needs to master, in learning-progression order. + """ + prompt = f"""You are an expert curriculum analyst. + +USER GOAL: {user_goal} +TIME FRAME: {time_query} + +RETRIEVED LEARNING MATERIALS: +{context[:5000]} + +Carefully read these materials and extract up to 8 core learning topics +that MUST be covered to achieve the user's goal. +Order them by logical learning progression (fundamentals first). +Use the actual topic names found in the materials — be specific, never generic. +Do NOT include "Unknown Title" or placeholder names.""" + + try: + structured = llm.with_structured_output(CurriculumAnalysis) + result = structured.invoke(prompt) + topics = [t.strip() for t in (result.core_topics or []) if t and t.strip()] + # Filter out placeholder junk + topics = [ + t for t in topics + if "unknown" not in t.lower() and "untitled" not in t.lower() + ] + print(f"[pipeline] Extracted topics: {topics}") + return topics[:8] + except Exception as exc: + print(f"[pipeline] Topic extraction failed: {exc}") + return [] + + +# ── Agent 2: Web enrichment ─────────────────────────────────────────────────── + +def _fetch_topic_content(topic: str, user_goal: str) -> str: + """ + Search DuckDuckGo for the topic, then fetch and parse the top pages + using BeautifulSoup to extract real learning content. + Returns a formatted text section for that topic. + """ + try: + import requests + from bs4 import BeautifulSoup + from duckduckgo_search import DDGS + + query = f"{topic} {user_goal} learn tutorial guide" + with DDGS() as ddgs: + results = list(ddgs.text(query, max_results=3)) or [] + + parts = [f"[Web — {topic}]"] + + for r in results[:2]: + url = r.get("href", "") + title = r.get("title", "Untitled") + snippet = (r.get("body") or "")[:350] + + if not url: + parts.append(f"• {title}: {snippet}") + continue + + # Fetch and parse the actual page + try: + resp = requests.get( + url, + timeout=6, + headers={"User-Agent": "Mozilla/5.0 (compatible; CurriculumOS/1.0)"}, + allow_redirects=True, + ) + ctype = resp.headers.get("content-type", "") + if resp.ok and "text/html" in ctype: + soup = BeautifulSoup(resp.text, "html.parser") + for noise in soup( + ["script", "style", "nav", "header", "footer", "aside", "form"] + ): + noise.decompose() + main = ( + soup.find("main") + or soup.find("article") + or soup.find("div", {"role": "main"}) + or soup.find("body") + ) + raw = main.get_text(separator=" ") if main else "" + page_text = " ".join(raw.split())[:800] + parts.append(f"• {title}\n {page_text or snippet}") + else: + parts.append(f"• {title}: {snippet}") + except Exception: + parts.append(f"• {title}: {snippet}") + + return "\n".join(parts) + + except Exception as exc: + print(f"[pipeline] Web fetch failed for '{topic}': {exc}") + return "" + + +def enrich_with_web_content(topics: list[str], user_goal: str) -> str: + """ + Runs web fetching for each topic concurrently and returns a combined + supplementary content block. + """ + if not topics: + return "" + + sections: list[str] = [] + with ThreadPoolExecutor(max_workers=3) as executor: + futures = { + executor.submit(_fetch_topic_content, topic, user_goal): topic + for topic in topics[:5] + } + for future in as_completed(futures, timeout=35): + try: + text = future.result() + if text: + sections.append(text) + except Exception as exc: + print(f"[pipeline] Web enrichment timeout: {exc}") + + return "\n\n".join(sections) + + +# ── Agent 3: Final generation prompt ───────────────────────────────────────── + +def build_enriched_generation_prompt( + user_goal: str, + time_query: str, + retrieved_context: str, + web_context: str, +) -> str: + web_section = ( + f"\nWEB-SOURCED SUPPLEMENTARY CONTENT:\n{web_context[:6000]}\n" + if web_context.strip() + else "" + ) + + return f"""You are an expert AI learning planner. + +Generate a structured, time-constrained learning roadmap using the +retrieved materials AND the web-sourced supplementary content below. ---------------------- USER GOAL: @@ -42,114 +224,98 @@ def build_time_constrained_prompt(user_goal: str, time_query: str, context: str) TIME CONSTRAINT: {time_query} -RETRIEVED KNOWLEDGE BASE: -{context} +RETRIEVED KNOWLEDGE BASE (from uploaded materials): +{retrieved_context} +{web_section} ---------------------- INSTRUCTIONS: -1. Interpret the TIME CONSTRAINT strictly and convert it into a day-by-day plan. - - Example: "2 months" is about 60 days - - Example: "3 weeks" is about 21 days +1. Convert the TIME CONSTRAINT strictly into the correct number of days: + - "2 weeks" = 14 days, "1 month" ≈ 30 days, "3 months" ≈ 90 days -2. Create a COMPLETE roadmap covering the FULL duration. +2. Create a COMPLETE roadmap covering the FULL duration with one entry per day. -3. Each day MUST include: - - Topic (what to learn) - - Tasks (specific actionable steps like read/watch/build/practice) +3. Each day MUST have: + - A clear, specific topic name drawn from the materials or web content + (NEVER output "Unknown Title", "Unknown", "Learning Focus", or any placeholder) + - 2–4 concrete, actionable tasks such as: + "Watch the video on [specific concept]", + "Implement [specific feature] in [language]", + "Read the section on [specific topic]", + "Solve [N] practice problems on [topic]" -4. Ensure: - - Logical progression (beginner -> intermediate -> advanced) - - No repetition of topics +4. Logical progression: + - Fundamentals → Core concepts → Advanced application + - No repeated topics - Balanced workload per day - Gradual increase in difficulty -5. Use ONLY the provided KNOWLEDGE BASE. - - Do NOT hallucinate topics outside context +5. Infer meaningful topics from context: + - If a source has no title, infer the topic from its content + - Use web content to supplement gaps left by uploaded materials -6. If the duration is long: - - Group learning into phases (e.g., fundamentals, core concepts, advanced) +6. For long durations, group into phases (e.g., Foundations, Core Skills, Project) ---------------------- OUTPUT FORMAT: -- Return ONLY a machine-parseable structured response that matches the schema: +Return ONLY a structured response matching this schema: days: [ {{ number: int, topic: str, tasks: string[] }} ] -- Do not include markdown, explanations, or keys outside this schema. -- Ensure each day has at least one actionable task. - ----------------------- - -IMPORTANT: -- Be precise and structured -- Avoid vague tasks like "study more" -- Prefer actionable tasks like: - - "Watch video on X" - - "Implement Y" - - "Revise Z" +No markdown, no explanations, no keys outside this schema. +Every day must have at least 2 tasks. """ -def build_context(docs: list[Document]) -> str: - sections = [] - max_doc_chars = int(os.getenv("RAG_CONTEXT_DOC_CHARS", "1200")) - max_total_chars = int(os.getenv("RAG_CONTEXT_TOTAL_CHARS", "8000")) - current_total = 0 - - for i, doc in enumerate(docs, start=1): - metadata = doc.metadata or {} - source_label = ( - metadata.get("title") - or metadata.get("filename") - or metadata.get("source") - or "document" - ) - content = doc.page_content.strip() - if max_doc_chars > 0: - content = content[:max_doc_chars] - - entry = f"[Source {i}: {source_label}]\n{content}" - entry_len = len(entry) - if max_total_chars > 0 and current_total + entry_len > max_total_chars: - break - - sections.append(entry) - current_total += entry_len - - return "\n\n".join(sections) - +# ── Main pipeline ───────────────────────────────────────────────────────────── async def pipeline( - documents, - time_query, - user_goal, + documents: list[Document], + time_query: str, + user_goal: str, processed_types: list[str], llm=None, ): + # ── Step 1: Chunk & embed uploaded content ──────────────────────────────── chunked_docs = chunk_documents(documents) - db = vector_db("paths") db.add_documents(chunked_docs) + # ── Step 2: Retrieve relevant chunks ────────────────────────────────────── retrieval_query = build_retrieval_query(user_goal, time_query) matched_docs = db.similarity_search(retrieval_query, k=6) - prompt_context = build_context(matched_docs) - prompt = build_time_constrained_prompt(user_goal, time_query, prompt_context) - + retrieved_context = build_context(matched_docs) + + # ── Step 3: Understanding agent ─────────────────────────────────────────── + # Reads retrieved docs and extracts the core curriculum topics + print("[pipeline] Running understanding agent...") + topics = extract_curriculum_topics(retrieved_context, user_goal, time_query, llm) + + # ── Step 4: Web enrichment ──────────────────────────────────────────────── + # Fetches real web content (DDG search + BeautifulSoup page scraping) + # for each topic to supplement the uploaded materials + web_context = "" + if topics: + print(f"[pipeline] Fetching web content for {len(topics)} topics...") + web_context = enrich_with_web_content(topics, user_goal) + print(f"[pipeline] Web content: {len(web_context)} chars") + + # ── Step 5: Final generation with with_structured_output ───────────────── + print("[pipeline] Running final generation agent...") + prompt = build_enriched_generation_prompt( + user_goal, time_query, retrieved_context, web_context + ) roadmap = generate_roadmap_structured(prompt, StructuredRoadmap, llm=llm) - normalized_days = sorted( - roadmap.days, - key=lambda d: d.number, - ) + normalized_days = sorted(roadmap.days, key=lambda d: d.number) roadmap_payload = { "days": [ { "number": day.number, "topic": day.topic.strip(), - "tasks": [task.strip() for task in day.tasks if task and task.strip()], + "tasks": [t.strip() for t in day.tasks if t and t.strip()], } for day in normalized_days ] @@ -164,4 +330,4 @@ async def pipeline( "time_query": time_query, "processed_types": processed_types, "documents_count": len(documents), - } \ No newline at end of file + } diff --git a/python/app/rag/processors/__pycache__/chunker.cpython-314.pyc b/python/app/rag/processors/__pycache__/chunker.cpython-314.pyc deleted file mode 100755 index 32b3789..0000000 Binary files a/python/app/rag/processors/__pycache__/chunker.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/processors/__pycache__/cleaner.cpython-314.pyc b/python/app/rag/processors/__pycache__/cleaner.cpython-314.pyc deleted file mode 100755 index a9fb79f..0000000 Binary files a/python/app/rag/processors/__pycache__/cleaner.cpython-314.pyc and /dev/null differ diff --git a/python/app/rag/processors/__pycache__/deduplicator.cpython-314.pyc b/python/app/rag/processors/__pycache__/deduplicator.cpython-314.pyc deleted file mode 100755 index 605532f..0000000 Binary files a/python/app/rag/processors/__pycache__/deduplicator.cpython-314.pyc and /dev/null differ diff --git a/python/app/routes/__pycache__/query.cpython-314.pyc b/python/app/routes/__pycache__/query.cpython-314.pyc deleted file mode 100755 index e05b652..0000000 Binary files a/python/app/routes/__pycache__/query.cpython-314.pyc and /dev/null differ diff --git a/python/app/routes/__pycache__/quiz.cpython-314.pyc b/python/app/routes/__pycache__/quiz.cpython-314.pyc deleted file mode 100755 index ca578b2..0000000 Binary files a/python/app/routes/__pycache__/quiz.cpython-314.pyc and /dev/null differ diff --git a/python/app/routes/__pycache__/upload.cpython-310.pyc b/python/app/routes/__pycache__/upload.cpython-310.pyc deleted file mode 100755 index a03e1bd..0000000 Binary files a/python/app/routes/__pycache__/upload.cpython-310.pyc and /dev/null differ diff --git a/python/app/routes/__pycache__/upload.cpython-314.pyc b/python/app/routes/__pycache__/upload.cpython-314.pyc deleted file mode 100755 index a7fa499..0000000 Binary files a/python/app/routes/__pycache__/upload.cpython-314.pyc and /dev/null differ diff --git a/python/app/routes/enrich.py b/python/app/routes/enrich.py new file mode 100644 index 0000000..39e4f5f --- /dev/null +++ b/python/app/routes/enrich.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter +from pydantic import BaseModel +from concurrent.futures import ThreadPoolExecutor, as_completed + +enrich_router = APIRouter() + +PLACEHOLDER_TOPICS = {"unknown title", "unknown", "untitled", "learning focus", ""} + + +class ResourceRequest(BaseModel): + topics: list[str] + user_goal: str + + +def _search_topic(topic: str, user_goal: str) -> tuple[str, list[dict]]: + search_query = topic.strip() + if search_query.lower() in PLACEHOLDER_TOPICS: + search_query = user_goal + + resources: list[dict] = [] + + try: + from duckduckgo_search import DDGS + + with DDGS() as ddgs: + try: + for r in ddgs.videos(f"{search_query} tutorial", max_results=2) or []: + url = r.get("content", "") + if url: + resources.append({ + "type": "video", + "title": r.get("title", "Video"), + "url": url, + "thumbnail": (r.get("images") or {}).get("small", ""), + "description": r.get("description", ""), + }) + except Exception as e: + print(f"[enrich] DDG video search failed for '{search_query}': {e}") + + try: + for r in ddgs.text( + f"{search_query} tutorial guide", max_results=4 + ) or []: + url = r.get("href", "") + if url: + resources.append({ + "type": "article", + "title": r.get("title", "Article"), + "url": url, + "description": (r.get("body") or "")[:200], + }) + except Exception as e: + print(f"[enrich] DDG text search failed for '{search_query}': {e}") + + except ImportError: + print("[enrich] duckduckgo_search not installed — returning empty resources") + except Exception as e: + print(f"[enrich] Unexpected error for '{search_query}': {e}") + + return topic.strip(), resources + + +@enrich_router.post("/resources") +async def fetch_resources(payload: ResourceRequest): + topics = [t.strip() for t in (payload.topics or [])[:6] if t and t.strip()] + if not topics: + return {"success": True, "resources": {}} + + results: dict[str, list[dict]] = {} + + with ThreadPoolExecutor(max_workers=3) as executor: + futures = { + executor.submit(_search_topic, topic, payload.user_goal): topic + for topic in topics + } + for future in as_completed(futures, timeout=25): + try: + topic_key, resources = future.result() + results[topic_key] = resources + except Exception as e: + original_topic = futures[future] + print(f"[enrich] Future failed for '{original_topic}': {e}") + results[original_topic] = [] + + return {"success": True, "resources": results} diff --git a/python/requirements.txt b/python/requirements.txt index 2182573..364e364 100755 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -33,3 +33,4 @@ sqlalchemy==2.0.41 asyncpg==0.30.0 python-dateutil==2.9.0.post0 +duckduckgo-search==8.0.1 diff --git a/server/Dockerfile b/server/Dockerfile index ece3593..4a3852d 100755 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,3 +1,4 @@ + FROM golang:1.25-alpine AS builder WORKDIR /backend diff --git a/server/Dockerfile.db b/server/Dockerfile.db new file mode 100644 index 0000000..07cd9a9 --- /dev/null +++ b/server/Dockerfile.db @@ -0,0 +1,7 @@ +FROM postgres:17-alpine + +ENV POSTGRES_USER=myuser +ENV POSTGRES_PASSWORD=mypassword +ENV POSTGRES_DB=mydatabase + +EXPOSE 5432 \ No newline at end of file diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 3b5c2d4..7b06f6c 100755 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -34,6 +34,7 @@ func main() { allowedOrigins := []string{ cfg.CLIENT_URL, cfg.PYTHON_URL, + "http://localhost:5173", } corsHandler := cors.New(cors.Options{ AllowedOrigins: allowedOrigins, diff --git a/server/db/models/roadmapModel.go b/server/db/models/roadmapModel.go index 82f030d..9d593b9 100755 --- a/server/db/models/roadmapModel.go +++ b/server/db/models/roadmapModel.go @@ -14,6 +14,7 @@ type Roadmap struct { RoadmapContent string `gorm:"type:text" json:"roadmapContent"` ResponsePayload string `gorm:"type:text" json:"responsePayload"` DayProgress string `gorm:"type:text" json:"dayProgress"` + TaskProgress string `gorm:"type:text" json:"taskProgress"` CreatedAt time.Time `json:"createdAt"` // Nullable to allow safe AutoMigrate on existing tables that may already contain rows. AuthorID uint `gorm:"index" json:"authorId"` diff --git a/server/docker-compose.dev.yml b/server/docker-compose.dev.yml new file mode 100644 index 0000000..379f7da --- /dev/null +++ b/server/docker-compose.dev.yml @@ -0,0 +1,51 @@ +version: "3.9" + +services: + backend: + build: + context: . + dockerfile: Dockerfile + container_name: go_backend + + ports: + - "8080:8080" + + environment: + DATABASE_URL: postgresql://myuser:mypassword@postgres:5432/mydatabase + + depends_on: + - postgres + + restart: unless-stopped + + networks: + - app_network + + + postgres: + image: postgres:17-alpine + container_name: postgres_db + + environment: + POSTGRES_USER: myuser + POSTGRES_PASSWORD: mypassword + POSTGRES_DB: mydatabase + + ports: + - "5432:5432" + + volumes: + - postgres_data:/var/lib/postgresql/data + + restart: unless-stopped + + networks: + - app_network + + +volumes: + postgres_data: + + +networks: + app_network: \ No newline at end of file diff --git a/server/go.mod b/server/go.mod index 231cc07..4d495b5 100755 --- a/server/go.mod +++ b/server/go.mod @@ -3,8 +3,10 @@ module curriculumOs go 1.25.0 require ( + github.com/jackc/pgx/v5 v5.6.0 github.com/joho/godotenv v1.5.1 github.com/rs/cors v1.11.1 + golang.org/x/crypto v0.47.0 golang.org/x/oauth2 v0.36.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 @@ -14,11 +16,9 @@ require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - golang.org/x/crypto v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/text v0.33.0 // indirect ) diff --git a/server/internal/handlers/pathHandler.go b/server/internal/handlers/pathHandler.go index 7f0bb4a..602c345 100755 --- a/server/internal/handlers/pathHandler.go +++ b/server/internal/handlers/pathHandler.go @@ -12,6 +12,7 @@ import ( "net/http" "strconv" "strings" + "time" ) func (h *Handler) CreatePath(w http.ResponseWriter, r *http.Request) { @@ -459,3 +460,383 @@ func extractInt(value any) int { return 0 } } + +// --- Task progress --- + +type taskProgressEntry struct { + DayLabel string `json:"dayLabel"` + TaskIndex int `json:"taskIndex"` + Completed bool `json:"completed"` +} + +func parseTaskProgress(raw string) []taskProgressEntry { + if strings.TrimSpace(raw) == "" { + return []taskProgressEntry{} + } + var result []taskProgressEntry + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return []taskProgressEntry{} + } + return result +} + +func (h *Handler) UpdateTaskProgress(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + services.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + user, err := h.currentUserFromRequest(r) + if err != nil { + services.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + + var payload struct { + RoadmapID uint `json:"roadmapId"` + DayLabel string `json:"dayLabel"` + TaskIndex int `json:"taskIndex"` + Completed bool `json:"completed"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + services.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + + if payload.RoadmapID == 0 || strings.TrimSpace(payload.DayLabel) == "" { + services.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "roadmapId and dayLabel are required"}) + return + } + + var roadmap models.Roadmap + if err := h.db.Where("id = ? AND author_id = ?", payload.RoadmapID, user.ID).First(&roadmap).Error; err != nil { + services.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "roadmap not found"}) + return + } + + progress := parseTaskProgress(roadmap.TaskProgress) + updated := false + for i := range progress { + if strings.EqualFold(progress[i].DayLabel, payload.DayLabel) && progress[i].TaskIndex == payload.TaskIndex { + progress[i].Completed = payload.Completed + updated = true + break + } + } + if !updated { + progress = append(progress, taskProgressEntry{ + DayLabel: payload.DayLabel, + TaskIndex: payload.TaskIndex, + Completed: payload.Completed, + }) + } + + serialized, err := json.Marshal(progress) + if err != nil { + services.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to serialize task progress"}) + return + } + + roadmap.TaskProgress = string(serialized) + if err := h.db.Save(&roadmap).Error; err != nil { + services.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save task progress"}) + return + } + + services.WriteJSON(w, http.StatusOK, map[string]any{ + "success": true, + "taskProgress": progress, + }) +} + +// --- Fetch resources (proxy to Python enrich service) --- + +func (h *Handler) FetchResources(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + services.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + _, err := h.currentUserFromRequest(r) + if err != nil { + services.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + services.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read body"}) + return + } + + pythonReq, err := http.NewRequest(http.MethodPost, h.cfg.PYTHON_URL+"/enrich/resources", bytes.NewReader(body)) + if err != nil { + services.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to build request"}) + return + } + pythonReq.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(pythonReq) + if err != nil { + services.WriteJSON(w, http.StatusBadGateway, map[string]string{"error": "resource service unavailable"}) + return + } + defer resp.Body.Close() + + normalized, err := services.Normalize_response(resp) + if err != nil { + services.WriteJSON(w, http.StatusBadGateway, map[string]string{"error": "invalid resource service response"}) + return + } + + services.WriteJSON(w, resp.StatusCode, normalized) +} + +// --- Stats types --- + +type pathStatsResponse struct { + TotalPaths int `json:"totalPaths"` + CompletedPaths int `json:"completedPaths"` + InProgressPaths int `json:"inProgressPaths"` + QueuedPaths int `json:"queuedPaths"` + CompletionRate float64 `json:"completionRate"` + ActivePaths []activePathStat `json:"activePaths"` + CompletedList []completedPath `json:"completedList"` + Distribution []distEntry `json:"distribution"` + WeeklyClosures []weekEntry `json:"weeklyClosures"` + MonthlyActivity []monthEntry `json:"monthlyActivity"` + CurrentFocus string `json:"currentFocus"` +} + +type activePathStat struct { + ID uint `json:"id"` + Name string `json:"name"` + Progress int `json:"progress"` + TotalDays int `json:"totalDays"` + CompletedDays int `json:"completedDays"` +} + +type completedPath struct { + ID uint `json:"id"` + Name string `json:"name"` + TotalDays int `json:"totalDays"` + CreatedAt time.Time `json:"createdAt"` +} + +type distEntry struct { + Name string `json:"name"` + Value int `json:"value"` +} + +type weekEntry struct { + Label string `json:"label"` + Completed int `json:"completed"` + Created int `json:"created"` +} + +type monthEntry struct { + Month string `json:"month"` + Focus int `json:"focus"` + Completion int `json:"completion"` +} + +// --- Stats helpers --- + +func parseTotalDaysFromContent(content string) int { + if strings.TrimSpace(content) == "" { + return 0 + } + var parsed struct { + Days []json.RawMessage `json:"days"` + } + if err := json.Unmarshal([]byte(content), &parsed); err != nil { + return 0 + } + return len(parsed.Days) +} + +func parseCompletedDaysCount(dayProgress string) int { + if strings.TrimSpace(dayProgress) == "" { + return 0 + } + var entries []struct { + Completed bool `json:"completed"` + } + if err := json.Unmarshal([]byte(dayProgress), &entries); err != nil { + return 0 + } + count := 0 + for _, e := range entries { + if e.Completed { + count++ + } + } + return count +} + +// --- GetStats handler --- + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + services.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + user, err := h.currentUserFromRequest(r) + if err != nil { + services.WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + + var roadmaps []models.Roadmap + if err := h.db.Where("author_id = ?", user.ID).Order("created_at DESC").Find(&roadmaps).Error; err != nil { + services.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to retrieve roadmaps"}) + return + } + + type rmStatus struct { + total int + completed int + kind string + } + + statusMap := make(map[uint]rmStatus, len(roadmaps)) + for _, rm := range roadmaps { + total := parseTotalDaysFromContent(rm.RoadmapContent) + done := parseCompletedDaysCount(rm.DayProgress) + var kind string + switch { + case total == 0 || done == 0: + kind = "queued" + case done >= total: + kind = "completed" + default: + kind = "in_progress" + } + statusMap[rm.ID] = rmStatus{total, done, kind} + } + + totalPaths := len(roadmaps) + var completedCount, inProgressCount, queuedCount int + for _, s := range statusMap { + switch s.kind { + case "completed": + completedCount++ + case "in_progress": + inProgressCount++ + default: + queuedCount++ + } + } + + var completionRate float64 + if totalPaths > 0 { + completionRate = float64(completedCount) / float64(totalPaths) * 100 + } + + activePaths := make([]activePathStat, 0) + for _, rm := range roadmaps { + s := statusMap[rm.ID] + if s.kind != "in_progress" { + continue + } + progress := 0 + if s.total > 0 { + progress = s.completed * 100 / s.total + } + activePaths = append(activePaths, activePathStat{ + ID: rm.ID, + Name: rm.Name, + Progress: progress, + TotalDays: s.total, + CompletedDays: s.completed, + }) + } + + completedList := make([]completedPath, 0) + for _, rm := range roadmaps { + if statusMap[rm.ID].kind != "completed" { + continue + } + completedList = append(completedList, completedPath{ + ID: rm.ID, + Name: rm.Name, + TotalDays: statusMap[rm.ID].total, + CreatedAt: rm.CreatedAt, + }) + } + + distribution := []distEntry{ + {Name: "Active", Value: inProgressCount}, + {Name: "Completed", Value: completedCount}, + {Name: "Queued", Value: queuedCount}, + } + + now := time.Now() + weeklyClosures := make([]weekEntry, 6) + for i := 0; i < 6; i++ { + weekStart := now.AddDate(0, 0, -(6-i)*7) + weekEnd := now.AddDate(0, 0, -(5-i)*7) + var created, completed int + for _, rm := range roadmaps { + if !rm.CreatedAt.Before(weekStart) && rm.CreatedAt.Before(weekEnd) { + created++ + if statusMap[rm.ID].kind == "completed" { + completed++ + } + } + } + weeklyClosures[i] = weekEntry{ + Label: fmt.Sprintf("W%d", i+1), + Completed: completed, + Created: created, + } + } + + monthlyActivity := make([]monthEntry, 6) + for i := 0; i < 6; i++ { + t := now.AddDate(0, -(5-i), 0) + monthKey := t.Format("2006-01") + var created, completed int + for _, rm := range roadmaps { + if rm.CreatedAt.Format("2006-01") == monthKey { + created++ + if statusMap[rm.ID].kind == "completed" { + completed++ + } + } + } + monthlyActivity[i] = monthEntry{ + Month: t.Format("Jan"), + Focus: created, + Completion: completed, + } + } + + currentFocus := "" + for _, rm := range roadmaps { + if statusMap[rm.ID].kind == "in_progress" { + currentFocus = rm.Name + break + } + } + + services.WriteJSON(w, http.StatusOK, map[string]any{ + "success": true, + "stats": pathStatsResponse{ + TotalPaths: totalPaths, + CompletedPaths: completedCount, + InProgressPaths: inProgressCount, + QueuedPaths: queuedCount, + CompletionRate: completionRate, + ActivePaths: activePaths, + CompletedList: completedList, + Distribution: distribution, + WeeklyClosures: weeklyClosures, + MonthlyActivity: monthlyActivity, + CurrentFocus: currentFocus, + }, + }) +} diff --git a/server/internal/routes/pathRoutes.go b/server/internal/routes/pathRoutes.go index 7fa1f6b..946a495 100755 --- a/server/internal/routes/pathRoutes.go +++ b/server/internal/routes/pathRoutes.go @@ -12,7 +12,10 @@ func RegisterPathRoutes(router *http.ServeMux, handler *handlers.Handler) { pathRouter.HandleFunc("/create", handler.CreatePath) pathRouter.HandleFunc("/getPaths", handler.GetPaths) + pathRouter.HandleFunc("/stats", handler.GetStats) pathRouter.HandleFunc("/day-progress", handler.UpdateDayProgress) + pathRouter.HandleFunc("/task-progress", handler.UpdateTaskProgress) + pathRouter.HandleFunc("/resources", handler.FetchResources) pathRouter.HandleFunc("/generate-quiz", handler.GenerateQuiz) }