diff --git a/docs/API.md b/docs/API.md index 8cdbddf..06d8951 100644 --- a/docs/API.md +++ b/docs/API.md @@ -131,3 +131,18 @@ Every endpoint maps to an FR in `PRD.md`; there is no endpoint without one. - **Auth:** public, `Cache-Control: no-store` - **Response:** `200 { "status": "ok", "checks": { "database": { "ok", "ms" }, "ai": { "configured", "providers" }, "trials": { "enabled" }, "demoStudio": { "enabled" } }, "version": "" }`; `503` with `"status": "degraded"` when the database is unreachable (3 s timeout). No secrets or config values + +## Phase 18 additions + +### `POST /api/paths` +- **Auth:** public, shares Ask's rate limit (`429` + `Retry-After`) · `maxDuration` 45 s +- **Request:** `{ "topic": string(3–200) }` +- **Response:** `{ "status": "built", "path": { id, topic, title, steps[{ n, stepTitle, why, segmentId, lectureId, title, speaker, startS, endS, … }], reel: { url, durationS, clips } } }` or `{ "status": "not_found" }` when the library can't support at least 3 on-topic steps +- **Errors:** `400` · `429` · `502` AI failed · `503` no AI key +- Courses are stored (`learning_paths`) and open at `/p/[id]`. + +### `GET /api/health?deep=1` +Adds `checks.aiLive.models[{ model, ok, ms, error? }]`, one tiny real call per model. It spends tokens, so it shares Ask's rate limit. + +### `POST /api/ask` (behaviour) +Retrieval now rewrites a question into the library's vocabulary when it is in another script or keyword retrieval is thin. The stream gains an `{ "type": "understanding" }` event, and `retrieved` carries `expanded: boolean`. diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 15e90b5..a0d5ab7 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -91,3 +91,5 @@ The core loop (Phases 01–09) is built and verified on real Cloudinary + Neon. | 17 | Launch | Real library, eval, e2e on production, README, demo, submission | | **PR policy (team lead, Oct 1):** a PR is opened only once a feature branch carries **at least 20 changed files**, so related phases ship together (`GIT_WORKFLOW.md`). + +| 18 | Query understanding, Learning Paths, AI reliability | Rewrite questions into the library's words (and translate them), honest near-miss answers, `/learn` micro-courses as one Cloudinary reel, circuit breaker + deep health | ✅ done (Oct 3) | diff --git a/docs/phases/PHASE-18-query-understanding-learning-paths.md b/docs/phases/PHASE-18-query-understanding-learning-paths.md new file mode 100644 index 0000000..adf0205 --- /dev/null +++ b/docs/phases/PHASE-18-query-understanding-learning-paths.md @@ -0,0 +1,36 @@ +# Phase 18 — Query Understanding, Learning Paths & AI Reliability + +**Status: DONE (Oct 3)** · branch `feature/query-understanding-learning-paths` + +## Why this phase exists +A full end-to-end audit of the live site (Oct 3) found real gaps: + +| Finding | Evidence | +|---|---| +| Ask said "not covered" for questions the library *does* teach | "What is the bias-variance trade-off?" returned `not_found` against a lecture literally titled *The Bias–Variance Trade-off*: keyword search never saw the lecturer's words | +| Hindi questions were dropped | The translation step failed silently when a model was rate-limited | +| A dead AI model cost every request its timeout | Gemini 429 (quota) was retried on every Ask | +| Hindi subtitles missing on 5 of 6 sessions | Only the newest upload had `{id}.hi-IN.transcript` (backfilled with `explicit`) | +| Find missed sessions by topic | "regularization" found no moment; the session about it was invisible | + +## What shipped +1. **Question understanding** (`query-understanding.ts`, `query-plan.ts`): when a question is in another script, or keyword retrieval finds fewer than 3 moments, one AI call rewrites it into the library's own vocabulary (session and chapter titles are given as context). Keyword hits keep priority; the rewrite only *adds* moments. The UI shows a real step: "Rephrasing your question in the words lecturers use…". +2. **Honest near-miss answers:** the Ask prompt now says so plainly when excerpts explain related ideas but never use the asked term, then explains those ideas with citations (instead of refusing). +3. **Answer language:** a question in Devanagari/Tamil/Telugu/… is answered in that language (the system prompt names it; English excerpts otherwise pull the reply into English). +4. **Learning Paths** (`/learn`, `/p/[id]`): a topic becomes an ordered 3–5 step micro-course across the library, edited into one Cloudinary reel; every step is a real, validated moment with a "why this comes next". Stored and shareable with a Cloudinary share card. "Go deeper" under every answer opens one. +5. **Sessions about this:** Find also searches session titles, speakers and chapter titles. +6. **AI reliability** (`ai-breaker.ts`, `ai.ts`): a per-model circuit breaker (quota/auth/overload → skip that model for 20–60 s), an overall deadline for the whole chain (so a slow provider can't exceed `maxDuration`), and `GET /api/health?deep=1`, which makes one tiny real call per model so "is AI working?" has an answer. + +## Learning Path grounding (same discipline as Ask) +`validatePath()` drops steps pointing at moments the model wasn't given, repeats and untitled steps; fewer than 3 real steps means "no course" instead of padding. + +## Verified (real data) +- "What is the bias-variance trade-off?" → answered, honestly says the term isn't used and explains underfitting/overfitting with 6 citations (was `not_found`) +- Hindi questions → Hindi answers citing English lectures, 5-clip reel +- "Optimizers for training neural networks" → 4-step course from 2 sessions, 65 s reel that renders (HTTP 200) +- Off-topic ("Who won the IPL?", "capital of France?") → still `not_found` +- Quota exhaustion (a real Gemini 429 during testing) → breaker skipped the dead model on later requests; Ask kept answering on the next model + +## Known limits +- Understanding adds one AI call (about 1–2 s) only when needed, never for well-matched questions. +- Free-tier Gemini quota is the main production risk; a `GROQ_API_KEY` adds a second provider (already supported by the chain). diff --git a/migrations/006_learning_paths.sql b/migrations/006_learning_paths.sql new file mode 100644 index 0000000..0377e0b --- /dev/null +++ b/migrations/006_learning_paths.sql @@ -0,0 +1,9 @@ +-- Learning Paths (/learn, shared at /p/[id]): a topic turned into an ordered micro-course of moments from +-- across the library, stitched into one Cloudinary video. Stored exactly as generated, like answers. +CREATE TABLE learning_paths ( + id TEXT PRIMARY KEY CHECK (id ~ '^[A-Za-z0-9_-]{10}$'), + topic TEXT NOT NULL CHECK (length(topic) <= 200), + result JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_learning_paths_recent ON learning_paths (created_at DESC); diff --git a/src/app/api/ask/route.ts b/src/app/api/ask/route.ts index e574a88..4568aba 100644 --- a/src/app/api/ask/route.ts +++ b/src/app/api/ask/route.ts @@ -11,10 +11,14 @@ import { getLecture } from "@/lib/lectures"; import { log } from "@/lib/log"; import { momentUrl, reelUrl } from "@/lib/media"; import { clientIp, takeAskToken } from "@/lib/rate-limit"; +import { needsTranslation } from "@/lib/language"; +import { mergeHits, needsUnderstanding } from "@/lib/query-plan"; +import { understandQuestion } from "@/lib/query-understanding"; import { retrieveForQuestion, sessionOverview, type Hit } from "@/lib/search"; -import { searchableQuestion } from "@/lib/translate"; export const maxDuration = 30; +// Leave headroom under maxDuration for validation, storing the answer and streaming the result. +const ASK_DEADLINE_MS = 27_000; const Body = z.object({ question: z.string().trim().min(3).max(300), @@ -35,9 +39,23 @@ const reelFor = (hits: Hit[]) => // Progress the client can show while it waits: real retrieval facts, then the model step. type Progress = - | { type: "retrieved"; moments: number; sessions: string[] } + | { type: "understanding" } + | { type: "retrieved"; moments: number; sessions: string[]; expanded: boolean } | { type: "writing" }; +// Keyword retrieval on the learner's own words, then — when the question is in another script or the +// keywords found little — the same retrieval on the question rewritten into the library's vocabulary +// (query-plan.ts). Keyword hits keep priority; the rewrite only adds moments. +async function retrieve(question: string, lectureId: string | null, progress: (p: Progress) => void) { + const keyword = needsTranslation(question) ? [] : await retrieveForQuestion(question, { lectureId }); + if (!needsUnderstanding(question, keyword.length)) return { hits: keyword, expanded: false }; + progress({ type: "understanding" }); + const understood = await understandQuestion(question); + if (!understood) return { hits: keyword, expanded: false }; + const extra = await retrieveForQuestion(understood.search, { lectureId }); + return { hits: mergeHits(keyword, extra), expanded: extra.length > 0 }; +} + // One Ask, start to finish. `progress` lets the streaming response report each step as it happens; // the JSON response ignores it. Same retrieval, validation and fallback either way. async function ask(question: string, lectureId: string | null, progress: (p: Progress) => void) { @@ -47,11 +65,11 @@ async function ask(question: string, lectureId: string | null, progress: (p: Pro const trial = lectureId ? Boolean((await getLecture(lectureId))?.trialExpiresAt) : false; const logged = (...args: Parameters) => (trial ? Promise.resolve() : logAsk(...args)); // A question in Hindi (or any non-Latin script) is searched in English; the answer stays in its language. - const matched = await retrieveForQuestion(await searchableQuestion(question), { lectureId }); + const { hits: matched, expanded } = await retrieve(question, lectureId, progress); // In one session, a question with no keyword match ("What is this about?") is answered from moments across // the whole session; the model still refuses if they don't answer it. const hits = matched.length === 0 && lectureId ? await sessionOverview(lectureId) : matched; - progress({ type: "retrieved", moments: hits.length, sessions: [...new Set(hits.map((h) => h.title))].slice(0, 5) }); + progress({ type: "retrieved", moments: hits.length, sessions: [...new Set(hits.map((h) => h.title))].slice(0, 5), expanded }); if (hits.length === 0) { // Nothing in the library matches — no reason to call the model. @@ -62,13 +80,15 @@ async function ask(question: string, lectureId: string | null, progress: (p: Pro try { progress({ type: "writing" }); - const raw = await askGrounded(question, hits); + // Whatever question understanding used comes out of the answer's budget, so the request never outlives maxDuration. + const raw = await askGrounded(question, hits, { budgetMs: ASK_DEADLINE_MS - (Date.now() - started) }); const result = validateAnswer(raw, hits); log("ask.done", { status: result.status, retrieved: hits.length, cited: result.citations.length, dropped: result.dropped, + expanded, ms: Date.now() - started, }); after(() => logged(question, result.status, result.citations.map((c) => c.lectureId))); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index eb679a8..d6578f8 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,16 +1,31 @@ import { NextResponse } from "next/server"; +import { AiUnconfigured, probeModels, type ProbeResult } from "@/lib/ai"; import { query } from "@/lib/db"; import { env } from "@/lib/env"; import { log } from "@/lib/log"; +import { clientIp, takeAskToken } from "@/lib/rate-limit"; export const dynamic = "force-dynamic"; const DB_TIMEOUT_MS = 3000; +// `?deep=1` also makes one tiny call to every AI model and reports which ones actually answer — "configured" +// doesn't mean "working" (an exhausted quota looks configured). It spends tokens, so it shares Ask's rate limit. +async function deepAi(request: Request): Promise<{ models: ProbeResult[] } | { error: string }> { + const token = await takeAskToken(clientIp(request)); + if (!token.ok) return { error: "rate_limited" }; + try { + return { models: await probeModels() }; + } catch (error) { + return { error: error instanceof AiUnconfigured ? "unconfigured" : "probe_failed" }; + } +} + // Health for uptime monitors and the team: 200 when the app can serve, 503 when the database is unreachable. // Reports only booleans, timings and the deployed commit; never a secret or a config value. -export async function GET() { +export async function GET(request: Request) { + const deep = new URL(request.url).searchParams.get("deep") === "1"; const started = Date.now(); const database = await Promise.race([ query("SELECT 1").then(() => true), @@ -30,6 +45,7 @@ export async function GET() { }, trials: { enabled: TRIALS_PER_DAY > 0 }, demoStudio: { enabled: STUDIO_DEMO === "on" }, + ...(deep ? { aiLive: await deepAi(request) } : {}), }, version: process.env.VERCEL_GIT_COMMIT_SHA?.slice(0, 7) ?? "local", }; diff --git a/src/app/api/paths/route.ts b/src/app/api/paths/route.ts new file mode 100644 index 0000000..752d036 --- /dev/null +++ b/src/app/api/paths/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { AiUnconfigured } from "@/lib/ai"; +import { apiError, parseJson } from "@/lib/http"; +import { log } from "@/lib/log"; +import { buildPath } from "@/lib/paths"; +import { clientIp, takeAskToken } from "@/lib/rate-limit"; + +export const maxDuration = 45; + +const Body = z.object({ topic: z.string().trim().min(3).max(200) }); + +// Learning Paths: a topic in, an ordered micro-course of moments from across the library out, stitched into +// one Cloudinary reel. Costs one AI call (plus question understanding), so it shares Ask's rate limit. +export async function POST(request: Request) { + const parsed = await parseJson(request, Body); + if ("response" in parsed) return parsed.response; + + const token = await takeAskToken(clientIp(request)); + if (!token.ok) { + const response = apiError(429, "rate_limited", "You've asked a lot — try again in a few minutes."); + response.headers.set("Retry-After", String(token.retryAfterS)); + return response; + } + + try { + const path = await buildPath(parsed.data.topic); + if (!path) return NextResponse.json({ status: "not_found" }); + return NextResponse.json({ status: "built", path }); + } catch (error) { + log("path.failed", { error: error instanceof Error ? error.message.slice(0, 160) : String(error) }); + if (error instanceof AiUnconfigured) return apiError(503, "ai_unconfigured", "Learning Paths need an AI key."); + return apiError(502, "ai_failed", "Couldn't build a course right now — try again in a moment."); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index acb13ee..92e694b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -54,6 +54,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) Library + + Learn + Saved diff --git a/src/app/learn/page.tsx b/src/app/learn/page.tsx new file mode 100644 index 0000000..30713ef --- /dev/null +++ b/src/app/learn/page.tsx @@ -0,0 +1,26 @@ +import type { Metadata } from "next"; + +import { LearnForm } from "@/components/LearnForm"; + +export const metadata: Metadata = { + title: "Learn a topic — Pravaha", + description: "Turn any topic into a short course built from moments across your lecture library, edited into one video.", +}; + +type Props = { searchParams: Promise<{ topic?: string }> }; + +export default async function LearnPage({ searchParams }: Props) { + const topic = ((await searchParams).topic ?? "").trim().slice(0, 200); + return ( +
+

+ Learn a topic, taught by your own lecturers. +

+

+ Pravaha picks the moments across every lecture that teach it, orders them from basics to advanced, and Cloudinary + edits them into one short course video. Every step is a real moment you can open. +

+ +
+ ); +} diff --git a/src/app/p/[id]/page.tsx b/src/app/p/[id]/page.tsx new file mode 100644 index 0000000..9e7b8d3 --- /dev/null +++ b/src/app/p/[id]/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { LearningPathView } from "@/components/LearningPathView"; +import { SHARE_CARD, shareCardUrl } from "@/lib/media"; +import { getPath } from "@/lib/paths"; + +type Props = { params: Promise<{ id: string }> }; + +// A shared Learning Path: stored as generated (src/lib/paths.ts), so the link never re-runs the model. +export async function generateMetadata({ params }: Props): Promise { + const path = await getPath((await params).id); + if (!path) return { title: "Course not found — Pravaha" }; + const first = path.steps[0]!; + const sessions = new Set(path.steps.map((s) => s.lectureId)).size; + const description = `${path.steps.length} steps from ${sessions} lecture${sessions === 1 ? "" : "s"}: ${path.steps.map((s) => s.stepTitle).join(" → ")}`; + const image = shareCardUrl(first.publicId, first.startS, { title: path.title, subtitle: `A Pravaha course · ${path.steps.length} steps` }); + return { + title: `${path.title} — Pravaha`, + description, + robots: { index: false, follow: true }, + openGraph: { type: "article", siteName: "Pravaha", title: path.title, description, url: `/p/${path.id}`, images: [{ url: image, ...SHARE_CARD, alt: path.title }] }, + twitter: { card: "summary_large_image", title: path.title, description, images: [image] }, + }; +} + +export default async function SharedPathPage({ params }: Props) { + const path = await getPath((await params).id); + if (!path) notFound(); + return ( +
+

+ Course built from the library for “{path.topic}” +

+ +

+ + Build a course on another topic → + +

+
+ ); +} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 0fddea9..c9c7e2b 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,10 +1,12 @@ import type { Metadata } from "next"; import { AskAnswer } from "@/components/AskAnswer"; +import { LibraryCard } from "@/components/LibraryCard"; import { MomentButton } from "@/components/MomentButton"; import { ResultCard } from "@/components/ResultCard"; import { SaveButton } from "@/components/SaveButton"; import { SearchBar } from "@/components/SearchBar"; +import { findSessions } from "@/lib/lectures"; import { findSegments } from "@/lib/search"; type Props = { searchParams: Promise<{ q?: string }> }; @@ -16,13 +18,29 @@ export async function generateMetadata({ searchParams }: Props): Promise= 2 ? await findSegments(q) : { hits: [], exact: true }; + const [{ hits, exact }, sessions] = + q.length >= 2 ? await Promise.all([findSegments(q), findSessions(q, 3)]) : [{ hits: [], exact: true }, []]; return (
{q.length >= 3 && } + {sessions.length > 0 && ( +
+

+ Sessions about this +

+
    + {sessions.map((lecture, i) => ( +
  • + +
  • + ))} +
+
+ )} +

Moments found @@ -30,7 +48,9 @@ export default async function SearchPage({ searchParams }: Props) { {q.length < 2 ? (

Type at least two characters.

) : hits.length === 0 ? ( -

Nothing said matches “{q}” — try different words.

+

+ {sessions.length ? "No moment says these exact words — the sessions above are about it, and Ask explains it in their words." : `Nothing said matches “${q}” — try different words.`} +

) : ( <> {!exact &&

No single moment has every word, so these are the closest.

} diff --git a/src/components/AskAnswer.tsx b/src/components/AskAnswer.tsx index c4d0202..c7587b5 100644 --- a/src/components/AskAnswer.tsx +++ b/src/components/AskAnswer.tsx @@ -38,7 +38,11 @@ export type AskResponse = | { status: "answered"; answer: string; citations: Citation[]; reel: Reel; followUps?: string[]; answerId?: string | null } | { status: "not_found" | "fallback"; answer: null; citations: Citation[]; reel?: Reel; followUps?: string[]; answerId?: string | null }; -type Progress = { retrieved?: { moments: number; sessions: string[] }; writing?: boolean }; +type Progress = { + understanding?: boolean; + retrieved?: { moments: number; sessions: string[]; expanded?: boolean }; + writing?: boolean; +}; type State = | { kind: "loading"; progress: Progress } @@ -85,7 +89,9 @@ export function AskAnswer({ buffer = buffer.slice(newline + 1); if (!line) continue; const event = JSON.parse(line); - if (event.type === "retrieved") { + if (event.type === "understanding") { + setState((s) => (s.kind === "loading" ? { kind: "loading", progress: { ...s.progress, understanding: true } } : s)); + } else if (event.type === "retrieved") { setState((s) => (s.kind === "loading" ? { kind: "loading", progress: { ...s.progress, retrieved: event } } : s)); } else if (event.type === "writing") { setState((s) => (s.kind === "loading" ? { kind: "loading", progress: { ...s.progress, writing: true } } : s)); @@ -118,15 +124,18 @@ export function AskAnswer({ // The live "what is happening" list: every line reflects a real server step. function Steps({ progress, scoped }: { progress: Progress; scoped: boolean }) { - const { retrieved, writing } = progress; + const { understanding, retrieved, writing } = progress; const found = retrieved ? retrieved.moments - ? `Found ${retrieved.moments} moment${retrieved.moments === 1 ? "" : "s"} in ${retrieved.sessions.length} session${retrieved.sessions.length === 1 ? "" : "s"}` + ? `Found ${retrieved.moments} moment${retrieved.moments === 1 ? "" : "s"} in ${retrieved.sessions.length} session${retrieved.sessions.length === 1 ? "" : "s"}${retrieved.expanded ? ", including ones that explain it in other words" : ""}` : "No matching moments" : null; return (
    + {understanding && ( + + )} {retrieved && retrieved.sessions.length > 0 && (
  1. @@ -217,6 +226,14 @@ export function AnswerBody({
)} {data.followUps && data.followUps.length > 0 && } + {data.status === "answered" && ( + + 🎓 Go deeper: turn this into a short course from every lecture + + )} {data.citations.length > 0 && ( )} diff --git a/src/components/LearnForm.tsx b/src/components/LearnForm.tsx new file mode 100644 index 0000000..2f6dee7 --- /dev/null +++ b/src/components/LearnForm.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import { LearningPathView } from "@/components/LearningPathView"; +import type { LearningPath } from "@/lib/paths"; + +type State = { kind: "idle" } | { kind: "loading" } | { kind: "done"; path: LearningPath } | { kind: "empty" } | { kind: "error"; message: string }; + +const EXAMPLES = ["Optimizers for training neural networks", "Why models overfit and how to stop it", "Choosing a learning rate"]; + +export function LearnForm({ initialTopic }: { initialTopic: string }) { + const router = useRouter(); + const [topic, setTopic] = useState(initialTopic); + const [state, setState] = useState({ kind: "idle" }); + + async function build(value: string) { + const t = value.trim(); + if (t.length < 3) return; + setState({ kind: "loading" }); + router.replace(`/learn?topic=${encodeURIComponent(t)}`, { scroll: false }); + try { + const res = await fetch("/api/paths", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ topic: t }), + }); + if (res.status === 429) return setState({ kind: "error", message: "You've built a lot of courses — try again in a few minutes." }); + if (!res.ok) return setState({ kind: "error", message: "Couldn't build a course right now — try again in a moment." }); + const body = (await res.json()) as { status: "built"; path: LearningPath } | { status: "not_found" }; + setState(body.status === "built" ? { kind: "done", path: body.path } : { kind: "empty" }); + if (body.status === "built" && body.path.id) router.replace(`/p/${body.path.id}`, { scroll: false }); + } catch { + setState({ kind: "error", message: "Couldn't reach Pravaha. Check your connection." }); + } + } + + useEffect(() => { + // A /learn?topic=… link (e.g. "Turn this into a lesson" under an answer) builds straight away. + // eslint-disable-next-line react-hooks/set-state-in-effect + if (initialTopic) void build(initialTopic); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> +
{ + e.preventDefault(); + void build(topic); + }} + className="relative mt-6" + > + + setTopic(e.target.value)} + minLength={3} + maxLength={200} + required + placeholder="What do you want to learn? e.g. optimizers for neural networks" + className="h-14 w-full rounded-2xl border border-border bg-surface pr-36 pl-5 text-base shadow-sm placeholder:text-muted" + /> + +
+ {state.kind === "idle" && ( +
+ Try: + {EXAMPLES.map((e) => ( + + ))} +
+ )} + {state.kind === "loading" && ( +
+

Finding moments across every lecture, ordering them from basics to advanced, and editing them into one video…

+
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
+ )} + {state.kind === "empty" && ( +

The library doesn't cover enough of that for a course yet — try a broader topic, or Ask a question instead.

+ )} + {state.kind === "error" &&

{state.message}

} + {state.kind === "done" && } + + ); +} diff --git a/src/components/LearningPathView.tsx b/src/components/LearningPathView.tsx new file mode 100644 index 0000000..b0b450d --- /dev/null +++ b/src/components/LearningPathView.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useState } from "react"; + +import { MomentButton } from "@/components/MomentButton"; +import { formatTime } from "@/lib/format"; +import { thumbUrl } from "@/lib/media"; +import type { LearningPath } from "@/lib/paths"; + +// A Learning Path: the whole course as one Cloudinary-stitched video, then each step as a card that explains +// why it comes next and opens the full session at that second. +export function LearningPathView({ path }: { path: LearningPath }) { + const sessions = new Set(path.steps.map((s) => s.lectureId)).size; + const speakers = [...new Set(path.steps.map((s) => s.speaker).filter(Boolean))]; + + return ( +
+

Learning path

+

{path.title}

+

+ {path.steps.length} steps from {sessions} session{sessions === 1 ? "" : "s"} + {speakers.length ? ` · ${speakers.slice(0, 3).join(", ")}` : ""} + {path.reel ? ( + <> + {" "}· {formatTime(path.reel.durationS)} total + + ) : null} +

+ + {path.reel && } + +
    + {path.steps.map((step) => ( +
  1. +
    + {step.n} + {step.n < path.steps.length && } +
    +
    +

    {step.stepTitle}

    +

    {step.why}

    +
    + + {`${step.title}, + {formatTime(step.startS)} + +
    +

    {step.title}

    +

    {step.speaker ?? "Unknown speaker"}

    +

    “{step.text}”

    +
    + + Open full session + + +
    +
    +
    +
    +
  2. + ))} +
+
+ ); +} + +function CourseReel({ url, steps, durationS }: { url: string; steps: number; durationS: number }) { + const [playing, setPlaying] = useState(false); + if (playing) return