Files
2026-07-13 12:09:03 +08:00

295 lines
8.7 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build a machine-readable catalog of the entire curriculum.
Requires Python 3.10+ (PEP 604 union types, Path.is_relative_to).
Walks every `phases/NN-slug/MM-slug/` lesson directory on disk and emits a
single JSON document with the truth of what exists in the repo: phases,
lessons, code files, outputs (skills / prompts / agents), and totals.
Usage:
python3 scripts/build_catalog.py # write catalog.json at repo root
python3 scripts/build_catalog.py --out path/to/catalog.json
python3 scripts/build_catalog.py --stdout # write to stdout, do not touch repo
Output shape (schema_version 1):
{
"schema_version": 1,
"totals": {"phases": ..., "lessons": ..., "skills": ..., "prompts": ..., "agents": ..., "code_files": ...},
"phases": [
{
"num": 0,
"slug": "00-setup-and-tooling",
"title": "Setup and Tooling",
"lesson_count": 12,
"lessons": [
{
"num": 1,
"slug": "01-...",
"title": "...", # H1 from docs/en.md
"path": "phases/00-.../01-...",
"has_docs": true,
"has_code": true,
"has_quiz": false,
"has_notebook": false,
"code_files": ["main.py", ...],
"outputs": [
{"type": "skill", "name": "...", "path": "...", "version": "1.0.0", "description": "...", "tags": [...]}
]
}
]
}
]
}
Stdlib only. No dependencies. Reuses frontmatter parsing logic similar to
install_skills.py but inlined to keep the script self-contained.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Iterable
sys.path.insert(0, str(Path(__file__).resolve().parent))
from _lib import parse_frontmatter as _parse_frontmatter # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
PHASES_DIR = ROOT / "phases"
PHASE_DIR_RE = re.compile(r"^([0-9]{2})-([a-z0-9][a-z0-9-]*)$")
LESSON_DIR_RE = re.compile(r"^([0-9]{2})-([a-z0-9][a-z0-9-]*)$")
H1_RE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
ARTIFACT_TYPES = ("skill", "prompt", "agent")
CODE_SUFFIXES = {".py", ".ts", ".tsx", ".js", ".mjs", ".rs", ".jl", ".go", ".swift", ".ipynb"}
def slug_to_title(slug: str) -> str:
words = slug.split("-")
fixups = {
"ai": "AI",
"ml": "ML",
"llm": "LLM",
"llms": "LLMs",
"nlp": "NLP",
"rl": "RL",
"mcp": "MCP",
"rag": "RAG",
"api": "API",
"rlhf": "RLHF",
"dpo": "DPO",
"lora": "LoRA",
"cnn": "CNN",
"rnn": "RNN",
"rnns": "RNNs",
"cnns": "CNNs",
"gpt": "GPT",
"tfidf": "TF-IDF",
"pos": "POS",
"ner": "NER",
"asr": "ASR",
"tts": "TTS",
"ios": "iOS",
"lats": "LATS",
"rewoo": "ReWoo",
"htn": "HTN",
"sft": "SFT",
}
return " ".join(fixups.get(w, w.capitalize()) for w in words)
def parse_frontmatter(text: str) -> dict[str, object]:
return _parse_frontmatter(text) or {}
def read_h1(doc_path: Path) -> str | None:
try:
text = doc_path.read_text(encoding="utf-8")
except (FileNotFoundError, UnicodeDecodeError):
return None
match = H1_RE.search(text)
return match.group(1).strip() if match else None
def list_code_files(code_dir: Path) -> list[str]:
if not code_dir.is_dir():
return []
files = []
for path in sorted(code_dir.rglob("*")):
if path.is_file() and path.suffix in CODE_SUFFIXES:
files.append(path.relative_to(code_dir).as_posix())
return files
def parse_artifact(path: Path) -> dict[str, object] | None:
stem = path.stem
artifact_type: str | None = None
for t in ARTIFACT_TYPES:
if stem.startswith(f"{t}-"):
artifact_type = t
break
if artifact_type is None:
return None
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return None
meta = parse_frontmatter(text)
name = str(meta.get("name", "")).strip() or stem
tags = meta.get("tags", [])
if not isinstance(tags, list):
tags = []
return {
"type": artifact_type,
"name": name,
"path": path.relative_to(ROOT).as_posix(),
"version": str(meta.get("version", "")).strip(),
"description": str(meta.get("description", "")).strip(),
"tags": list(tags),
}
def list_outputs(outputs_dir: Path) -> list[dict[str, object]]:
if not outputs_dir.is_dir():
return []
artifacts: list[dict[str, object]] = []
for path in sorted(outputs_dir.iterdir()):
if path.suffix != ".md" or not path.is_file():
continue
record = parse_artifact(path)
if record is not None:
artifacts.append(record)
return artifacts
def build_lesson_entry(lesson_dir: Path) -> dict[str, object] | None:
match = LESSON_DIR_RE.match(lesson_dir.name)
if not match:
return None
num = int(match.group(1))
slug = match.group(2)
docs_path = lesson_dir / "docs" / "en.md"
code_dir = lesson_dir / "code"
outputs_dir = lesson_dir / "outputs"
notebook_dir = lesson_dir / "notebook"
quiz_path = lesson_dir / "quiz.json"
code_files = list_code_files(code_dir)
outputs = list_outputs(outputs_dir)
has_docs = docs_path.is_file()
if not has_docs and not code_files and not outputs and not quiz_path.is_file():
return None
title = read_h1(docs_path) or slug_to_title(slug)
return {
"num": num,
"slug": lesson_dir.name,
"title": title,
"path": lesson_dir.relative_to(ROOT).as_posix(),
"has_docs": has_docs,
"has_code": code_dir.is_dir(),
"has_quiz": quiz_path.is_file(),
"has_notebook": notebook_dir.is_dir(),
"code_files": code_files,
"outputs": outputs,
}
def iter_phase_dirs() -> Iterable[Path]:
if not PHASES_DIR.is_dir():
return
for path in sorted(PHASES_DIR.iterdir()):
if path.is_dir() and PHASE_DIR_RE.match(path.name):
yield path
def build_phase_entry(phase_dir: Path) -> dict[str, object]:
match = PHASE_DIR_RE.match(phase_dir.name)
assert match is not None
num = int(match.group(1))
slug = match.group(2)
lessons: list[dict[str, object]] = []
for lesson_dir in sorted(phase_dir.iterdir()):
if lesson_dir.is_dir():
entry = build_lesson_entry(lesson_dir)
if entry is not None:
lessons.append(entry)
return {
"num": num,
"slug": phase_dir.name,
"title": slug_to_title(slug),
"lesson_count": len(lessons),
"lessons": lessons,
}
def compute_totals(phases: list[dict[str, object]]) -> dict[str, int]:
totals = {
"phases": len(phases),
"lessons": 0,
"skills": 0,
"prompts": 0,
"agents": 0,
"code_files": 0,
}
for phase in phases:
for lesson in phase["lessons"]:
totals["lessons"] += 1
totals["code_files"] += len(lesson["code_files"])
for artifact in lesson["outputs"]:
key = f"{artifact['type']}s"
totals[key] = totals.get(key, 0) + 1
return totals
def build_catalog() -> dict[str, object]:
phases = [build_phase_entry(p) for p in iter_phase_dirs()]
catalog = {
"schema_version": 1,
"totals": compute_totals(phases),
"phases": phases,
}
return catalog
def main(argv: list[str]) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--out",
type=Path,
default=ROOT / "catalog.json",
help="output path (default: <repo>/catalog.json)",
)
parser.add_argument(
"--stdout",
action="store_true",
help="write JSON to stdout instead of a file",
)
args = parser.parse_args(argv)
catalog = build_catalog()
payload = json.dumps(catalog, indent=2, ensure_ascii=False) + "\n"
if args.stdout:
sys.stdout.write(payload)
return 0
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(payload, encoding="utf-8")
totals = catalog["totals"]
sys.stdout.write(
f"catalog: {args.out.relative_to(ROOT) if args.out.is_relative_to(ROOT) else args.out}\n"
)
sys.stdout.write(
f" phases={totals['phases']} lessons={totals['lessons']} "
f"skills={totals['skills']} prompts={totals['prompts']} "
f"agents={totals['agents']} code_files={totals['code_files']}\n"
)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))