Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/web/prompts/scout/extract.v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
version: 2
purpose: Extract study material and a level from one free learning resource
---

You turn one free learning resource into study material for a developer.

You receive a title, creator, description, and the resource's text. For videos the text is a transcript with `[mm:ss]` markers; for articles it is cleaned markdown.

Rules:

1. Use only the supplied text and description. Never add facts, chapters, tools or opinions that are not there.
2. `summary` is 2–4 sentences in plain prose. No marketing language, no bullet points.
3. `keyPoints` are the ideas a learner should remember. Each one is a single sentence.
4. Every key point that makes a factual or technical claim must include a `quote` copied verbatim from the supplied text (at least 12 characters) and the `atSeconds` where that quote appears for videos. If you cannot find an exact quote, omit the key point.
5. `skills` are 1–5 short labels naming the capability the resource teaches, e.g. "layout", "state management", "sql joins". Lowercase, no version numbers.
6. Only include `chapters` for videos. Use the description's chapter list when it exists. Otherwise propose 3–8 chapters, each with the second it starts and a short title. Never invent timestamps beyond the video duration supplied.
7. Ignore sponsor reads, subscribe requests, channel plugs and course advertisements.
8. If the text is too thin to support any claim, return an empty `keyPoints` array and say so in `summary`.
9. `level` is the level a learner needs to get value from this resource:
- `basics`: assumes no prior knowledge of the topic; introduces core terms and the first working example.
- `intermediate`: assumes the basics; builds real features, covers common patterns, trade-offs and debugging.
- `advanced`: assumes working experience; covers internals, performance, scaling, edge cases or architecture.
Judge from what the text teaches and assumes, not from words like "beginner" or "ultimate" in the title. If the text is too thin to judge, return null.
39 changes: 39 additions & 0 deletions apps/web/prompts/scout/plan.v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
version: 2
purpose: Turn approved resources into a video ladder of levels with a small core set per level
---

You build a short, honest video ladder from resources a learner has already approved. The learner learns by watching videos, basics first, then moving up one level at a time. Follow the 80/20 rule: in each level, pick the few videos that cover most of what matters and mark everything else as optional.

You receive the learner's goal and a list of resources. Each resource has an id, title, creator, kind (`video`, `article`, `doc`, `book`), an optional `level` hint (`basics`, `intermediate`, `advanced` or null), summary, skill tags and chapters. Videos also carry a duration and chapter start times.

Levels:

1. Group the material into modules, and give every module a `level`: `basics`, `intermediate` or `advanced`. A module is one capability, not one resource.
2. Use at most 3 modules per level. Skip a level when the material does not support it; never invent a level to fill the ladder. A ladder with only `basics` is fine.
3. List modules in level order (basics, then intermediate, then advanced). Inside a level, order modules so each depends only on earlier ones.
4. Judge the level from what the material teaches and assumes, not from the goal's ambition. Use a resource's `level` hint when it agrees with the content.

Core picks (80/20):

5. Every task has `core` (true or false). Core tasks are the small set that covers most of what matters at that level.
6. Each level has at most 5 core videos, ideally 3. Count distinct videos: several chapter tasks from the same video count as one video.
7. Prefer videos for core. An article or doc may be core only when the level has no suitable video.
8. Books are never core.
9. For every core task, `coreReason` is one line (under 20 words) saying why this pick matters most at its level, e.g. "Covers the request lifecycle every later module builds on." Leave `coreReason` empty for non-core tasks.
10. Everything that is not core is a "go deeper" item: optional, useful, skippable.

Tasks:

11. Every task must belong to a module and point at one real `resourceId` from the input. Resource ids must be copied exactly. Never create new ids.
12. For a video with chapters, turn chapters into watch tasks. Task titles read like "Watch <chapter title> (12:30–25:10)". Set `startSeconds` and `endSeconds` from the chapters. A video without chapters is one watch task.
13. For an article or doc, create one read task. Set `resourceId` and leave timestamps null.
14. Books are a separate reading track. Put every book task in one module titled "Reading track" with level `basics`; its level is ignored. Create one task per chapter title supplied. Never invent chapter numbers that are not in the input.
15. Do not create tasks that require paid accounts, sign-ups, or resources outside the input.
16. `estimatedMinutes` is realistic: video tasks use the chapter length; reading tasks assume 4 minutes per 1000 words when word count is known, otherwise 20 minutes.

Roadmap fields:

17. `summary` is 2–3 sentences describing what the learner will be able to do at the end.
18. `objectives` are 3–5 concrete abilities, each starting with a verb.
19. `difficulty` is `beginner`, `intermediate` or `advanced`: the level of the hardest material in the ladder.
252 changes: 139 additions & 113 deletions apps/web/src/components/scout/PickList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Check, FileText, Video } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatTimestamp, humanDuration } from "@/lib/scout/format";
import {
groupPicks,
PICK_GROUP_LABEL,
PICK_ROLE_LABEL,
type PickRole,
} from "@/lib/scout/picks";
import type { ScoutResource } from "@/lib/scout/types";

interface PickListProps {
Expand All @@ -15,126 +21,146 @@ export default function PickList({
onToggle,
}: PickListProps) {
return (
<ul className="divide-y divide-border">
{resources.map((resource) => {
const checked = selected.has(resource.id);
const Icon = resource.kind === "video" ? Video : FileText;
const quote = resource.keyPoints.find(
(point) => point.quote,
)?.quote;
const chapters = resource.chapters.slice(0, 4);
<div>
{groupPicks(resources).map((group) => (
<section key={group.level} className="mt-6 first:mt-0">
<h3 className="text-[13px] font-medium text-text-soft">
{PICK_GROUP_LABEL[group.level]}
</h3>
<ul className="mt-2 divide-y divide-border">
{group.items.map(({ resource, role }) => (
<PickRow
key={resource.id}
resource={resource}
role={role}
checked={selected.has(resource.id)}
onToggle={onToggle}
/>
))}
</ul>
</section>
))}
</div>
);
}

return (
<li key={resource.id} className="py-5">
<div className="flex items-start gap-4">
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={`Keep ${resource.title}`}
onClick={() => onToggle(resource.id)}
className={cn(
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] border transition-colors",
checked
? "border-text bg-text text-page"
: "border-border-strong hover:border-text-soft",
)}
>
{checked ? (
<Check size={13} strokeWidth={3} />
) : null}
</button>
function PickRow({
resource,
role,
checked,
onToggle,
}: {
resource: ScoutResource;
role: PickRole;
checked: boolean;
onToggle: (id: string) => void;
}) {
const Icon = resource.kind === "video" ? Video : FileText;
const quote = resource.keyPoints.find((point) => point.quote)?.quote;
const chapters = resource.chapters.slice(0, 4);

<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-soft">
<span className="inline-flex items-center gap-1.5">
<Icon size={11} /> {resource.kind}
</span>
{resource.creator ? (
<span className="normal-case tracking-normal text-[var(--color-text-muted)]">
{resource.creator}
</span>
) : null}
{resource.durationSeconds ? (
<span className="tabular-nums">
{humanDuration(
resource.durationSeconds,
)}
</span>
) : null}
{resource.transcriptStatus ===
"unavailable" ||
resource.transcriptStatus === "skipped" ? (
<span className="normal-case tracking-normal text-warning/80">
description only
</span>
) : null}
</div>
return (
<li className="py-5">
<div className="flex items-start gap-4">
<button
type="button"
role="checkbox"
aria-checked={checked}
aria-label={`Keep ${resource.title}`}
onClick={() => onToggle(resource.id)}
className={cn(
"mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] border transition-colors",
checked
? "border-text bg-text text-page"
: "border-border-strong hover:border-text-soft",
)}
>
{checked ? <Check size={13} strokeWidth={3} /> : null}
</button>

<a
href={resource.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 block break-words text-[15px] font-medium leading-snug text-[var(--color-text)] underline-offset-4 hover:underline"
>
{resource.title}
</a>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-soft">
<span className="inline-flex items-center gap-1.5">
<Icon size={11} /> {resource.kind}
</span>
<span className="studio-badge text-text-muted">
{PICK_ROLE_LABEL[role]}
</span>
{resource.creator ? (
<span className="normal-case tracking-normal text-[var(--color-text-muted)]">
{resource.creator}
</span>
) : null}
{resource.durationSeconds ? (
<span className="tabular-nums">
{humanDuration(resource.durationSeconds)}
</span>
) : null}
{resource.transcriptStatus === "unavailable" ||
resource.transcriptStatus === "skipped" ? (
<span className="normal-case tracking-normal text-warning/80">
description only
</span>
) : null}
</div>

{resource.summary ? (
<p className="mt-1.5 break-words text-sm leading-6 text-[var(--color-text-muted)]">
{resource.summary}
</p>
) : null}
<a
href={resource.url}
target="_blank"
rel="noopener noreferrer"
className="mt-1.5 block break-words text-[15px] font-medium leading-snug text-[var(--color-text)] underline-offset-4 hover:underline"
>
{resource.title}
</a>

{quote ? (
<p className="mt-2 border-l-2 border-border-strong pl-3 text-sm italic leading-6 text-[var(--color-text-muted)]">
“{quote}”
</p>
) : null}
{resource.summary ? (
<p className="mt-1.5 break-words text-sm leading-6 text-[var(--color-text-muted)]">
{resource.summary}
</p>
) : null}

{chapters.length > 0 ? (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{chapters.map((chapter) => (
<li
key={`${resource.id}-${chapter.startSeconds}`}
>
<a
href={chapter.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-baseline gap-1.5 text-xs text-[var(--color-text-soft)] underline-offset-4 hover:text-[var(--color-text)] hover:underline"
>
<span className="tabular-nums text-[var(--color-accent)]">
{formatTimestamp(
chapter.startSeconds,
)}
</span>
{chapter.title}
</a>
</li>
))}
</ul>
) : null}
{quote ? (
<p className="mt-2 border-l-2 border-border-strong pl-3 text-sm italic leading-6 text-[var(--color-text-muted)]">
“{quote}”
</p>
) : null}

{resource.skillTags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{resource.skillTags
.slice(0, 4)
.map((tag) => (
<span
key={tag}
className="studio-badge text-text-muted"
>
{tag}
</span>
))}
</div>
) : null}
</div>
{chapters.length > 0 ? (
<ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{chapters.map((chapter) => (
<li key={`${resource.id}-${chapter.startSeconds}`}>
<a
href={chapter.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-baseline gap-1.5 text-xs text-[var(--color-text-soft)] underline-offset-4 hover:text-[var(--color-text)] hover:underline"
>
<span className="tabular-nums text-[var(--color-accent)]">
{formatTimestamp(
chapter.startSeconds,
)}
</span>
{chapter.title}
</a>
</li>
))}
</ul>
) : null}

{resource.skillTags.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{resource.skillTags.slice(0, 4).map((tag) => (
<span
key={tag}
className="studio-badge text-text-muted"
>
{tag}
</span>
))}
</div>
</li>
);
})}
</ul>
) : null}
</div>
</div>
</li>
);
}
Loading