diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..9c458ff
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+# Stable LF checkouts keep generated JSON, source checksums and solution hashes
+# identical across contributor platforms, independent of core.autocrlf.
+* text=auto eol=lf
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index 39ea102..0000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-version: 2
-updates:
- - package-ecosystem: pip
- directory: "/"
- schedule:
- interval: weekly
- day: monday
- time: "09:00"
- timezone: Asia/Shanghai
- open-pull-requests-limit: 5
- groups:
- python-dependencies:
- patterns:
- - "*"
-
- - package-ecosystem: github-actions
- directory: "/"
- schedule:
- interval: weekly
- day: monday
- time: "09:15"
- timezone: Asia/Shanghai
- open-pull-requests-limit: 5
- groups:
- actions:
- patterns:
- - "*"
diff --git a/.github/workflows/notify-site.yml b/.github/workflows/notify-site.yml
deleted file mode 100644
index c05e404..0000000
--- a/.github/workflows/notify-site.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-name: Dispatch Website Build
-
-on:
- push:
- branches:
- - master
- - main
- tags:
- - "v*"
-
-jobs:
- dispatch:
- runs-on: ubuntu-latest
- steps:
- - name: Trigger Website Workflow
- uses: peter-evans/repository-dispatch@v3
- if: env.SYNC_TOKEN != ''
- with:
- token: ${{ secrets.WEBSITE_SYNC_TOKEN }}
- repository: flypythoncom/flypython.com
- event-type: python-repo-updated
- client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}"}'
- env:
- SYNC_TOKEN: ${{ secrets.WEBSITE_SYNC_TOKEN }}
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
index c7862b0..b7bb128 100644
--- a/.github/workflows/validate.yml
+++ b/.github/workflows/validate.yml
@@ -1,65 +1,51 @@
name: Validate
on:
+ workflow_dispatch:
pull_request:
push:
branches:
- master
- - main
permissions:
contents: read
concurrency:
group: validate-${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+ # Preserve a complete default-branch validation; superseded PR runs can stop.
+ cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
+ # Lowest and latest supported CPython; the universal lock already resolves
+ # every version between them, so the middle ones ran the identical suite.
validate:
runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
- python-version: ["3.11", "3.12", "3.13"]
+ python-version: ["3.11", "3.14"]
timeout-minutes: 15
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
- uses: astral-sh/setup-uv@v5
+ uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
+ version: "0.12.19"
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
run: uv python install ${{ matrix.python-version }}
- - name: Install dependencies and dev tools
+ - name: Install locked dev dependencies
run: |
uv venv --python ${{ matrix.python-version }}
- uv pip install -r requirements-dev.lock.txt ruff
+ uv pip install -r requirements-dev.lock.txt
- - name: Lint with ruff
- run: .venv/bin/ruff check .
+ - name: Run all checks
+ run: make check PYTHON=.venv/bin/python
- - name: Run tests
- run: .venv/bin/python -m pytest
-
- - name: Validate catalog
- run: .venv/bin/python tools/validate_catalog.py
-
- - name: Verify public JSON exports
- run: .venv/bin/python tools/export_catalog.py --check --target both
-
- - name: Verify browsable README indexes
- run: .venv/bin/python tools/render_readmes.py --check
-
- - name: Verify first-party content manifest
- run: .venv/bin/python tools/build_content_manifest.py --check
-
- - name: Verify runnable examples
- run: .venv/bin/python tools/verify_examples.py
-
- - name: Verify course folders
- run: .venv/bin/python tools/verify_courses.py
- - name: Verify learning-path contracts
- run: .venv/bin/python tools/verify_paths.py
+ - name: Lock files match pyproject.toml
+ if: matrix.python-version == '3.11'
+ run: make lock-check
diff --git a/AGENTS.md b/AGENTS.md
index 939f85b..444e4f0 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -40,8 +40,11 @@ reviewed catalog data, and stable public JSON contracts.
pairs (objective, exercise, checkpoint, expected evidence), `scenario/`
data files for each skin, `TASK.md`/`TASK_cn.md` (task contract),
`starter/` and `solution/` runnable pairs, a self-contained `verify.py`
- that fails on `starter` and passes on `solution`, exposes deterministic
- claim codes through `verify.py progress` (five checkpoints), and runs on
+ that fails on `starter` and passes on `solution`, reports five checkpoints
+ through `python verify.py` and the versioned `check --json` interface
+ (deterministic claim codes; self-reported checkpoints emit a code only after
+ explicit `--attest ID`; `progress --json` stays only for legacy signed
+ receipts), and runs on
the standard library unless the course ships its own `requirements.txt`
(as the pandas/matplotlib data-analysis courses do), and `REVIEW.md`
recording the maintainer run-through (date, tool, version, observed agent
@@ -73,6 +76,16 @@ reviewed catalog data, and stable public JSON contracts.
- After merge, verify the immutable raw `catalog.json`. Verify flypython.com only
after a separate, deliberate consumer-version update.
+## Engineering entry points
+
+- `make help` lists the existing validation and generation tools.
+- `make setup` installs the locked dev environment; use
+ `make check PYTHON=.venv/bin/python` afterwards.
+- `make generated-check` is read-only; `make generate` rewrites derived content
+ in dependency order. Neither publishes or changes the website content pin.
+- Keep agent rules here; `CLAUDE.md` imports this file rather than copying it.
+- Engineering configuration rationale is in `docs/ENGINEERING_CONFIG.md`.
+
## Review priorities
1. Content accuracy and source quality.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c0a179..0256851 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,13 +4,162 @@ This file records notable catalog-contract and maintenance changes.
## [Unreleased]
+### Added — 2026-10-11 three courses (EN+ZH)
+
+- `courses/hands-on-with-github-copilot/` (`course-github-copilot`): the
+ shared `report-tool` core taught with GitHub Copilot in VS Code — session
+ controls, Manual permissions, `.github/copilot-instructions.md` and
+ `applyTo` instructions files, checkpoints, and Source Control review.
+ Authored from the VS Code Copilot documentation fetched 2026-10-11; not
+ yet run inside VS Code (see its `REVIEW.md`).
+- `courses/llm-features-in-python/` (`course-llm-features`): validate
+ structured model output, allowlist and type-check tool calls, retry with
+ the error fed back, fall back to a human, and score an offline eval that
+ divides by every case. Replies are hand-written recordings; no model API
+ is called. 15 tests, three scenario skins.
+- `courses/secure-ai-generated-code/` (`course-secure-code`): review a
+ plausible notes service for SQL injection, path traversal, a hard-coded
+ secret, fast password hashes, and pickled sessions, one flaw class per
+ turn, and prove each fix with an attack-replay harness. 16 tests, two
+ scenario skins.
+- Both topic courses record a challenge-mode solvability run in
+ `REVIEW.md`; its findings tightened both `TASK.md` contracts, added a
+ tool-call-budget test, and fixed a `TypeError` on non-ASCII session
+ tokens in the security reference solution. Agent-taught run-throughs
+ are pending for all three courses.
+
+### Changed — 2026-10-11 foundation path and agent index
+
+- The foundation path offers GitHub Copilot as a seventh first-agent
+ module (`m3e`); later module `order` values shift by one. The
+ orientation and route-challenge modules (EN+ZH) list seven tools.
+- `llms.txt`, both READMEs, and `paths/README*.md` list the new courses
+ and tool.
+
+## [0.1.2] - 2026-10-11
+
+Repository-only release: there is no website release on this train, and
+flypython.com keeps building from the commit pinned in its own
+`src/data/content-pin.json`. The `catalog-v1`, `radar-v1`, and
+`content-manifest-v1` schemas are unchanged. Learner verifier v2 adds
+`check --json` and `--attest` while keeping `progress --json` for v1
+receipts, so this is a patch release.
+
+### Changed — 2026-10-11 agent index, README links, and plan status
+
+- `llms.txt` lists all 12 courses: it had 8, pointed at a nonexistent
+ `courses/hands-on-with-openai-codex-cli/` folder, and recommended
+ `uv run pytest`, which fails because pytest lives in the `dev` extra. The
+ new `tests/test_llms_txt.py` fails on any listed path that does not exist
+ and on any course folder that is not listed.
+- The English README's two "continue on flypython.com" links point at the
+ live `/from-github` landing route. The Chinese README keeps the site root
+ because that route has no Chinese version yet; the rule is recorded in
+ `docs/REPO_TO_WEBSITE.md`.
+- `docs/repo-plan-0.0.3.md` TODOs are reconciled against the repository,
+ and both plan headers point at `CHANGELOG.md`,
+ `docs/REPO_TO_WEBSITE.md`, and the website's `content-pin.json` instead of
+ stale SHAs.
+- `docs/repo-plan-0.0.4.md` §9 records the FP-414 evaluation: keep the
+ single website-hosted Skill rather than per-course skills. The live Skill
+ fails two Agent Skills spec checks (`skills-ref` 0.1.1), and its `uv run`
+ advice for pandas courses contradicts the course setup; both are
+ website-side fixes.
+
+### Changed — 2026-10-11 dependency updates
+
+- `ruff` 0.16.9 → 0.16.10 (dev lock; `pyproject.toml` floor `>=0.16.10`).
+- Removed `.github/dependabot.yml`: Dependabot no longer opens monthly
+ version-update PRs for pip or GitHub Actions. Dependencies are bumped by
+ hand with `make lock` and verified by `make lock-check`; SHA-pinned actions
+ in `.github/workflows/` are updated by hand. Dependabot security alerts and
+ security-update PRs are repository settings and are unaffected by this file.
+
+### Changed — 2026-10-04 CI hardening
+
+- `requirements.lock.txt` and `requirements-dev.lock.txt` are now generated
+ by `make lock` (`uv pip compile --universal` from the Python 3.11 floor)
+ instead of hand-maintained. All existing pins were kept; the Windows-only
+ `colorama` and Python <3.13 `typing-extensions` transitive pins the manual
+ lock had missed are now included. `make lock-check` re-resolves the
+ committed locks in place and fails on any diff; CI runs it on the 3.11 leg.
+- New `tests/test_dependency_pins.py`: every course/path `requirements.txt`
+ must pin exactly the versions CI tests with.
+- Validate is a single `make check` job (the same command as local) on the
+ Python floor and latest (3.11 + 3.14 — the universal lock resolves every
+ version between), no longer cancels sibling versions on the first failure,
+ SHA-pins actions, moves `setup-uv` v7 → v10.2.0, and pins uv 0.12.19.
+- Removed the unused root `requirements.txt` and `requirements-dev.txt`;
+ dependencies are declared only in `pyproject.toml` and the two locks.
+- Removed `notify-site.yml`: its `WEBSITE_SYNC_TOKEN` secret was never
+ configured, so all 32 runs skipped the dispatch step while reporting
+ success, and the website builds from its own content pin regardless.
+- Removed the unused `mypy`, `types-PyYAML`, and `types-requests` dev
+ dependencies and the `[tool.mypy]` config; mypy was never installed by
+ the lock or run by CI.
+
+### Changed — 2026-10-04 README restructure
+
+- Root READMEs now carry a condensed catalog index (learning-path bullets
+ linking into the catalog pages); the full 33-resource tables moved to the
+ new generated `catalog/README.md` and `catalog/README_cn.md`, both rendered
+ by `tools/render_readmes.py`.
+- Root READMEs: badges moved directly under the title, the internal
+ "Current status" section collapsed to a one-line release pointer (details
+ in `docs/REPO_TO_WEBSITE.md`), the course-verify section rewritten as a
+ checkpoint-state list, and the locally duplicated validation commands
+ replaced by a link to `CONTRIBUTING.md`.
+- Added `assets/verify-demo.svg`, a terminal demo of a real `verify.py`
+ checkpoint report, embedded in both READMEs.
+
+### Fixed — 2026-10-04 CI and course setup
+
+- CI: `ruff` is pinned in `requirements-dev.lock.txt`. CI had installed the
+ latest unpinned ruff, whose new rules failed every Validate run from
+ 2026-09-16; the three findings were fixed without behavior change.
+- CI: the dev lock installs on Python 3.11 again (`numpy` and `contourpy`
+ use `python_version` markers); the 3.11 job had failed at install since
+ the lock was added.
+- Tests: guide heading extraction requires whitespace after the step number
+ (#90), and guide URL extraction ignores Markdown link titles (adapted from
+ #91).
+- Actions: `actions/checkout` v7, `astral-sh/setup-uv` v7,
+ `peter-evans/repository-dispatch` v4 (#89).
+- Courses: `da-eda` and `da-visualization` setup (EN+ZH) and the `verify.py`
+ missing-dependency hint no longer say `uv sync`, which fails in a
+ standalone course folder (no `pyproject.toml`) and does not install pandas
+ in a clone (it lives in the `dev` extra). They now create a virtual
+ environment and run `pip install -r requirements.txt`.
+- Tooling: removed the stale, unused `uv.lock` (`requirements*.lock.txt` are
+ the only lock files); `make check` now also runs ruff and pytest, as CI
+ does; Dependabot ignores pandas and matplotlib, which course folders pin;
+ workflows no longer trigger on the deleted `main` branch.
+
+### Changed — 2026-09-16 learner verifier v2
+
+- All 12 public course verifiers now expose `check --json` and require
+ explicit `--attest ID` confirmation before self-reported checkpoints emit
+ learner-submittable codes.
+- The default learner command tests `starter/`, reports `[open]`, `[passed]`,
+ `[pending]`, and `[attested]`, and exits successfully only after every gate
+ is passed or explicitly attested.
+- `progress --json` remains available only for the v1 signed-receipt contract;
+ its legacy self-reported rows must not be submitted as learner claims.
+- `tools/verify_courses.py`, both READMEs, paired course guidance, and
+ `content-manifest.json` were updated together. The website consumer pin was
+ `39a3e79be939f491ac3c779494bf5c655c5588a0` at the time; the current pin is
+ recorded in the website's `src/data/content-pin.json`.
+
+## [0.1.1] - 2026-09-14
+
0.1.0 — companion-repository half of the public launch (website
`docs/product-and-growth-plan-0.1.0.md`; see the website `CHANGELOG.md`
for the platform half).
Release mapping (FP-1012): this repository ships **v0.1.1** on this
release train — the earlier `v0.1.0` tag stays immutably on `e420fca`.
-Compatibility: website `v0.1.0` ↔ repository `v0.1.1`.
+Compatibility: website `v0.1.0` ↔ repository `v0.1.1`. The immutable tag is
+`db85cdd…`; later changes are in `[0.1.2]`.
### Changed
@@ -65,11 +214,11 @@ website plan `docs/product-and-growth-plan-0.0.8.md`).
entry sentence and the six agent-tool courses instead of a clone +
download flow.
-## [Unreleased-0.0.4-phase-A]
+### 0.0.4 phase A (2026-09-12, shipped in 0.1.1)
-0.0.4 phase A — challenge-platform rework on top of 0.1.0.
+Challenge-platform rework on top of 0.1.0.
-### Added
+#### Added
- Per-checkpoint claim codes, badge frontmatter contracts, and the
deterministic `verify.py progress` subcommand (FP-411/412/415), enforced
@@ -84,7 +233,7 @@ website plan `docs/product-and-growth-plan-0.0.8.md`).
- Checkpoint hints, the branch-path contract, and agent-solvability review
records across all eight courses (2026-09-13).
-### Changed
+#### Changed
- Repository narrative moved from agent-taught courses to challenge courses
with `TASK.md` contracts and `verify.py` claim codes; `COURSE.md` remains
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..43c994c
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index eac0d39..6f5d14f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -76,6 +76,26 @@ python -m venv .venv
python -m pip install -r requirements-dev.lock.txt
```
+The lock files are generated, not hand-edited. To change a dependency, edit
+`pyproject.toml` and run `make lock` (needs [uv](https://docs.astral.sh/uv/));
+existing pins are kept wherever they still satisfy the new constraints. CI runs
+`make lock-check` and fails if the locks drift from `pyproject.toml`. Course
+and path folders that ship their own `requirements.txt` must pin exactly the
+versions in `requirements-dev.lock.txt` (`tests/test_dependency_pins.py`), so
+CI tests the stack learners install.
+
+With `uv` installed, `make setup` is an alternative to the manual setup above:
+it uses `.python-version` and synchronizes the exact dev lock into `.venv`.
+Then use `make check PYTHON=.venv/bin/python`; Make otherwise defaults to
+`python3`. The Make aliases assume a POSIX shell (Windows contributors can use
+the direct Python commands below or WSL). Run from this repository or use
+`make -C path/to/python`, not `make -f path/to/Makefile` from another directory.
+
+`make generated-check` checks derived content without rewriting it, including
+course solution hashes. `make generate` regenerates exports, README indexes,
+the content manifest and solution hashes in order; `make all` then runs checks.
+Both preserve ordering even when invoked with `make -j`.
+
After changing catalog sources, regenerate the public export:
```bash
@@ -84,14 +104,16 @@ python tools/render_readmes.py
python tools/build_content_manifest.py
```
-Run the same deterministic checks as CI:
+Run the same deterministic checks as CI (or `make check`):
```bash
+python -m ruff check .
python -m pytest
python tools/validate_catalog.py
python tools/export_catalog.py --check --target both
python tools/render_readmes.py --check
python tools/build_content_manifest.py --check
+python tools/build_solution_hashes.py --check
python tools/verify_examples.py
python tools/verify_courses.py
python tools/verify_paths.py
diff --git a/Makefile b/Makefile
index 7759721..c3083a4 100644
--- a/Makefile
+++ b/Makefile
@@ -1,27 +1,67 @@
-.PHONY: help check export render manifest test verify courses paths lint typecheck all
+# Recipes use repository-relative paths. Like Magpie's Makefile, fail closed
+# when invoked with `make -f path/to/Makefile` from an unrelated directory.
+ifeq ($(and $(wildcard pyproject.toml),$(wildcard tools/validate_catalog.py),$(wildcard catalog/catalog.yml)),)
+ $(error run make in the Python repository (make -C path/to/python))
+endif
+
+.DEFAULT_GOAL := help
+.PHONY: help setup check generated-check generate export render manifest solutions test verify courses paths lint lock lock-check all
PYTHON ?= python3
+UV ?= uv
+
+# Lock files are resolved once for every supported Python and platform
+# (--universal from the 3.11 floor). The dev lock is constrained to the
+# runtime lock so both always agree on shared packages.
+UV_COMPILE = $(UV) pip compile pyproject.toml --universal --python-version 3.11 \
+ --custom-compile-command "make lock" --quiet
+define compile_locks
+ $(UV_COMPILE) -o requirements.lock.txt
+ $(UV_COMPILE) --extra dev -c requirements.lock.txt -o requirements-dev.lock.txt
+endef
help:
@echo "FlyPython Development Workflow:"
- @echo " make check - Run all catalog, radar, export, readme, manifest, example, and course checks"
+ @echo " make setup - Create .venv using .python-version and install locked dev dependencies (requires uv)"
+ @echo " make check - Run CI checks (use PYTHON=.venv/bin/python after setup)"
+ @echo " make generated-check - Check catalog, exports, READMEs, manifest, and solution hashes without rewriting"
+ @echo " make generate - Regenerate all derived content in dependency order"
@echo " make export - Regenerate catalog.json and radar.json"
- @echo " make render - Regenerate README and README_cn catalog indexes plus the Radar table"
+ @echo " make render - Regenerate root and catalog README indexes plus the Radar table"
@echo " make manifest - Regenerate content-manifest.json"
+ @echo " make solutions - Regenerate course-solutions.json"
+ @echo " make lint - Run ruff"
@echo " make test - Run pytest test suite"
@echo " make verify - Verify all runnable examples"
@echo " make courses - Verify all course folders"
@echo " make paths - Verify all learning-path contracts"
+ @echo " make lock - Re-resolve requirements*.lock.txt from pyproject.toml (keeps existing pins where valid)"
+ @echo " make lock-check - Fail if the lock files no longer match pyproject.toml"
@echo " make all - Regenerate all exports and run all checks and tests"
-check:
+setup:
+ $(UV) venv --python "$$(cat .python-version)" .venv
+ $(UV) pip sync --python .venv/bin/python requirements-dev.lock.txt
+
+check: lint test generated-check
+ $(PYTHON) tools/verify_examples.py
+ $(PYTHON) tools/verify_courses.py
+ $(PYTHON) tools/verify_paths.py
+
+generated-check:
$(PYTHON) tools/validate_catalog.py
$(PYTHON) tools/export_catalog.py --check --target both
$(PYTHON) tools/render_readmes.py --check
$(PYTHON) tools/build_content_manifest.py --check
- $(PYTHON) tools/verify_examples.py
- $(PYTHON) tools/verify_courses.py
- $(PYTHON) tools/verify_paths.py
+ $(PYTHON) tools/build_solution_hashes.py --check
+
+# Keep writers sequential even under make -j: README/manifest checks must not
+# race against a producer. No automatic commit, content-pin update or deploy.
+generate:
+ $(MAKE) export
+ $(MAKE) render
+ $(MAKE) manifest
+ $(MAKE) solutions
export:
$(PYTHON) tools/export_catalog.py --target both
@@ -32,6 +72,12 @@ render:
manifest:
$(PYTHON) tools/build_content_manifest.py
+solutions:
+ $(PYTHON) tools/build_solution_hashes.py
+
+lint:
+ $(PYTHON) -m ruff check .
+
test:
$(PYTHON) -m pytest
@@ -44,4 +90,16 @@ courses:
paths:
$(PYTHON) tools/verify_paths.py
-all: export render manifest test check
+lock:
+ $(compile_locks)
+
+# uv keeps every committed pin that still satisfies pyproject.toml, so any
+# diff after re-resolution means the locks drifted — the printed diff is the
+# change to commit.
+lock-check:
+ @$(compile_locks)
+ @git diff --exit-code requirements.lock.txt requirements-dev.lock.txt || \
+ { echo "lock files drifted from pyproject.toml — commit the regenerated files"; exit 1; }
+
+all: generate
+ $(MAKE) check
diff --git a/README.md b/README.md
index d45799a..1e4f568 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,24 @@
# FlyPython: Learn to ship Python with AI coding agents
-Learn to ship Python with AI coding agents — real projects; your agent does
-the typing, `verify.py` decides when you're done.
[](https://github.com/flypythoncom/python/stargazers)
[](https://github.com/flypythoncom/python/actions/workflows/validate.yml)
[](https://flypython.com)
[English](README.md) · [中文](README_cn.md) · [🌐 Online Portal](https://flypython.com)
+Real projects; your agent does the typing, `verify.py` decides when you're
+done.
+
> **Challenge courses with objective verification.** Pick a folder from
> [`courses/`](courses/), solve the contract in `TASK.md` with your coding
> agent as the tool, and prove it with `verify.py` — which prints a claim
> code per checkpoint. Prefer a guided path? `COURSE.md` still runs an
> agent-taught mode, and [`paths/`](paths/README.md) sequences courses into badge
-> routes. Continue on [flypython.com](https://flypython.com/).
+> routes. Continue on [flypython.com](https://flypython.com/from-github).
+
+
+
+
FlyPython is a practical, bilingual repository for writing good Python and
turning it into products people can rely on. It combines AI-coding methods,
@@ -25,12 +30,16 @@ wrote code” to “a user outcome is verified.” The repository owns the revie
source content and data; [flypython.com](https://flypython.com/) turns pinned
versions into a browsable learning experience.
+**Current release:** `v0.1.2` — see [CHANGELOG.md](CHANGELOG.md); release and
+delivery status is tracked in the
+[repository-to-website operating model](docs/REPO_TO_WEBSITE.md).
+
## Start in three minutes
You need a coding agent that can run commands and reach the network
-(Claude Code, the Codex app, Cursor, DeepSeek Harness, Kimi Code, or
-ZCode — chat-only web AIs cannot run these courses). Install the
-[FlyPython Skill](https://flypython.com/skills/flypython/SKILL.md) in
+(Claude Code, the Codex app, Cursor, GitHub Copilot in VS Code,
+DeepSeek Harness, Kimi Code, or ZCode — chat-only web AIs cannot run
+these courses). Install the [FlyPython Skill](https://flypython.com/skills/flypython/SKILL.md) in
your agent, then paste this one sentence:
> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `da-eda`.
@@ -40,6 +49,7 @@ password), fetches the course files itself — you download nothing — and
drives the challenges with you. New to driving an agent? Start with the
tool course for *your* agent: [Hands-on Python with Claude Code](courses/hands-on-python-with-claude-code/COURSE.md),
[Codex app](courses/hands-on-with-openai-codex/COURSE.md), [Cursor](courses/hands-on-with-cursor/COURSE.md),
+[GitHub Copilot](courses/hands-on-with-github-copilot/COURSE.md),
[DeepSeek Harness](courses/hands-on-with-deepseek-harness/COURSE.md),
[Kimi Code](courses/hands-on-with-kimi-code/COURSE.md), or
[ZCode](courses/hands-on-with-zcode/COURSE.md).
@@ -53,6 +63,21 @@ python examples/product-slug/verify.py starter --expect-failure
python examples/product-slug/verify.py solution
```
+## Verify and record a course
+
+In the course folder, run `python verify.py` after each change. It tests only
+`starter/` and reports every checkpoint:
+
+- `[open]` — the task is unfinished.
+- `[passed]` — the tests pass and `verify.py` prints a claim code.
+- `[pending]` — a reflection checkpoint: answer that lesson's questions, then
+ run `python verify.py --attest l01` (repeat for each completed reflection)
+ to get its `[attested]` code.
+
+Submit only `[passed]` and `[attested]` codes through your agent or the
+dashboard. `check --json` is the versioned v2 interface; `progress --json`
+remains for older signed-receipt integrations.
+
## Choose what you need to accomplish
| Goal | Start here | What you will produce |
@@ -63,7 +88,7 @@ python examples/product-slug/verify.py solution
| Finish a recurring engineering task | [Playbooks](playbooks/README.md) | A bug fix, API change, integration, dependency upgrade, or release |
| Practice instead of only reading | [Runnable examples](examples/README.md) | Local testable projects with failing starters and verified solutions |
| Give an agent better instructions | [Templates](templates/README.md) | Task contracts, plans, reviews, IDE rules, and verification records |
-| Build agents, Skills, MCP, APIs, or automation | [Reviewed source catalog](#browse-the-reviewed-source-catalog) | A primary-source path selected for your use case |
+| Build agents, Skills, MCP, APIs, or automation | [Reviewed source catalog](catalog/README.md) | A primary-source path selected for your use case |
| Find current Python projects | [Project Radar review queue](catalog/projects/README.md) | An evidence-backed shortlist after maintainer review |
The working loop is simple: define the user outcome, inspect the real context,
@@ -72,7 +97,7 @@ record what remains unverified. AI accelerates the loop; it does not replace
engineering judgment.
Prefer a guided reading path and ongoing updates? Continue on
-[flypython.com](https://flypython.com/). The repository remains the inspectable
+[flypython.com](https://flypython.com/from-github). The repository remains the inspectable
source; the website helps you choose the next useful step.
## For AI Agents and LLMs
@@ -83,87 +108,21 @@ If you are an LLM agent or coding assistant (Cursor, Windsurf, Claude Code, Copi
- Machine-readable manifest: [`content-manifest.json`](content-manifest.json)
- Official web knowledge dump: `https://flypython.com/llms-full.txt`
-## Browse the reviewed source catalog
+## Reviewed source catalog
-Choose a learning path below and open the official source that matches your
-current goal. The table explains why each source is included, who it suits, and
-which access or safety constraints deserve attention.
+Four maintainer-reviewed learning paths cover foundations, web and APIs,
+automation, and AI agents:
> **33 reviewed resources** · Catalog reviewed 2026-09-06 · 25 intermediate or advanced · Primary sources first
-### Choose a learning path
-
-- [**Python foundations**](#path-foundations) — Learn the language, environments, dependencies, typing, and tests that reliable Python work depends on. (8 resources)
-- [**Web and APIs**](#path-web-apis) — Build typed services and applications that connect Python logic to users and other systems. (7 resources)
-- [**Automation**](#path-automation) — Turn repeatable work into maintainable scripts, browser workflows, and data pipelines. (7 resources)
-- [**AI agents**](#path-ai-agents) — Learn tools, structured output, state, evaluation, and the safety boundaries of agent systems. (11 resources)
-
-
-### Python foundations
-
-Learn the language, environments, dependencies, typing, and tests that reliable Python work depends on.
-
-| Resource | Why it is useful | Level and language | Access and risk | Reviewed |
-| --- | --- | --- | --- | --- |
-| [The Python Tutorial](https://docs.python.org/3/tutorial/) Official docs · Featured | The primary introduction to Python syntax, data structures, modules, errors, classes, and the standard library. | Beginner English | No API key Low risk | 2026-08-31 |
-| [venv — Creation of virtual environments](https://docs.python.org/3/library/venv.html) Official docs | The standard-library reference for creating isolated Python environments and understanding their behavior. | Beginner English | No API key Low risk | 2026-08-31 |
-| [Python Packaging User Guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/) Official docs | The PyPA tutorial for project layout, build metadata, distributions, and publishing Python packages. | Intermediate English | No API key Low risk | 2026-08-31 |
-| [uv documentation](https://docs.astral.sh/uv/) Official project · Featured | A modern workflow for Python versions, virtual environments, dependencies, lockfiles, and project commands. | All levels English | No API key Low risk | 2026-08-31 |
-| [pytest documentation](https://docs.pytest.org/en/stable/) Official project | The official guide to test discovery, assertions, fixtures, parametrization, and maintainable test suites. | Intermediate English | No API key Low risk | 2026-08-31 |
-| [Python typing documentation](https://typing.python.org/en/latest/) Official docs | The canonical home for Python's type-system specification, guides, and typing best practices. | Intermediate English | No API key Low risk | 2026-08-31 |
-| [Ruff documentation](https://docs.astral.sh/ruff/) Official project · Featured | An extremely fast Python linter and code formatter written in Rust, replacing Flake8, Black, isort, and more. | All levels English | No API key Low risk | 2026-09-02 |
-| [Mypy documentation](https://mypy.readthedocs.io/en/stable/) Official project | The standard static type checker for Python to catch bugs and verify type contracts before running code. | Intermediate English | No API key Low risk | 2026-09-02 |
-
-
-### Web and APIs
-
-Build typed services and applications that connect Python logic to users and other systems.
-
-| Resource | Why it is useful | Level and language | Access and risk | Reviewed |
-| --- | --- | --- | --- | --- |
-| [FastAPI documentation](https://fastapi.tiangolo.com/) Official docs · Featured | A direct route from typed Python functions to tested APIs with validation and generated documentation. | Intermediate English | No API key Low risk | 2026-08-31 |
-| [Django documentation: First steps](https://docs.djangoproject.com/en/stable/intro/tutorial01/) Official docs | The official path through Django projects, models, views, templates, forms, tests, and reusable apps. | Beginner English | No API key Low risk | 2026-08-31 |
-| [Flask documentation](https://flask.palletsprojects.com/en/stable/) Official docs | The maintained reference and tutorial for Flask applications, requests, templates, testing, and deployment. | Beginner English | No API key Low risk | 2026-08-31 |
-| [Pydantic documentation](https://pydantic.dev/docs/validation/latest/get-started/) Official docs | The primary guide to validating untrusted data and expressing typed contracts at Python system boundaries. | Intermediate English | No API key Low risk | 2026-08-31 |
-| [HTTPX documentation](https://www.python-httpx.org/) Official project | The official reference for synchronous and asynchronous HTTP clients, timeouts, streaming, and transport control. | Intermediate English | No API key Low risk | 2026-08-31 |
-| [SQLAlchemy documentation](https://docs.sqlalchemy.org/en/20/) Official project | The definitive Python SQL toolkit and Object Relational Mapper, providing type-safe database access and migrations. | Intermediate English | No API key Low risk | 2026-09-02 |
-| [Alembic documentation](https://alembic.sqlalchemy.org/en/latest/) Official project | Database migration tool for SQLAlchemy, providing transactional schema changes and rollback support. | Intermediate English | No API key Review permissions and side effects | 2026-09-02 |
-
-
-### Automation
-
-Turn repeatable work into maintainable scripts, browser workflows, and data pipelines.
-
-| Resource | Why it is useful | Level and language | Access and risk | Reviewed |
-| --- | --- | --- | --- | --- |
-| [pathlib — Object-oriented filesystem paths](https://docs.python.org/3/library/pathlib.html) Official docs | The standard-library reference for readable, cross-platform file and directory automation. | Beginner English | No API key Low risk | 2026-08-31 |
-| [subprocess — Subprocess management](https://docs.python.org/3/library/subprocess.html) Official docs | The standard-library contract for launching processes, capturing output, handling failures, and avoiding unsafe shell usage. | Intermediate English | No API key Review permissions and side effects | 2026-08-31 |
-| [Playwright for Python](https://playwright.dev/python/docs/intro) Official docs | The official Python guide to reliable browser automation, locators, assertions, traces, and isolated contexts. | Intermediate English | No API key Review permissions and side effects | 2026-08-31 |
-| [Scrapy tutorial](https://docs.scrapy.org/en/latest/intro/tutorial.html) Official docs | The official introduction to crawl structure, extraction, item pipelines, persistence, and responsible operation. | Intermediate English | No API key Review permissions and side effects | 2026-08-31 |
-| [pandas getting started guides](https://pandas.pydata.org/docs/getting_started/index.html) Official docs | The primary entry point for tabular data loading, cleaning, transformation, analysis, and export. | Beginner English | No API key Low risk | 2026-08-31 |
-| [Polars documentation](https://docs.pola.rs/) Official project | Lightning-fast DataFrame library built on Apache Arrow and Rust, optimized for efficient data pipelines and parallel processing. | Intermediate English | No API key Low risk | 2026-09-02 |
-| [DuckDB Python API documentation](https://duckdb.org/docs/api/python/overview) Official project | Fast in-process analytical SQL engine for automated data transformation, Parquet querying, and embedded analytics. | Intermediate English | No API key Low risk | 2026-09-02 |
-
-
-### AI agents
-
-Learn tools, structured output, state, evaluation, and the safety boundaries of agent systems.
-
-| Resource | Why it is useful | Level and language | Access and risk | Reviewed |
-| --- | --- | --- | --- | --- |
-| [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) Official docs · Featured | The official Python toolkit for agents, tools, handoffs, guardrails, sessions, tracing, and orchestration. | Intermediate English | API key typically required Review permissions and side effects | 2026-08-31 |
-| [Pydantic AI](https://pydantic.dev/docs/ai/overview/) Official docs · Featured | Typed agent development built around validated inputs, structured output, tools, testing, and model portability. | Intermediate English | API key typically required Review permissions and side effects | 2026-08-31 |
-| [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) Official standard · Featured | The open protocol and reference documentation for connecting AI applications to tools, data, and reusable context. | Intermediate English | No API key Review permissions and side effects | 2026-08-31 |
-| [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview) Official docs | The official Python guide to stateful graph workflows, durable execution, memory, interrupts, and human review. | Advanced English | API key typically required Review permissions and side effects | 2026-08-31 |
-| [Google Agent Development Kit](https://adk.dev/) Official docs | Google's official documentation for developing, evaluating, and deploying modular agent systems. | Advanced English | API key typically required Review permissions and side effects | 2026-08-31 |
-| [Instructor documentation](https://python.useinstructor.com/) Official project | Structured outputs and validation for LLMs powered by Pydantic, enabling predictable tool calling and schema enforcement. | Intermediate English | API key typically required Review permissions and side effects | 2026-09-02 |
-| [Model Context Protocol Python SDK](https://github.com/modelcontextprotocol/python-sdk) Official project | Official Python implementation of the Model Context Protocol for building MCP servers and clients. | Intermediate English | No API key Review permissions and side effects | 2026-09-02 |
-| [MCP 2026-07-28 specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28/) Official docs | The official release announcement for the 2026-07-28 Model Context Protocol specification, covering the stateless redesign and breaking changes. | Intermediate English | No API key Low risk | 2026-09-06 |
-| [MCP 2026-07-28 release candidate overview](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) Official docs | The official release-candidate post that enumerates the 2026-07-28 breaking changes and the migration window for SDK maintainers. | Intermediate English | No API key Low risk | 2026-09-06 |
-| [Anthropic donating MCP to the Agentic AI Foundation](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation) Official docs | Anthropic's announcement establishing neutral Linux Foundation governance for MCP under the Agentic AI Foundation. | Intermediate English | No API key Low risk | 2026-09-06 |
-| [MCP under the Agentic AI Foundation](https://aaif.io/projects/model-context-protocol) Official project | The Agentic AI Foundation project page for MCP, the neutral governance home for specification development and roadmaps. | Intermediate English | No API key Low risk | 2026-09-06 |
+- [**Python foundations**](catalog/README.md#path-foundations) — Learn the language, environments, dependencies, typing, and tests that reliable Python work depends on. (8 resources)
+- [**Web and APIs**](catalog/README.md#path-web-apis) — Build typed services and applications that connect Python logic to users and other systems. (7 resources)
+- [**Automation**](catalog/README.md#path-automation) — Turn repeatable work into maintainable scripts, browser workflows, and data pipelines. (7 resources)
+- [**AI agents**](catalog/README.md#path-ai-agents) — Learn tools, structured output, state, evaluation, and the safety boundaries of agent systems. (11 resources)
+
+Full tables — why each source is included, its level, and its access and risk notes — live in [the catalog README](catalog/README.md).
Missing an important official source? [Propose a resource or report a correction](https://github.com/flypythoncom/python/issues/new/choose).
@@ -185,11 +144,14 @@ to add measurable calls to action without inventing unavailable products.
## Repository structure
```text
+assets/ README images (verify.py terminal demo)
catalog/
- catalog.yml catalog status and review date
- paths.yml bilingual learning-path definitions
- resources/ one reviewed resource per YAML file
- projects/ human-review queue for current Python projects
+ README.md browsable reviewed catalog (English, generated)
+ README_cn.md browsable reviewed catalog (Chinese, generated)
+ catalog.yml catalog status and review date
+ paths.yml bilingual learning-path definitions
+ resources/ one reviewed resource per YAML file
+ projects/ human-review queue for current Python projects
courses/ challenge courses with TASK.md contracts and verify.py claim codes
paths/ learning paths sequencing courses into badge routes
guides/ Python engineering and AI-coding methods
@@ -218,55 +180,13 @@ https://raw.githubusercontent.com/flypythoncom/python//catalog.
```
The content manifest lets flypython.com render the matching bilingual guide or
-playbook without owning a second editable copy. Project recommendations remain
-empty until a maintainer has reviewed source, maintenance, license, release,
-security, and practical user-value evidence.
-
-## Validate a change
-
-Use the Python version declared in `.python-version`, then install the locked
-development dependencies:
-
-```bash
-# Fast setup using uv (recommended):
-uv venv
-uv pip install -r requirements-dev.lock.txt
-uv run pytest
-
-# Or using standard pip:
-python -m venv .venv
-source .venv/bin/activate
-python -m pip install -r requirements-dev.lock.txt
-pytest
-```
-
-Run the deterministic checks:
-
-```bash
-python -m pytest
-python tools/validate_catalog.py
-python tools/export_catalog.py --check --target both
-python tools/render_readmes.py --check
-python tools/build_content_manifest.py --check
-python tools/verify_examples.py
-python tools/verify_courses.py
-python tools/verify_paths.py
-```
-
-After changing catalog sources, regenerate the public export before running the
-checks:
-
-```bash
-python tools/export_catalog.py
-python tools/render_readmes.py
-python tools/build_content_manifest.py
-```
+playbook without owning a second editable copy.
-Network link fetching is intentionally excluded from pull-request validation.
-Maintainers run it through the scheduled or manual **Catalog link audit**
-workflow.
+## Contribute
-Read [CONTRIBUTING.md](CONTRIBUTING.md) and the
-[curation policy](docs/CURATION_POLICY.md) before proposing a resource or
-changing its classification. Website integrations should also follow the
-[consumer contract](docs/CONSUMING.md).
+Propose a resource, report a correction, or improve a course. Read
+[CONTRIBUTING.md](CONTRIBUTING.md) first — it covers the local setup, the
+deterministic checks CI runs, and how to regenerate the generated exports.
+Also read the [curation policy](docs/CURATION_POLICY.md) before proposing a
+resource or changing its classification, and the
+[consumer contract](docs/CONSUMING.md) for website integrations.
diff --git a/README_cn.md b/README_cn.md
index 569c8a8..40697ab 100644
--- a/README_cn.md
+++ b/README_cn.md
@@ -1,19 +1,23 @@
# FlyPython:用 AI 编码 Agent 把 Python 真正交付
-用 AI 编码 Agent 把 Python 真正交付——真实项目;Agent 负责敲键盘,
-`verify.py` 决定你什么时候算完成。
[](https://github.com/flypythoncom/python/stargazers)
[](https://github.com/flypythoncom/python/actions/workflows/validate.yml)
[](https://flypython.com)
[English](README.md) · [中文](README_cn.md) · [🌐 官方在线门户](https://flypython.com)
+真实项目;Agent 负责敲键盘,`verify.py` 决定你什么时候算完成。
+
> **带客观验证的挑战课程。** 从 [`courses/`](courses/) 挑一个文件夹,把
> `TASK.md` 中的契约交给你的编码 Agent 去解决,并用 `verify.py` 证明结果——
> 它会在每个检查点打印一个认领码。想要有人带?`COURSE.md` 仍支持 Agent
> 授课模式,[`paths/`](paths/README.md) 则把课程串成徽章路线。可继续在
> [flypython.com](https://flypython.com/) 上学习。
+
+
+
+
FlyPython 是一个面向实践的双语仓库,帮助你写好 Python,并把代码变成用户可以依赖的
产品。这里同时提供 AI Coding 方法、任务 Playbook、可运行示例、复用模板,以及 API、
自动化、Agent、Skill 和 MCP 的一手资料。
@@ -22,11 +26,14 @@ FlyPython 是一个面向实践的双语仓库,帮助你写好 Python,并把
维护经过审核的源内容与数据,[flypython.com](https://flypython.com/) 把固定版本呈现为更适合
阅读和发现的学习体验。
+**当前版本:** `v0.1.2`——版本与交付状态见 [CHANGELOG.md](CHANGELOG.md) 和
+[仓库到网站的运营模型](docs/REPO_TO_WEBSITE.md)。
+
## 3 分钟开始
你需要一个能执行命令并联网的编码 Agent(Claude Code、Codex 应用、
-Cursor、DeepSeek Harness、Kimi Code 或 ZCode——只能聊天的网页 AI 做
-不了这些课程)。在你的 Agent 里装上
+Cursor、VS Code 里的 GitHub Copilot、DeepSeek Harness、Kimi Code 或
+ZCode——只能聊天的网页 AI 做不了这些课程)。在你的 Agent 里装上
[FlyPython Skill](https://flypython.com/skills/flypython/SKILL.md),
然后粘贴这一句话:
@@ -36,6 +43,7 @@ Agent 会用一次性授权链接为你授权(你不用把密码交给它)
课程文件——你不下载任何东西——并陪你完成挑战。还没用过编码 Agent?
从*你的*工具对应的工具课开始:[Claude Code 实战](courses/hands-on-python-with-claude-code/COURSE_cn.md)、
[Codex 应用](courses/hands-on-with-openai-codex/COURSE_cn.md)、[Cursor](courses/hands-on-with-cursor/COURSE_cn.md)、
+[GitHub Copilot](courses/hands-on-with-github-copilot/COURSE_cn.md)、
[DeepSeek Harness](courses/hands-on-with-deepseek-harness/COURSE_cn.md)、
[Kimi Code](courses/hands-on-with-kimi-code/COURSE_cn.md)、
[ZCode](courses/hands-on-with-zcode/COURSE_cn.md)。
@@ -49,6 +57,20 @@ python examples/product-slug/verify.py starter --expect-failure
python examples/product-slug/verify.py solution
```
+## 验证并记录课程进度
+
+在课程文件夹里,每次修改后运行 `python verify.py`。它只测试 `starter/`,
+并报告每个检查点:
+
+- `[open]`——任务未完成。
+- `[passed]`——测试通过,`verify.py` 打印认领码。
+- `[pending]`——自报检查点:回答该课的课后问题,然后运行
+ `python verify.py --attest l01`(每个已完成的自报检查点分别确认),
+ 取得 `[attested]` 认领码。
+
+只通过 Agent 或工作台提交 `[passed]` 和 `[attested]` 的码。`check --json`
+是版本化的 v2 接口;`progress --json` 只为旧版签名回执集成保留。
+
## 选择你现在要完成的事
| 目标 | 从这里开始 | 最终产出 |
@@ -59,8 +81,8 @@ python examples/product-slug/verify.py solution
| 完成反复出现的工程任务 | [Playbook](playbooks/README_cn.md) | Bug 修复、API 修改、外部集成、依赖升级或发布 |
| 动手练习而不只是阅读 | [可运行示例](examples/README_cn.md) | 包含失败 starter 与验证通过 solution 的本地实战项目 |
| 给 Agent 更好的上下文与指令 | [模板](templates/README_cn.md) | 任务契约、计划、IDE 规则、Review 与验证记录 |
-| 构建 Agent、Skill、MCP、API 或自动化 | [已审核资料目录](#浏览经过审核的一手资料) | 适合当前用途的一手资料路径 |
-| 发现近期值得关注的 Python 项目 | [Project Radar 审核队列](catalog/projects/README.md) | 经维护者审核、带证据的项目短名单 |
+| 构建 Agent、Skill、MCP、API 或自动化 | [已审核资料目录](catalog/README_cn.md) | 适合当前用途的一手资料路径 |
+| 发现近期值得关注的 Python 项目 | [Project Radar 审核队列](catalog/projects/README_cn.md) | 经维护者审核、带证据的项目短名单 |
工作循环很简单:定义用户结果、检查真实上下文、完成最小可测试修改、验证行为、审查副作用,
并记录尚未验证的部分。AI 可以加速这个循环,但不能替代工程判断。
@@ -76,86 +98,20 @@ python examples/product-slug/verify.py solution
- 机器可读内容清单:[`content-manifest.json`](content-manifest.json)
- 官网无噪纯文本知识汇总:`https://flypython.com/llms-full.txt`
-## 浏览经过审核的一手资料
+## 已审核资料目录
-先选择一条学习路径,再打开符合当前目标的官方资料。目录会说明每项资源为什么被收录、
-适合什么阶段,以及需要关注的访问条件和安全边界。
+四条经维护者审核的学习路径覆盖基础、Web 与 API、自动化和 AI Agent:
> **33 条已审核资源** · 最近整体审核:2026-09-06 · 25 条进阶或高级资源 · 一手来源优先
-### 选择学习路径
-
-- [**Python 基础**](#path-foundations) — 掌握语言基础、环境、依赖、类型与测试,为可靠的 Python 项目打好根基。 (8 条资源)
-- [**Web 与 API**](#path-web-apis) — 构建有类型约束的服务和应用,把 Python 逻辑连接到用户与其他系统。 (7 条资源)
-- [**自动化**](#path-automation) — 把重复工作变成可维护的脚本、浏览器工作流和数据管道。 (7 条资源)
-- [**AI Agent**](#path-ai-agents) — 理解工具调用、结构化输出、状态、评测以及 Agent 系统的安全边界。 (11 条资源)
-
-
-### Python 基础
-
-掌握语言基础、环境、依赖、类型与测试,为可靠的 Python 项目打好根基。
-
-| 资源 | 为什么值得看 | 难度与语言 | 访问与风险 | 审核日期 |
-| --- | --- | --- | --- | --- |
-| [The Python Tutorial](https://docs.python.org/3/tutorial/) 官方文档 · 精选 | Python 官方入门教程,覆盖语法、数据结构、模块、异常、类与标准库。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [venv — Creation of virtual environments](https://docs.python.org/3/library/venv.html) 官方文档 | Python 标准库的虚拟环境参考,说明如何创建隔离环境及其运行方式。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [Python Packaging User Guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/) 官方文档 | PyPA 官方教程,覆盖项目结构、构建元数据、分发包与 Python 包发布。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [uv documentation](https://docs.astral.sh/uv/) 官方项目 · 精选 | 现代 Python 工作流,统一管理版本、虚拟环境、依赖、锁文件和项目命令。 | 所有阶段 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [pytest documentation](https://docs.pytest.org/en/stable/) 官方项目 | pytest 官方指南,覆盖测试发现、断言、fixture、参数化和可维护测试套件。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [Python typing documentation](https://typing.python.org/en/latest/) 官方文档 | Python 类型系统规范、指南与类型标注实践的权威入口。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [Ruff documentation](https://docs.astral.sh/ruff/) 官方项目 · 精选 | 极速的 Rust 驱动 Python 代码检查与格式化工具,统一替代 Flake8、Black 与 isort。 | 所有阶段 英语 | 无需 API Key 低风险 | 2026-09-02 |
-| [Mypy documentation](https://mypy.readthedocs.io/en/stable/) 官方项目 | Python 官方与社区主流的静态类型检查器,在运行前拦截类型错误并验证代码契约。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-02 |
-
-
-### Web 与 API
-
-构建有类型约束的服务和应用,把 Python 逻辑连接到用户与其他系统。
-
-| 资源 | 为什么值得看 | 难度与语言 | 访问与风险 | 审核日期 |
-| --- | --- | --- | --- | --- |
-| [FastAPI documentation](https://fastapi.tiangolo.com/) 官方文档 · 精选 | 从带类型的 Python 函数出发,构建具备验证、测试和自动文档的 API。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [Django documentation: First steps](https://docs.djangoproject.com/en/stable/intro/tutorial01/) 官方文档 | Django 官方入门路径,覆盖项目、模型、视图、模板、表单、测试和可复用应用。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [Flask documentation](https://flask.palletsprojects.com/en/stable/) 官方文档 | Flask 官方参考与教程,覆盖应用、请求、模板、测试和部署。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [Pydantic documentation](https://pydantic.dev/docs/validation/latest/get-started/) 官方文档 | 在 Python 系统边界验证不可信数据、定义类型契约的官方指南。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [HTTPX documentation](https://www.python-httpx.org/) 官方项目 | 同步与异步 HTTP 客户端、超时、流式响应和传输控制的官方参考。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [SQLAlchemy documentation](https://docs.sqlalchemy.org/en/20/) 官方项目 | Python 权威的 SQL 工具包与对象关系映射器(ORM),提供类型安全的数据持久化与访问。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-02 |
-| [Alembic documentation](https://alembic.sqlalchemy.org/en/latest/) 官方项目 | 基于 SQLAlchemy 的数据库迁移事实标准工具,提供事务性架构演进与版本回滚支持。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-09-02 |
-
-
-### 自动化
-
-把重复工作变成可维护的脚本、浏览器工作流和数据管道。
-
-| 资源 | 为什么值得看 | 难度与语言 | 访问与风险 | 审核日期 |
-| --- | --- | --- | --- | --- |
-| [pathlib — Object-oriented filesystem paths](https://docs.python.org/3/library/pathlib.html) 官方文档 | 使用可读、跨平台的对象式路径完成文件与目录自动化的标准库参考。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [subprocess — Subprocess management](https://docs.python.org/3/library/subprocess.html) 官方文档 | 启动进程、捕获输出、处理失败并避免不安全 shell 用法的标准库契约。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-08-31 |
-| [Playwright for Python](https://playwright.dev/python/docs/intro) 官方文档 | 可靠浏览器自动化、定位器、断言、追踪和隔离上下文的 Python 官方指南。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-08-31 |
-| [Scrapy tutorial](https://docs.scrapy.org/en/latest/intro/tutorial.html) 官方文档 | 爬虫结构、数据提取、item pipeline、持久化与负责任运行的官方入门。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-08-31 |
-| [pandas getting started guides](https://pandas.pydata.org/docs/getting_started/index.html) 官方文档 | 表格数据读取、清洗、转换、分析和导出的官方入门入口。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
-| [Polars documentation](https://docs.pola.rs/) 官方项目 | 基于 Apache Arrow 和 Rust 构建的极速 DataFrame 库,专为并行计算与现代数据管道优化。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-02 |
-| [DuckDB Python API documentation](https://duckdb.org/docs/api/python/overview) 官方项目 | 进程内高性能分析型 SQL 引擎,适用于自动化数据转换、Parquet 文件快速查询与嵌入式分析。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-02 |
-
-
-### AI Agent
-
-理解工具调用、结构化输出、状态、评测以及 Agent 系统的安全边界。
-
-| 资源 | 为什么值得看 | 难度与语言 | 访问与风险 | 审核日期 |
-| --- | --- | --- | --- | --- |
-| [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) 官方文档 · 精选 | 面向 Agent、工具、handoff、guardrail、session、追踪与编排的官方 Python 工具包。 | 进阶 英语 | 通常需要 API Key 检查权限与副作用 | 2026-08-31 |
-| [Pydantic AI](https://pydantic.dev/docs/ai/overview/) 官方文档 · 精选 | 以输入验证、结构化输出、工具、测试和模型可移植性为核心的类型安全 Agent 开发。 | 进阶 英语 | 通常需要 API Key 检查权限与副作用 | 2026-08-31 |
-| [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) 正式标准 · 精选 | 连接 AI 应用、工具、数据与可复用上下文的开放协议和权威参考。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-08-31 |
-| [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview) 官方文档 | 有状态图工作流、持久执行、记忆、中断与人工审核的 Python 官方指南。 | 高级 英语 | 通常需要 API Key 检查权限与副作用 | 2026-08-31 |
-| [Google Agent Development Kit](https://adk.dev/) 官方文档 | Google 官方模块化 Agent 系统开发、评测与部署文档。 | 高级 英语 | 通常需要 API Key 检查权限与副作用 | 2026-08-31 |
-| [Instructor documentation](https://python.useinstructor.com/) 官方项目 | 基于 Pydantic 的大模型结构化输出与严格验证框架,确保工具调用与数据提取的确定性。 | 进阶 英语 | 通常需要 API Key 检查权限与副作用 | 2026-09-02 |
-| [Model Context Protocol Python SDK](https://github.com/modelcontextprotocol/python-sdk) 官方项目 | Model Context Protocol 官方 Python SDK,用于快速构建和集成标准的 MCP 工具与服务。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-09-02 |
-| [MCP 2026-07-28 specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28/) 官方文档 | Model Context Protocol 2026-07-28 规范的官方发布公告,说明无状态重构与破坏性变更。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-06 |
-| [MCP 2026-07-28 release candidate overview](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) 官方文档 | 官方 RC 发布说明,逐项列出 2026-07-28 的破坏性变更以及留给 SDK 维护者的迁移窗口。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-06 |
-| [Anthropic donating MCP to the Agentic AI Foundation](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation) 官方文档 | Anthropic 官方公告,宣布将 MCP 交由 Linux 基金会旗下的 Agentic AI Foundation 中立治理。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-06 |
-| [MCP under the Agentic AI Foundation](https://aaif.io/projects/model-context-protocol) 官方项目 | Agentic AI Foundation 的 MCP 项目主页,是规范开发与路线图的中立治理入口。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-06 |
+- [**Python 基础**](catalog/README_cn.md#path-foundations) — 掌握语言基础、环境、依赖、类型与测试,为可靠的 Python 项目打好根基。 (8 条资源)
+- [**Web 与 API**](catalog/README_cn.md#path-web-apis) — 构建有类型约束的服务和应用,把 Python 逻辑连接到用户与其他系统。 (7 条资源)
+- [**自动化**](catalog/README_cn.md#path-automation) — 把重复工作变成可维护的脚本、浏览器工作流和数据管道。 (7 条资源)
+- [**AI Agent**](catalog/README_cn.md#path-ai-agents) — 理解工具调用、结构化输出、状态、评测以及 Agent 系统的安全边界。 (11 条资源)
+
+完整的收录理由、难度、访问与风险说明见[完整目录](catalog/README_cn.md)。
没有找到合适的官方资料?可以[建议资源或报告错误](https://github.com/flypythoncom/python/issues/new/choose)。
@@ -176,11 +132,14 @@ python examples/product-slug/verify.py solution
## 仓库结构
```text
+assets/ README 配图(verify.py 终端演示)
catalog/
- catalog.yml 目录状态和审核日期
- paths.yml 中英文学习路径定义
- resources/ 每项资源一个 YAML 文件
- projects/ 最新 Python 项目的人工审核队列
+ README.md 可浏览的已审核目录(英文,生成)
+ README_cn.md 可浏览的已审核目录(中文,生成)
+ catalog.yml 目录状态和审核日期
+ paths.yml 中英文学习路径定义
+ resources/ 每项资源一个 YAML 文件
+ projects/ 最新 Python 项目的人工审核队列
courses/ 带 TASK.md 契约和 verify.py 认领码的挑战课程
paths/ 把课程串成徽章路线的学习路径
guides/ Python 工程与 AI Coding 方法
@@ -208,50 +167,11 @@ https://raw.githubusercontent.com/flypythoncom/python//catalog.
```
内容 manifest 让 flypython.com 可以渲染对应的中英文指南或 Playbook,而不用维护第二份
-可编辑副本。项目推荐在维护者完成源码、维护状态、许可证、发布、安全与实际用户价值审核前
-保持为空,不用 AI 生成推荐理由来填充栏目。
-
-## 本地校验
-
-使用 `.python-version` 指定的 Python,并安装锁定的开发依赖:
-
-```bash
-# 推荐:使用 uv 极速安装与测试
-uv venv
-uv pip install -r requirements-dev.lock.txt
-uv run pytest
-
-# 或使用标准 pip:
-python -m venv .venv
-source .venv/bin/activate
-python -m pip install -r requirements-dev.lock.txt
-pytest
-```
-
-运行与 CI 相同的确定性校验:
-
-```bash
-python -m pytest
-python tools/validate_catalog.py
-python tools/export_catalog.py --check --target both
-python tools/render_readmes.py --check
-python tools/build_content_manifest.py --check
-python tools/verify_examples.py
-python tools/verify_courses.py
-python tools/verify_paths.py
-```
-
-修改源内容后重新生成:
-
-```bash
-python tools/export_catalog.py
-python tools/render_readmes.py
-python tools/build_content_manifest.py
-```
+可编辑副本。
-网络链接检查不会在 Pull Request 中运行,而是由维护者通过定时或手动的
-**Catalog link audit** 工作流执行。
+## 参与贡献
-提交资源或修改分类前,请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和
-[策展政策](docs/CURATION_POLICY.md)。网站接入还应遵循
+建议资源、报告错误或改进课程,请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)——其中
+包含本地环境搭建、CI 运行的确定性校验,以及重新生成导出文件的方法。提交资源或修改
+分类前,请先阅读[策展政策](docs/CURATION_POLICY.md);网站接入还应遵循
[消费方契约](docs/CONSUMING.md)。
diff --git a/assets/verify-demo.svg b/assets/verify-demo.svg
new file mode 100644
index 0000000..cbed313
--- /dev/null
+++ b/assets/verify-demo.svg
@@ -0,0 +1,30 @@
+
diff --git a/catalog/README.md b/catalog/README.md
new file mode 100644
index 0000000..ec677f7
--- /dev/null
+++ b/catalog/README.md
@@ -0,0 +1,87 @@
+# Reviewed source catalog
+
+Every entry below passed maintainer review: why it is included, who it suits,
+and which access or safety constraints deserve attention. The canonical source
+data lives in the surrounding `catalog/` YAML files — this page is generated by
+`tools/render_readmes.py` and must not be edited by hand.
+
+[English](README.md) · [中文](README_cn.md) · [Repository README](../README.md)
+
+
+
+> **33 reviewed resources** · Catalog reviewed 2026-09-06 · 25 intermediate or advanced · Primary sources first
+
+### Choose a learning path
+
+- [**Python foundations**](#path-foundations) — Learn the language, environments, dependencies, typing, and tests that reliable Python work depends on. (8 resources)
+- [**Web and APIs**](#path-web-apis) — Build typed services and applications that connect Python logic to users and other systems. (7 resources)
+- [**Automation**](#path-automation) — Turn repeatable work into maintainable scripts, browser workflows, and data pipelines. (7 resources)
+- [**AI agents**](#path-ai-agents) — Learn tools, structured output, state, evaluation, and the safety boundaries of agent systems. (11 resources)
+
+
+### Python foundations
+
+Learn the language, environments, dependencies, typing, and tests that reliable Python work depends on.
+
+| Resource | Why it is useful | Level and language | Access and risk | Reviewed |
+| --- | --- | --- | --- | --- |
+| [The Python Tutorial](https://docs.python.org/3/tutorial/) Official docs · Featured | The primary introduction to Python syntax, data structures, modules, errors, classes, and the standard library. | Beginner English | No API key Low risk | 2026-08-31 |
+| [venv — Creation of virtual environments](https://docs.python.org/3/library/venv.html) Official docs | The standard-library reference for creating isolated Python environments and understanding their behavior. | Beginner English | No API key Low risk | 2026-08-31 |
+| [Python Packaging User Guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/) Official docs | The PyPA tutorial for project layout, build metadata, distributions, and publishing Python packages. | Intermediate English | No API key Low risk | 2026-08-31 |
+| [uv documentation](https://docs.astral.sh/uv/) Official project · Featured | A modern workflow for Python versions, virtual environments, dependencies, lockfiles, and project commands. | All levels English | No API key Low risk | 2026-08-31 |
+| [pytest documentation](https://docs.pytest.org/en/stable/) Official project | The official guide to test discovery, assertions, fixtures, parametrization, and maintainable test suites. | Intermediate English | No API key Low risk | 2026-08-31 |
+| [Python typing documentation](https://typing.python.org/en/latest/) Official docs | The canonical home for Python's type-system specification, guides, and typing best practices. | Intermediate English | No API key Low risk | 2026-08-31 |
+| [Ruff documentation](https://docs.astral.sh/ruff/) Official project · Featured | An extremely fast Python linter and code formatter written in Rust, replacing Flake8, Black, isort, and more. | All levels English | No API key Low risk | 2026-09-02 |
+| [Mypy documentation](https://mypy.readthedocs.io/en/stable/) Official project | The standard static type checker for Python to catch bugs and verify type contracts before running code. | Intermediate English | No API key Low risk | 2026-09-02 |
+
+
+### Web and APIs
+
+Build typed services and applications that connect Python logic to users and other systems.
+
+| Resource | Why it is useful | Level and language | Access and risk | Reviewed |
+| --- | --- | --- | --- | --- |
+| [FastAPI documentation](https://fastapi.tiangolo.com/) Official docs · Featured | A direct route from typed Python functions to tested APIs with validation and generated documentation. | Intermediate English | No API key Low risk | 2026-08-31 |
+| [Django documentation: First steps](https://docs.djangoproject.com/en/stable/intro/tutorial01/) Official docs | The official path through Django projects, models, views, templates, forms, tests, and reusable apps. | Beginner English | No API key Low risk | 2026-08-31 |
+| [Flask documentation](https://flask.palletsprojects.com/en/stable/) Official docs | The maintained reference and tutorial for Flask applications, requests, templates, testing, and deployment. | Beginner English | No API key Low risk | 2026-08-31 |
+| [Pydantic documentation](https://pydantic.dev/docs/validation/latest/get-started/) Official docs | The primary guide to validating untrusted data and expressing typed contracts at Python system boundaries. | Intermediate English | No API key Low risk | 2026-08-31 |
+| [HTTPX documentation](https://www.python-httpx.org/) Official project | The official reference for synchronous and asynchronous HTTP clients, timeouts, streaming, and transport control. | Intermediate English | No API key Low risk | 2026-08-31 |
+| [SQLAlchemy documentation](https://docs.sqlalchemy.org/en/20/) Official project | The definitive Python SQL toolkit and Object Relational Mapper, providing type-safe database access and migrations. | Intermediate English | No API key Low risk | 2026-09-02 |
+| [Alembic documentation](https://alembic.sqlalchemy.org/en/latest/) Official project | Database migration tool for SQLAlchemy, providing transactional schema changes and rollback support. | Intermediate English | No API key Review permissions and side effects | 2026-09-02 |
+
+
+### Automation
+
+Turn repeatable work into maintainable scripts, browser workflows, and data pipelines.
+
+| Resource | Why it is useful | Level and language | Access and risk | Reviewed |
+| --- | --- | --- | --- | --- |
+| [pathlib — Object-oriented filesystem paths](https://docs.python.org/3/library/pathlib.html) Official docs | The standard-library reference for readable, cross-platform file and directory automation. | Beginner English | No API key Low risk | 2026-08-31 |
+| [subprocess — Subprocess management](https://docs.python.org/3/library/subprocess.html) Official docs | The standard-library contract for launching processes, capturing output, handling failures, and avoiding unsafe shell usage. | Intermediate English | No API key Review permissions and side effects | 2026-08-31 |
+| [Playwright for Python](https://playwright.dev/python/docs/intro) Official docs | The official Python guide to reliable browser automation, locators, assertions, traces, and isolated contexts. | Intermediate English | No API key Review permissions and side effects | 2026-08-31 |
+| [Scrapy tutorial](https://docs.scrapy.org/en/latest/intro/tutorial.html) Official docs | The official introduction to crawl structure, extraction, item pipelines, persistence, and responsible operation. | Intermediate English | No API key Review permissions and side effects | 2026-08-31 |
+| [pandas getting started guides](https://pandas.pydata.org/docs/getting_started/index.html) Official docs | The primary entry point for tabular data loading, cleaning, transformation, analysis, and export. | Beginner English | No API key Low risk | 2026-08-31 |
+| [Polars documentation](https://docs.pola.rs/) Official project | Lightning-fast DataFrame library built on Apache Arrow and Rust, optimized for efficient data pipelines and parallel processing. | Intermediate English | No API key Low risk | 2026-09-02 |
+| [DuckDB Python API documentation](https://duckdb.org/docs/api/python/overview) Official project | Fast in-process analytical SQL engine for automated data transformation, Parquet querying, and embedded analytics. | Intermediate English | No API key Low risk | 2026-09-02 |
+
+
+### AI agents
+
+Learn tools, structured output, state, evaluation, and the safety boundaries of agent systems.
+
+| Resource | Why it is useful | Level and language | Access and risk | Reviewed |
+| --- | --- | --- | --- | --- |
+| [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) Official docs · Featured | The official Python toolkit for agents, tools, handoffs, guardrails, sessions, tracing, and orchestration. | Intermediate English | API key typically required Review permissions and side effects | 2026-08-31 |
+| [Pydantic AI](https://pydantic.dev/docs/ai/overview/) Official docs · Featured | Typed agent development built around validated inputs, structured output, tools, testing, and model portability. | Intermediate English | API key typically required Review permissions and side effects | 2026-08-31 |
+| [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) Official standard · Featured | The open protocol and reference documentation for connecting AI applications to tools, data, and reusable context. | Intermediate English | No API key Review permissions and side effects | 2026-08-31 |
+| [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview) Official docs | The official Python guide to stateful graph workflows, durable execution, memory, interrupts, and human review. | Advanced English | API key typically required Review permissions and side effects | 2026-08-31 |
+| [Google Agent Development Kit](https://adk.dev/) Official docs | Google's official documentation for developing, evaluating, and deploying modular agent systems. | Advanced English | API key typically required Review permissions and side effects | 2026-08-31 |
+| [Instructor documentation](https://python.useinstructor.com/) Official project | Structured outputs and validation for LLMs powered by Pydantic, enabling predictable tool calling and schema enforcement. | Intermediate English | API key typically required Review permissions and side effects | 2026-09-02 |
+| [Model Context Protocol Python SDK](https://github.com/modelcontextprotocol/python-sdk) Official project | Official Python implementation of the Model Context Protocol for building MCP servers and clients. | Intermediate English | No API key Review permissions and side effects | 2026-09-02 |
+| [MCP 2026-07-28 specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28/) Official docs | The official release announcement for the 2026-07-28 Model Context Protocol specification, covering the stateless redesign and breaking changes. | Intermediate English | No API key Low risk | 2026-09-06 |
+| [MCP 2026-07-28 release candidate overview](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) Official docs | The official release-candidate post that enumerates the 2026-07-28 breaking changes and the migration window for SDK maintainers. | Intermediate English | No API key Low risk | 2026-09-06 |
+| [Anthropic donating MCP to the Agentic AI Foundation](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation) Official docs | Anthropic's announcement establishing neutral Linux Foundation governance for MCP under the Agentic AI Foundation. | Intermediate English | No API key Low risk | 2026-09-06 |
+| [MCP under the Agentic AI Foundation](https://aaif.io/projects/model-context-protocol) Official project | The Agentic AI Foundation project page for MCP, the neutral governance home for specification development and roadmaps. | Intermediate English | No API key Low risk | 2026-09-06 |
+
+Missing an important official source? [Propose a resource or report a correction](https://github.com/flypythoncom/python/issues/new/choose).
+
diff --git a/catalog/README_cn.md b/catalog/README_cn.md
new file mode 100644
index 0000000..62ae31d
--- /dev/null
+++ b/catalog/README_cn.md
@@ -0,0 +1,86 @@
+# 已审核资料目录
+
+下方每一项资源都经过维护者审核:为什么收录、适合什么阶段、需要留意哪些
+访问条件与安全边界。权威源数据位于 `catalog/` 下的 YAML 文件——本页面由
+`tools/render_readmes.py` 生成,不能手工修改。
+
+[English](README.md) · [中文](README_cn.md) · [仓库 README](../README.md)
+
+
+
+> **33 条已审核资源** · 最近整体审核:2026-09-06 · 25 条进阶或高级资源 · 一手来源优先
+
+### 选择学习路径
+
+- [**Python 基础**](#path-foundations) — 掌握语言基础、环境、依赖、类型与测试,为可靠的 Python 项目打好根基。 (8 条资源)
+- [**Web 与 API**](#path-web-apis) — 构建有类型约束的服务和应用,把 Python 逻辑连接到用户与其他系统。 (7 条资源)
+- [**自动化**](#path-automation) — 把重复工作变成可维护的脚本、浏览器工作流和数据管道。 (7 条资源)
+- [**AI Agent**](#path-ai-agents) — 理解工具调用、结构化输出、状态、评测以及 Agent 系统的安全边界。 (11 条资源)
+
+
+### Python 基础
+
+掌握语言基础、环境、依赖、类型与测试,为可靠的 Python 项目打好根基。
+
+| 资源 | 为什么值得看 | 难度与语言 | 访问与风险 | 审核日期 |
+| --- | --- | --- | --- | --- |
+| [The Python Tutorial](https://docs.python.org/3/tutorial/) 官方文档 · 精选 | Python 官方入门教程,覆盖语法、数据结构、模块、异常、类与标准库。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [venv — Creation of virtual environments](https://docs.python.org/3/library/venv.html) 官方文档 | Python 标准库的虚拟环境参考,说明如何创建隔离环境及其运行方式。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [Python Packaging User Guide](https://packaging.python.org/en/latest/tutorials/packaging-projects/) 官方文档 | PyPA 官方教程,覆盖项目结构、构建元数据、分发包与 Python 包发布。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [uv documentation](https://docs.astral.sh/uv/) 官方项目 · 精选 | 现代 Python 工作流,统一管理版本、虚拟环境、依赖、锁文件和项目命令。 | 所有阶段 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [pytest documentation](https://docs.pytest.org/en/stable/) 官方项目 | pytest 官方指南,覆盖测试发现、断言、fixture、参数化和可维护测试套件。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [Python typing documentation](https://typing.python.org/en/latest/) 官方文档 | Python 类型系统规范、指南与类型标注实践的权威入口。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [Ruff documentation](https://docs.astral.sh/ruff/) 官方项目 · 精选 | 极速的 Rust 驱动 Python 代码检查与格式化工具,统一替代 Flake8、Black 与 isort。 | 所有阶段 英语 | 无需 API Key 低风险 | 2026-09-02 |
+| [Mypy documentation](https://mypy.readthedocs.io/en/stable/) 官方项目 | Python 官方与社区主流的静态类型检查器,在运行前拦截类型错误并验证代码契约。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-02 |
+
+
+### Web 与 API
+
+构建有类型约束的服务和应用,把 Python 逻辑连接到用户与其他系统。
+
+| 资源 | 为什么值得看 | 难度与语言 | 访问与风险 | 审核日期 |
+| --- | --- | --- | --- | --- |
+| [FastAPI documentation](https://fastapi.tiangolo.com/) 官方文档 · 精选 | 从带类型的 Python 函数出发,构建具备验证、测试和自动文档的 API。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [Django documentation: First steps](https://docs.djangoproject.com/en/stable/intro/tutorial01/) 官方文档 | Django 官方入门路径,覆盖项目、模型、视图、模板、表单、测试和可复用应用。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [Flask documentation](https://flask.palletsprojects.com/en/stable/) 官方文档 | Flask 官方参考与教程,覆盖应用、请求、模板、测试和部署。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [Pydantic documentation](https://pydantic.dev/docs/validation/latest/get-started/) 官方文档 | 在 Python 系统边界验证不可信数据、定义类型契约的官方指南。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [HTTPX documentation](https://www.python-httpx.org/) 官方项目 | 同步与异步 HTTP 客户端、超时、流式响应和传输控制的官方参考。 | 进阶 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [SQLAlchemy documentation](https://docs.sqlalchemy.org/en/20/) 官方项目 | Python 权威的 SQL 工具包与对象关系映射器(ORM),提供类型安全的数据持久化与访问。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-02 |
+| [Alembic documentation](https://alembic.sqlalchemy.org/en/latest/) 官方项目 | 基于 SQLAlchemy 的数据库迁移事实标准工具,提供事务性架构演进与版本回滚支持。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-09-02 |
+
+
+### 自动化
+
+把重复工作变成可维护的脚本、浏览器工作流和数据管道。
+
+| 资源 | 为什么值得看 | 难度与语言 | 访问与风险 | 审核日期 |
+| --- | --- | --- | --- | --- |
+| [pathlib — Object-oriented filesystem paths](https://docs.python.org/3/library/pathlib.html) 官方文档 | 使用可读、跨平台的对象式路径完成文件与目录自动化的标准库参考。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [subprocess — Subprocess management](https://docs.python.org/3/library/subprocess.html) 官方文档 | 启动进程、捕获输出、处理失败并避免不安全 shell 用法的标准库契约。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-08-31 |
+| [Playwright for Python](https://playwright.dev/python/docs/intro) 官方文档 | 可靠浏览器自动化、定位器、断言、追踪和隔离上下文的 Python 官方指南。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-08-31 |
+| [Scrapy tutorial](https://docs.scrapy.org/en/latest/intro/tutorial.html) 官方文档 | 爬虫结构、数据提取、item pipeline、持久化与负责任运行的官方入门。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-08-31 |
+| [pandas getting started guides](https://pandas.pydata.org/docs/getting_started/index.html) 官方文档 | 表格数据读取、清洗、转换、分析和导出的官方入门入口。 | 入门 英语 | 无需 API Key 低风险 | 2026-08-31 |
+| [Polars documentation](https://docs.pola.rs/) 官方项目 | 基于 Apache Arrow 和 Rust 构建的极速 DataFrame 库,专为并行计算与现代数据管道优化。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-02 |
+| [DuckDB Python API documentation](https://duckdb.org/docs/api/python/overview) 官方项目 | 进程内高性能分析型 SQL 引擎,适用于自动化数据转换、Parquet 文件快速查询与嵌入式分析。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-02 |
+
+
+### AI Agent
+
+理解工具调用、结构化输出、状态、评测以及 Agent 系统的安全边界。
+
+| 资源 | 为什么值得看 | 难度与语言 | 访问与风险 | 审核日期 |
+| --- | --- | --- | --- | --- |
+| [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/) 官方文档 · 精选 | 面向 Agent、工具、handoff、guardrail、session、追踪与编排的官方 Python 工具包。 | 进阶 英语 | 通常需要 API Key 检查权限与副作用 | 2026-08-31 |
+| [Pydantic AI](https://pydantic.dev/docs/ai/overview/) 官方文档 · 精选 | 以输入验证、结构化输出、工具、测试和模型可移植性为核心的类型安全 Agent 开发。 | 进阶 英语 | 通常需要 API Key 检查权限与副作用 | 2026-08-31 |
+| [Model Context Protocol](https://modelcontextprotocol.io/docs/getting-started/intro) 正式标准 · 精选 | 连接 AI 应用、工具、数据与可复用上下文的开放协议和权威参考。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-08-31 |
+| [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview) 官方文档 | 有状态图工作流、持久执行、记忆、中断与人工审核的 Python 官方指南。 | 高级 英语 | 通常需要 API Key 检查权限与副作用 | 2026-08-31 |
+| [Google Agent Development Kit](https://adk.dev/) 官方文档 | Google 官方模块化 Agent 系统开发、评测与部署文档。 | 高级 英语 | 通常需要 API Key 检查权限与副作用 | 2026-08-31 |
+| [Instructor documentation](https://python.useinstructor.com/) 官方项目 | 基于 Pydantic 的大模型结构化输出与严格验证框架,确保工具调用与数据提取的确定性。 | 进阶 英语 | 通常需要 API Key 检查权限与副作用 | 2026-09-02 |
+| [Model Context Protocol Python SDK](https://github.com/modelcontextprotocol/python-sdk) 官方项目 | Model Context Protocol 官方 Python SDK,用于快速构建和集成标准的 MCP 工具与服务。 | 进阶 英语 | 无需 API Key 检查权限与副作用 | 2026-09-02 |
+| [MCP 2026-07-28 specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28/) 官方文档 | Model Context Protocol 2026-07-28 规范的官方发布公告,说明无状态重构与破坏性变更。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-06 |
+| [MCP 2026-07-28 release candidate overview](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) 官方文档 | 官方 RC 发布说明,逐项列出 2026-07-28 的破坏性变更以及留给 SDK 维护者的迁移窗口。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-06 |
+| [Anthropic donating MCP to the Agentic AI Foundation](https://www.anthropic.com/news/donating-the-model-context-protocol-and-establishing-of-the-agentic-ai-foundation) 官方文档 | Anthropic 官方公告,宣布将 MCP 交由 Linux 基金会旗下的 Agentic AI Foundation 中立治理。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-06 |
+| [MCP under the Agentic AI Foundation](https://aaif.io/projects/model-context-protocol) 官方项目 | Agentic AI Foundation 的 MCP 项目主页,是规范开发与路线图的中立治理入口。 | 进阶 英语 | 无需 API Key 低风险 | 2026-09-06 |
+
+没有找到合适的官方资料?可以[建议资源或报告错误](https://github.com/flypythoncom/python/issues/new/choose)。
+
diff --git a/content-manifest.json b/content-manifest.json
index b0156a3..f887a81 100644
--- a/content-manifest.json
+++ b/content-manifest.json
@@ -59,14 +59,14 @@
"path": "courses/agent-rules-single-source/COURSE.md",
"title": "One source of truth for agent rules",
"summary": "Stop maintaining diverging AGENTS.md, CLAUDE.md, and .cursorrules files — build a checker that proves your repository has exactly one rule source, taught hands-on with your coding agent.",
- "sha256": "ea30434d93440708952bd4acb895efb5c8b59bf9764ccddeaab53f97eaa283e8"
+ "sha256": "b75cb686586e3ae0ecb92d9b68c220212e9364b752808b5ba63c273da339fe87"
},
{
"lang": "zh-CN",
"path": "courses/agent-rules-single-source/COURSE_cn.md",
"title": "Agent 规则的单一真源",
"summary": "不再同时维护彼此打架的 AGENTS.md、CLAUDE.md 与 .cursorrules——亲手构建一个能证明仓库只有一处规则真源的检查器,由你的编码 Agent 授课。",
- "sha256": "5b11e590514fe81dc01b6ad4555a55abfc3b730ca4d14ff1fa4cd5b50fb15bb9"
+ "sha256": "9d5efe0d5ee5615be9d90001fe8a53c209b285a9e7ca6364222f71779d83eedf"
}
]
},
@@ -197,14 +197,14 @@
"path": "courses/hands-on-python-with-claude-code/COURSE.md",
"title": "Hands-on Python with Claude Code",
"summary": "An agent-taught course that takes you from a cloned folder to a tested, verified Python report tool using Claude Code — including the task contract, the bounded change, and the objective pass/fail evidence.",
- "sha256": "44b874c07240c58bf7d3b240fa45fe52a0981802d2b4bbe95900567f20c12d69"
+ "sha256": "3076ed936b16ecae0891ecfa881b6bb553080175639e270b1bc0031a01753006"
},
{
"lang": "zh-CN",
"path": "courses/hands-on-python-with-claude-code/COURSE_cn.md",
"title": "用 Claude Code 实战 Python",
"summary": "一门由 Agent 授课的课程:从下载课程文件夹到交付一个经过测试与验证的 Python 报表工具——包括任务契约、最小变更和客观的通过/失败证据。",
- "sha256": "6c385a6554c914efabb37ec5b94f7f65c194094cb75d353db0bba2a35da0c6d4"
+ "sha256": "a7cb245d0247f6057f11e07e58963513358e698dde1eaf63e52633a4eea2b829"
}
]
},
@@ -335,14 +335,14 @@
"path": "courses/hands-on-with-openai-codex/COURSE.md",
"title": "Hands-on with the OpenAI Codex App",
"summary": "The verified Python workflow — task contract, bounded change, objective verify.py evidence — taught hands-on in the Codex desktop app, where an agent thread runs the loop and you supervise diffs and approvals.",
- "sha256": "da4f82fcfce2751c335d1a140cc2e55fafa9f6b1b5353f7925f5ca342ebc6da7"
+ "sha256": "597bb0d7a6e4529dcbf63a755d4516793ceac21681fa78728540aa42c430c688"
},
{
"lang": "zh-CN",
"path": "courses/hands-on-with-openai-codex/COURSE_cn.md",
"title": "上手 OpenAI Codex 应用",
"summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 Codex 桌面应用里手把手教:由 Agent 线程跑闭环,你监督 diff 与审批。",
- "sha256": "481e2e7fa7873b9695f5ad4c9427ea68f456b54ce6901cb9f01ac57b3f2cf6a3"
+ "sha256": "60bdd00ee11995fbefe8f46aded4878e936efbf437586fcdcd543fa50b7d0956"
}
]
},
@@ -473,14 +473,14 @@
"path": "courses/hands-on-with-cursor/COURSE.md",
"title": "Hands-on with Cursor",
"summary": "The verified Python workflow — task contract, bounded change, objective verify.py evidence — taught hands-on in Cursor, where Agent mode runs the loop and you supervise diffs and project rules.",
- "sha256": "242e0a0838ba61eadf6ed1b2e88c87989e34cd47dfc5b569f6b1455b3139c3b6"
+ "sha256": "ae57993829cf1c271f1b1ad5d723103d484acb7dcb2dd4a03c318f2b5b012bc7"
},
{
"lang": "zh-CN",
"path": "courses/hands-on-with-cursor/COURSE_cn.md",
"title": "上手 Cursor",
"summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 Cursor 里手把手教:由 Agent 模式跑闭环,你监督 diff 与项目规则。",
- "sha256": "567a8e1a76d5be9b72aa0dd445af38159132cb5660246a74053d634dc75b8d9d"
+ "sha256": "1e6536d0e6ef22d9152615d1c6a3af6d9a17dba5253a4383748abf6c03786886"
}
]
},
@@ -610,15 +610,15 @@
"lang": "en-US",
"path": "courses/da-eda/COURSE.md",
"title": "Exploratory Data Analysis with an Agent",
- "summary": "A challenge course — point your agent at a messy shop export and produce a verified EDA summary (results.json) whose numbers are checked against ground truth. First non-stdlib course in the catalog; pandas is managed with uv.",
- "sha256": "d36b48bd7f5ee3864fd7b2653dff42703469f64728c440d343a9a5c8076fa99b"
+ "summary": "A challenge course — point your agent at a messy shop export and produce a verified EDA summary (results.json) whose numbers are checked against ground truth. First non-stdlib course in the catalog; pandas is pinned in requirements.txt.",
+ "sha256": "412cd414e1ecafa7827772f1c4355c1d2be4e7f5da7cd52700bb88c5c782fa56"
},
{
"lang": "zh-CN",
"path": "courses/da-eda/COURSE_cn.md",
"title": "用 Agent 做探索性数据分析",
- "summary": "挑战式课程——让你的 Agent 处理一份脏的商店导出数据,产出一份经过真值校验的 EDA 摘要(results.json)。目录中第一门非标准库课程;pandas 由 uv 管理。",
- "sha256": "693d2da589858d6716c0dabf34042ac012875620092eefdea688ba5601e7a41b"
+ "summary": "挑战式课程——让你的 Agent 处理一份脏的商店导出数据,产出一份经过真值校验的 EDA 摘要(results.json)。目录中第一门非标准库课程;pandas 版本固定在 requirements.txt。",
+ "sha256": "509913a62ecc46d0972b7572578f3f716c5cc98155734b19d4cb077c218780ac"
}
]
},
@@ -749,14 +749,14 @@
"path": "courses/da-report/COURSE.md",
"title": "From Analysis to Report with an Agent",
"summary": "Challenge course — turn a verified EDA summary into a structured report (report.json + report.md) where every number traces back to its input. Verification checks structure and number consistency, not writing quality.",
- "sha256": "f0aea2909ed3e187497e9a79cf008d88324cf22e0ffc4251540825ecd8f08a46"
+ "sha256": "3c80de32162187285964142406f1b482dc6c422608de63825783e0444a0f582a"
},
{
"lang": "zh-CN",
"path": "courses/da-report/COURSE_cn.md",
"title": "用 Agent 从分析到报告",
"summary": "挑战课程——把经过验证的 EDA 摘要变成结构化报告(report.json + report.md),其中每个数字都可回溯到输入。验证检查结构与数字一致性,不评价文笔。",
- "sha256": "f141a356b60de463d329fc72bb13374a8194cdbe5ecfc973a4f6f8c0e13c64c1"
+ "sha256": "40e0e079eaa4c9419b0ff72756a6ee28936bfe5c31cbcc530c48082574b2bd5a"
}
]
},
@@ -887,14 +887,14 @@
"path": "courses/da-visualization/COURSE.md",
"title": "Data Visualization with an Agent",
"summary": "Challenge course — turn a clean sales dataset into three spec'd charts plus a summary.json whose numbers are verified against the data. Charts must be generated from data, never hardcoded.",
- "sha256": "8f2dba8784b18a8ca15ac578629f5099245299e8a80184a82bea2e29b3e4545c"
+ "sha256": "fd71dfd018467977e3430e61d290dfaf23ca888177af62bf4d81ac80a51da909"
},
{
"lang": "zh-CN",
"path": "courses/da-visualization/COURSE_cn.md",
"title": "用 Agent 做数据可视化",
"summary": "挑战课程——把干净的销售数据变成三张有规格的图和一份 summary.json,其中数字会与数据集校验。图必须由数据生成,不许硬编码。",
- "sha256": "80f412edb8375f5e2027285a6b27f828e7670cf536d149400fde4cf2948d297b"
+ "sha256": "c67db40a964dbca98e46d61d6dbe5fb9bd4aa557bb49d9bb4af24e509e1281f5"
}
]
},
@@ -1025,14 +1025,14 @@
"path": "courses/hands-on-with-deepseek-harness/COURSE.md",
"title": "Hands-on with DeepSeek Harness",
"summary": "The verified Python workflow — task contract, bounded change, objective verify.py evidence — taught hands-on in DeepSeek Harness, where the agent loop is composed from plugins and every step lands in an inspectable trajectory.",
- "sha256": "fb3b25a0f5233613b96f51e44f31292e1ba844603b12d9b1baeac34bd9e8504f"
+ "sha256": "f3e928e96dfa6f45547bc2b07ba151271551af6cdd22dff4281d032df5276186"
},
{
"lang": "zh-CN",
"path": "courses/hands-on-with-deepseek-harness/COURSE_cn.md",
"title": "上手 DeepSeek Harness",
"summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 DeepSeek Harness 里手把手教:Agent 闭环由插件组装,每一步都落进可检查的轨迹日志。",
- "sha256": "cabe3a68415798d70463afcfa1051d76a247793611b041b0077bba9fcb8cbbae"
+ "sha256": "3bef27e9425c37709924781f53d12f42b883cd5174eb94cc32675c01f5d1bfac"
}
]
},
@@ -1151,6 +1151,144 @@
}
]
},
+ {
+ "id": "course-github-copilot",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/hands-on-with-github-copilot/COURSE.md",
+ "title": "Hands-on with GitHub Copilot",
+ "summary": "The verified Python workflow — task contract, bounded change, objective verify.py evidence — taught hands-on with GitHub Copilot in VS Code, where the Agent role runs the loop and you supervise permissions, instructions files, and diffs.",
+ "sha256": "d78fa83c4664e368faccc89490ff34b7e6b859b32d37cb53b699af2a2cfbe80c"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/hands-on-with-github-copilot/COURSE_cn.md",
+ "title": "上手 GitHub Copilot",
+ "summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 VS Code 的 GitHub Copilot 里手把手教:由 Agent 角色跑闭环,你监督权限、指令文件与 diff。",
+ "sha256": "c17c53dcf6814e1df569e642b92705974fd52614da0852199b029ca044ed72a7"
+ }
+ ]
+ },
+ {
+ "id": "course-github-copilot-l01",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/hands-on-with-github-copilot/lessons/L01.md",
+ "title": "Lesson 1: Set up Copilot in VS Code, install the FlyPython Skill, fetch the course",
+ "summary": "Get GitHub Copilot running in VS Code with the Agent role and Manual permissions, optionally install the FlyPython Skill, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective.",
+ "sha256": "c8da93c48af23c9e9bfe8d6c1c750be357a21511e42d5751046697fd6b6ba998"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/hands-on-with-github-copilot/lessons/L01_cn.md",
+ "title": "第 1 课:在 VS Code 里装好 Copilot、装上 FlyPython Skill、让 Agent 取回课程",
+ "summary": "让 GitHub Copilot 在 VS Code 里以 Agent 角色、Manual permissions 跑起来,(可选)装好 FlyPython Skill,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。",
+ "sha256": "6fc5c71d4c2b3c0953be4e4972e51a407524198a6d1b9c18536b0f6bd5d740b3"
+ }
+ ]
+ },
+ {
+ "id": "course-github-copilot-l02",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/hands-on-with-github-copilot/lessons/L02.md",
+ "title": "Lesson 2: Write the task contract, let .github instructions carry the rules",
+ "summary": "Turn \"make it better\" into a bounded, testable contract — then make it durable with `.github/copilot-instructions.md` and an `applyTo` instructions file, so every Copilot session starts with the same rules.",
+ "sha256": "74c4519aad83e0a8e49930459f319b94ee963e69e315aef698a3951cd43f6dfa"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/hands-on-with-github-copilot/lessons/L02_cn.md",
+ "title": "第 2 课:写任务契约,让 .github 指令文件承载规则",
+ "summary": "把「改好一点」变成有边界、可测试的契约——再用 `.github/copilot-instructions.md` 和带 `applyTo` 的指令文件把它持久化,让每个 Copilot 会话都从同一套规则开始。",
+ "sha256": "7458f9ec3aca85e6f5389cd3c2c9851643452a06e1e9b5b56f9d0174bf70368c"
+ }
+ ]
+ },
+ {
+ "id": "course-github-copilot-l03",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/hands-on-with-github-copilot/lessons/L03.md",
+ "title": "Lesson 3: The bounded change, reviewed turn by turn",
+ "summary": "Drive the starter to green through bounded turns — review each turn's diff before the next, and use Restore Checkpoint and Git to keep every change yours.",
+ "sha256": "cea8e1536f2865c3f0eb7af39e198d03f309300ae98483019ca10d9b8a58c30b"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/hands-on-with-github-copilot/lessons/L03_cn.md",
+ "title": "第 3 课:有界改动,一轮一审",
+ "summary": "通过有界的轮次把 starter 推到全绿——每轮先审 diff 再发下一轮,用 Restore Checkpoint 和 Git 守住每一处改动。",
+ "sha256": "edd5a69ac53ee0cd515bec1b601fd906b1afd4580e8cff92e8beb80ac15b21e6"
+ }
+ ]
+ },
+ {
+ "id": "course-github-copilot-l04",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/hands-on-with-github-copilot/lessons/L04.md",
+ "title": "Lesson 4: Verify like a reviewer, not a spectator",
+ "summary": "Green tests are the floor, not the ceiling — run both suites, walk a scenario skin, and review the accumulated diff in Source Control the way you would review a colleague's.",
+ "sha256": "e66f1a3652d94beae772d9fcdc3a86fa1ffa21c03146f546188f39ebe7d141ba"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/hands-on-with-github-copilot/lessons/L04_cn.md",
+ "title": "第 4 课:像评审者一样验证,别当观众",
+ "summary": "测试全绿是地板不是天花板——两个套件都跑、走完一个皮肤、在 Source Control 里按评审同事的标准审这份累积 diff。",
+ "sha256": "61c92a8c81eed42e13686ba1574622c235406144617393fd38ea481abd627a2e"
+ }
+ ]
+ },
+ {
+ "id": "course-github-copilot-l05",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/hands-on-with-github-copilot/lessons/L05.md",
+ "title": "Lesson 5: Take the workflow to your own project",
+ "summary": "The tool generalizes: task contract, instructions files, bounded agent work, diff review — run the same loop with Copilot on a repository you actually own.",
+ "sha256": "ef91a8baea18e3821d4f285c9bbb0dc13bb4bfcfdd3f9e9f2ef08aa5825fdcfb"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/hands-on-with-github-copilot/lessons/L05_cn.md",
+ "title": "第 5 课:把工作流带回自己的项目",
+ "summary": "工具会泛化:任务契约、指令文件、有界 Agent 工作、diff 评审——用 Copilot 在你真正拥有的仓库上跑同一套闭环。",
+ "sha256": "bc38cf9a8e747a6191973689b8d6524c7229c05a67d64733b1216a7577c7324d"
+ }
+ ]
+ },
{
"id": "course-kimi-code",
"type": "course",
@@ -1163,14 +1301,14 @@
"path": "courses/hands-on-with-kimi-code/COURSE.md",
"title": "Hands-on with Kimi Code",
"summary": "The verified Python workflow — task contract, bounded change, objective verify.py evidence — taught hands-on in Kimi Code, where built-in subagents split read-only exploration, planning, and the edits you approve.",
- "sha256": "749ea832bc28f7074f99770e0c827255d221159cef48a79a316a8de9c2b55296"
+ "sha256": "d574ba24d74c189cbc4cfa599f14d64ed327a65299272564300c2e85e6d6a272"
},
{
"lang": "zh-CN",
"path": "courses/hands-on-with-kimi-code/COURSE_cn.md",
"title": "上手 Kimi Code",
"summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 Kimi Code 里手把手教:内置子代理把只读勘察、规划和经你批准的编辑分在三条道上。",
- "sha256": "b080c36ac7ffbb1bfaeaad98db2ef68ca61a5f6c72f445c860a5801acf57e762"
+ "sha256": "a7c09dafda26d905430d182258485d6792b43a55703822a3438ce552518c5815"
}
]
},
@@ -1289,6 +1427,144 @@
}
]
},
+ {
+ "id": "course-llm-features",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/llm-features-in-python/COURSE.md",
+ "title": "Build LLM features in Python: structured output, tool calls, and evals",
+ "summary": "Turn a demo-day model call into a feature you can ship — validate structured output, allowlist tool calls, retry with the error fed back, fall back to a human instead of guessing, and measure it with an offline eval that divides by every case.",
+ "sha256": "39bf1f7869da13115552c62ea0128db8a03092ba7b04184aa3a98832491dad9a"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/llm-features-in-python/COURSE_cn.md",
+ "title": "用 Python 构建 LLM 功能:结构化输出、工具调用与评测",
+ "summary": "把演示日的一次模型调用变成能上线的功能——校验结构化输出、给工具调用设白名单、带着错误信息重试、拿不准就转人工而不是瞎猜,并用分母覆盖全部用例的离线评测来度量。",
+ "sha256": "be86fb13168251a5a5baec99c1c68f914bed7956329e95ebedf126afa9838516"
+ }
+ ]
+ },
+ {
+ "id": "course-llm-features-l01",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/llm-features-in-python/lessons/L01.md",
+ "title": "Lesson 1: A demo reply is not a feature",
+ "summary": "Fetch the course through the FlyPython Skill, run the starter's eval, and catch the number that cannot be true — then map every failing test to the real-world reply that breaks it.",
+ "sha256": "da3b2c3657a499c6a39efec4b4cb7e7a257a11cb2c9bf00fa959a508acbaba58"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/llm-features-in-python/lessons/L01_cn.md",
+ "title": "第 1 课:演示能跑不等于功能可靠",
+ "summary": "通过 FlyPython Skill 取回课程,跑一遍 starter 的评测,抓住那个不可能为真的数字——再把每个失败测试对应到让它坏掉的真实回复。",
+ "sha256": "69c4a6e854928aaef5c6b16bff89f7fc7a7adff12a074313093c36ded7879bf2"
+ }
+ ]
+ },
+ {
+ "id": "course-llm-features-l02",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/llm-features-in-python/lessons/L02.md",
+ "title": "Lesson 2: Write the output contract",
+ "summary": "The prompt asks; the contract checks. Read TASK.md as the boundary between your code and the model — what a reply must look like, which tools it may run, and what happens when it does not comply.",
+ "sha256": "cf19964e23e6604e4f534f452b70fd05d75f4b5e934399c7827c222b5f513667"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/llm-features-in-python/lessons/L02_cn.md",
+ "title": "第 2 课:把模型输出写成契约",
+ "summary": "提示词负责请求,契约负责检查。把 TASK.md 当成你的代码与模型之间的边界来读——回复必须长什么样、能运行哪些工具、不照做时怎么办。",
+ "sha256": "07f4d9afae77009603f97918c0ad564cae582a0a2f02be9b49c99f950c38567b"
+ }
+ ]
+ },
+ {
+ "id": "course-llm-features-l03",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/llm-features-in-python/lessons/L03.md",
+ "title": "Lesson 3: Validate, allowlist tools, retry — one group at a time",
+ "summary": "Drive the starter to green in four bounded turns — the parser, the tool gate, the retry-and-fallback loop, the eval — and watch one fix expose a bug the old code was hiding.",
+ "sha256": "0def13edb7bb2eb306f5cf9788744cc5304500bb399b398a6eaca6dfa244f69e"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/llm-features-in-python/lessons/L03_cn.md",
+ "title": "第 3 课:校验、工具白名单、重试——一次一组",
+ "summary": "用四个有界轮次把 starter 推到全绿——解析器、工具关口、重试与兜底循环、评测——并亲眼看到一处修复暴露出旧代码藏着的 bug。",
+ "sha256": "80e99a0c00f6726fe16e83e9c73af181cc56be7d6ec0e9bcd3f540ba3fdec3f6"
+ }
+ ]
+ },
+ {
+ "id": "course-llm-features-l04",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/llm-features-in-python/lessons/L04.md",
+ "title": "Lesson 4: Read the eval honestly",
+ "summary": "A green suite proves the contract; the eval measures the feature. Run all three skins, accept the one that fails its threshold, and write down what a handful of recorded replies can and cannot tell you.",
+ "sha256": "8a56c73e8b6fd4668cd33624d667fe6d36d094e3e378953b3641118d1cbb3377"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/llm-features-in-python/lessons/L04_cn.md",
+ "title": "第 4 课:诚实地读评测",
+ "summary": "套件全绿证明的是契约;评测度量的是功能。跑完三个皮肤,接受那个没过阈值的结果,并写下少量录好的回复能说明什么、不能说明什么。",
+ "sha256": "ec79c0448247b288842dd1ae9307d1dbfc7daff02e1e1c09574f07e22cb18aa0"
+ }
+ ]
+ },
+ {
+ "id": "course-llm-features-l05",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/llm-features-in-python/lessons/L05.md",
+ "title": "Lesson 5: Apply it to your own LLM feature",
+ "summary": "Put a real model behind the same seam: a client callable, an output contract, an allowlist, a fallback, and an eval you record once and replay on every change.",
+ "sha256": "3480e2cd27efdda14efbfa68d530987776f8b06d233e3036d4d00426ec54461f"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/llm-features-in-python/lessons/L05_cn.md",
+ "title": "第 5 课:接到你自己的模型功能上",
+ "summary": "把真实模型放到同一个接缝后面:一个 client 可调用对象、一份输出契约、一张白名单、一个兜底,以及录一次、每次改动都回放的评测。",
+ "sha256": "b8639d63f0d399ca633c1f0fdb330348afd81215100c840ecc038b7993aaf6b6"
+ }
+ ]
+ },
{
"id": "course-mcp-tools",
"type": "course",
@@ -1301,14 +1577,14 @@
"path": "courses/mcp-server-in-python/COURSE.md",
"title": "Give your agent tools with MCP (Python)",
"summary": "Build a stateless Model Context Protocol tool server in pure Python — JSON-RPC 2.0 dispatch, schema validation, error isolation, and the 2026-07-28 input_required round-trip — and learn where a real tool belongs.",
- "sha256": "33af5c6245ce1dc1f9fbba6431ba6fa911c63ee2b7137d9e91600370685f44d8"
+ "sha256": "6df68da45b69a9a62ba46cdba2b39963edc443759cdfdea00f94b9cdd1f4de40"
},
{
"lang": "zh-CN",
"path": "courses/mcp-server-in-python/COURSE_cn.md",
"title": "用 MCP 给你的 Agent 装上工具(Python)",
"summary": "用纯 Python 构建一个无状态的 Model Context Protocol 工具服务——JSON-RPC 2.0 分发、schema 校验、错误隔离与 2026-07-28 的 input_required 多轮交互——并理解真正的工具该放在哪里。",
- "sha256": "322660423993cb400ec53c8b0c79b55a276229765fbc1c3fa1891729d7c1c72c"
+ "sha256": "197797063a2f01da277d2c51a3cb3816077feca8eae9dfa00b76706bc0ef4887"
}
]
},
@@ -1427,6 +1703,144 @@
}
]
},
+ {
+ "id": "course-secure-code",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/secure-ai-generated-code/COURSE.md",
+ "title": "Security review for AI-written Python",
+ "summary": "Review a plausible, AI-style notes service the way an attacker would — close SQL injection, path traversal, a hard-coded secret, fast password hashes, and pickled sessions one class at a time, then prove each fix by replaying the attacks.",
+ "sha256": "17e8571f7192fbb86e5167e6c4505ed0a8d482e067efd4ef4da623ec2173a303"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/secure-ai-generated-code/COURSE_cn.md",
+ "title": "AI 写的 Python 代码的安全审查",
+ "summary": "像攻击者一样审查一个看起来没问题、AI 风格的笔记服务——一次一类地堵上 SQL 注入、路径穿越、硬编码密钥、快速密码哈希和 pickle 会话,再通过重放攻击证明每处修复。",
+ "sha256": "e4e388235efde9d2c6ea14e914792906fa8aeb887426650490609eab6be7af70"
+ }
+ ]
+ },
+ {
+ "id": "course-secure-code-l01",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/secure-ai-generated-code/lessons/L01.md",
+ "title": "Lesson 1: Plausible is not safe",
+ "summary": "Fetch the course through the FlyPython Skill, read a notes service that looks fine, then replay nine attacks against it and watch every one succeed.",
+ "sha256": "e2c5160ee73219de7bfff49e883fa5c36273afb977ef31744e79e5b368c396a7"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/secure-ai-generated-code/lessons/L01_cn.md",
+ "title": "第 1 课:看起来对不等于安全",
+ "summary": "通过 FlyPython Skill 取回课程,读一个看起来没问题的笔记服务,然后对它重放九个攻击,看着每一个都得手。",
+ "sha256": "37eb221ca507e3792b1aaa36b5c0e0f73f6cc58eeb6363e0e5c111b9430b55e7"
+ }
+ ]
+ },
+ {
+ "id": "course-secure-code-l02",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/secure-ai-generated-code/lessons/L02.md",
+ "title": "Lesson 2: Write the threat model as a contract",
+ "summary": "For each function, name who controls the input, what they want, and what the code must refuse — then trace every refusal in TASK.md to a test and to the Python documentation behind it.",
+ "sha256": "ef7330b170094dcb114779a3b0ea1e0c2fce9397cd9af286cbca825a036805fa"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/secure-ai-generated-code/lessons/L02_cn.md",
+ "title": "第 2 课:把威胁模型写成契约",
+ "summary": "对每个函数,说清谁控制输入、他想要什么、代码必须拒绝什么——再把 TASK.md 里的每一条拒绝追查到一个测试,以及它背后的 Python 文档。",
+ "sha256": "b0847c2fbe8715fd22f662d7ce605b982f7c7467ef321198dea68cba5f7a1125"
+ }
+ ]
+ },
+ {
+ "id": "course-secure-code-l03",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/secure-ai-generated-code/lessons/L03.md",
+ "title": "Lesson 3: Close one flaw class per turn",
+ "summary": "Five bounded turns — search, attachments, secret key, passwords, sessions — each closing one weakness class with a mechanism, never a filter, and each proven by its tests and the replay.",
+ "sha256": "042a147514c02ddba30ab17fd4f445023bd42b5bceea2e8538abde0dcb67ff75"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/secure-ai-generated-code/lessons/L03_cn.md",
+ "title": "第 3 课:每轮只堵一类漏洞",
+ "summary": "五个有界轮次——搜索、附件、密钥、密码、会话——每轮用机制(绝不用过滤)堵上一类弱点,并用测试和重放证明。",
+ "sha256": "aeda117a3ec242a4c896479ed9b9c80469e77ce47487fb9d7c1ba251e4cfcf5e"
+ }
+ ]
+ },
+ {
+ "id": "course-secure-code-l04",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/secure-ai-generated-code/lessons/L04.md",
+ "title": "Lesson 4: Review like an attacker",
+ "summary": "Green tests and a 9/9 replay are the floor. Replay the second skin, run a static scanner and note what it misses, attack one thing the suite never tried, and write down what is still unreviewed.",
+ "sha256": "b59c907a4d3ae0d47aa03a590eeebf9128031d071379100cc98c80a6153825ca"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/secure-ai-generated-code/lessons/L04_cn.md",
+ "title": "第 4 课:像攻击者一样复查",
+ "summary": "测试全绿和 9/9 的重放只是地板。重放第二个皮肤、跑一遍静态扫描并记下它漏了什么、攻击一处套件从没试过的地方,并写下还有什么没审查。",
+ "sha256": "3fdedd08c18ada2d6aacd429c8096432cdfac0d36c1e7f1e7e06a13a323f74d5"
+ }
+ ]
+ },
+ {
+ "id": "course-secure-code-l05",
+ "type": "course",
+ "content_version": 1,
+ "status": "reviewed",
+ "reviewed_on": "2026-10-11",
+ "locales": [
+ {
+ "lang": "en-US",
+ "path": "courses/secure-ai-generated-code/lessons/L05.md",
+ "title": "Lesson 5: Review your own AI-written code",
+ "summary": "Take the five questions and the scanner pass to a repository you own: find where input crosses a trust boundary, check each crossing for a mechanism, and write down what you did not review.",
+ "sha256": "db30cf89a2a6da03807264f3e8dff1beb0161b47bbe1b188a6d628963c2e222c"
+ },
+ {
+ "lang": "zh-CN",
+ "path": "courses/secure-ai-generated-code/lessons/L05_cn.md",
+ "title": "第 5 课:审查你自己的 AI 代码",
+ "summary": "把五个问题和扫描步骤带到你自己的仓库:找出输入跨越信任边界的地方,检查每处是否用了机制,并写下你没有审查的部分。",
+ "sha256": "cb305a1346b3e0820f4c1cb2943e7416c75503fb0a8cfb4b0126f86c9b11399d"
+ }
+ ]
+ },
{
"id": "course-verify-ship",
"type": "course",
@@ -1439,14 +1853,14 @@
"path": "courses/verifying-ai-generated-code/COURSE.md",
"title": "From \"it runs\" to \"it ships\": verifying AI-generated code",
"summary": "Build the release-evidence machine your AI-written project is missing — a ship check that runs the tests, parses what actually ran, refuses zero-test projects, and writes an honest delivery record.",
- "sha256": "f329af811dee9fb767f0789c5b176fef435e6bf9a2bd6e36a86e98992e232d6d"
+ "sha256": "f21f75bd6a716245b614ee2d9b8d782354e33d890694683a66fbd7a1fe53f46b"
},
{
"lang": "zh-CN",
"path": "courses/verifying-ai-generated-code/COURSE_cn.md",
"title": "从「能跑」到「能上线」:验证 AI 写的代码",
"summary": "为你的 AI 项目补上缺失的发布证据机器——一个 ship check:运行测试、解析实际跑了什么、拒绝零测试项目,并写出诚实的交付记录。",
- "sha256": "4e9f9675fd80851670b5585a48161b78be080640d832b1a4f40a0ad75078b83d"
+ "sha256": "ade471c2a4f88526bdb5630b67791b4b5c0757e2a6daf59d2a56566b72f34da9"
}
]
},
@@ -1577,14 +1991,14 @@
"path": "courses/hands-on-with-zcode/COURSE.md",
"title": "Hands-on with ZCode",
"summary": "The verified Python workflow — task contract, bounded change, objective verify.py evidence — taught hands-on in the ZCode desktop workspace, where Goal Mode decomposes the work and the Git state shows every change.",
- "sha256": "f1549e06594afe047d386bbf379467006df7dcaad24a9e3b848d8be0451e8f6c"
+ "sha256": "051fee38652ea32840258ec50e304190124bdef5965309c0e24578ecbf5c5481"
},
{
"lang": "zh-CN",
"path": "courses/hands-on-with-zcode/COURSE_cn.md",
"title": "上手 ZCode",
"summary": "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 ZCode 桌面工作区里手把手教:Goal 模式拆解任务,Git 状态展示每一处改动。",
- "sha256": "38a1ede3d23dc65fd44e0b764914d4a5f637aa1156c5b07a3d6ed5c1c141c034"
+ "sha256": "a3c9916af904b3c054bab2ae84c486d8ee629c699ec024ddbb8c7e993c8fe3a5"
}
]
},
@@ -1890,46 +2304,46 @@
{
"id": "path-foundation-challenge",
"type": "path",
- "content_version": 2,
+ "content_version": 3,
"status": "reviewed",
- "reviewed_on": "2026-09-13",
+ "reviewed_on": "2026-10-11",
"locales": [
{
"lang": "en-US",
"path": "paths/foundation/modules/route-challenge.md",
"title": "Route challenge: dual-agent, double-verified",
- "summary": "Foundation path capstone — run the same task through any two of the six path agents and verify both outputs yourself.",
- "sha256": "5dd9a96fb85e3582b57455b1714827ff8503a53cef65eaf5159666771542bf7f"
+ "summary": "Foundation path capstone — run the same task through any two of the seven path agents and verify both outputs yourself.",
+ "sha256": "8982609d52576c51a7b680ddc904951c479ecbef8eb285c33af80f8ee1cb0867"
},
{
"lang": "zh-CN",
"path": "paths/foundation/modules/route-challenge_cn.md",
"title": "路线挑战:双 Agent、双重验证",
- "summary": "基础路线收官——把同一个任务分别交给六个路线 Agent 中任选的两个,并亲手验证两份产出。",
- "sha256": "eafb3dc130a96a8ba6b8128c4353c689f2590abbfe2ecd8c06fd00940f7e0ea5"
+ "summary": "基础路线收官——把同一个任务分别交给七个路线 Agent 中任选的两个,并亲手验证两份产出。",
+ "sha256": "89ce1e3e13a6b07c87f56a3a65136a9f5e01c642abce771b119f22b73d9be4f6"
}
]
},
{
"id": "path-foundation-m0",
"type": "path",
- "content_version": 2,
+ "content_version": 3,
"status": "reviewed",
- "reviewed_on": "2026-09-13",
+ "reviewed_on": "2026-10-11",
"locales": [
{
"lang": "en-US",
"path": "paths/foundation/modules/m0-orientation.md",
"title": "The agent tool landscape",
- "summary": "Foundation path module 0 — orientation across six agentic coding tools; pick the one that fits your environment and observe its evidence habits.",
- "sha256": "16f44353eb09b2292d3ff49af6bde9906a865a471a8818301ebb9b7260454e1a"
+ "summary": "Foundation path module 0 — orientation across seven agentic coding tools; pick the one that fits your environment and observe its evidence habits.",
+ "sha256": "41fda71a48d8382fbff39da5813b78adf2b15c97079b9b6f96aeb44de34899db"
},
{
"lang": "zh-CN",
"path": "paths/foundation/modules/m0-orientation_cn.md",
"title": "Agent 工具生态总览",
- "summary": "基础路线模块 0——六个 Agentic 编程工具生态导览;按你的工作环境选一个,并观察它的证据习惯。",
- "sha256": "9d416d3f68766ee6d241dff0e00edb21a654ba27c8013ff4de8e23ff90852a1e"
+ "summary": "基础路线模块 0——七个 Agentic 编程工具生态导览;按你的工作环境选一个,并观察它的证据习惯。",
+ "sha256": "3857bf97e35ab16af157fd506f897f665c752985e2623bd57c8c4312c4623542"
}
]
},
diff --git a/course-solutions.json b/course-solutions.json
index c753681..6da4237 100644
--- a/course-solutions.json
+++ b/course-solutions.json
@@ -36,6 +36,11 @@
"course_id": "course-deepseek-harness",
"solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699"
},
+ {
+ "slug": "hands-on-with-github-copilot",
+ "course_id": "course-github-copilot",
+ "solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699"
+ },
{
"slug": "hands-on-with-kimi-code",
"course_id": "course-kimi-code",
@@ -51,10 +56,20 @@
"course_id": "course-zcode",
"solution_sha256": "d0aa4b9a5486f9d50abf2fe34f9166815174591e7fc0c2fb23ae3111a5a16699"
},
+ {
+ "slug": "llm-features-in-python",
+ "course_id": "course-llm-features",
+ "solution_sha256": "3d9979d543024389632f08ac25605531f69c1072bc7c29b61c6756672ead38ac"
+ },
{
"slug": "mcp-server-in-python",
"course_id": "course-mcp-tools",
- "solution_sha256": "e9a0c348e12bd1f04d83783689f299e3ece8884f3f8a5be2f8a40655e211b790"
+ "solution_sha256": "3f58792fafaf0025f8766ca84ac8cae660e73868193fe55b61bb97548ac2aca7"
+ },
+ {
+ "slug": "secure-ai-generated-code",
+ "course_id": "course-secure-code",
+ "solution_sha256": "71e13f1cc12634baeef6b5fabc491465629f25d612fbb85ad0ca4ee2ecdd1404"
},
{
"slug": "verifying-ai-generated-code",
diff --git a/courses/agent-rules-single-source/COURSE.md b/courses/agent-rules-single-source/COURSE.md
index 5702146..79cca49 100644
--- a/courses/agent-rules-single-source/COURSE.md
+++ b/courses/agent-rules-single-source/COURSE.md
@@ -71,7 +71,7 @@ directory, three file names, one truth.
- Badge: **Measure the drift Badge** (badge id `course-agent-rules`) - earned by claiming all five checkpoints.
- Challenges: L01-L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` - L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/agent-rules-single-source/COURSE_cn.md b/courses/agent-rules-single-source/COURSE_cn.md
index aba1764..8384ea4 100644
--- a/courses/agent-rules-single-source/COURSE_cn.md
+++ b/courses/agent-rules-single-source/COURSE_cn.md
@@ -66,7 +66,7 @@ flypython.com 上的 AGENTS.md 指南)、多仓库配置、机器策略强制
- 徽章:**量化漂移徽章**(徽章 id `course-agent-rules`)——认领全部五个检查点后获得。
- 挑战:L01–L05 检查点各 10 分;五项全部在 flypython.com 认领后另加 50 分课程徽章奖励。
- 证据:`python verify.py` —— L03(边界修改)与 L04(验证与审查)由测试套件客观判定;L01/L02/L05 为学习者自报。
-- 提交:每个通过的检查点会打印确定性认领码,在 flypython.com 上记入你的账号。这是自我报告的证据,绝不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码,在 flypython.com 上记入你的账号。这是自我报告的证据,绝不是证书。
## 文件夹结构
diff --git a/courses/agent-rules-single-source/verify.py b/courses/agent-rules-single-source/verify.py
index e5cd879..0f3c3ba 100644
--- a/courses/agent-rules-single-source/verify.py
+++ b/courses/agent-rules-single-source/verify.py
@@ -183,21 +183,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
environment = os.environ.copy()
diff --git a/courses/da-eda/COURSE.md b/courses/da-eda/COURSE.md
index f4141bb..29f92ff 100644
--- a/courses/da-eda/COURSE.md
+++ b/courses/da-eda/COURSE.md
@@ -2,7 +2,7 @@
id: course-da-eda
type: course
title: Exploratory Data Analysis with an Agent
-summary: A challenge course — point your agent at a messy shop export and produce a verified EDA summary (results.json) whose numbers are checked against ground truth. First non-stdlib course in the catalog; pandas is managed with uv.
+summary: A challenge course — point your agent at a messy shop export and produce a verified EDA summary (results.json) whose numbers are checked against ground truth. First non-stdlib course in the catalog; pandas is pinned in requirements.txt.
lang: en-US
content_version: 1
status: reviewed
@@ -37,7 +37,9 @@ This is the first course in the catalog that leaves the standard library:
```bash
cd courses/da-eda
-uv sync # or: pip install -r requirements.txt
+python -m venv .venv # or: uv venv
+source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install -r requirements.txt # or: uv pip install -r requirements.txt
```
`verify.py` checks for pandas and prints this hint if it is missing.
@@ -58,9 +60,9 @@ uv sync # or: pip install -r requirements.txt
five checkpoints.
- Challenges: L01–L05, 10 points each; +50 course-badge bonus when all
five are claimed on flypython.com.
-- Evidence: `python verify.py progress` — L03 and L04 are objectively
+- Evidence: `python verify.py` — L03 and L04 are objectively
gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code;
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`;
record it on flypython.com against your account. Self-reported
evidence, never a certificate.
diff --git a/courses/da-eda/COURSE_cn.md b/courses/da-eda/COURSE_cn.md
index 22faaa0..224ff5f 100644
--- a/courses/da-eda/COURSE_cn.md
+++ b/courses/da-eda/COURSE_cn.md
@@ -2,7 +2,7 @@
id: course-da-eda
type: course
title: 用 Agent 做探索性数据分析
-summary: 挑战式课程——让你的 Agent 处理一份脏的商店导出数据,产出一份经过真值校验的 EDA 摘要(results.json)。目录中第一门非标准库课程;pandas 由 uv 管理。
+summary: 挑战式课程——让你的 Agent 处理一份脏的商店导出数据,产出一份经过真值校验的 EDA 摘要(results.json)。目录中第一门非标准库课程;pandas 版本固定在 requirements.txt。
lang: zh-CN
content_version: 1
status: reviewed
@@ -35,7 +35,9 @@ course_id: course-da-eda
```bash
cd courses/da-eda
-uv sync # 或:pip install -r requirements.txt
+python -m venv .venv # 或:uv venv
+source .venv/bin/activate # Windows:.venv\Scripts\activate
+pip install -r requirements.txt # 或:uv pip install -r requirements.txt
```
`verify.py` 会检查 pandas,缺失时打印这条提示。
@@ -56,9 +58,9 @@ uv sync # 或:pip install -r requirements.txt
认领后获得。
- 挑战:L01–L05 各 10 分;五个全部在 flypython.com 认领后 +50 课程
徽章奖励分。
-- 证据:`python verify.py progress`——L03/L04 由套件客观判卷,
+- 证据:`python verify.py`——L03/L04 由套件客观判卷,
L01/L02/L05 为学习者自报。
-- 提交:每个通过的检查点打印一个确定性认领码,在 flypython.com 上
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码,在 flypython.com 上
记入你的账号。自我报告的证据,绝非证书。
## 本课程不覆盖的内容
diff --git a/courses/da-eda/verify.py b/courses/da-eda/verify.py
index dd7c580..4c6f429 100644
--- a/courses/da-eda/verify.py
+++ b/courses/da-eda/verify.py
@@ -7,8 +7,9 @@
that the failing tests are the intended ones. ``progress`` prints the
checkpoint claim codes for recording on flypython.com.
-Dependencies (pandas) are managed with uv — run ``uv sync`` (or
-``pip install -r requirements.txt``) inside this folder first.
+Dependencies (pandas) are pinned in requirements.txt — install them into a
+virtual environment inside this folder first (``python -m venv .venv``,
+activate it, then ``pip install -r requirements.txt``).
"""
from __future__ import annotations
@@ -71,8 +72,9 @@ def _deps_available():
def _deps_hint():
print(
- "pandas is not installed. Run `uv sync` (or "
- "`pip install -r requirements.txt`) inside this course folder first.",
+ "pandas is not installed. In this course folder, create and activate "
+ "a virtual environment, then run `pip install -r requirements.txt` "
+ "(or `uv pip install -r requirements.txt`).",
file=sys.stderr,
)
@@ -211,21 +213,69 @@ def run_progress(as_json, receipt_out=None, default=False):
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
if not _deps_available():
_deps_hint()
diff --git a/courses/da-report/COURSE.md b/courses/da-report/COURSE.md
index bab9f36..e4ed25b 100644
--- a/courses/da-report/COURSE.md
+++ b/courses/da-report/COURSE.md
@@ -51,7 +51,7 @@ cd courses/da-report
checkpoints claimed.
- Challenges: L01–L05, 10 points each; +50 course-badge bonus on
flypython.com.
-- Evidence: `python verify.py progress` — L03/L04 objective, L01/L02/L05
+- Evidence: `python verify.py` — L03/L04 objective, L01/L02/L05
attested. Self-reported evidence, never a certificate.
## What this course does NOT cover
diff --git a/courses/da-report/COURSE_cn.md b/courses/da-report/COURSE_cn.md
index 28d9ab3..b2b0bd4 100644
--- a/courses/da-report/COURSE_cn.md
+++ b/courses/da-report/COURSE_cn.md
@@ -48,7 +48,7 @@ cd courses/da-report
- 徽章:**分析报告挑战者**(badge id `da-report`)——五个检查点
全部认领。
- 挑战:L01–L05 各 10 分;flypython.com 上 +50 课程徽章奖励分。
-- 证据:`python verify.py progress`——L03/L04 客观判卷,
+- 证据:`python verify.py`——L03/L04 客观判卷,
L01/L02/L05 自报。自我报告的证据,绝非证书。
## 本课程不覆盖的内容
diff --git a/courses/da-report/verify.py b/courses/da-report/verify.py
index ce7ad0c..62fbe35 100644
--- a/courses/da-report/verify.py
+++ b/courses/da-report/verify.py
@@ -180,21 +180,69 @@ def run_progress(as_json, receipt_out=None, default=False):
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
environment = os.environ.copy()
environment["PYTHONPATH"] = str(ROOT / args.implementation)
diff --git a/courses/da-visualization/COURSE.md b/courses/da-visualization/COURSE.md
index 5d98e85..d48fd63 100644
--- a/courses/da-visualization/COURSE.md
+++ b/courses/da-visualization/COURSE.md
@@ -33,7 +33,9 @@ A matplotlib script that produces `out/revenue_by_region.png`,
```bash
cd courses/da-visualization
-uv sync # pandas + matplotlib (or: pip install -r requirements.txt)
+python -m venv .venv # or: uv venv
+source .venv/bin/activate # Windows: .venv\Scripts\activate
+pip install -r requirements.txt # pandas + matplotlib (or: uv pip install -r requirements.txt)
```
## Challenges (checkpoints)
@@ -52,7 +54,7 @@ uv sync # pandas + matplotlib (or: pip install -r requirements.txt)
all five checkpoints claimed.
- Challenges: L01–L05, 10 points each; +50 course-badge bonus on
flypython.com.
-- Evidence: `python verify.py progress` — L03/L04 objective, L01/L02/L05
+- Evidence: `python verify.py` — L03/L04 objective, L01/L02/L05
attested. Self-reported evidence, never a certificate.
## What this course does NOT cover
diff --git a/courses/da-visualization/COURSE_cn.md b/courses/da-visualization/COURSE_cn.md
index 1fafda2..edf3f37 100644
--- a/courses/da-visualization/COURSE_cn.md
+++ b/courses/da-visualization/COURSE_cn.md
@@ -32,7 +32,9 @@ course_id: course-da-visualization
```bash
cd courses/da-visualization
-uv sync # pandas + matplotlib(或 pip install -r requirements.txt)
+python -m venv .venv # 或:uv venv
+source .venv/bin/activate # Windows:.venv\Scripts\activate
+pip install -r requirements.txt # pandas + matplotlib(或 uv pip install -r requirements.txt)
```
## 挑战(检查点)
@@ -50,7 +52,7 @@ uv sync # pandas + matplotlib(或 pip install -r requirements.txt
- 徽章:**可视化挑战者**(badge id `da-visualization`)——五个检查点
全部认领。
- 挑战:L01–L05 各 10 分;flypython.com 上 +50 课程徽章奖励分。
-- 证据:`python verify.py progress`——L03/L04 客观判卷,L01/L02/L05
+- 证据:`python verify.py`——L03/L04 客观判卷,L01/L02/L05
自报。自我报告的证据,绝非证书。
## 本课程不覆盖的内容
diff --git a/courses/da-visualization/verify.py b/courses/da-visualization/verify.py
index 6d6f237..e982b42 100644
--- a/courses/da-visualization/verify.py
+++ b/courses/da-visualization/verify.py
@@ -3,8 +3,8 @@
Objective completion evidence for "Data visualization" (da-visualization).
``progress`` prints checkpoint claim codes for recording on flypython.com.
-Dependencies (pandas, matplotlib) managed with uv — run ``uv sync`` inside
-this folder first.
+Dependencies (pandas, matplotlib) are pinned in requirements.txt — install
+them into a virtual environment inside this folder first.
"""
from __future__ import annotations
@@ -60,8 +60,9 @@ def _deps_available():
def _deps_hint():
print(
- "pandas/matplotlib not installed. Run `uv sync` (or "
- "`pip install -r requirements.txt`) inside this course folder first.",
+ "pandas/matplotlib not installed. In this course folder, create and "
+ "activate a virtual environment, then run `pip install -r "
+ "requirements.txt` (or `uv pip install -r requirements.txt`).",
file=sys.stderr,
)
@@ -200,21 +201,69 @@ def run_progress(as_json, receipt_out=None, default=False):
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
if not _deps_available():
_deps_hint()
diff --git a/courses/hands-on-python-with-claude-code/COURSE.md b/courses/hands-on-python-with-claude-code/COURSE.md
index 756fc75..a1a8c37 100644
--- a/courses/hands-on-python-with-claude-code/COURSE.md
+++ b/courses/hands-on-python-with-claude-code/COURSE.md
@@ -84,7 +84,7 @@ companion repository has guides and playbooks — link, don't improvise.
- Badge: **Reproduce the failure Badge** (badge id `course-claude-code`) - earned by claiming all five checkpoints.
- Challenges: L01-L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` - L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/hands-on-python-with-claude-code/COURSE_cn.md b/courses/hands-on-python-with-claude-code/COURSE_cn.md
index 9a08206..3c8bd04 100644
--- a/courses/hands-on-python-with-claude-code/COURSE_cn.md
+++ b/courses/hands-on-python-with-claude-code/COURSE_cn.md
@@ -75,7 +75,7 @@ Claude Code 安装、提示词技巧、多文件架构、pandas 或真正的 Exc
- 徽章:**复现故障徽章**(徽章 id `course-claude-code`)——认领全部五个检查点后获得。
- 挑战:L01–L05 检查点各 10 分;五项全部在 flypython.com 认领后另加 50 分课程徽章奖励。
- 证据:`python verify.py` —— L03(边界修改)与 L04(验证与审查)由测试套件客观判定;L01/L02/L05 为学习者自报。
-- 提交:每个通过的检查点会打印确定性认领码,在 flypython.com 上记入你的账号。这是自我报告的证据,绝不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码,在 flypython.com 上记入你的账号。这是自我报告的证据,绝不是证书。
## 文件夹结构
diff --git a/courses/hands-on-python-with-claude-code/verify.py b/courses/hands-on-python-with-claude-code/verify.py
index 400e691..d692cdd 100644
--- a/courses/hands-on-python-with-claude-code/verify.py
+++ b/courses/hands-on-python-with-claude-code/verify.py
@@ -187,21 +187,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
environment = os.environ.copy()
diff --git a/courses/hands-on-with-cursor/COURSE.md b/courses/hands-on-with-cursor/COURSE.md
index 935b53a..8864dec 100644
--- a/courses/hands-on-with-cursor/COURSE.md
+++ b/courses/hands-on-with-cursor/COURSE.md
@@ -88,7 +88,7 @@ guides and playbooks — link, don't improvise.
- Badge: **Reproduce with Cursor in the loop Badge** (badge id `course-cursor`) — earned by claiming all five checkpoints.
- Challenges: L01–L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` — L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/hands-on-with-cursor/COURSE_cn.md b/courses/hands-on-with-cursor/COURSE_cn.md
index b06a955..fa09bca 100644
--- a/courses/hands-on-with-cursor/COURSE_cn.md
+++ b/courses/hands-on-with-cursor/COURSE_cn.md
@@ -77,7 +77,7 @@ Cursor 的安装、套餐定价、模型选择、MCP 配置、团队/管理功
- 徽章:**Cursor 协同复现徽章**(badge id `course-cursor`)——认领全部五个检查点获得。
- 挑战:L01–L05 检查点,各 10 分;在 flypython.com 上集齐五个再加 50 分课程徽章奖励。
- 证据:`python verify.py`——L03(有界改动)与 L04(验证与评审)由套件客观把关;L01/L02/L05 为学习者自我报告。
-- 提交:每个通过的检查点打印一个确定性的认领码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
## 文件夹地图
diff --git a/courses/hands-on-with-cursor/verify.py b/courses/hands-on-with-cursor/verify.py
index b653ff6..1b07828 100644
--- a/courses/hands-on-with-cursor/verify.py
+++ b/courses/hands-on-with-cursor/verify.py
@@ -187,21 +187,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
environment = os.environ.copy()
diff --git a/courses/hands-on-with-deepseek-harness/COURSE.md b/courses/hands-on-with-deepseek-harness/COURSE.md
index 7331a39..2ef23b1 100644
--- a/courses/hands-on-with-deepseek-harness/COURSE.md
+++ b/courses/hands-on-with-deepseek-harness/COURSE.md
@@ -93,7 +93,7 @@ link, don't improvise.
- Badge: **Reproduce with DeepSeek Harness in the loop Badge** (badge id `course-deepseek-harness`) — earned by claiming all five checkpoints.
- Challenges: L01–L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` — L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/hands-on-with-deepseek-harness/COURSE_cn.md b/courses/hands-on-with-deepseek-harness/COURSE_cn.md
index 7de7a92..2d05251 100644
--- a/courses/hands-on-with-deepseek-harness/COURSE_cn.md
+++ b/courses/hands-on-with-deepseek-harness/COURSE_cn.md
@@ -81,7 +81,7 @@ course_id: course-deepseek-harness
- 徽章:**DeepSeek Harness 协同复现徽章**(badge id `course-deepseek-harness`)——认领全部五个检查点获得。
- 挑战:L01–L05 检查点,各 10 分;在 flypython.com 上集齐五个再加 50 分课程徽章奖励。
- 证据:`python verify.py`——L03(有界改动)与 L04(验证与评审)由套件客观把关;L01/L02/L05 为学习者自我报告。
-- 提交:每个通过的检查点打印一个确定性的认领码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
## 文件夹地图
diff --git a/courses/hands-on-with-deepseek-harness/verify.py b/courses/hands-on-with-deepseek-harness/verify.py
index 3e71d12..4b7d970 100644
--- a/courses/hands-on-with-deepseek-harness/verify.py
+++ b/courses/hands-on-with-deepseek-harness/verify.py
@@ -187,21 +187,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
environment = os.environ.copy()
diff --git a/courses/hands-on-with-github-copilot/COURSE.md b/courses/hands-on-with-github-copilot/COURSE.md
new file mode 100644
index 0000000..053134e
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/COURSE.md
@@ -0,0 +1,125 @@
+---
+id: course-github-copilot
+type: course
+title: Hands-on with GitHub Copilot
+summary: The verified Python workflow — task contract, bounded change, objective verify.py evidence — taught hands-on with GitHub Copilot in VS Code, where the Agent role runs the loop and you supervise permissions, instructions files, and diffs.
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+badge:
+ id: course-github-copilot
+ name_en: Reproduce with Copilot in the loop
+ name_zh: Copilot 协同复现
+ requires: All five checkpoints claimed (L01–L05)
+course_id: course-github-copilot
+---
+
+# Hands-on with GitHub Copilot
+
+> TL;DR: open VS Code with GitHub Copilot signed in, optionally install the
+> FlyPython Skill, and let the agent fetch this course — lesson 1 walks you
+> through all three steps and you download nothing. Open the Chat view,
+> pick the **Agent** role, and say **"start lesson 1"**. You finish with a
+> working report tool, a reproducible pass/fail command, and the Copilot
+> workflow: Agent/Plan/Ask roles, manual permissions, `.github` instructions
+> files, checkpoints, and diff review. The exercise core is the same one the
+> Claude Code, Codex, and Cursor courses use — only the tool you drive
+> changes.
+
+## What you build
+
+A small Python report tool that reads messy real-world data (CSV or JSON),
+isolates invalid rows instead of crashing, aggregates valid rows, and writes
+its report atomically. Three scenario "skins" ship with the course so you can
+practice on a domain you recognize:
+
+| Skin | You are… | Data |
+| --- | --- | --- |
+| `scenario/excel-report/` | turning a weekly orders export into a region summary | `orders.csv` |
+| `scenario/data-monitor/` | checking service latency numbers from your servers | `metrics.csv` |
+| `scenario/api-tool/` | summarizing ticket data pulled from an internal API | `tickets.json` |
+
+All three run through the same code and the same tests. The skills —
+contracts, error isolation, rounding, atomic writes, verification — are the
+actual product; the skins just make them concrete.
+
+## Teaching contract (read this first, agent)
+
+If you are GitHub Copilot teaching this course, follow these rules:
+
+- **Audience:** a project owner who already has Copilot in their editor —
+ often through work — and uses it for completions or chat, but has not
+ yet trusted the agent to change a project end to end. Not a Python
+ beginner tutorial; not a prompt-engineering course.
+- **Prerequisites:** Python 3.11+ on PATH, VS Code installed, and a GitHub
+ account signed in to GitHub Copilot in VS Code. No packages to install —
+ the course is standard library only. Whether the Copilot Free plan's
+ monthly chat request allowance lasts the whole course is unverified at
+ authoring time — see `REVIEW.md`.
+- **Tool:** authored against VS Code 1.134 with GitHub Copilot, as
+ documented at code.visualstudio.com/docs/copilot (fetched 2026-10-11):
+ the Chat view with the Copilot session target, the Agent/Plan/Ask roles,
+ the Manual permissions / Allow all permission levels, Folder vs. New
+ Worktree code isolation, `.github/copilot-instructions.md` and
+ `.github/instructions/*.instructions.md` (`applyTo` front matter),
+ Agent Skills in `.github/skills/`, and checkpoints. A live teaching
+ run-through is pending — `REVIEW.md` records what has and has not been
+ exercised. A VS Code or Copilot major change to these surfaces triggers a
+ course re-review.
+- **Lesson order:** L01 → L02 → L03 → L04 → L05, one lesson per session.
+ Never skip the checkpoint at the end of a lesson.
+- **Teaching style:** work from the files in this folder, not from general
+ knowledge. Quote the exact contract line you are satisfying. Make the
+ smallest change that moves a test from red to green. Never add
+ dependencies, never edit `solution/`, and ask before touching any file
+ not named in the current lesson. Keep the learner on **Manual
+ permissions** — every edit and command waits for their approval. Read
+ `AGENTS.md` in the repository root if one exists — Copilot reads it too.
+- **When to stop:** a lesson is done when its checkpoint command runs and
+ the learner can say, in their own words, what failed and why. If the
+ learner cannot, re-teach from the failing test, not from the solution.
+- **How to use `verify.py`:** the learner's command is plain
+ `python verify.py` — it runs the suites, prints per-checkpoint status
+ and earned claim codes, and exits non-zero until every checkpoint is passed (l03, l04) or
+ attested (l01, l02, l05). `starter --expect-failure` and `solution`
+ remain maintainer checks. "Done" means the default command shows every
+ gate `[passed]` after the learner's bounded change.
+- **Honesty rules:** say what you did not verify. Do not claim the code is
+ production-ready. Do not promise learning or career outcomes.
+
+## What this course does NOT cover
+
+Copilot plans and pricing, model selection, inline completions, the Copilot
+cloud agent on GitHub.com, Copilot code review on pull requests, MCP setup,
+organization policies, or selling anything. For those, the companion
+repository has guides and playbooks — link, don't improvise.
+
+## Badge contract
+
+- Badge: **Reproduce with Copilot in the loop Badge** (badge id `course-github-copilot`) — earned by claiming all five checkpoints.
+- Challenges: L01–L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
+- Evidence: `python verify.py` — L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+
+## Folder map
+
+```
+COURSE.md / COURSE_cn.md this file (EN / 中文)
+lessons/L01.md … L05.md lessons (each has an _cn.md pair)
+scenario// data files and scenario.json per skin
+TASK.md / TASK_cn.md the task contract the change must satisfy
+starter/report_tool.py the deliberately unfinished implementation
+solution/report_tool.py the reviewed solution (do not copy in lesson 3)
+tests/test_report_tool.py the contract suite (read-only)
+verify.py objective pass/fail evidence
+REVIEW.md maintainer run-through record
+```
+
+## Evidence and licensing
+
+The course folder is reviewed content: `REVIEW.md` records the last
+run-through with dates, tool versions, and observed deviations. Code in this
+folder is MIT-licensed; lesson prose is CC BY 4.0 (see repository `LICENSE`).
+Report teaching drift or unclear lessons via the repository's
+`course-feedback` issue form.
diff --git a/courses/hands-on-with-github-copilot/COURSE_cn.md b/courses/hands-on-with-github-copilot/COURSE_cn.md
new file mode 100644
index 0000000..1b4e44e
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/COURSE_cn.md
@@ -0,0 +1,111 @@
+---
+id: course-github-copilot
+type: course
+title: "上手 GitHub Copilot"
+summary: "经过验证的 Python 工作流——任务契约、有界改动、客观 verify.py 证据——在 VS Code 的 GitHub Copilot 里手把手教:由 Agent 角色跑闭环,你监督权限、指令文件与 diff。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+badge:
+ id: course-github-copilot
+ name_en: Reproduce with Copilot in the loop
+ name_zh: Copilot 协同复现
+ requires: 认领全部五个检查点(L01–L05)
+course_id: course-github-copilot
+---
+
+# 上手 GitHub Copilot
+
+> 一句话:打开已登录 GitHub Copilot 的 VS Code、(可选)装上 FlyPython
+> Skill、让 Agent 取回本课文件(第 1 课就是这三步,你什么都不用下载),
+> 打开 Chat 视图、选 **Agent** 角色,对它说**「开始第 1 课」**。完成时你会
+> 得到一个能跑的报表工具、一条可复现的通过/失败命令,以及 Copilot 工作流:
+> Agent/Plan/Ask 角色、手动权限、`.github` 指令文件、检查点与 diff 审查。
+> 练习核心与 Claude Code、Codex、Cursor 课程完全相同——变的只是你驱动的
+> 工具。
+
+## 你要做的东西
+
+一个小型 Python 报表工具:读进脏乱的真实数据(CSV 或 JSON)、隔离
+无效行而不是直接崩溃、聚合有效行、原子地写出报告。课程自带三个
+「场景皮肤」,让你在自己熟悉的领域里练习:
+
+| 皮肤 | 你是…… | 数据 |
+| --- | --- | --- |
+| `scenario/excel-report/` | 把每周订单导出整理成区域汇总 | `orders.csv` |
+| `scenario/data-monitor/` | 检查自家服务器的延迟数据 | `metrics.csv` |
+| `scenario/api-tool/` | 汇总从内部 API 拉取的工单数据 | `tickets.json` |
+
+三个皮肤跑的是同一份代码、同一套测试。真正的产品是这些技能——契约、
+错误隔离、舍入、原子写、验证——皮肤只是让它们变得具体。
+
+## 教学契约(先读,Agent)
+
+如果你是教授本课程的 GitHub Copilot,请遵守以下规则:
+
+- **受众:** 编辑器里已经有 Copilot(常常是公司配的)、用它补全或聊天,
+ 但还没放心让 Agent 端到端改一个项目的项目所有者。这不是 Python 入门
+ 教程,也不是提示词工程课。
+- **前置条件:** PATH 上有 Python 3.11+,装有 VS Code,并用 GitHub 账号
+ 在 VS Code 里登录了 GitHub Copilot。无需安装任何包——课程只用标准库。
+ Copilot Free 套餐每月的对话请求额度能否撑完整门课在编写时未经证实——
+ 见 `REVIEW.md`。
+- **工具:** 按 code.visualstudio.com/docs/copilot 上文档描述的 VS Code
+ 1.134 + GitHub Copilot 编写(2026-10-11 抓取):选 Copilot 会话目标的
+ Chat 视图、Agent/Plan/Ask 角色、Manual permissions / Allow all 两档
+ 权限、Folder 与 New Worktree 两种代码隔离、`.github/copilot-instructions.md`
+ 与 `.github/instructions/*.instructions.md`(`applyTo` front matter)、
+ `.github/skills/` 下的 Agent Skills,以及检查点。真实授课实跑尚待
+ 完成——`REVIEW.md` 记录已验证与未验证的部分。VS Code 或 Copilot 在
+ 这些界面上的重大变更会触发课程重审。
+- **课序:** L01 → L02 → L03 → L04 → L05,一次课一个会话。
+ 绝不跳过课尾的检查点。
+- **教学风格:** 从本文件夹里的文件出发,不要凭通用知识发挥。引用
+ 你正在满足的那条契约原文。做让测试从红变绿的最小改动。绝不加
+ 依赖、绝不改 `solution/`、动当前课程未点名的任何文件前先询问。
+ 让学习者保持 **Manual permissions**——每处编辑、每条命令都等他批准。
+ 仓库根若有 `AGENTS.md`,读它——Copilot 同样会读。
+- **何时停下:** 一节课的检查点命令跑通、且学习者能用自己的话说清
+ 哪里坏了、为什么,这节课才算完。学习者说不出就从失败测试重新教,
+ 不要从 solution 教。
+- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它跑套件、
+ 打印逐检查点状态与已获认领码,直到每个检查点都通过(l03、l04)或
+ 已自报确认(l01、l02、l05)之前都以非零退出。`starter --expect-failure` 与 `solution` 留作维护者检查。
+ 「完成」指学习者做完有界改动后,默认命令把所有门显示为 `[passed]`。
+- **诚实规则:** 说清你没验证什么。不要声称代码达到生产可用。不要
+ 承诺学习或职业结果。
+
+## 本课程不覆盖什么
+
+Copilot 套餐与定价、模型选择、行内补全、GitHub.com 上的 Copilot 云端
+Agent、拉取请求上的 Copilot 代码评审、MCP 配置、组织策略,以及任何
+推销内容。这些由配套仓库的指南与手册承载——给链接,不即兴发挥。
+
+## 徽章契约
+
+- 徽章:**Copilot 协同复现徽章**(badge id `course-github-copilot`)——认领全部五个检查点获得。
+- 挑战:L01–L05 检查点,各 10 分;在 flypython.com 上集齐五个再加 50 分课程徽章奖励。
+- 证据:`python verify.py`——L03(有界改动)与 L04(验证与评审)由套件客观把关;L01/L02/L05 为学习者自我报告。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
+
+## 文件夹地图
+
+```
+COURSE.md / COURSE_cn.md 本文件(EN / 中文)
+lessons/L01.md … L05.md 课文(每课都有一个 _cn.md 对)
+scenario// 每个皮肤的数据文件与 scenario.json
+TASK.md / TASK_cn.md 改动必须满足的任务契约
+starter/report_tool.py 有意未完成的实现
+solution/report_tool.py 评审过的解答(第 3 课不要抄)
+tests/test_report_tool.py 契约套件(只读)
+verify.py 客观的通过/失败证据
+REVIEW.md 维护者实跑记录
+```
+
+## 证据与许可
+
+本课程文件夹属于已评审内容:`REVIEW.md` 记录最近一次实跑——日期、
+工具版本、观察到的偏差。文件夹内代码采用 MIT 许可;课文文字采用
+CC BY 4.0(见仓库 `LICENSE`)。发现教学漂移或课程不清楚之处,请
+通过仓库的 `course-feedback` issue 表单反馈。
diff --git a/courses/hands-on-with-github-copilot/REVIEW.md b/courses/hands-on-with-github-copilot/REVIEW.md
new file mode 100644
index 0000000..aa7ddae
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/REVIEW.md
@@ -0,0 +1,59 @@
+# Maintainer run-through record
+
+## 2026-10-11 — authored from official documentation (mechanical verification only)
+
+- Course authored against the VS Code GitHub Copilot documentation at
+ code.visualstudio.com/docs/copilot, fetched 2026-10-11 (pages: local
+ agents and session controls, custom instructions, review and revert
+ agent changes, Agent Skills), and the shared `report-tool` core.
+ `TASK.md`, `tests/`, `scenario/`, `starter/`, `solution/` are
+ byte-identical to the Claude Code / Codex / Cursor course cores —
+ enforced by the `core-group` check in `tools/verify_courses.py`.
+- Version reference: VS Code 1.134 with the Copilot harness on the Agent
+ Host, as the documentation describes it on that date. No VS Code build
+ was installed for this record.
+- Documented behaviors the lessons depend on (sourced, not observed):
+ - Chat input controls: Session Target (Copilot), Agent role
+ (Agent / Plan / Ask), Permissions (Manual permissions / Allow all),
+ Code isolation (Folder / New Worktree; worktree sessions are always
+ Allow all; the isolation choice exists only in the Agents window).
+ - In Agent Host sessions edits are saved directly with no pending
+ Keep/Undo state; review happens in the diff view and Source Control;
+ Restore Checkpoint restores files and chat history but does not
+ reverse completed terminal commands.
+ - `chat.checkpoints.showFileChanges` and the
+ `chat.tools.edits.autoApprove` glob setting for sensitive-file
+ approval.
+ - `.github/copilot-instructions.md` / `AGENTS.md` as always-on project
+ instructions; `.github/instructions/*.instructions.md` attached by
+ `applyTo` when the agent creates or modifies a matching file;
+ References in the response show which instructions were used.
+ - Agent Skills discovered from `.github/skills//SKILL.md`, where
+ `` must equal the skill's `name` field. Lesson 1 therefore
+ installs the live FlyPython Skill (name `flypython-challenges`) at
+ `.github/skills/flypython-challenges/SKILL.md`.
+- Commands and results (Linux, Python 3.12):
+ - `python verify.py starter --expect-failure` — exit 0; the seven
+ expected failures reproduced.
+ - `python verify.py solution` — exit 0; 9/9 tests pass.
+ - `python verify.py` — exit 1 on the shipped starter; l03/l04 `[open]`,
+ l01/l02/l05 `[pending]`.
+ - `python tools/verify_courses.py` — contract satisfied, core identical
+ with the other `report-tool` members.
+- **Not done:** any run inside VS Code with Copilot. No Copilot account was
+ used; every Copilot behavior described in the lessons is sourced from
+ official documentation, not observed.
+
+## Pending before this course can be called taught-in-tool
+
+- One full agent-taught run-through with GitHub Copilot in VS Code
+ ("start lesson 1" through the Lesson 5 checkpoint) on a current build,
+ recording observed deviations from `COURSE.md` here — in particular
+ whether Manual permissions prompts before the Skill URL fetch, and
+ whether the `applyTo` file shows up in References as Lesson 2 expects.
+- Whether the Copilot Free plan's monthly chat request allowance lasts the
+ whole course is unverified.
+
+## Deviation log
+
+(none yet — no live run has happened)
diff --git a/courses/hands-on-with-github-copilot/TASK.md b/courses/hands-on-with-github-copilot/TASK.md
new file mode 100644
index 0000000..e923183
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/TASK.md
@@ -0,0 +1,37 @@
+# Task contract: scenario report tool (hands-on course core)
+
+Change only `starter/report_tool.py`. Standard library only; no new
+dependencies.
+
+- `load_records(path) -> list[dict]`:
+ - `.csv` files load through `csv.DictReader`.
+ - `.json` files load as a list of objects; a non-list or non-object payload
+ raises `ValueError`.
+ - Any other suffix raises `ValueError` naming the unsupported file.
+- `build_report(records, *, required_fields, numeric_field, group_field) -> dict`:
+ - Returns `{"total", "valid", "invalid", "groups", "errors"}`.
+ - Invalid rows never abort the run: each is collected in `errors` as
+ `{"index": , "reason": }`. Invalid means:
+ not a dict, missing or blank required field, or a non-numeric
+ `numeric_field` value (booleans do not count as numbers).
+ - Valid rows aggregate into `groups[group_value] = {"count": int,
+ "total": float}`; each group total is rounded to two decimals.
+ - `total = valid + invalid` must hold for every input.
+- `write_report(report, destination)`:
+ - Writes JSON (UTF-8, indent 2, trailing newline) atomically: write a
+ sibling temp file, then `os.replace`.
+ - Creates missing parent directories.
+ - Never leaves a `.tmp` file behind on success.
+- `run_scenario(scenario_dir) -> dict`:
+ - Reads `scenario.json` (`data_file`, `required_fields`, `numeric_field`,
+ `group_field`, `report_file`), processes the data file, writes the
+ report inside the scenario directory, and returns the report.
+- `main(argv=None) -> int`:
+ - Exactly one argument (the scenario directory). Otherwise print usage to
+ stderr and return 2.
+ - On success print `total=... valid=... invalid=...` to stdout and return 0.
+ - On input failure print `error: ...` to stderr and return 1.
+
+Done means `python verify.py starter` exits 0 with all nine tests passing,
+and `python verify.py starter --expect-failure` exits nonzero because the
+starter no longer reproduces the unfinished state.
diff --git a/courses/hands-on-with-github-copilot/TASK_cn.md b/courses/hands-on-with-github-copilot/TASK_cn.md
new file mode 100644
index 0000000..7af2609
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/TASK_cn.md
@@ -0,0 +1,34 @@
+# 任务契约:场景报表工具(实战课程核心)
+
+只修改 `starter/report_tool.py`。仅用标准库;不新增依赖。
+
+- `load_records(path) -> list[dict]`:
+ - `.csv` 文件经 `csv.DictReader` 加载。
+ - `.json` 文件按对象列表加载;不是列表或元素不是对象时抛出
+ `ValueError`。
+ - 其他后缀抛出 `ValueError`,并指明不支持的文件名。
+- `build_report(records, *, required_fields, numeric_field, group_field) -> dict`:
+ - 返回 `{"total", "valid", "invalid", "groups", "errors"}`。
+ - 无效行绝不中止运行:每行以 `{"index": <行位置>, "reason": <简短
+ 说明>}` 收集进 `errors`。无效指:不是字典、必填字段缺失或为空、
+ `numeric_field` 不是数字(布尔值不算数字)。
+ - 有效行聚合为 `groups[分组值] = {"count": int, "total": float}`;每个
+ 分组总计保留两位小数。
+ - 任何输入下都必须满足 `total = valid + invalid`。
+- `write_report(report, destination)`:
+ - 原子地写入 JSON(UTF-8、缩进 2、末尾换行):先写同名临时文件,再
+ `os.replace`。
+ - 自动创建缺失的父目录。
+ - 成功后不留任何 `.tmp` 文件。
+- `run_scenario(scenario_dir) -> dict`:
+ - 读取 `scenario.json`(`data_file`、`required_fields`、
+ `numeric_field`、`group_field`、`report_file`),处理数据文件,把
+ 报告写进场景目录,并返回报告。
+- `main(argv=None) -> int`:
+ - 恰好一个参数(场景目录)。否则向 stderr 打印用法并返回 2。
+ - 成功时向 stdout 打印 `total=... valid=... invalid=...` 并返回 0。
+ - 输入失败时向 stderr 打印 `error: ...` 并返回 1。
+
+完成的标准是 `python verify.py starter` 以 0 退出且九个测试全部通过,
+同时 `python verify.py starter --expect-failure` 非零退出——因为
+starter 已不再复现未完成状态。
diff --git a/courses/hands-on-with-github-copilot/core-group b/courses/hands-on-with-github-copilot/core-group
new file mode 100644
index 0000000..b56b0eb
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/core-group
@@ -0,0 +1 @@
+report-tool
diff --git a/courses/hands-on-with-github-copilot/lessons/L01.md b/courses/hands-on-with-github-copilot/lessons/L01.md
new file mode 100644
index 0000000..2f9d6f3
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L01.md
@@ -0,0 +1,166 @@
+---
+id: course-github-copilot-l01
+type: course
+title: "Lesson 1: Set up Copilot in VS Code, install the FlyPython Skill, fetch the course"
+summary: "Get GitHub Copilot running in VS Code with the Agent role and Manual permissions, optionally install the FlyPython Skill, and let the agent fetch this course's files — you download nothing. Then make the starter's failure objective."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That you can reproduce the failure before fixing it: `python verify.py` must show l03 and l04 as `[open]` (`starter --expect-failure` prints the named tests). In Copilot, the Agent role does the work — the command is still the evidence."
+ - title: "Ask, Plan, or Agent"
+ body: "Ask answers questions without changing code; Plan researches and writes a plan before any change; Agent edits files and runs commands. This lesson only needs reading — tell the agent not to change anything, or use Ask for the survey."
+ - title: "When you are done"
+ body: "You can say which nine behaviors the suite proves and why the starter cannot fake them. `python verify.py` then shows this checkpoint's claim code after you attest it."
+---
+
+# Lesson 1: Set up Copilot in VS Code, install the FlyPython Skill, fetch the course
+
+## Objective
+
+By the end of this lesson GitHub Copilot is running in VS Code with the
+session controls set for supervised work, the FlyPython Skill is available
+to it, and the agent has fetched this course's files into your working
+folder — you downloaded nothing by hand. With the folder in place you run
+the course's objective check and explain what "the starter fails" means in
+test names. No code is changed in this lesson.
+
+## Step 1 — Get Copilot running in VS Code
+
+Install VS Code, sign in to GitHub Copilot when VS Code prompts you, then
+`File → Open Folder` on an empty working folder. Open the **Chat** view and
+set the controls in the chat input:
+
+| Control | Choose | Why |
+| --- | --- | --- |
+| Session Target | **Copilot** | the day-to-day Copilot harness on your machine |
+| Agent | **Agent** | it can read files and run commands |
+| Permissions | **Manual permissions** | commands and tool calls wait for your approval |
+
+If the Agents window offers a **Code isolation** choice, pick **Folder**:
+a New Worktree session is always Allow all, which removes the approval
+prompts this course relies on.
+
+## Step 2 — (Optional) Pre-install the FlyPython Skill
+
+Optional: Step 3's entry sentence reads the Skill straight from its URL,
+so the course works without this step — pre-installing just saves a
+permission round-trip on later sessions.
+
+VS Code discovers Agent Skills in `.github/skills//SKILL.md`, and the
+folder name must match the `name` field inside the file or the skill does
+not load. The FlyPython Skill is named `flypython-challenges`:
+
+```bash
+mkdir -p .github/skills/flypython-challenges
+curl -s https://flypython.com/skills/flypython/SKILL.md -o .github/skills/flypython-challenges/SKILL.md
+```
+
+If your setup blocks the network, download the file in a browser and save
+it to the same path. The Skill is one file, the same in every tool: it
+tells the agent how to authorize you, fetch course files, verify, and
+submit claim codes.
+
+## Step 3 — Let the agent fetch this course (you download nothing)
+
+Start a new chat session in the working folder and paste this one
+sentence:
+
+> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-with-github-copilot`.
+
+Copilot asks before fetching the URL and before each command — read each
+request, then approve it. The agent gives you an authorization link with a
+short code. Open it, log in, make sure the code matches what the agent
+showed you, and click Allow — then tell the agent "done". It takes a token
+once and writes this course's files to
+`courses/hands-on-with-github-copilot/`.
+
+**Prerequisite:** these courses need a coding agent that can run commands
+*and* reach the network. A chat-only web AI cannot do them.
+
+## Why this lesson exists
+
+Most AI-written Python dies the same way: the demo input works, the real
+input arrives, and the script crashes on row 3 — or worse, quietly produces
+a wrong number. The fix does not start with a better prompt. It starts with
+turning "it's broken" into a command that anyone can run and get the same
+answer. With Manual permissions, Copilot runs that command only after you
+have read it.
+
+## Warm-up (2 minutes)
+
+Open `starter/report_tool.py` in the editor and read it top to bottom. It
+looks reasonable: functions with docstrings, type hints, a CLI. This is
+exactly what AI-generated code looks like — clean surface, missing
+behavior. Now open `TASK.md` and keep it next to you.
+
+## The lesson
+
+The course files are in `courses/hands-on-with-github-copilot/` now — no
+download happened on your side. Ask the agent to survey them without
+changing anything:
+
+**"Read COURSE.md — it is the teaching contract for this folder. Then read
+TASK.md and starter/report_tool.py. Do not change any file. Tell me which
+behaviors the tests expect that the starter does not have."**
+
+When it asks to run the learner's command, approve it — this is your
+single check for the rest of the course:
+
+```bash
+python verify.py
+```
+
+You should see the starter reproduce five real-world failure classes
+plus two end-to-end consequences — seven named failing tests in all —
+mapped to test names:
+
+| Failing test | Real-world meaning |
+| --- | --- |
+| `test_load_json_records_returns_list_of_dicts` | the tool only handles CSV; the API scenario crashes |
+| `test_unsupported_suffix_raises_value_error` | an `.xlsx` upload becomes a confusing crash instead of a clear rejection |
+| `test_invalid_records_are_isolated_with_reasons` | one empty cell aborts the entire run — no partial report, no reason |
+| `test_group_totals_are_rounded_to_two_decimals` | `0.1 + 0.2` prints as `0.30000000000000004` in your report |
+| `test_write_report_creates_missing_parent_directories` | the tool cannot write into a fresh output directory |
+| `test_run_scenario_writes_report_file`, `test_main_prints_summary_and_returns_zero` | end-to-end consequences of the above |
+
+If you want the missing behaviors as exact failing test names, the
+maintainer command prints them:
+
+```bash
+python verify.py starter --expect-failure
+```
+
+(The reference `solution/` exists so maintainers can prove the target is
+reachable. You never need to run it, and it is never the completion
+standard — your implementation in `starter/` is.)
+
+## Exercise
+
+Pick the scenario skin you most identify with (`excel-report`,
+`data-monitor`, or `api-tool`), open its data file, and find the rows that
+will be rejected. Write down, before running anything, which test each bad
+row will trigger. Then check yourself against the table above — and ask the
+agent to confirm, not to fix.
+
+## Checkpoint
+
+Run `python verify.py` in the course folder. You pass this lesson when you
+can answer, without looking:
+
+1. Which five behaviors separate starter from solution, and which two
+ failing tests are their end-to-end consequences?
+2. Why does `python verify.py` exit non-zero on purpose — what state is
+ it reporting, and why is that a success condition rather than an error?
+3. What does the row you found in the exercise do to the starter — crash,
+ isolate, or silently pass?
+
+Once you can answer, claim the checkpoint with
+`python verify.py --attest l01`.
+
+## Expected evidence
+
+The transcript of the default command, and your three answers. Keep them;
+Lesson 4 will ask for them again.
diff --git a/courses/hands-on-with-github-copilot/lessons/L01_cn.md b/courses/hands-on-with-github-copilot/lessons/L01_cn.md
new file mode 100644
index 0000000..6b8e23a
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L01_cn.md
@@ -0,0 +1,148 @@
+---
+id: course-github-copilot-l01
+type: course
+title: "第 1 课:在 VS Code 里装好 Copilot、装上 FlyPython Skill、让 Agent 取回课程"
+summary: "让 GitHub Copilot 在 VS Code 里以 Agent 角色、Manual permissions 跑起来,(可选)装好 FlyPython Skill,让 Agent 取回本课文件——你不下载任何东西,然后让 starter 的失败变得客观。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "先复现失败再动手修:`python verify.py` 必须把 l03 和 l04 显示为 `[open]`(`starter --expect-failure` 会打印点名测试)。在 Copilot 里,Agent 角色负责干活——但命令仍然是证据。"
+ - title: "Ask、Plan 还是 Agent"
+ body: "Ask 只回答问题、不改代码;Plan 先调研并写计划,不做任何改动;Agent 会改文件、跑命令。本课只需要读——明确告诉 Agent 不要改任何文件,或者用 Ask 做这次通读。"
+ - title: "完成的标志"
+ body: "你能说出套件证明了哪九种行为、为什么 starter 伪造不出来。自报本检查点后,`python verify.py` 会显示它的认领码。"
+---
+
+# 第 1 课:在 VS Code 里装好 Copilot、装上 FlyPython Skill、让 Agent 取回课程
+
+## 目标
+
+本课结束时,GitHub Copilot 已在 VS Code 里跑起来,会话控件已设成适合
+受监督工作的状态,FlyPython Skill 可供它使用,Agent 已把本课文件取回你
+的工作目录——你没有手动下载任何东西。文件夹就位后,你运行课程的客观
+检查命令,并解释「starter 失败」在测试名里的含义。本课不改任何代码。
+
+## 第 1 步——在 VS Code 里把 Copilot 跑起来
+
+安装 VS Code,按 VS Code 的提示登录 GitHub Copilot,然后
+`File → Open Folder` 打开一个空工作目录。打开 **Chat** 视图,在对话
+输入框里设置这些控件:
+
+| 控件 | 选择 | 原因 |
+| --- | --- | --- |
+| Session Target | **Copilot** | 在你本机运行的日常 Copilot harness |
+| Agent | **Agent** | 它能读文件、跑命令 |
+| Permissions | **Manual permissions** | 命令与工具调用都等你批准 |
+
+如果 Agents 窗口给出 **Code isolation** 选项,选 **Folder**:New Worktree
+会话固定是 Allow all,会去掉本课程依赖的批准提示。
+
+## 第 2 步——(可选)预装 FlyPython Skill
+
+这一步可选:第 3 步的开课句子会直接从 URL 读取 Skill,不装也能上课——
+预装只是在之后的会话里省掉一次权限往返。
+
+VS Code 从 `.github/skills//SKILL.md` 发现 Agent Skills,而且文件夹名
+必须与文件里的 `name` 字段一致,否则 skill 不会加载。FlyPython Skill 的
+名字是 `flypython-challenges`:
+
+```bash
+mkdir -p .github/skills/flypython-challenges
+curl -s https://flypython.com/skills/flypython/SKILL.md -o .github/skills/flypython-challenges/SKILL.md
+```
+
+如果你的环境禁网,就用浏览器下载这个文件,存到同一路径。Skill 只是一个
+文件,在每个工具里都一样:它告诉 Agent 如何为你授权、取课程文件、验证、
+提交认领码。
+
+## 第 3 步——让 Agent 取回本课文件(你不下载)
+
+在工作目录里新开一个对话会话,粘贴这一句话:
+
+> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `hands-on-with-github-copilot`.
+
+Copilot 抓取 URL 前、每条命令运行前都会询问——读清每个请求再批准。
+Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 Agent
+显示的一致后点「允许」,然后对 Agent 说「好了」。它取一次 token,把本课
+文件写到 `courses/hands-on-with-github-copilot/`。
+
+**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。
+只能聊天的网页 AI 做不了。
+
+## 为什么有这一课
+
+AI 写的 Python 大多是同一种死法:演示输入能跑,真实输入一到,脚本
+在第 3 行崩掉——或者更糟,悄悄算出一个错的数。修复不从更好的
+提示词开始,而从把「坏了」变成一条任何人都能跑、且得到相同答案的
+命令开始。在 Manual permissions 下,Copilot 只在你读过这条命令之后
+才运行它。
+
+## 热身(2 分钟)
+
+在编辑器里打开 `starter/report_tool.py`,从头读到尾。它看起来很
+合理:带 docstring 的函数、类型标注、CLI。这正是 AI 生成代码的
+样子——表面干净、行为缺失。现在打开 `TASK.md`,放在手边。
+
+## 本课内容
+
+本课文件现在已经位于 `courses/hands-on-with-github-copilot/`——你这边
+没有发生任何下载。让 Agent 先只读不动手:
+
+**“读 COURSE.md——它是本文件夹的教学契约。再读 TASK.md 和
+starter/report_tool.py。不要改任何文件。告诉我测试期望哪些行为、
+starter 缺了哪些。”**
+
+它请求运行学员命令时批准——整个课程你只需要这一条检查命令:
+
+```bash
+python verify.py
+```
+
+你会看到 starter 复现五类真实世界故障,外加两个端到端后果——共七个
+具名失败测试——并与测试名一一对应:
+
+| 失败的测试 | 真实含义 |
+| --- | --- |
+| `test_load_json_records_returns_list_of_dicts` | 工具只处理 CSV;API 场景直接崩 |
+| `test_unsupported_suffix_raises_value_error` | 一个 `.xlsx` 上传变成莫名其妙的崩溃,而不是清楚的拒绝 |
+| `test_invalid_records_are_isolated_with_reasons` | 一个空单元格中止整个运行——没有部分报告,没有原因 |
+| `test_group_totals_are_rounded_to_two_decimals` | `0.1 + 0.2` 在报告里打印成 `0.30000000000000004` |
+| `test_write_report_creates_missing_parent_directories` | 工具无法写进新建的输出目录 |
+| `test_run_scenario_writes_report_file`、`test_main_prints_summary_and_returns_zero` | 上述问题的端到端后果 |
+
+想看缺失行为对应的失败测试名,维护者命令会打印它们:
+
+```bash
+python verify.py starter --expect-failure
+```
+
+(参考答案 `solution/` 是给维护者证明目标可达用的。你不需要运行
+它,它也从来不是完成标准——你的实现(`starter/`)才是。)
+
+## 练习
+
+挑一个你最有代入感的场景皮肤(`excel-report`、`data-monitor` 或
+`api-tool`),打开它的数据文件,找出会被拒绝的行。先不看运行
+结果,写下每个坏行会触发哪个测试,再对照上面的表自查——并让
+Agent 确认,而不是让它修。
+
+## 检查点
+
+在课程文件夹里运行 `python verify.py`。不看材料能回答下面三个
+问题即算通过:
+
+1. 哪五个行为把 starter 和 solution 区分开?其中哪两个失败测试是它们的端到端后果?
+2. `python verify.py` 为什么故意以非零退出——它在报告什么状态?
+ 为什么这是成功条件而不是报错?
+3. 你在练习里找到的那一行会让 starter 崩溃、被隔离,还是悄悄
+ 通过?
+
+能回答之后,用 `python verify.py --attest l01` 认领本检查点。
+
+## 预期证据
+
+默认命令的完整输出记录,加你的三个答案。保留好——第 4 课还会
+用到。
diff --git a/courses/hands-on-with-github-copilot/lessons/L02.md b/courses/hands-on-with-github-copilot/lessons/L02.md
new file mode 100644
index 0000000..a2caafe
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L02.md
@@ -0,0 +1,104 @@
+---
+id: course-github-copilot-l02
+type: course
+title: "Lesson 2: Write the task contract, let .github instructions carry the rules"
+summary: 'Turn "make it better" into a bounded, testable contract — then make it durable with `.github/copilot-instructions.md` and an `applyTo` instructions file, so every Copilot session starts with the same rules.'
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That you can read `TASK.md` as testable statements, not prose. Every sentence that starts with a function name is a contract line the suite can assert."
+ - title: "Two kinds of instructions"
+ body: "`.github/copilot-instructions.md` (or `AGENTS.md`) is always on for the workspace. A `.github/instructions/*.instructions.md` file is attached when its `applyTo` glob matches a file the agent creates or modifies, or loaded on demand when its `description` matches the task."
+ - title: "The testability check"
+ body: "If you cannot tell whether a statement is testable, ask: could a suite assert it without reading your mind? Numbers, exit codes, files on disk — never vibes."
+---
+
+# Lesson 2: Write the task contract, let .github instructions carry the rules
+
+## Objective
+
+You can read `TASK.md` as a set of testable statements, trace each
+statement to a test in `tests/test_report_tool.py`, and write durable
+instructions a Copilot session picks up automatically — and you can prove
+from the response's References that they were used.
+
+## Why this lesson exists
+
+Vague requests produce vague code. "Handle bad rows better" gives an agent
+permission to guess; "invalid rows are collected in `errors` with index and
+reason, valid rows still produce a report" gives it a target and gives you
+a way to check. In Copilot there is a second failure mode: a rule you typed
+into one chat does not exist in the next. The contract fixes the task;
+instructions files fix the working rules.
+
+## The lesson
+
+Open `TASK.md`. Notice what every line has in common: it names an
+observable behavior, not an implementation. Four statements from the
+contract, and the tests that pin them:
+
+| Contract line | Test |
+| --- | --- |
+| "a JSON file loads into the same record list as CSV" | `test_load_json_records_returns_list_of_dicts` |
+| "invalid rows land in `errors` with index and reason; valid rows still aggregate" | `test_invalid_records_are_isolated_with_reasons` |
+| "group totals are rounded to two decimals" | `test_group_totals_are_rounded_to_two_decimals` |
+| "the report write is atomic and creates missing parents" | `test_write_report_creates_missing_parent_directories` |
+
+Now the durable half. Copilot sessions read two kinds of instructions
+files:
+
+- **Always-on:** `.github/copilot-instructions.md` at the workspace root
+ (or `AGENTS.md`, which Copilot also reads). Every request in the
+ workspace gets it.
+- **Targeted:** `.github/instructions/.instructions.md` with YAML
+ front matter. `applyTo` is a glob relative to the workspace root; VS
+ Code attaches the file when the agent creates or modifies a matching
+ file. `description` lets the agent load it on demand when the task
+ matches.
+
+This course's working rules only matter when the course folder is being
+changed, so they belong in a targeted file. Tell the agent:
+
+**"Create `.github/instructions/flypython-course.instructions.md` with
+front matter `name: FlyPython course rules`, a one-line `description`, and
+`applyTo: 'courses/hands-on-with-github-copilot/**'`, containing these
+working rules: only starter/report_tool.py may change; tests/, solution/,
+scenario/ are read-only; one failing test group per turn; run
+`python verify.py starter` after each; standard library only."**
+
+Read the file it creates before you approve anything else — the `applyTo`
+glob is the part most often wrong.
+
+Then open a *new* chat session and ask it to change a docstring in
+`starter/report_tool.py` and to summarize the rules it is following. Expand
+**References** in the response: the instructions file should be listed. If
+it is not, open the Agent Customizations editor and check the file is
+discovered, then check that `applyTo` matches the path the agent edited.
+Undo the docstring change before you continue (Lesson 3 shows how).
+
+## Exercise
+
+Write one contract line for a script you actually own, using the same
+shape: inputs, outputs, error cases, and "done means `` exits 0".
+Then write two rules for that project and decide which belongs in
+`.github/copilot-instructions.md` and which in an `applyTo` file.
+
+## Checkpoint
+
+When you can answer the questions below, claim this checkpoint with
+`python verify.py --attest l02`:
+
+1. Which `TASK.md` line does `test_invalid_records_are_isolated_with_reasons`
+ pin, in your own words?
+2. Which mechanism carries rules across sessions — the chat prompt, or an
+ instructions file?
+3. When does an `applyTo` instructions file get attached, and how did you
+ confirm that yours was?
+
+## Expected evidence
+
+Your drafted contract line, your two rules with their file choice, and the
+References list from the new session showing the instructions file.
diff --git a/courses/hands-on-with-github-copilot/lessons/L02_cn.md b/courses/hands-on-with-github-copilot/lessons/L02_cn.md
new file mode 100644
index 0000000..c37a958
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L02_cn.md
@@ -0,0 +1,94 @@
+---
+id: course-github-copilot-l02
+type: course
+title: "第 2 课:写任务契约,让 .github 指令文件承载规则"
+summary: "把「改好一点」变成有边界、可测试的契约——再用 `.github/copilot-instructions.md` 和带 `applyTo` 的指令文件把它持久化,让每个 Copilot 会话都从同一套规则开始。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "你能把 `TASK.md` 当成可测试的陈述来读,而不是散文。每个以函数名开头的句子都是套件能断言的契约行。"
+ - title: "两类指令文件"
+ body: "`.github/copilot-instructions.md`(或 `AGENTS.md`)对整个工作区常驻生效。`.github/instructions/*.instructions.md` 在 Agent 创建或修改的文件匹配其 `applyTo` glob 时附加,或在 `description` 与任务相符时按需加载。"
+ - title: "可测试性检查"
+ body: "判断一句话能不能测:测试套件不看你的心思能不能断言它?数字、退出码、磁盘上的文件——不看感觉。"
+---
+
+# 第 2 课:写任务契约,让 .github 指令文件承载规则
+
+## 目标
+
+你能把 `TASK.md` 当成一组可测试陈述来读,把每条陈述追查到
+`tests/test_report_tool.py` 里的一个测试,写出 Copilot 会话会自动读取的
+持久指令——并能从回复的 References 里证明它们确实被用上了。
+
+## 为什么有这节课
+
+含糊的需求产出含糊的代码。「把坏行处理好一点」是许可 Agent 去猜;
+「无效行收进 `errors`、带下标和原因,有效行照常产出报告」是给它
+目标、也给你检查的方法。在 Copilot 里还有第二种失败:你在某个
+对话里敲的规则,下一个对话里根本不存在。契约固定任务本身;
+指令文件固定工作规则。
+
+## 本课内容
+
+打开 `TASK.md`。注意每行的共同点:它描述的是可观察的行为,不是
+实现。契约里的四句陈述,以及钉住它们的测试:
+
+| 契约行 | 测试 |
+| --- | --- |
+| 「JSON 文件要加载成与 CSV 相同的记录列表」 | `test_load_json_records_returns_list_of_dicts` |
+| 「无效行带下标和原因进 `errors`;有效行照常聚合」 | `test_invalid_records_are_isolated_with_reasons` |
+| 「分组总计保留两位小数」 | `test_group_totals_are_rounded_to_two_decimals` |
+| 「报告写出是原子的,且会创建缺失的父目录」 | `test_write_report_creates_missing_parent_directories` |
+
+再看持久的另一半。Copilot 会话读取两类指令文件:
+
+- **常驻:** 工作区根下的 `.github/copilot-instructions.md`(或
+ `AGENTS.md`,Copilot 同样会读)。工作区里的每次请求都带上它。
+- **定向:** 带 YAML front matter 的
+ `.github/instructions/.instructions.md`。`applyTo` 是相对工作区
+ 根的 glob;Agent 创建或修改匹配的文件时,VS Code 附加这个文件。
+ `description` 让 Agent 在任务相符时按需加载。
+
+本课程的工作规则只在改动课程文件夹时才有意义,所以应该放定向文件。
+告诉 Agent:
+
+**“创建 `.github/instructions/flypython-course.instructions.md`,front
+matter 写 `name: FlyPython course rules`、一行 `description`、
+`applyTo: 'courses/hands-on-with-github-copilot/**'`,写入这些工作规则:
+只许改 starter/report_tool.py;tests/、solution/、scenario/ 只读;每轮
+只修一组失败测试;修完跑 `python verify.py starter`;只用标准库。”**
+
+在批准任何其他操作之前先读它创建的文件——`applyTo` glob 是最常写错
+的地方。
+
+然后开一个*新*对话会话,让它改 `starter/report_tool.py` 里的一个
+docstring,并总结它正在遵守的规则。展开回复里的 **References**:指令
+文件应该在列表里。不在的话,先打开 Agent Customizations 编辑器确认文件
+被发现,再检查 `applyTo` 是否匹配 Agent 编辑的路径。继续之前撤销那个
+docstring 改动(第 3 课讲怎么撤)。
+
+## 练习
+
+为你真正拥有的一个脚本写一条契约行,用同样的形状:输入、输出、
+错误情况、以及「完成 = `<命令>` 退出码为 0」。再为那个项目写两条
+规则,并决定哪条放 `.github/copilot-instructions.md`、哪条放带
+`applyTo` 的文件。
+
+## 检查点
+
+能回答下面三题时,用 `python verify.py --attest l02` 认领本检查点:
+
+1. 用你自己的话说,`test_invalid_records_are_isolated_with_reasons`
+ 钉住的是 `TASK.md` 哪一行?
+2. 跨会话携带规则的是对话提示词还是指令文件?
+3. 带 `applyTo` 的指令文件什么时候被附加?你是怎么确认自己那个被
+ 附加了的?
+
+## 预期证据
+
+你起草的契约行、两条规则及其文件选择,以及新会话里显示该指令文件的
+References 列表。
diff --git a/courses/hands-on-with-github-copilot/lessons/L03.md b/courses/hands-on-with-github-copilot/lessons/L03.md
new file mode 100644
index 0000000..6e2ccab
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L03.md
@@ -0,0 +1,121 @@
+---
+id: course-github-copilot-l03
+type: course
+title: "Lesson 3: The bounded change, reviewed turn by turn"
+summary: "Drive the starter to green through bounded turns — review each turn's diff before the next, and use Restore Checkpoint and Git to keep every change yours."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That the starter passes all nine tests through changes you supervised — `python verify.py` shows this code only once the starter suite is green."
+ - title: "Edits land first, review comes next"
+ body: "In Copilot sessions the agent saves edits directly; there is no pending Keep/Undo step. Your review gate is the changed-files diff after each turn, Restore Checkpoint to roll a turn back, and a Git commit once a group is green."
+ - title: "When you are done"
+ body: "Every diff maps to a contract line, and you can explain each one without opening solution/."
+---
+
+# Lesson 3: The bounded change, reviewed turn by turn
+
+## Objective
+
+The starter passes all nine tests through a sequence of bounded changes,
+and you can explain every diff the agent produced without reading the
+solution first.
+
+## Why this lesson exists
+
+This is the lesson people skip — and the one that builds the actual skill.
+Letting the agent rewrite the file in one shot teaches you nothing and
+gives you a diff you cannot honestly review. Supervising small diffs, each
+pinned to a contract line, is how the code stays yours.
+
+Copilot adds one thing you must know: in a Copilot (Agent Host) session the
+agent applies and saves edits directly. Manual permissions makes commands
+and tool calls wait for you; it does not hold file edits for approval. So
+the boundary is not an "accept" button — it is the review you do after
+each turn, before you send the next prompt.
+
+## Set up the safety net (once)
+
+1. **A Git baseline.** If the working folder is not a Git repository yet,
+ run `git init`, then `git add -A` and `git commit -m "baseline"`. The
+ Source Control view now shows exactly what each turn changed.
+2. **Per-request change summaries.** In Settings, turn on
+ `chat.checkpoints.showFileChanges`. Each completed request then lists
+ the files it changed with line counts, and **View All File Changes**
+ opens a multi-file diff.
+3. **(Optional) Ask before touching read-only files.** The
+ `chat.tools.edits.autoApprove` setting takes glob patterns; files set
+ to `false` show a diff and wait for your approval before the edit is
+ applied. Put this in `.vscode/settings.json`:
+
+ ```json
+ {
+ "chat.tools.edits.autoApprove": {
+ "**/*": true,
+ "**/tests/**": false,
+ "**/solution/**": false,
+ "**/scenario/**": false
+ }
+ }
+ ```
+
+## The lesson
+
+Start a fresh chat session (a finished task's chat carries stale context),
+keep the **Agent** role and **Manual permissions**, and say:
+
+**"Work through TASK.md against starter/report_tool.py. One failing test
+group at a time: JSON loading, then validation isolation, then rounding,
+then atomic writes, then the end-to-end tests. After each group, run
+`python verify.py starter`, then stop and wait for me before the next
+group."**
+
+After every turn, before you reply:
+
+- Open the turn's changed files (or Source Control) and read every hunk.
+ Only `starter/report_tool.py` should have changed. If anything in
+ `tests/`, `solution/`, or `scenario/` moved, hover over that request and
+ select **Restore Checkpoint**, then ask why.
+- No new imports outside the standard library — and no import that is not
+ needed by the change being made.
+- Each change should move toward one contract line. Reject drive-by
+ refactors ("while I was here I renamed…") by restoring the checkpoint and
+ re-asking with a narrower prompt.
+- If the agent wants to change a test, the answer is no. Tests are the
+ contract; the code moves.
+- When a group is green and the diff makes sense, commit it in Source
+ Control. That commit is your permanent record; checkpoints are temporary.
+
+Read every command approval request too: `python verify.py starter` is
+safe; `pip install` anything is a stop sign. Restore Checkpoint rolls back
+files and chat history, but it does **not** reverse a command that already
+ran — which is why commands are the thing Manual permissions holds for you.
+
+Expect the failing count to drop group by group: 7 → 5 → 4 → 3 → 2 → 0.
+
+## Exercise
+
+Pick one hunk the agent produced — ideally the validation-isolation
+change — and explain it back in the chat: "line N does X; that satisfies
+contract line Y." If you cannot, ask the agent to walk you through its own
+diff before sending the next group.
+
+## Checkpoint
+
+Run `python verify.py`. This checkpoint's code appears only when
+the starter suite is green. You pass the lesson when you can answer:
+
+1. How many turns did the bounded change take, and what made each turn
+ bounded?
+2. Which diff or command did you roll back or narrow, and how — checkpoint,
+ denied approval, or a narrower prompt?
+3. Point at the hunk that implements "isolate invalid rows with reasons" —
+ where is it?
+
+## Expected evidence
+
+A green `python verify.py starter` run, your per-group commits, and your
+hunk-by-hunk explanation of the change that earned them.
diff --git a/courses/hands-on-with-github-copilot/lessons/L03_cn.md b/courses/hands-on-with-github-copilot/lessons/L03_cn.md
new file mode 100644
index 0000000..aaafe3d
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L03_cn.md
@@ -0,0 +1,107 @@
+---
+id: course-github-copilot-l03
+type: course
+title: "第 3 课:有界改动,一轮一审"
+summary: "通过有界的轮次把 starter 推到全绿——每轮先审 diff 再发下一轮,用 Restore Checkpoint 和 Git 守住每一处改动。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "starter 通过你监督的改动让九个测试全绿——`python verify.py` 只在 starter 套件变绿后显示本检查点的码。"
+ - title: "先落盘,后审查"
+ body: "在 Copilot 会话里,Agent 直接保存编辑,没有待定的 Keep/Undo 步骤。你的审查关口是每轮之后的改动文件 diff、用 Restore Checkpoint 回滚一轮,以及一组变绿后的 Git 提交。"
+ - title: "完成的标志"
+ body: "每个 diff 都能对上一条契约行,而且不看 solution/ 你也讲得清每一处改动。"
+---
+
+# 第 3 课:有界改动,一轮一审
+
+## 目标
+
+starter 通过一串有界改动让九个测试全绿,并且不看 solution,你也
+讲得清 Agent 产出的每一个 diff。
+
+## 为什么有这节课
+
+这节课最容易被跳过——也恰恰是长本事的那节。放任 Agent 把文件一次
+重写,什么也学不到,只留下一份你没底气审查的 diff。监督一连串小 diff、
+各自钉在一条契约行上,才能让代码仍然是你的。
+
+Copilot 有一点你必须知道:在 Copilot(Agent Host)会话里,Agent 直接
+应用并保存编辑。Manual permissions 让命令和工具调用等你批准;它不会
+扣住文件编辑等你批准。所以边界不是一个「接受」按钮——而是你在每轮
+之后、发下一条提示词之前做的审查。
+
+## 先铺好安全网(一次)
+
+1. **Git 基线。** 如果工作目录还不是 Git 仓库,先跑 `git init`,再
+ `git add -A` 和 `git commit -m "baseline"`。之后 Source Control 视图
+ 会准确显示每一轮改了什么。
+2. **逐请求的改动摘要。** 在设置里打开 `chat.checkpoints.showFileChanges`。
+ 每次请求完成后都会列出它改的文件和行数,**View All File Changes**
+ 打开多文件 diff。
+3. **(可选)动只读文件前先问。** `chat.tools.edits.autoApprove` 设置
+ 接受 glob;设成 `false` 的文件在编辑生效前会显示 diff 并等你批准。
+ 写进 `.vscode/settings.json`:
+
+ ```json
+ {
+ "chat.tools.edits.autoApprove": {
+ "**/*": true,
+ "**/tests/**": false,
+ "**/solution/**": false,
+ "**/scenario/**": false
+ }
+ }
+ ```
+
+## 本课内容
+
+开一个新的对话会话(已完结任务的对话带着过期上下文),保持 **Agent**
+角色和 **Manual permissions**,说:
+
+**“按 TASK.md 改 starter/report_tool.py。一次只修一组失败测试:
+先 JSON 加载,再无效行隔离,再舍入,再原子写,最后是端到端测试。
+每修完一组跑 `python verify.py starter`,然后停下来等我,再做下一组。”**
+
+每一轮之后、回复之前:
+
+- 打开这一轮改动的文件(或 Source Control),逐块读完。只应有
+ `starter/report_tool.py` 变了。`tests/`、`solution/`、`scenario/` 里
+ 任何东西动了,就把鼠标悬停在那次请求上选 **Restore Checkpoint**,
+ 再问为什么。
+- 标准库之外不加 import——连与本改动无关的 import 也不要。
+- 每处改动朝一条契约行推进。顺手重构(「我顺便重命名了……」)就恢复
+ 检查点,用更窄的提示词重问。
+- Agent 想改测试,答案是不行。测试是契约;动的是代码。
+- 一组变绿、diff 讲得通,就在 Source Control 里提交。这个提交是你的
+ 永久记录;检查点是临时的。
+
+命令批准请求也要读:`python verify.py starter` 安全;`pip install` 任何
+东西都是停止信号。Restore Checkpoint 回滚文件和对话历史,但**不会**撤销
+已经跑过的命令——这正是 Manual permissions 替你扣住命令的原因。
+
+预期失败数逐组下降:7 → 5 → 4 → 3 → 2 → 0。
+
+## 练习
+
+挑 Agent 产出的一个 hunk——最好是「无效行隔离」那段——在对话里
+讲回给它听:「第 N 行做了 X,满足契约行 Y」。讲不清就先让它走读
+自己的 diff,再发下一组。
+
+## 检查点
+
+运行 `python verify.py`。本检查点的码只在 starter 套件
+全绿时出现。不看材料能回答即算通过:
+
+1. 这次有界改动走了几轮,是什么让每轮保持有界?
+2. 你回滚或收窄过哪个 diff 或命令,用的是什么——检查点、拒绝批准,
+ 还是更窄的提示词?
+3. 指出实现「无效行带原因隔离」的那个 hunk——它在哪?
+
+## 预期证据
+
+一次全绿的 `python verify.py starter` 运行、你按组做的提交,和你对这次
+改动的逐块讲解。
diff --git a/courses/hands-on-with-github-copilot/lessons/L04.md b/courses/hands-on-with-github-copilot/lessons/L04.md
new file mode 100644
index 0000000..5bc6ef4
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L04.md
@@ -0,0 +1,88 @@
+---
+id: course-github-copilot-l04
+type: course
+title: "Lesson 4: Verify like a reviewer, not a spectator"
+summary: "Green tests are the floor, not the ceiling — run both suites, walk a scenario skin, and review the accumulated diff in Source Control the way you would review a colleague's."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That starter AND solution both pass — the maintainer pair `verify.py starter` / `verify.py solution` must both be green, and `python verify.py` prints this code once your starter suite passes. The gate is the pair, not a single run."
+ - title: "Review the delta, not the vibes"
+ body: "Source Control's diff against your baseline commit shows the whole accumulated change. Read it as a reviewer: every hunk maps to a contract line, nothing extra moved, no test weakened."
+ - title: "When you are done"
+ body: "You have written three evidence lines — verified / not verified / known limits — and could defend the change in a code review."
+---
+
+# Lesson 4: Verify like a reviewer, not a spectator
+
+## Objective
+
+Both suites pass, and you hold a written record of what the green runs do
+and do not prove — plus a reviewed diff you would defend to a colleague.
+
+## Why this lesson exists
+
+"All tests pass" is where most AI-assisted work stops — and where most
+silent bugs start. A suite proves the contract lines it asserts; it says
+nothing about the rows nobody wrote a test for. Verification is a report
+you write, not a feeling you have.
+
+## The lesson
+
+Run the full pair in the integrated terminal yourself — not through the
+agent — so you see the raw output:
+
+```bash
+python verify.py starter # the implementation you supervised
+python verify.py solution # the reviewed reference
+```
+
+Both green means the contract is satisfied *as written*. Now do the review
+the suite cannot do for you:
+
+1. **Walk a skin end-to-end.** Pick a `scenario//` folder and run
+ the tool on it: `python starter/report_tool.py scenario/`. Read
+ the produced report against the input file — recompute one group total
+ by hand.
+2. **Review the accumulated diff.** In Source Control, compare against the
+ baseline commit from Lesson 3 (or run `git diff ` in the
+ terminal). Read it top to bottom. Every hunk should map to a contract
+ line you named in Lesson 2. Anything that does not is scope creep —
+ revert or justify it. Also check for untracked files the agent created
+ through terminal commands: those do not always appear in the chat's
+ changed-files list.
+3. **Check the edges the tests skip.** Feed the tool an empty CSV, a CSV
+ with only invalid rows, a missing file. The contract does not pin
+ these — your judgment does. Write down what it does.
+
+If you want a second opinion, ask Copilot in plain words to critique the
+change (its built-in Rubber Duck reviewer is read-only). Treat whatever it
+finds as leads to check by running something, not as evidence.
+
+## Exercise
+
+Write the three-line evidence record for this change:
+
+- **Verified:** the commands you ran and what they proved.
+- **Not verified:** the inputs and behaviors no test pins.
+- **Known limits:** anything you saw that the contract never promised.
+
+Keep it honest — "not verified" is a list of facts, not a confession.
+
+## Checkpoint
+
+Run `python verify.py`. This checkpoint's code prints once the starter
+suite passes; the maintainer pair above must both be green for the gate to
+mean what it says. You pass the lesson when you can answer:
+
+1. What did your by-hand recompute of a group total show?
+2. Name one input the suite does not pin and what the tool does with it.
+3. Which hunk, if any, would you ask a colleague to justify?
+
+## Expected evidence
+
+Both green runs, your three-line evidence record, and the diff review
+notes.
diff --git a/courses/hands-on-with-github-copilot/lessons/L04_cn.md b/courses/hands-on-with-github-copilot/lessons/L04_cn.md
new file mode 100644
index 0000000..e9046b2
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L04_cn.md
@@ -0,0 +1,82 @@
+---
+id: course-github-copilot-l04
+type: course
+title: "第 4 课:像评审者一样验证,别当观众"
+summary: "测试全绿是地板不是天花板——两个套件都跑、走完一个皮肤、在 Source Control 里按评审同事的标准审这份累积 diff。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "starter 和 solution 双双通过——维护者那一对命令 `verify.py starter` / `verify.py solution` 都必须是绿的,你的 starter 套件通过后 `python verify.py` 打印本检查点的码。门槛是一对结果,不是一次运行。"
+ - title: "审增量,不凭感觉"
+ body: "Source Control 里对比基线提交的 diff 展示整个累积改动。按评审者的方式读:每个 hunk 对上一条契约行、没有多余挪动、没有测试被放松。"
+ - title: "完成的标志"
+ body: "你已写下三行证据——已验证 / 未验证 / 已知局限——并且能在代码评审里为这次改动辩护。"
+---
+
+# 第 4 课:像评审者一样验证,别当观众
+
+## 目标
+
+两个套件都通过,你手握一份书面记录:说清绿色运行证明了什么、没
+证明什么——外加一份你敢向同事辩护的 diff。
+
+## 为什么有这节课
+
+「测试全过」是大多数 AI 辅助工作停下的地方——也是大多数隐性
+bug 开始的地方。套件只证明它断言过的契约行;没人写过测试的行,
+它一概不担保。验证是你写的一份报告,不是一种感觉。
+
+## 本课内容
+
+在集成终端里亲自跑完整的一对命令——不经过 Agent——这样你看到的是
+原始输出:
+
+```bash
+python verify.py starter # 你监督出来的实现
+python verify.py solution # 评审过的参考实现
+```
+
+双全绿意味着契约*按字面*被满足。现在做套件替不了你的评审:
+
+1. **走完一个皮肤。** 挑一个 `scenario//` 文件夹,用工具跑
+ 它:`python starter/report_tool.py scenario/`。对照输入
+ 文件读产出的报告——亲手重算一个分组总计。
+2. **审累积的 diff。** 在 Source Control 里对比第 3 课的基线提交(或在
+ 终端跑 `git diff `)。从头读到尾。每个 hunk 都应能对上你在
+ 第 2 课点名的契约行。对不上的就是范围蔓延——回退或让它解释。还要
+ 检查 Agent 通过终端命令创建的未跟踪文件:它们不一定出现在对话的
+ 改动文件列表里。
+3. **检查测试没钉的边界。** 喂给工具一个空 CSV、一个全是无效行
+ 的 CSV、一个不存在的文件。契约没钉这些——你的判断钉。记下
+ 它的行为。
+
+想要第二意见,就用大白话让 Copilot 评审这次改动(它内置的 Rubber Duck
+评审者是只读的)。它发现的东西都当作要靠实际运行去核实的线索,不当
+证据。
+
+## 练习
+
+为这次改动写三行证据记录:
+
+- **已验证:** 你跑过的命令和它们证明了什么。
+- **未验证:** 没有测试钉住的输入与行为。
+- **已知局限:** 你看到的、契约从未承诺过的东西。
+
+写得诚实——「未验证」是一份事实清单,不是忏悔书。
+
+## 检查点
+
+运行 `python verify.py`。starter 套件通过后本检查点的码就会打印;上面
+那一对维护者命令都必须是绿的,这道门才名副其实。不看材料能回答即算
+通过:
+
+1. 你手工重算的分组总计结果如何?
+2. 说出一个套件没钉的输入,以及工具对它的实际行为。
+3. 哪个 hunk(如果有)你会请同事来解释?
+
+## 预期证据
+
+两次全绿运行、你的三行证据记录、diff 评审笔记。
diff --git a/courses/hands-on-with-github-copilot/lessons/L05.md b/courses/hands-on-with-github-copilot/lessons/L05.md
new file mode 100644
index 0000000..67412aa
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L05.md
@@ -0,0 +1,82 @@
+---
+id: course-github-copilot-l05
+type: course
+title: "Lesson 5: Take the workflow to your own project"
+summary: "The tool generalizes: task contract, instructions files, bounded agent work, diff review — run the same loop with Copilot on a repository you actually own."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "Self-attested: you confirm you applied the loop to your own project. The evidence is your contract, your instructions file, your reviewed commits, and your verification record — not a code we can check."
+ - title: "Carry the shape, not the code"
+ body: "What transfers is the loop: contract first, one bounded change at a time, every turn's diff read before the next prompt, verification written down."
+ - title: "When you are done"
+ body: "Your own project has a contract, one bounded change you can explain, and a verified/not-verified/known-limits record — or a documented reason the loop did not fit."
+---
+
+# Lesson 5: Take the workflow to your own project
+
+## Objective
+
+You apply the whole loop — contract, instructions files, bounded agent
+work, diff review, written verification — to a script or repository you
+actually own, and record what happened.
+
+## Why this lesson exists
+
+A course folder is a safe sandbox; your own codebase is not. The point of
+the last four lessons was never the report tool — it was a repeatable way
+to drive an agent that keeps you in charge of what ships. This checkpoint
+exists to prove the transfer happened. It is self-attested: only you know
+whether the loop actually ran.
+
+## The lesson
+
+Pick a real script you own — one with a bug you have been putting off, or
+a behavior you want but have not written. Then run the same five moves:
+
+1. **Contract.** Write the task contract first: inputs, outputs, error
+ cases, and "done means `` exits 0". One short paragraph, every
+ sentence testable.
+2. **Rules that persist.** Open the repository in VS Code and put the
+ project-wide rules in `.github/copilot-instructions.md` (or the
+ repository's existing `AGENTS.md`) — the verification command, the
+ dependency policy. Put folder-specific rules in an `applyTo`
+ instructions file. Confirm in a new session's References that they
+ load. If the repository is shared, these files are reviewed like code:
+ your teammates' Copilot sessions will read them too.
+3. **Bounded work.** One chat session, Agent role, Manual permissions, one
+ contract line per turn. Read every changed file before the next prompt;
+ read every command before approving it; commit each green step.
+4. **Verify.** Run the verification command yourself, then do the review
+ the suite cannot do — walk one real input end-to-end and check the
+ output by hand.
+5. **Record.** Write the three lines: verified / not verified / known
+ limits.
+
+If the loop fought you — the task was too small to need a contract, or too
+vague to test — that is a finding too. Write down where the shape did not
+fit; that judgment is the actual skill.
+
+## Exercise
+
+If your project has more than one boundary worth pinning, draft the next
+contract line now — you will want it when you open the next session.
+
+## Checkpoint
+
+This checkpoint is self-attested. When you can answer the questions below,
+claim it with `python verify.py --attest l05`:
+
+1. What was your contract's "done means" command?
+2. Which diff or command did you push back on?
+3. What is on your "not verified" list that a stranger would assume was
+ covered?
+
+## Expected evidence
+
+Your contract paragraph, the instructions file you wrote, and the
+three-line verification record. These are yours — they are the proof the
+workflow transferred.
diff --git a/courses/hands-on-with-github-copilot/lessons/L05_cn.md b/courses/hands-on-with-github-copilot/lessons/L05_cn.md
new file mode 100644
index 0000000..d7fdd65
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/lessons/L05_cn.md
@@ -0,0 +1,72 @@
+---
+id: course-github-copilot-l05
+type: course
+title: "第 5 课:把工作流带回自己的项目"
+summary: "工具会泛化:任务契约、指令文件、有界 Agent 工作、diff 评审——用 Copilot 在你真正拥有的仓库上跑同一套闭环。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "自我报告:你确认把这套闭环用到了自己的项目上。证据是你的契约、你的指令文件、你审过的提交、你的验证记录——不是我们查得了的码。"
+ - title: "带走形状,不是代码"
+ body: "能迁移的是闭环:契约先行、一次一处有界改动、每轮的 diff 在下一条提示词前读完、验证写下来。"
+ - title: "完成的标志"
+ body: "你自己的项目有了一份契约、一处你讲得清的有界改动、一份已验证/未验证/已知局限记录——或一条写清楚的「闭环不合身」的原因。"
+---
+
+# 第 5 课:把工作流带回自己的项目
+
+## 目标
+
+你把整套闭环——契约、指令文件、有界 Agent 工作、diff 评审、书面
+验证——用到自己真正拥有的脚本或仓库上,并记录发生了什么。
+
+## 为什么有这节课
+
+课程文件夹是安全沙箱;你自己的代码库不是。前四课的意义从来不是
+那个报表工具——而是一种可复用的、让你始终掌控产出的 Agent 驱动
+方式。本检查点要证明迁移真的发生了。它是自我报告的:闭环跑没跑
+过只有你知道。
+
+## 本课内容
+
+挑一个你真正拥有的脚本——一个你拖了很久的 bug,或一个想要但
+还没写的行为。然后走同样的五步:
+
+1. **契约。** 先写任务契约:输入、输出、错误情况、以及「完成 =
+ `<命令>` 退出码为 0」。一小段,每句都可测试。
+2. **能留存的规则。** 在 VS Code 里打开仓库,把项目级规则写进
+ `.github/copilot-instructions.md`(或仓库已有的 `AGENTS.md`)——验证
+ 命令、依赖政策。目录专属规则放带 `applyTo` 的指令文件。在新会话的
+ References 里确认它们被加载。如果仓库是共享的,这些文件要像代码
+ 一样评审:同事的 Copilot 会话也会读它们。
+3. **有界工作。** 一个对话会话、Agent 角色、Manual permissions、每轮
+ 一条契约行。下一条提示词之前读完每个改动文件;批准前读完每条
+ 命令;每个变绿的步骤都提交。
+4. **验证。** 亲自跑验证命令,然后做套件做不了的评审——拿一个真实
+ 输入端到端走一遍,手工核对输出。
+5. **记录。** 写三行:已验证 / 未验证 / 已知局限。
+
+如果闭环跟你打架——任务小到不需要契约,或模糊到不可测——那也
+是发现。写下形状不合身的地方;这个判断本身就是真本事。
+
+## 练习
+
+如果你的项目还有不止一处值得钉住的边界,现在就起草下一条契约行
+——开下一个会话时你会用得上。
+
+## 检查点
+
+本检查点自我报告。能回答下面三题时,用
+`python verify.py --attest l05` 认领:
+
+1. 你的契约里「完成 =」命令是什么?
+2. 你在哪个 diff 或命令上顶回去过?
+3. 你的「未验证」清单上有什么是陌生人会以为已覆盖的?
+
+## 预期证据
+
+你的契约段落、你写的指令文件、三行验证记录。它们是你自己的——是
+工作流完成迁移的证明。
diff --git a/courses/hands-on-with-github-copilot/scenario/api-tool/scenario.json b/courses/hands-on-with-github-copilot/scenario/api-tool/scenario.json
new file mode 100644
index 0000000..e17e7c2
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/scenario/api-tool/scenario.json
@@ -0,0 +1,8 @@
+{
+ "skin": "api-tool",
+ "data_file": "tickets.json",
+ "required_fields": ["ticket_id", "priority", "minutes"],
+ "numeric_field": "minutes",
+ "group_field": "priority",
+ "report_file": "report.json"
+}
diff --git a/courses/hands-on-with-github-copilot/scenario/api-tool/tickets.json b/courses/hands-on-with-github-copilot/scenario/api-tool/tickets.json
new file mode 100644
index 0000000..03e35b7
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/scenario/api-tool/tickets.json
@@ -0,0 +1,9 @@
+[
+ {"ticket_id": "T-101", "priority": "high", "minutes": "45"},
+ {"ticket_id": "T-102", "priority": "normal", "minutes": "20"},
+ {"ticket_id": "T-103", "priority": "low", "minutes": "10"},
+ {"ticket_id": "T-104", "priority": "high", "minutes": "90"},
+ {"ticket_id": "T-105", "priority": "", "minutes": "30"},
+ {"ticket_id": "T-106", "priority": "normal", "minutes": "n/a"},
+ {"ticket_id": "T-107", "priority": "low", "minutes": "25"}
+]
diff --git a/courses/hands-on-with-github-copilot/scenario/data-monitor/metrics.csv b/courses/hands-on-with-github-copilot/scenario/data-monitor/metrics.csv
new file mode 100644
index 0000000..15a5a15
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/scenario/data-monitor/metrics.csv
@@ -0,0 +1,8 @@
+host,service,latency_ms
+web-1,api,182
+web-2,api,205
+db-1,postgres,44
+web-3,frontend,96
+db-2,,51
+web-4,api,too-slow
+cache-1,redis,12
diff --git a/courses/hands-on-with-github-copilot/scenario/data-monitor/scenario.json b/courses/hands-on-with-github-copilot/scenario/data-monitor/scenario.json
new file mode 100644
index 0000000..b103477
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/scenario/data-monitor/scenario.json
@@ -0,0 +1,8 @@
+{
+ "skin": "data-monitor",
+ "data_file": "metrics.csv",
+ "required_fields": ["host", "service", "latency_ms"],
+ "numeric_field": "latency_ms",
+ "group_field": "service",
+ "report_file": "report.json"
+}
diff --git a/courses/hands-on-with-github-copilot/scenario/excel-report/orders.csv b/courses/hands-on-with-github-copilot/scenario/excel-report/orders.csv
new file mode 100644
index 0000000..a41fca1
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/scenario/excel-report/orders.csv
@@ -0,0 +1,8 @@
+order_id,region,amount,item_count
+1001,emea,249.99,3
+1002,apac,89.50,1
+1003,emea,,2
+1004,americas,120.00,5
+1005,,310.00,4
+1006,apac,45.25,2
+1007,emea,640.10,6
diff --git a/courses/hands-on-with-github-copilot/scenario/excel-report/scenario.json b/courses/hands-on-with-github-copilot/scenario/excel-report/scenario.json
new file mode 100644
index 0000000..66b2bd9
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/scenario/excel-report/scenario.json
@@ -0,0 +1,8 @@
+{
+ "skin": "excel-report",
+ "data_file": "orders.csv",
+ "required_fields": ["order_id", "region", "amount"],
+ "numeric_field": "amount",
+ "group_field": "region",
+ "report_file": "report.json"
+}
diff --git a/courses/hands-on-with-github-copilot/solution/report_tool.py b/courses/hands-on-with-github-copilot/solution/report_tool.py
new file mode 100644
index 0000000..65ba0bf
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/solution/report_tool.py
@@ -0,0 +1,134 @@
+"""Scenario report tool (reviewed solution).
+
+Reads a scenario data file (CSV or JSON), isolates invalid rows instead of
+crashing, aggregates valid rows per group with two-decimal rounding, and
+writes the report atomically so an interrupted run never leaves a half-written
+file. Standard library only.
+"""
+
+from __future__ import annotations
+
+import csv
+import json
+import os
+import sys
+from pathlib import Path
+
+SUPPORTED_SUFFIXES = {".csv", ".json"}
+
+
+def load_records(path: str | Path) -> list[dict]:
+ """Load CSV or JSON records from *path* as a list of dicts."""
+ source = Path(path)
+ suffix = source.suffix.lower()
+ if suffix not in SUPPORTED_SUFFIXES:
+ raise ValueError(f"unsupported data file type: {source.name}")
+ if suffix == ".csv":
+ with source.open("r", encoding="utf-8", newline="") as handle:
+ records = list(csv.DictReader(handle))
+ else:
+ data = json.loads(source.read_text(encoding="utf-8"))
+ records = data if isinstance(data, list) else None
+ if not all(isinstance(record, dict) for record in records):
+ raise ValueError("data file must contain a list of objects")
+ return records
+
+
+def _field_text(record: dict, field: str) -> str:
+ value = record.get(field)
+ if not isinstance(value, str):
+ return "" if value is None else str(value).strip()
+ return value.strip()
+
+
+def build_report(
+ records: list[dict],
+ *,
+ required_fields: list[str],
+ numeric_field: str,
+ group_field: str,
+) -> dict:
+ """Aggregate *records*, isolating invalid rows with recorded reasons."""
+ groups: dict[str, dict] = {}
+ errors: list[dict] = []
+ for index, record in enumerate(records):
+ if not isinstance(record, dict):
+ errors.append({"index": index, "reason": "record is not an object"})
+ continue
+ missing = [
+ field for field in required_fields if not _field_text(record, field)
+ ]
+ if missing:
+ errors.append(
+ {"index": index, "reason": f"missing required field: {missing[0]}"}
+ )
+ continue
+ raw_number = _field_text(record, numeric_field)
+ try:
+ number = float(raw_number)
+ except ValueError:
+ errors.append(
+ {
+ "index": index,
+ "reason": f"field {numeric_field!r} is not a number: {raw_number!r}",
+ }
+ )
+ continue
+ group = _field_text(record, group_field)
+ bucket = groups.setdefault(group, {"count": 0, "total": 0.0})
+ bucket["count"] += 1
+ bucket["total"] = round(bucket["total"] + number, 2)
+ return {
+ "total": len(records),
+ "valid": len(records) - len(errors),
+ "invalid": len(errors),
+ "groups": groups,
+ "errors": errors,
+ }
+
+
+def write_report(report: dict, destination: str | Path) -> None:
+ """Atomically write *report* as JSON, creating parent directories."""
+ target = Path(destination)
+ target.parent.mkdir(parents=True, exist_ok=True)
+ temporary = target.with_name(target.name + ".tmp")
+ temporary.write_text(
+ json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
+ )
+ os.replace(temporary, target)
+
+
+def run_scenario(scenario_dir: str | Path) -> dict:
+ """Load scenario.json, process its data file, and write the report."""
+ directory = Path(scenario_dir)
+ config = json.loads((directory / "scenario.json").read_text(encoding="utf-8"))
+ records = load_records(directory / config["data_file"])
+ report = build_report(
+ records,
+ required_fields=config["required_fields"],
+ numeric_field=config["numeric_field"],
+ group_field=config["group_field"],
+ )
+ write_report(report, directory / config["report_file"])
+ return report
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Run one scenario directory and print a one-line summary."""
+ arguments = sys.argv[1:] if argv is None else argv
+ if len(arguments) != 1:
+ print("usage: python report_tool.py ", file=sys.stderr)
+ return 2
+ try:
+ report = run_scenario(arguments[0])
+ except (OSError, ValueError, KeyError, json.JSONDecodeError) as error:
+ print(f"error: {error}", file=sys.stderr)
+ return 1
+ print(
+ f"total={report['total']} valid={report['valid']} invalid={report['invalid']}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/hands-on-with-github-copilot/starter/report_tool.py b/courses/hands-on-with-github-copilot/starter/report_tool.py
new file mode 100644
index 0000000..019edb8
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/starter/report_tool.py
@@ -0,0 +1,88 @@
+"""Scenario report tool (starter, deliberately incomplete).
+
+Reads a scenario data file, aggregates valid rows, and writes a JSON report.
+This starter reproduces the classic "it runs on the happy path" state of an
+AI-written script: JSON inputs crash, invalid rows abort the run, group totals
+carry floating-point noise, and reports cannot be written into a fresh
+directory. The task contract in ../TASK.md defines the expected behavior.
+"""
+
+from __future__ import annotations
+
+import csv
+import json
+import sys
+from pathlib import Path
+
+
+def load_records(path: str | Path) -> list[dict]:
+ """Load CSV or JSON records from *path* as a list of dicts."""
+ source = Path(path)
+ with source.open("r", encoding="utf-8", newline="") as handle:
+ return list(csv.DictReader(handle))
+
+
+def build_report(
+ records: list[dict],
+ *,
+ required_fields: list[str],
+ numeric_field: str,
+ group_field: str,
+) -> dict:
+ """Aggregate *records* into a summary report."""
+ groups: dict[str, dict] = {}
+ for record in records:
+ for field in required_fields:
+ value = (record.get(field) or "").strip()
+ if not value:
+ raise KeyError(f"missing required field: {field}")
+ group = record[group_field].strip()
+ bucket = groups.setdefault(group, {"count": 0, "total": 0.0})
+ bucket["count"] += 1
+ bucket["total"] += float(record[numeric_field])
+ return {
+ "total": len(records),
+ "valid": len(records),
+ "invalid": 0,
+ "groups": groups,
+ "errors": [],
+ }
+
+
+def write_report(report: dict, destination: str | Path) -> None:
+ """Write *report* as JSON to *destination*."""
+ Path(destination).write_text(
+ json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
+ )
+
+
+def run_scenario(scenario_dir: str | Path) -> dict:
+ """Load scenario.json, process its data file, and write the report."""
+ directory = Path(scenario_dir)
+ config = json.loads((directory / "scenario.json").read_text(encoding="utf-8"))
+ records = load_records(directory / config["data_file"])
+ report = build_report(
+ records,
+ required_fields=config["required_fields"],
+ numeric_field=config["numeric_field"],
+ group_field=config["group_field"],
+ )
+ write_report(report, directory / config["report_file"])
+ return report
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Run one scenario directory and print a one-line summary."""
+ arguments = sys.argv[1:] if argv is None else argv
+ if len(arguments) != 1:
+ print("usage: python report_tool.py ", file=sys.stderr)
+ return 2
+ report = run_scenario(arguments[0])
+ print(
+ f"total={report['total']} valid={report['valid']} invalid={report['invalid']}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/hands-on-with-github-copilot/tests/test_report_tool.py b/courses/hands-on-with-github-copilot/tests/test_report_tool.py
new file mode 100644
index 0000000..3fbc19f
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/tests/test_report_tool.py
@@ -0,0 +1,115 @@
+"""Contract tests for the scenario report tool.
+
+The suite is run twice: against ``starter/`` (specific failures expected) and
+against ``solution/`` (everything must pass). See ../TASK.md for the contract.
+"""
+
+from __future__ import annotations
+
+import json
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+
+import report_tool
+
+COURSE_ROOT = Path(__file__).resolve().parent.parent
+SCENARIO_ROOT = COURSE_ROOT / "scenario"
+
+
+class LoadRecordsTest(unittest.TestCase):
+ def test_load_csv_records_returns_list_of_dicts(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ data = Path(tmp) / "rows.csv"
+ data.write_text("id,region,amount\n1,emea,10.5\n2,apac,3\n", encoding="utf-8")
+ records = report_tool.load_records(data)
+ self.assertEqual(records, [{"id": "1", "region": "emea", "amount": "10.5"}, {"id": "2", "region": "apac", "amount": "3"}])
+
+ def test_load_json_records_returns_list_of_dicts(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ data = Path(tmp) / "rows.json"
+ data.write_text('[{"id": "1", "amount": "2"}]', encoding="utf-8")
+ records = report_tool.load_records(data)
+ self.assertEqual(records, [{"id": "1", "amount": "2"}])
+
+ def test_unsupported_suffix_raises_value_error(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ data = Path(tmp) / "rows.xlsx"
+ with self.assertRaises(ValueError):
+ report_tool.load_records(data)
+
+
+class BuildReportTest(unittest.TestCase):
+ def test_invalid_records_are_isolated_with_reasons(self) -> None:
+ records = [
+ {"id": "1", "region": "emea", "amount": "10"},
+ {"id": "2", "region": "", "amount": "5"}, # missing group value
+ {"id": "", "region": "emea", "amount": "5"}, # missing required id
+ {"id": "3", "region": "apac", "amount": "n/a"} # non-numeric amount
+ ]
+ report = report_tool.build_report(
+ records,
+ required_fields=["id", "region", "amount"],
+ numeric_field="amount",
+ group_field="region",
+ )
+ self.assertEqual(report["total"], 4)
+ self.assertEqual(report["valid"], 1)
+ self.assertEqual(report["invalid"], 3)
+ self.assertEqual([error["index"] for error in report["errors"]], [1, 2, 3])
+ self.assertTrue(all(error["reason"] for error in report["errors"]))
+ self.assertEqual(report["groups"], {"emea": {"count": 1, "total": 10.0}})
+
+ def test_group_totals_are_rounded_to_two_decimals(self) -> None:
+ records = [
+ {"id": "1", "region": "emea", "amount": "0.1"},
+ {"id": "2", "region": "emea", "amount": "0.2"},
+ ]
+ report = report_tool.build_report(
+ records,
+ required_fields=["id", "region", "amount"],
+ numeric_field="amount",
+ group_field="region",
+ )
+ self.assertEqual(report["groups"]["emea"]["total"], 0.3)
+
+
+class WriteReportTest(unittest.TestCase):
+ def test_write_report_creates_missing_parent_directories(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ destination = Path(tmp) / "out" / "nested" / "report.json"
+ report_tool.write_report({"total": 1}, destination)
+ self.assertTrue(destination.exists())
+ self.assertEqual(json.loads(destination.read_text(encoding="utf-8"))["total"], 1)
+ self.assertFalse(destination.with_name(destination.name + ".tmp").exists())
+
+
+class ScenarioTest(unittest.TestCase):
+ def _copy(self, name: str) -> Path:
+ target = Path(tempfile.mkdtemp(prefix="scenario-")) / name
+ shutil.copytree(SCENARIO_ROOT / name, target)
+ self.addCleanup(shutil.rmtree, target.parent, ignore_errors=True)
+ return target
+
+ def test_run_scenario_writes_report_file(self) -> None:
+ scenario = self._copy("api-tool")
+ report = report_tool.run_scenario(scenario)
+ written = json.loads((scenario / "report.json").read_text(encoding="utf-8"))
+ self.assertEqual(written, report)
+ self.assertEqual(report["total"], 7)
+ self.assertEqual(report["invalid"], 2)
+ self.assertEqual(sorted(report["groups"]), ["high", "low", "normal"])
+
+ def test_main_prints_summary_and_returns_zero(self) -> None:
+ scenario = self._copy("excel-report")
+ exit_code = report_tool.main([str(scenario)])
+ self.assertEqual(exit_code, 0)
+ self.assertTrue((scenario / "report.json").exists())
+
+ def test_main_rejects_missing_argument(self) -> None:
+ self.assertEqual(report_tool.main([]), 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/courses/hands-on-with-github-copilot/verify.py b/courses/hands-on-with-github-copilot/verify.py
new file mode 100644
index 0000000..e97532c
--- /dev/null
+++ b/courses/hands-on-with-github-copilot/verify.py
@@ -0,0 +1,291 @@
+#!/usr/bin/env python3
+"""Run the course contract against starter or solution.
+
+Objective completion evidence for a FlyPython shared-core course — the
+teaching contract lives in COURSE.md; this file is the exercise. Exit 0
+means the selected implementation satisfies the task contract;
+``--expect-failure`` reproduces the unfinished starter state and checks that
+the failing tests are the intended ones.
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+
+# Optional shared claim-receipt producer (docs/CLAIM-RECEIPT.md). The course
+# folder still verifies standalone — without the tools/ sibling or without
+# FLYPYTHON_CLAIM_SECRET set, verify.py behaves exactly as before.
+try:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools"))
+ import claim_receipt
+except ImportError:
+ claim_receipt = None
+
+EXPECTED_STARTER_FAILURES = (
+ "test_load_json_records_returns_list_of_dicts",
+ "test_unsupported_suffix_raises_value_error",
+ "test_invalid_records_are_isolated_with_reasons",
+ "test_group_totals_are_rounded_to_two_decimals",
+ "test_write_report_creates_missing_parent_directories",
+ "test_run_scenario_writes_report_file",
+ "test_main_prints_summary_and_returns_zero",
+)
+
+
+
+# ── PER-COURSE BLOCK ────────────────────────────────────────────
+# The only section allowed to differ across shared-core courses
+# (checkpoint ids and gates must still match — titles may carry the
+# tool's flavor). tools/verify_courses.py enforces this.
+COURSE_ID = 'course-github-copilot'
+# Documented constant: claim codes derive deterministically from
+# (COURSE_ID, checkpoint_id, COURSE_SALT). They are spot-checkable
+# self-reported evidence, not tamper-proof secrets — see
+# docs/repo-plan-0.0.4.md FP-411.
+COURSE_SALT = 'a5910c8a74df7af1'
+
+CHECKPOINTS = [
+ {"id": "l01", "gate": "attest", "title": '在 VS Code 里复现故障 / Reproduce the failure in VS Code'},
+ {"id": "l02", "gate": "attest", "title": '任务契约与指令文件 / Task contract and instructions files'},
+ {"id": "l03", "gate": "starter-suite", "title": '一轮一审的有界改动 / Bounded change, reviewed turn by turn'},
+ {"id": "l04", "gate": "both-suites", "title": '跑套件与审阅 / Run the suite and review'},
+ {"id": "l05", "gate": "attest", "title": '应用到自己的项目 / Apply to your project'},
+]
+# ── END PER-COURSE BLOCK ────────────────────────────────────────
+
+def _claim_code(checkpoint_id):
+ digest = hashlib.sha256(
+ (COURSE_ID + ":" + checkpoint_id + ":" + COURSE_SALT).encode("utf-8")
+ ).digest()
+ return base64.b32encode(digest).decode("ascii")[:8]
+
+
+def _display_title(title):
+ # FP-820: shared-core checkpoint titles are stored "Chinese / English";
+ # the default command prints English first, Chinese after (FP-709 debt).
+ if " / " in title:
+ left, _, right = title.partition(" / ")
+ if any("\u4e00" <= character <= "\u9fff" for character in left):
+ return right + " / " + left
+ return title
+
+def _run_suite(implementation):
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(ROOT / implementation)
+ return subprocess.run(
+ [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")],
+ env=environment, check=False, capture_output=True, text=True,
+ )
+
+def run_progress(as_json, receipt_out=None, default=False):
+ import time
+ t0 = time.monotonic()
+ starter = _run_suite("starter")
+ starter_ms = int((time.monotonic() - t0) * 1000)
+ t0 = time.monotonic()
+ solution = _run_suite("solution")
+ solution_ms = int((time.monotonic() - t0) * 1000)
+ starter_ok = starter.returncode == 0
+ solution_ok = solution.returncode == 0
+ rows = []
+ for checkpoint in CHECKPOINTS:
+ gate = checkpoint["gate"]
+ if gate == "attest":
+ status, kind = "attest", "attested"
+ elif gate == "starter-suite":
+ status = "passed" if starter_ok else "open"
+ kind = "objective"
+ else:
+ status = "passed" if (starter_ok and solution_ok) else "open"
+ kind = "objective"
+ code = _claim_code(checkpoint["id"]) if status in ("passed", "attest") else None
+ row = dict(checkpoint)
+ row["status"] = status
+ row["kind"] = kind
+ row["claim_code"] = code
+ rows.append(row)
+ secret = claim_receipt.receipts_enabled() if claim_receipt else None
+ receipts = []
+ if secret:
+ starter_tests = claim_receipt.count_tests(starter.stderr)
+ solution_tests = claim_receipt.count_tests(solution.stderr)
+ solution_hash = claim_receipt.solution_sha256(ROOT)
+ for checkpoint in CHECKPOINTS:
+ gate = checkpoint["gate"]
+ if gate == "starter-suite":
+ passed, tests, ms = starter_ok, starter_tests, starter_ms
+ elif gate == "both-suites":
+ passed = starter_ok and solution_ok
+ tests, ms = starter_tests + solution_tests, starter_ms + solution_ms
+ else:
+ continue
+ receipts.append(claim_receipt.make_receipt(
+ COURSE_ID, checkpoint["id"], passed=passed, tests=tests,
+ duration_ms=ms, impl_dir=ROOT / "starter",
+ solution_hash=solution_hash, secret=secret))
+ if default and not as_json:
+ # FP-820: bare ``python verify.py`` — check the learner's own
+ # implementation, print per-checkpoint status and earned claim codes,
+ # English first then Chinese (FP-709 debt). ``progress`` keeps its
+ # exact published output; this block is the only new surface.
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("passed" if starter_ok else "not passed")
+ + " / solution " + ("passed" if solution_ok else "not passed"))
+ for row in rows:
+ state = row["status"] + (" (self-attested)" if row["kind"] == "attested" else "")
+ code = row["claim_code"] if row["claim_code"] else "\u2014"
+ print(" " + row["id"] + " " + _display_title(row["title"]) + " [" + state + "] " + code)
+ open_gates = [row["id"] for row in rows
+ if row["kind"] == "objective" and row["status"] != "passed"]
+ if open_gates:
+ print("Next: keep implementing starter/ until " + ", ".join(open_gates)
+ + " show [passed]; then re-run: python verify.py")
+ else:
+ print("All objective checkpoints passed — the codes above are ready"
+ " to submit (batch POST /api/claims, see SKILL.md §5).")
+ print("\u4e2d\u6587\uff1a\u68c0\u67e5\u70b9\u72b6\u6001\u4e0e\u8ba4\u9886\u7801\u89c1\u4e0a\uff1b\u5e26 [passed]/[attest] \u7684\u884c\u6709\u8ba4\u9886\u7801\uff0c[open] \u7684\u884c\u7ee7\u7eed\u5728 starter/ \u91cc\u5b9e\u73b0\u540e\u518d\u8dd1\u3002")
+ print("Claim codes are self-reported evidence, recorded at flypython.com; never a certificate.")
+ print("\u8ba4\u9886\u7801\u662f\u81ea\u6211\u62a5\u544a\u7684\u8bc1\u636e\uff0c\u8bb0\u5f55\u5728 flypython.com\uff1b\u4e0d\u662f\u8bc1\u4e66\u3002")
+ if secret:
+ print(f"Signed run receipts prepared for {len(receipts)} gated checkpoint(s);"
+ " submit each with its claim to mark it as a local-run receipt.")
+ return 1 if open_gates else 0
+ if as_json:
+ document = {"course": COURSE_ID,
+ "starter_suite_passed": starter_ok,
+ "solution_suite_passed": solution_ok,
+ "checkpoints": rows}
+ if secret:
+ document["receipts"] = receipts
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ starter_state = "passed" if starter_ok else "not passed"
+ solution_state = "passed" if solution_ok else "not passed"
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + starter_state + " / solution " + solution_state)
+ for row in rows:
+ state = row["status"] + (" (self-attested)" if row["kind"] == "attested" else "")
+ code = "claim code " + row["claim_code"] if row["claim_code"] else "—"
+ print(" " + row["id"] + " " + row["title"] + " [" + state + "] " + code)
+ print("Claim codes are self-reported evidence, recorded at flypython.com; never a certificate.")
+ if secret:
+ print(f"Signed run receipts prepared for {len(receipts)} gated checkpoint(s);"
+ " submit each with its claim to mark them as a local-run receipt.")
+ if receipt_out and secret:
+ Path(receipt_out).write_text(
+ json.dumps({"receipts": receipts}, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8")
+ print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
+ return 0
+
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
+ nargs="?", default=None)
+ parser.add_argument("--json", action="store_true")
+ parser.add_argument("--expect-failure", action="store_true")
+ parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
+ args = parser.parse_args()
+
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
+
+ command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(ROOT / args.implementation)
+ result = subprocess.run(
+ command, env=environment, check=False, capture_output=True, text=True
+ )
+
+ if args.expect_failure:
+ if result.returncode == 0:
+ print("Expected the starter to fail, but it passed.", file=sys.stderr)
+ return 1
+ output = (result.stdout or "") + (result.stderr or "")
+ missing = [
+ name for name in EXPECTED_STARTER_FAILURES if name not in output
+ ]
+ if missing:
+ print(
+ "Starter failed for unexpected reasons; missing expected failures:",
+ file=sys.stderr,
+ )
+ print("\n".join(missing), file=sys.stderr)
+ print(output, file=sys.stderr)
+ return 1
+ print(
+ "Expected starter state reproduced: JSON inputs crash, invalid rows "
+ "abort the run, totals are unrounded, and reports need an existing "
+ "directory."
+ )
+ return 0
+ if result.returncode == 0:
+ print(f"{args.implementation}: all tests passed")
+ else:
+ sys.stderr.write((result.stderr or "") or (result.stdout or ""))
+ return result.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/hands-on-with-kimi-code/COURSE.md b/courses/hands-on-with-kimi-code/COURSE.md
index aa47d2d..12ca154 100644
--- a/courses/hands-on-with-kimi-code/COURSE.md
+++ b/courses/hands-on-with-kimi-code/COURSE.md
@@ -88,7 +88,7 @@ guides and playbooks — link, don't improvise.
- Badge: **Reproduce with Kimi Code in the loop Badge** (badge id `course-kimi-code`) — earned by claiming all five checkpoints.
- Challenges: L01–L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` — L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/hands-on-with-kimi-code/COURSE_cn.md b/courses/hands-on-with-kimi-code/COURSE_cn.md
index fe28282..c393657 100644
--- a/courses/hands-on-with-kimi-code/COURSE_cn.md
+++ b/courses/hands-on-with-kimi-code/COURSE_cn.md
@@ -79,7 +79,7 @@ Kimi Code 的安装与计费、自定义子代理编写、ACP 客户端开发,
- 徽章:**Kimi Code 协同复现徽章**(badge id `course-kimi-code`)——认领全部五个检查点获得。
- 挑战:L01–L05 检查点,各 10 分;在 flypython.com 上集齐五个再加 50 分课程徽章奖励。
- 证据:`python verify.py`——L03(有界改动)与 L04(验证与评审)由套件客观把关;L01/L02/L05 为学习者自我报告。
-- 提交:每个通过的检查点打印一个确定性的认领码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
## 文件夹地图
diff --git a/courses/hands-on-with-kimi-code/verify.py b/courses/hands-on-with-kimi-code/verify.py
index 4a178a1..54bf486 100644
--- a/courses/hands-on-with-kimi-code/verify.py
+++ b/courses/hands-on-with-kimi-code/verify.py
@@ -187,21 +187,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
environment = os.environ.copy()
diff --git a/courses/hands-on-with-openai-codex/COURSE.md b/courses/hands-on-with-openai-codex/COURSE.md
index 78ed58d..468808e 100644
--- a/courses/hands-on-with-openai-codex/COURSE.md
+++ b/courses/hands-on-with-openai-codex/COURSE.md
@@ -88,7 +88,7 @@ guides and playbooks — link, don't improvise.
- Badge: **Reproduce with Codex in the loop Badge** (badge id `course-codex-cli`) — earned by claiming all five checkpoints.
- Challenges: L01–L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` — L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/hands-on-with-openai-codex/COURSE_cn.md b/courses/hands-on-with-openai-codex/COURSE_cn.md
index de0bb91..4dbd825 100644
--- a/courses/hands-on-with-openai-codex/COURSE_cn.md
+++ b/courses/hands-on-with-openai-codex/COURSE_cn.md
@@ -78,7 +78,7 @@ Codex 应用的安装与计费、Codex CLI 的旗标、CI/CD 云端任务、MCP
- 徽章:**Codex 协同复现徽章**(badge id `course-codex-cli`)——认领全部五个检查点获得。
- 挑战:L01–L05 检查点,各 10 分;在 flypython.com 上集齐五个再加 50 分课程徽章奖励。
- 证据:`python verify.py`——L03(有界改动)与 L04(验证与评审)由套件客观把关;L01/L02/L05 为学习者自我报告。
-- 提交:每个通过的检查点打印一个确定性的认领码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
## 文件夹地图
diff --git a/courses/hands-on-with-openai-codex/verify.py b/courses/hands-on-with-openai-codex/verify.py
index e13bcec..e424a37 100644
--- a/courses/hands-on-with-openai-codex/verify.py
+++ b/courses/hands-on-with-openai-codex/verify.py
@@ -187,21 +187,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
environment = os.environ.copy()
diff --git a/courses/hands-on-with-zcode/COURSE.md b/courses/hands-on-with-zcode/COURSE.md
index 543765a..c72017a 100644
--- a/courses/hands-on-with-zcode/COURSE.md
+++ b/courses/hands-on-with-zcode/COURSE.md
@@ -87,7 +87,7 @@ companion repository has guides and playbooks — link, don't improvise.
- Badge: **Reproduce with ZCode in the loop Badge** (badge id `course-zcode`) — earned by claiming all five checkpoints.
- Challenges: L01–L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` — L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/hands-on-with-zcode/COURSE_cn.md b/courses/hands-on-with-zcode/COURSE_cn.md
index c279e61..992b4be 100644
--- a/courses/hands-on-with-zcode/COURSE_cn.md
+++ b/courses/hands-on-with-zcode/COURSE_cn.md
@@ -76,7 +76,7 @@ ZCode 的安装与套餐、Remote Control 的配对设置(仅提及)、模
- 徽章:**ZCode 协同复现徽章**(badge id `course-zcode`)——认领全部五个检查点获得。
- 挑战:L01–L05 检查点,各 10 分;在 flypython.com 上集齐五个再加 50 分课程徽章奖励。
- 证据:`python verify.py`——L03(有界改动)与 L04(验证与评审)由套件客观把关;L01/L02/L05 为学习者自我报告。
-- 提交:每个通过的检查点打印一个确定性的认领码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
## 文件夹地图
diff --git a/courses/hands-on-with-zcode/verify.py b/courses/hands-on-with-zcode/verify.py
index 3724123..2353490 100644
--- a/courses/hands-on-with-zcode/verify.py
+++ b/courses/hands-on-with-zcode/verify.py
@@ -187,21 +187,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
environment = os.environ.copy()
diff --git a/courses/llm-features-in-python/COURSE.md b/courses/llm-features-in-python/COURSE.md
new file mode 100644
index 0000000..42f98ab
--- /dev/null
+++ b/courses/llm-features-in-python/COURSE.md
@@ -0,0 +1,117 @@
+---
+id: course-llm-features
+type: course
+title: "Build LLM features in Python: structured output, tool calls, and evals"
+summary: Turn a demo-day model call into a feature you can ship — validate structured output, allowlist tool calls, retry with the error fed back, fall back to a human instead of guessing, and measure it with an offline eval that divides by every case.
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+badge:
+ id: course-llm-features
+ name_en: Model output is untrusted input
+ name_zh: 模型输出是不可信输入
+ requires: All five checkpoints claimed (L01–L05)
+course_id: course-llm-features
+---
+
+# Build LLM features in Python: structured output, tool calls, and evals
+
+> TL;DR: install the FlyPython Skill in your coding agent and let it fetch
+> this course, then say **"start lesson 1"**. (Lesson files arrive via the
+> Skill — you download nothing by hand.) You finish with `ticket_triage.py`:
+> a support-ticket triage feature that treats every model reply as
+> untrusted input, plus an offline eval with a pass/fail threshold. No API
+> key, no network, no third-party packages — a replay client stands in for
+> the model.
+
+## What you build
+
+A triage feature for support tickets: the model reads a ticket and replies
+with a category, a priority, a one-line summary, and whether a human must
+look at it. It may call a lookup tool first. Three scenario "skins" ship
+with the course, each with labelled cases and recorded model replies:
+
+| Skin | You are… | Tool the model may call |
+| --- | --- | --- |
+| `scenario/shop-support/` | routing an online shop's support inbox | `lookup_order(order_id)` |
+| `scenario/it-helpdesk/` | sorting internal IT requests | `lookup_device(asset_tag)` |
+| `scenario/saas-feedback/` | classifying in-app product feedback | none |
+
+The recorded replies are hand-written fixtures that imitate failure shapes
+models really produce — JSON in a Markdown fence, a category that is not on
+the list, `"priority": "high"`, an extra field, a tool that does not exist,
+a tool argument of the wrong type, chatty prose instead of JSON. They are
+**not** captured from any model, and the course makes no claim about how
+often any real model produces them.
+
+## Teaching contract (read this first, agent)
+
+- **Audience:** a Python developer who has called a model API in a script
+ or notebook and now has to put that call inside a product — where a
+ malformed reply, a hallucinated tool call, or an eval that looks better
+ than it is becomes somebody's incident. Not a prompt-engineering course
+ and not an introduction to Python.
+- **Prerequisites:** Python 3.11+ on PATH and any coding agent that can run
+ commands. Standard library only; no API key and no network access are
+ needed for the exercise.
+- **Tool:** tool-agnostic. The course has not yet been taught in a tool;
+ `REVIEW.md` records the mechanical verification and the pending
+ agent-taught run-through, which will name the exact tool and version.
+- **Lesson order:** L01 → L02 → L03 → L04 → L05, one lesson per session.
+ Never skip the checkpoint at the end of a lesson.
+- **Teaching style:** work from the files in this folder, not from general
+ knowledge about any provider's API. Quote the `TASK.md` line you are
+ satisfying. Make the smallest change that moves a test from red to
+ green. Never add dependencies, never edit `solution/`, `tests/`, or
+ `scenario/`, and ask before touching any file not named in the current
+ lesson. Never weaken validation to make a recorded reply pass.
+- **When to stop:** a lesson is done when its checkpoint command runs and
+ the learner can say, in their own words, what failed and why. If the
+ learner cannot, re-teach from the failing test, not from the solution.
+- **How to use `verify.py`:** the learner's command is plain
+ `python verify.py` — it runs the suite against `starter/`, prints
+ per-checkpoint status and earned claim codes, and exits non-zero until
+ every checkpoint is passed (l03, l04) or attested (l01, l02, l05). `starter --expect-failure`
+ and `solution` remain maintainer checks.
+- **Honesty rules:** an offline eval over a handful of recorded replies
+ says nothing about how a live model will behave on live traffic — say so.
+ Do not claim the feature is production-ready. Do not promise learning or
+ career outcomes.
+
+## What this course does NOT cover
+
+Any specific provider SDK or its structured-output mode, API keys and
+secrets handling, prompt wording, retrieval (RAG) and embeddings,
+fine-tuning, agent frameworks, streaming, cost and latency budgets, or
+online evaluation on production traffic. Lesson 5 shows where a real
+client plugs in; the companion repository has guides for the rest — link,
+don't improvise.
+
+## Badge contract
+
+- Badge: **Model output is untrusted input Badge** (badge id `course-llm-features`) — earned by claiming all five checkpoints.
+- Challenges: L01–L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
+- Evidence: `python verify.py` — L03 (validation, tool calls, retries) and L04 (honest eval) are objectively gated by the suite; L01/L02/L05 are learner-attested.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+
+## Folder map
+
+```
+COURSE.md / COURSE_cn.md this file (EN / 中文)
+lessons/L01.md … L05.md lessons (each has an _cn.md pair)
+scenario// scenario.json, cases.json, recordings.json, lookup data
+TASK.md / TASK_cn.md the task contract the change must satisfy
+starter/ticket_triage.py the demo-day implementation
+solution/ticket_triage.py the reviewed solution (do not copy in lesson 3)
+tests/test_ticket_triage.py the contract suite (15 tests, read-only)
+verify.py objective pass/fail evidence
+REVIEW.md maintainer run-through record
+```
+
+## Evidence and licensing
+
+`REVIEW.md` records the run-through state with dates, tool versions, and
+observed deviations. Code in this folder is MIT-licensed; lesson prose is
+CC BY 4.0 (see repository `LICENSE`). Report teaching drift or unclear
+lessons via the repository's `course-feedback` issue form.
diff --git a/courses/llm-features-in-python/COURSE_cn.md b/courses/llm-features-in-python/COURSE_cn.md
new file mode 100644
index 0000000..95a15ca
--- /dev/null
+++ b/courses/llm-features-in-python/COURSE_cn.md
@@ -0,0 +1,103 @@
+---
+id: course-llm-features
+type: course
+title: "用 Python 构建 LLM 功能:结构化输出、工具调用与评测"
+summary: "把演示日的一次模型调用变成能上线的功能——校验结构化输出、给工具调用设白名单、带着错误信息重试、拿不准就转人工而不是瞎猜,并用分母覆盖全部用例的离线评测来度量。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+badge:
+ id: course-llm-features
+ name_en: Model output is untrusted input
+ name_zh: 模型输出是不可信输入
+ requires: 认领全部五个检查点(L01–L05)
+course_id: course-llm-features
+---
+
+# 用 Python 构建 LLM 功能:结构化输出、工具调用与评测
+
+> 一句话:在你的编码 Agent 里装上 FlyPython Skill,让它取回本课程,然后
+> 说**「开始第 1 课」**。(课程文件经 Skill 送达——你不手动下载任何东西。)
+> 完成时你会得到 `ticket_triage.py`:一个把每条模型回复都当作不可信输入的
+> 工单分诊功能,外加一个带通过/失败阈值的离线评测。不需要 API key、不需要
+> 联网、不需要第三方包——由一个回放 client 代替模型。
+
+## 你要做的东西
+
+一个工单分诊功能:模型读一张工单,回复分类、优先级、一句话摘要,以及
+是否必须由人来看。它可以先调用一个查询工具。课程自带三个「场景皮肤」,
+每个都有带标注的用例和录好的模型回复:
+
+| 皮肤 | 你是…… | 模型可调用的工具 |
+| --- | --- | --- |
+| `scenario/shop-support/` | 给网店客服收件箱分流 | `lookup_order(order_id)` |
+| `scenario/it-helpdesk/` | 整理公司内部 IT 请求 | `lookup_device(asset_tag)` |
+| `scenario/saas-feedback/` | 给应用内产品反馈分类 | 无 |
+
+录好的回复是手写的练习数据,模仿模型真实会出现的失败形状——包在
+Markdown 围栏里的 JSON、不在列表里的分类、`"priority": "high"`、多出来的
+字段、不存在的工具、类型不对的工具参数、用闲聊散文代替 JSON。它们**不是**
+从任何模型采集的,课程也不对任何真实模型出现这些问题的频率做任何声明。
+
+## 教学契约(先读,Agent)
+
+- **受众:** 在脚本或 notebook 里调过模型 API、现在要把这个调用放进产品
+ 的 Python 开发者——在产品里,一条格式错误的回复、一次幻觉出来的工具
+ 调用、一个看起来比实际好的评测,都会变成某个人的事故。这不是提示词
+ 工程课,也不是 Python 入门。
+- **前置条件:** PATH 上有 Python 3.11+,以及任意一个能执行命令的编码
+ Agent。只用标准库;练习不需要 API key,也不需要联网。
+- **工具:** 与工具无关。本课程尚未在任何工具里实际授课;`REVIEW.md`
+ 记录机械验证结果和待完成的 Agent 授课实跑,实跑时会写明确切的工具与
+ 版本。
+- **课序:** L01 → L02 → L03 → L04 → L05,一次课一个会话。
+ 绝不跳过课尾的检查点。
+- **教学风格:** 从本文件夹里的文件出发,不要凭任何厂商 API 的通用知识
+ 发挥。引用你正在满足的那条 `TASK.md` 原文。做让测试从红变绿的最小
+ 改动。绝不加依赖,绝不改 `solution/`、`tests/` 或 `scenario/`,动当前
+ 课程未点名的任何文件前先询问。绝不为了让某条录好的回复通过而放松校验。
+- **何时停下:** 一节课的检查点命令跑通、且学习者能用自己的话说清
+ 哪里坏了、为什么,这节课才算完。学习者说不出就从失败测试重新教,
+ 不要从 solution 教。
+- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它针对
+ `starter/` 跑套件、打印逐检查点状态与已获认领码,直到每个检查点都
+ 通过(l03、l04)或已自报确认(l01、l02、l05)之前都以非零退出。`starter --expect-failure` 与 `solution` 留作
+ 维护者检查。
+- **诚实规则:** 在少量录好的回复上跑的离线评测,说明不了真实模型在真实
+ 流量上的表现——要讲明白。不要声称功能达到生产可用。不要承诺学习或
+ 职业结果。
+
+## 本课程不覆盖什么
+
+任何具体厂商的 SDK 或其结构化输出模式、API key 与密钥管理、提示词措辞、
+检索增强(RAG)与 embedding、微调、Agent 框架、流式输出、成本与延迟
+预算,以及在生产流量上的在线评测。第 5 课展示真实 client 从哪里接入;
+其余内容由配套仓库的指南承载——给链接,不即兴发挥。
+
+## 徽章契约
+
+- 徽章:**模型输出是不可信输入徽章**(badge id `course-llm-features`)——认领全部五个检查点获得。
+- 挑战:L01–L05 检查点,各 10 分;在 flypython.com 上集齐五个再加 50 分课程徽章奖励。
+- 证据:`python verify.py`——L03(校验、工具调用、重试)与 L04(诚实评测)由套件客观把关;L01/L02/L05 为学习者自我报告。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
+
+## 文件夹地图
+
+```
+COURSE.md / COURSE_cn.md 本文件(EN / 中文)
+lessons/L01.md … L05.md 课文(每课都有一个 _cn.md 对)
+scenario// scenario.json、cases.json、recordings.json、查询数据
+TASK.md / TASK_cn.md 改动必须满足的任务契约
+starter/ticket_triage.py 演示日版本的实现
+solution/ticket_triage.py 评审过的解答(第 3 课不要抄)
+tests/test_ticket_triage.py 契约套件(15 个测试,只读)
+verify.py 客观的通过/失败证据
+REVIEW.md 维护者实跑记录
+```
+
+## 证据与许可
+
+`REVIEW.md` 记录实跑状态——日期、工具版本、观察到的偏差。文件夹内代码
+采用 MIT 许可;课文文字采用 CC BY 4.0(见仓库 `LICENSE`)。发现教学漂移
+或课程不清楚之处,请通过仓库的 `course-feedback` issue 表单反馈。
diff --git a/courses/llm-features-in-python/REVIEW.md b/courses/llm-features-in-python/REVIEW.md
new file mode 100644
index 0000000..102b3b0
--- /dev/null
+++ b/courses/llm-features-in-python/REVIEW.md
@@ -0,0 +1,75 @@
+# Maintainer run-through record
+
+## 2026-10-11 — contract verification (mechanical)
+
+- Environment: Linux x86_64, Python 3.12.3, standard library only.
+- The scenario recordings (`scenario/*/recordings.json`) are hand-written
+ fixtures that imitate common reply failures (Markdown fences, invented
+ categories, string priorities, prose, out-of-allowlist tool calls). They
+ were not captured from any model, and no model API was called while
+ authoring or verifying this course.
+- Commands and results:
+ - `python verify.py starter --expect-failure` — exit 0; the twelve
+ expected failure names reproduced; three tests pass on the starter.
+ - `python verify.py solution` — exit 0; 15/15 tests pass.
+ - `python verify.py` — exit 1 on the shipped starter; l03/l04 `[open]`,
+ l01/l02/l05 `[pending]` with no code.
+ - Solution eval CLI:
+
+ | Skin | Output | Exit |
+ | --- | --- | --- |
+ | `shop-support` | `accuracy=0.750 correct=6/8 fallbacks=1 min_accuracy=0.800` | 1 |
+ | `it-helpdesk` | `accuracy=0.833 correct=5/6 fallbacks=1 min_accuracy=0.800` | 0 |
+ | `saas-feedback` | `accuracy=0.800 correct=4/5 fallbacks=0 min_accuracy=0.800` | 0 |
+
+ The starter prints `accuracy=1.000 correct=5/6` on `it-helpdesk`, the
+ contradiction Lesson 1 is built on.
+ - Lesson 3's failing-count table (12 → 9 → 7 → 3 → 0) was measured by
+ swapping the solution's functions into a scratch copy of the starter
+ one group at a time; `test_tool_result_is_fed_back_to_the_model` turns
+ red after the parser group, as the lesson says.
+ - The Lesson 3 exercise one-liner prints the retry feedback message
+ after the `triage` turn.
+ - `python tools/verify_courses.py` — contract satisfied.
+
+## Pending before this course can be called taught-in-tool
+
+- One full agent-taught run-through ("start lesson 1" through the Lesson 5
+ checkpoint) in a named coding agent and version, recording observed
+ deviations from `COURSE.md` here. The course is tool-agnostic; no
+ agent-taught session has happened yet.
+- Lesson 5 asks learners to apply the pattern to their own model-backed
+ feature with a real provider. Whether the contract transfers cleanly to a
+ provider's native structured-output or tool-calling API is unverified
+ here.
+
+## Deviation log
+
+### 2026-10-11 — agent solvability run (challenge mode, not taught mode)
+
+- Agent: a Cursor Cloud Agent subagent (Claude Opus 5.5), Linux, Python
+ 3.12.3, standard library only.
+- Method: worked in a copy of the course with `solution/` and this file
+ removed; implemented `starter/ticket_triage.py` from `TASK.md` and the
+ tests only, without access to the reference solution.
+- Result: green after one implementation pass (14/14 on the revision it
+ was given); the eval CLI printed the same three lines as the solution
+ table above.
+- Observed deviations and the changes they triggered:
+ - `TASK.md` did not define `attempts` in the fallback; the agent inferred
+ "number of rejected replies" from a test. `TASK.md`/`TASK_cn.md` now
+ state it.
+ - `TASK.md` did not say whether a tool call beyond `max_tool_calls` runs,
+ or whether refused calls count as `tool_calls`, and no test exercised
+ the budget. The contract now says only calls that ran are counted and
+ the over-budget call is not run; the new
+ `test_tool_call_budget_ends_in_the_fallback` enforces it (suite 14 → 15
+ tests, starter failures 11 → 12, lessons updated). The agent's
+ implementation passes the new test unchanged.
+ - `COURSE.md` said plain `python verify.py` exits non-zero "while l03 and
+ l04 are open"; it actually exits non-zero until l01/l02/l05 are also
+ attested. Wording corrected in `COURSE.md`/`COURSE_cn.md`.
+- Limitations: challenge-mode run (TASK.md → tests), not the taught
+ COURSE.md walkthrough; teaching quality remains pending. The agent
+ implemented all four functions in one pass, so it did not walk Lesson 3's
+ per-turn sequence (that sequence was measured mechanically, above).
diff --git a/courses/llm-features-in-python/TASK.md b/courses/llm-features-in-python/TASK.md
new file mode 100644
index 0000000..46394d0
--- /dev/null
+++ b/courses/llm-features-in-python/TASK.md
@@ -0,0 +1,64 @@
+# Task contract: ticket triage with a language model
+
+Change only `starter/ticket_triage.py`. Standard library only. Never modify
+`tests/`, `solution/`, or `scenario/`. `ReplayClient`, `build_messages`,
+`load_scenario`, and `main` are provided and already correct; the work is in
+the four functions below. A *client* is any callable
+`client(messages) -> str`; every string it returns is untrusted input.
+
+- `parse_triage(raw, categories) -> dict`:
+ - Strip surrounding whitespace and one surrounding Markdown code fence
+ (```` ``` ```` or ```` ```json ````) before parsing.
+ - The reply must be a JSON **object** with exactly the fields
+ `category`, `priority`, `summary`, `needs_human` — a missing field or an
+ extra field raises `TriageError` naming that field.
+ - `category` is one of *categories*; `priority` is an `int` from 1 to 4
+ (`bool` and `float` are rejected); `summary` is a non-empty string of at
+ most 120 characters after stripping; `needs_human` is a `bool`.
+ - Every rejection raises `TriageError` whose message names the offending
+ field. Non-JSON text and non-object JSON also raise `TriageError`.
+ - Return exactly the four fields, with `summary` stripped.
+- `run_tool_call(call, tools) -> (name, result)`:
+ - *tools* maps a tool name to
+ `{"function": callable, "parameters": {name: type}}` — it is the
+ allowlist.
+ - *call* must have exactly the keys `tool` and `arguments`. An
+ unregistered tool name raises `ToolCallError` naming the tool.
+ - *arguments* must have exactly the declared parameter names, each an
+ instance of its declared type (`bool` is not accepted for `int`).
+ Missing, unexpected, or mistyped arguments raise `ToolCallError` naming
+ the argument. The tool function is never called on a refused call.
+ - Return `(name, function(**arguments))`.
+- `triage(ticket_text, client, *, categories, tools=None, max_attempts=2, max_tool_calls=3) -> dict`:
+ - Start from `build_messages(...)`. After each reply, append
+ `{"role": "assistant", "content": raw}`.
+ - A reply that decodes to an object with a `tool` key is a tool call. On
+ success, append `{"role": "tool", "name": name, "content": json.dumps(result)}`
+ and ask the client again; successful tool calls do not count as
+ attempts. `tool_calls` counts only calls that ran. A tool call that
+ would exceed *max_tool_calls* is not run and ends in the fallback.
+ - Any rejected reply — invalid answer or refused tool call — counts as one
+ attempt and appends `{"role": "user", "content": ...}` that contains the
+ `TriageError` message, then asks again.
+ - A valid answer returns the four fields plus `attempts` (rejected replies
+ + 1), `tool_calls`, and `fallback: False`.
+ - After *max_attempts* rejected replies, return the fallback:
+ `{"category": None, "priority": None, "summary": ticket_text.strip()[:120], "needs_human": True}`
+ plus `attempts` (the number of rejected replies), `tool_calls`, and
+ `fallback: True`. Never guess a category.
+- `evaluate(cases, client_for, *, categories, tools=None) -> dict`:
+ - Run `triage` for every case with `client_for(case)`. A case is correct
+ when `category` and `needs_human` both match `case["expected"]`.
+ - Return `total` (every case), `correct`, `fallbacks`,
+ `schema_valid` (`total - fallbacks`), `accuracy`
+ (`round(correct / total, 3)`, `0.0` for no cases), and `failures`: one
+ `{"id", "reason"}` per incorrect or fallback case, in case order.
+ - Never skip a case: the denominator is every case you were given.
+
+`python ticket_triage.py [--min-accuracy X]` prints
+`accuracy=… correct=N/T fallbacks=F min_accuracy=…`, one line per failure,
+and returns 0 when `accuracy >= min_accuracy`, else 1; usage errors return 2.
+The threshold defaults to the scenario's `min_accuracy`.
+
+Done means `python verify.py starter` exits 0 with all fifteen tests
+passing.
diff --git a/courses/llm-features-in-python/TASK_cn.md b/courses/llm-features-in-python/TASK_cn.md
new file mode 100644
index 0000000..c6ff4f6
--- /dev/null
+++ b/courses/llm-features-in-python/TASK_cn.md
@@ -0,0 +1,59 @@
+# 任务契约:用语言模型做工单分诊
+
+只修改 `starter/ticket_triage.py`。仅用标准库。绝不修改 `tests/`、
+`solution/` 或 `scenario/`。`ReplayClient`、`build_messages`、
+`load_scenario` 和 `main` 已经提供且正确;要做的是下面四个函数。
+*client* 是任意可调用对象 `client(messages) -> str`;它返回的每个字符串
+都是不可信输入。
+
+- `parse_triage(raw, categories) -> dict`:
+ - 解析前去掉首尾空白,以及外面包着的一层 Markdown 代码围栏
+ (```` ``` ```` 或 ```` ```json ````)。
+ - 回复必须是一个 JSON **对象**,且恰好包含 `category`、`priority`、
+ `summary`、`needs_human` 四个字段——缺字段或多字段都抛出点名该字段的
+ `TriageError`。
+ - `category` 属于 *categories*;`priority` 是 1 到 4 的 `int`(`bool`
+ 和 `float` 都拒绝);`summary` 去掉首尾空白后是非空、最多 120 个字符的
+ 字符串;`needs_human` 是 `bool`。
+ - 每一种拒绝都抛出 `TriageError`,消息里点名出错的字段。非 JSON 文本和
+ 非对象 JSON 也抛 `TriageError`。
+ - 只返回这四个字段,`summary` 去掉首尾空白。
+- `run_tool_call(call, tools) -> (name, result)`:
+ - *tools* 把工具名映射到
+ `{"function": callable, "parameters": {name: type}}`——它就是白名单。
+ - *call* 必须恰好有 `tool` 和 `arguments` 两个键。未登记的工具名抛出
+ 点名该工具的 `ToolCallError`。
+ - *arguments* 必须恰好是声明的参数名,且每个值都是声明类型的实例
+ (`int` 参数不接受 `bool`)。缺参数、多参数、类型错都抛出点名该参数
+ 的 `ToolCallError`。被拒绝的调用绝不调用工具函数。
+ - 返回 `(name, function(**arguments))`。
+- `triage(ticket_text, client, *, categories, tools=None, max_attempts=2, max_tool_calls=3) -> dict`:
+ - 从 `build_messages(...)` 开始。每收到一条回复,追加
+ `{"role": "assistant", "content": raw}`。
+ - 解码后是带 `tool` 键的对象的回复是工具调用。成功时追加
+ `{"role": "tool", "name": name, "content": json.dumps(result)}` 再问一次
+ client;成功的工具调用不算尝试次数。`tool_calls` 只统计真正执行过的
+ 调用。会超出 *max_tool_calls* 的那次工具调用不执行,直接进入兜底。
+ - 任何被拒绝的回复——无效答案或被拒的工具调用——计一次尝试,并追加一条
+ 包含 `TriageError` 消息的 `{"role": "user", "content": ...}`,然后再问。
+ - 有效答案返回四个字段,外加 `attempts`(被拒回复数 + 1)、`tool_calls`
+ 和 `fallback: False`。
+ - 被拒回复达到 *max_attempts* 次后,返回兜底结果:
+ `{"category": None, "priority": None, "summary": ticket_text.strip()[:120], "needs_human": True}`,
+ 外加 `attempts`(被拒回复的次数)、`tool_calls` 和 `fallback: True`。
+ 绝不猜一个分类。
+- `evaluate(cases, client_for, *, categories, tools=None) -> dict`:
+ - 对每个用例用 `client_for(case)` 跑 `triage`。`category` 与
+ `needs_human` 都与 `case["expected"]` 一致才算正确。
+ - 返回 `total`(全部用例)、`correct`、`fallbacks`、
+ `schema_valid`(`total - fallbacks`)、`accuracy`
+ (`round(correct / total, 3)`,没有用例时为 `0.0`),以及 `failures`:
+ 每个错误或兜底的用例一条 `{"id", "reason"}`,按用例顺序。
+ - 绝不跳过用例:分母是你拿到的全部用例。
+
+`python ticket_triage.py [--min-accuracy X]` 打印
+`accuracy=… correct=N/T fallbacks=F min_accuracy=…`,每个失败一行;
+`accuracy >= min_accuracy` 时返回 0,否则 1;用法错误返回 2。阈值缺省为
+场景的 `min_accuracy`。
+
+完成的标准是 `python verify.py starter` 以 0 退出且全部十五个测试通过。
diff --git a/courses/llm-features-in-python/lessons/L01.md b/courses/llm-features-in-python/lessons/L01.md
new file mode 100644
index 0000000..e5b8443
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L01.md
@@ -0,0 +1,124 @@
+---
+id: course-llm-features-l01
+type: course
+title: "Lesson 1: A demo reply is not a feature"
+summary: "Fetch the course through the FlyPython Skill, run the starter's eval, and catch the number that cannot be true — then map every failing test to the real-world reply that breaks it."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That you can reproduce the failure before fixing anything: `python verify.py` must show l03 and l04 as `[open]`, and you can explain what the starter's eval output gets wrong."
+ - title: "Read the eval line twice"
+ body: "On `it-helpdesk` the starter prints `accuracy=1.000 correct=5/6`. Both numbers cannot be right. Find which one is lying and why."
+ - title: "When you are done"
+ body: "You can name, for one skin, which recorded reply each failing test stands for. Then claim the checkpoint with `python verify.py --attest l01`."
+---
+
+# Lesson 1: A demo reply is not a feature
+
+## Objective
+
+The course files are in your working folder, fetched by your agent. You
+have run the starter's eval, found the number in its output that cannot be
+true, and can say which real-world model reply each failing test stands
+for. No code is changed in this lesson.
+
+## Step 1 — Let the agent fetch this course (you download nothing)
+
+Open your coding agent in an empty working folder and paste this one
+sentence:
+
+> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `llm-features-in-python`.
+
+The agent gives you an authorization link with a short code. Open it, log
+in, make sure the code matches what the agent showed you, and click
+Allow — then tell the agent "done". It writes this course's files to
+`courses/llm-features-in-python/`.
+
+**Prerequisite:** these courses need a coding agent that can run commands
+*and* reach the network. A chat-only web AI cannot do them. The exercise
+itself needs no API key and makes no network calls.
+
+## Why this lesson exists
+
+The first version of almost every model-powered feature looks like the
+starter: send a prompt, `json.loads` the reply, use the fields. It works in
+the demo because the demo reply was well-formed. In a product, the model
+eventually wraps its JSON in a Markdown fence, invents a category, says
+`"priority": "high"`, asks for a tool you never built, or answers in prose.
+Every one of those is a normal reply — the code that handles it is the
+feature.
+
+## The lesson
+
+Ask the agent to survey without changing anything:
+
+**"Read COURSE.md — it is the teaching contract for this folder. Then read
+TASK.md and starter/ticket_triage.py. Do not change any file. Tell me which
+behaviors the tests expect that the starter does not have."**
+
+Then run the learner's command — your single check for the rest of the
+course:
+
+```bash
+python verify.py
+```
+
+Now run the starter's eval on two skins:
+
+```bash
+python starter/ticket_triage.py scenario/it-helpdesk
+python starter/ticket_triage.py scenario/shop-support
+```
+
+Read the first line of the `it-helpdesk` output: `accuracy=1.000
+correct=5/6`. Five out of six is not 100%. The starter's `evaluate` skips
+every case that crashed and divides by the survivors — so the harder the
+model fails, the better the eval looks.
+
+The suite has twelve failing tests. Grouped by the reply that breaks them:
+
+| Failing tests | The reply that breaks the starter |
+| --- | --- |
+| `test_parse_strips_a_markdown_fence` | ```` ```json { … } ``` ```` instead of bare JSON |
+| `test_parse_rejects_an_unknown_category_naming_the_field`, `test_parse_rejects_bad_priorities`, `test_parse_rejects_missing_extra_and_mistyped_fields` | `"category": "login"`, `"priority": "high"`, an extra `"confidence"`, prose, a JSON list — all accepted or crashing |
+| `test_unregistered_tool_is_refused`, `test_tool_arguments_are_validated_before_the_call` | `{"tool": "issue_refund", …}` or `{"asset_tag": 301}` — the model choosing what your code runs |
+| `test_invalid_reply_is_retried_with_the_error_fed_back`, `test_repeated_invalid_replies_fall_back_to_a_human`, `test_tool_call_budget_ends_in_the_fallback` | one bad reply ends the request instead of getting a second chance — or a safe fallback — and a model that keeps calling tools is never stopped |
+| `test_accuracy_divides_by_every_case_and_counts_fallbacks` | the eval quietly drops crashed cases |
+| `test_shop_support_eval_is_reported_below_threshold`, `test_it_helpdesk_eval_passes_its_threshold` | end-to-end: the eval line you just read |
+
+Three tests already pass — a valid reply parses, a tool result is sent back
+to the model, and a missing argument prints usage. The starter is not all
+wrong; it is wrong exactly where replies stop being polite.
+
+The maintainer command prints the failing names exactly:
+
+```bash
+python verify.py starter --expect-failure
+```
+
+## Exercise
+
+Pick the skin you most identify with, open its `recordings.json` next to
+its `cases.json`, and for each case write one line: what the starter does
+with that reply (accepts, crashes, or gets it wrong) and what it *should*
+do. Check yourself by asking the agent to confirm — not to fix.
+
+## Checkpoint
+
+Run `python verify.py`. When you can answer these without looking, claim
+the checkpoint with `python verify.py --attest l01`:
+
+1. Why does the starter's `it-helpdesk` eval print `accuracy=1.000` with
+ `correct=5/6`, and which of the two numbers is honest?
+2. Name two recorded replies that crash the starter and one it accepts
+ even though it is wrong.
+3. Why is a reply in a Markdown fence a normal reply rather than a model
+ bug you can ignore?
+
+## Expected evidence
+
+The default command's output, the two eval lines, and your per-case notes
+for one skin. Lesson 4 compares them against the fixed eval.
diff --git a/courses/llm-features-in-python/lessons/L01_cn.md b/courses/llm-features-in-python/lessons/L01_cn.md
new file mode 100644
index 0000000..283ee78
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L01_cn.md
@@ -0,0 +1,113 @@
+---
+id: course-llm-features-l01
+type: course
+title: "第 1 课:演示能跑不等于功能可靠"
+summary: "通过 FlyPython Skill 取回课程,跑一遍 starter 的评测,抓住那个不可能为真的数字——再把每个失败测试对应到让它坏掉的真实回复。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "先复现失败再动手:`python verify.py` 必须把 l03 和 l04 显示为 `[open]`,并且你能解释 starter 的评测输出错在哪。"
+ - title: "评测那一行读两遍"
+ body: "在 `it-helpdesk` 上 starter 打印 `accuracy=1.000 correct=5/6`。两个数不可能都对。找出哪个在撒谎、为什么。"
+ - title: "完成的标志"
+ body: "对某一个皮肤,你能说出每个失败测试代表哪条录好的回复。然后用 `python verify.py --attest l01` 认领本检查点。"
+---
+
+# 第 1 课:演示能跑不等于功能可靠
+
+## 目标
+
+课程文件已由你的 Agent 取回工作目录。你跑过 starter 的评测,找到了输出里
+那个不可能为真的数字,并能说出每个失败测试代表哪一种真实的模型回复。
+本课不改任何代码。
+
+## 第 1 步——让 Agent 取回本课(你不下载)
+
+在一个空工作目录里打开你的编码 Agent,粘贴这一句话:
+
+> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `llm-features-in-python`.
+
+Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 Agent
+显示的一致后点「允许」,然后对 Agent 说「好了」。它把本课文件写到
+`courses/llm-features-in-python/`。
+
+**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。只能
+聊天的网页 AI 做不了。练习本身不需要 API key,也不发任何网络请求。
+
+## 为什么有这一课
+
+几乎每个模型驱动功能的第一版都长得像这个 starter:发提示词、对回复
+`json.loads`、直接用字段。演示时能跑,因为演示那条回复格式恰好规整。
+进了产品,模型迟早会把 JSON 包进 Markdown 围栏、编一个分类、写
+`"priority": "high"`、要调用一个你根本没写的工具,或者干脆用散文回答。
+这些都是正常回复——处理它们的代码才是功能本身。
+
+## 本课内容
+
+让 Agent 先只读不动手:
+
+**“读 COURSE.md——它是本文件夹的教学契约。再读 TASK.md 和
+starter/ticket_triage.py。不要改任何文件。告诉我测试期望哪些行为、
+starter 缺了哪些。”**
+
+然后运行学员命令——整个课程你只需要这一条检查命令:
+
+```bash
+python verify.py
+```
+
+再在两个皮肤上跑 starter 的评测:
+
+```bash
+python starter/ticket_triage.py scenario/it-helpdesk
+python starter/ticket_triage.py scenario/shop-support
+```
+
+读 `it-helpdesk` 输出的第一行:`accuracy=1.000 correct=5/6`。六个对五个
+不是 100%。starter 的 `evaluate` 跳过每个崩掉的用例,再用剩下的做分母——
+模型失败得越厉害,评测反而越好看。
+
+套件里有十二个失败测试。按让它们坏掉的回复分组:
+
+| 失败的测试 | 让 starter 坏掉的回复 |
+| --- | --- |
+| `test_parse_strips_a_markdown_fence` | ```` ```json { … } ``` ```` 而不是裸 JSON |
+| `test_parse_rejects_an_unknown_category_naming_the_field`、`test_parse_rejects_bad_priorities`、`test_parse_rejects_missing_extra_and_mistyped_fields` | `"category": "login"`、`"priority": "high"`、多出来的 `"confidence"`、散文、JSON 列表——要么照单全收,要么崩溃 |
+| `test_unregistered_tool_is_refused`、`test_tool_arguments_are_validated_before_the_call` | `{"tool": "issue_refund", …}` 或 `{"asset_tag": 301}`——由模型决定你的代码运行什么 |
+| `test_invalid_reply_is_retried_with_the_error_fed_back`、`test_repeated_invalid_replies_fall_back_to_a_human`、`test_tool_call_budget_ends_in_the_fallback` | 一条坏回复直接终止请求,既不给第二次机会,也没有安全兜底;模型不停调用工具也没人拦 |
+| `test_accuracy_divides_by_every_case_and_counts_fallbacks` | 评测悄悄丢掉崩溃的用例 |
+| `test_shop_support_eval_is_reported_below_threshold`、`test_it_helpdesk_eval_passes_its_threshold` | 端到端:就是你刚读的那行评测输出 |
+
+有三个测试已经通过——合法回复能解析、工具结果会回传给模型、缺参数时
+打印用法。starter 不是全错;它恰好错在回复不再「客气」的地方。
+
+维护者命令会精确打印失败测试名:
+
+```bash
+python verify.py starter --expect-failure
+```
+
+## 练习
+
+挑一个你最有代入感的皮肤,把它的 `recordings.json` 和 `cases.json` 并排
+打开,每个用例写一行:starter 对这条回复做了什么(接受、崩溃、还是判错),
+以及它*应该*做什么。写完让 Agent 确认——不是让它修。
+
+## 检查点
+
+运行 `python verify.py`。不看材料能回答下面三题时,用
+`python verify.py --attest l01` 认领本检查点:
+
+1. starter 在 `it-helpdesk` 上为什么打印 `accuracy=1.000` 却又是
+ `correct=5/6`?两个数里哪个是诚实的?
+2. 说出两条会让 starter 崩溃的录好回复,以及一条它接受了但其实是错的。
+3. 为什么包在 Markdown 围栏里的回复是正常回复,而不是可以忽略的模型
+ bug?
+
+## 预期证据
+
+默认命令的输出、那两行评测输出,以及你对一个皮肤的逐用例笔记。第 4 课
+会拿它们和修好后的评测对比。
diff --git a/courses/llm-features-in-python/lessons/L02.md b/courses/llm-features-in-python/lessons/L02.md
new file mode 100644
index 0000000..f355ed8
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L02.md
@@ -0,0 +1,96 @@
+---
+id: course-llm-features-l02
+type: course
+title: "Lesson 2: Write the output contract"
+summary: "The prompt asks; the contract checks. Read TASK.md as the boundary between your code and the model — what a reply must look like, which tools it may run, and what happens when it does not comply."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That you can read `TASK.md` as testable statements and trace each to a test — and say why the format request in `build_messages` is not enough on its own."
+ - title: "A prompt is a request, not a guarantee"
+ body: "`build_messages` tells the model the exact format. The model usually follows it. 'Usually' is the reason `parse_triage` exists."
+ - title: "A tool call is the model choosing what your code runs"
+ body: "The tool registry is an allowlist and the arguments are untrusted input — the same rule you would apply to a web form."
+---
+
+# Lesson 2: Write the output contract
+
+## Objective
+
+You can read `TASK.md` as a set of testable statements about model replies,
+trace each statement to a test in `tests/test_ticket_triage.py`, and
+explain the three decisions the contract makes for you: what a valid reply
+is, which tool calls may run, and what happens when the model does not
+comply.
+
+## Why this lesson exists
+
+Open `build_messages` in the starter. It already tells the model the exact
+JSON fields, the allowed categories, the priority range, and how to call a
+tool. Many teams stop there — the prompt says what we want, so the reply
+will be what we want. The prompt is a request. The contract is what your
+code enforces when the request is ignored, and the tests are how you know
+it does.
+
+Provider APIs often offer a JSON or schema-constrained output mode. Where
+you use one, it reduces malformed replies; it does not tell you that the
+category is right, that a tool argument is safe to act on, or that your
+eval is honest. The contract in this course still applies.
+
+## The lesson
+
+Read `TASK.md` with the tests open beside it. The contract makes three
+decisions:
+
+**1. What a valid reply is.** Exactly four fields, each with a type and a
+range. Anything else is rejected with an error that names the field.
+
+| Contract line | Test |
+| --- | --- |
+| "strip … one surrounding Markdown code fence" | `test_parse_strips_a_markdown_fence` |
+| "`category` is one of *categories*" | `test_parse_rejects_an_unknown_category_naming_the_field` |
+| "`priority` is an `int` from 1 to 4 (`bool` and `float` are rejected)" | `test_parse_rejects_bad_priorities` |
+| "a missing field or an extra field raises `TriageError` naming that field" | `test_parse_rejects_missing_extra_and_mistyped_fields` |
+
+Why name the field? Because the error message is going back to the model in
+Lesson 3. "Invalid reply" gives it nothing to correct; "category must be one
+of billing, shipping, …; got 'login'" tells it exactly what to change.
+
+**2. Which tool calls may run.** The `tools` dict is the allowlist. An
+unregistered name is refused before anything runs, and arguments are
+checked against declared names and types before the function is called.
+The recorded reply `{"tool": "issue_refund", …}` in `shop-support` is the
+case to remember: a model that can name any function can make your code
+do anything that function does.
+
+**3. What happens on failure.** One retry with the error fed back, then a
+fallback that sends the ticket to a human with no category. The fallback
+never guesses — a confident wrong category is worse than an honest
+"needs a person".
+
+## Exercise
+
+Take a model call from a project you know — or invent one — and write its
+output contract in the same shape: the exact fields with types and ranges,
+the tools it may call with their argument types, and the failure policy
+(how many retries, what the fallback returns). Then mark each sentence:
+could a test assert it without reading your mind?
+
+## Checkpoint
+
+When you can answer these, claim the checkpoint with
+`python verify.py --attest l02`:
+
+1. `build_messages` already states the format. Why does `parse_triage`
+ still have to check it?
+2. Why must a rejected tool call never reach the tool function — what
+ could `issue_refund` have done?
+3. Why does the fallback return `category: None` instead of the most
+ likely category?
+
+## Expected evidence
+
+Your drafted output contract with each sentence marked testable or not.
diff --git a/courses/llm-features-in-python/lessons/L02_cn.md b/courses/llm-features-in-python/lessons/L02_cn.md
new file mode 100644
index 0000000..3012b54
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L02_cn.md
@@ -0,0 +1,82 @@
+---
+id: course-llm-features-l02
+type: course
+title: "第 2 课:把模型输出写成契约"
+summary: "提示词负责请求,契约负责检查。把 TASK.md 当成你的代码与模型之间的边界来读——回复必须长什么样、能运行哪些工具、不照做时怎么办。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "你能把 `TASK.md` 当成可测试陈述来读、把每条追查到一个测试——并说清为什么 `build_messages` 里的格式要求单靠它自己不够。"
+ - title: "提示词是请求,不是保证"
+ body: "`build_messages` 把确切格式告诉了模型。模型通常会照做。「通常」正是 `parse_triage` 存在的原因。"
+ - title: "工具调用就是模型在决定你的代码运行什么"
+ body: "工具注册表是白名单,参数是不可信输入——和你对待网页表单的规则一样。"
+---
+
+# 第 2 课:把模型输出写成契约
+
+## 目标
+
+你能把 `TASK.md` 当成一组关于模型回复的可测试陈述来读,把每条陈述追查到
+`tests/test_ticket_triage.py` 里的一个测试,并解释契约替你做的三个决定:
+什么是合法回复、哪些工具调用可以运行、模型不照做时怎么办。
+
+## 为什么有这节课
+
+打开 starter 里的 `build_messages`。它已经把确切的 JSON 字段、允许的分类、
+优先级范围、怎样调用工具都告诉了模型。很多团队到此为止——提示词说了我们
+要什么,回复就会是我们要的。提示词是请求。契约是请求被忽略时你的代码
+强制执行的东西,而测试是你确认它真的强制执行了的方法。
+
+厂商 API 往往提供 JSON 或受 schema 约束的输出模式。用上它能减少格式错误
+的回复;但它不会告诉你分类对不对、工具参数能不能放心执行、评测是否诚实。
+本课程的契约照样适用。
+
+## 本课内容
+
+把测试并排打开,读 `TASK.md`。契约做了三个决定:
+
+**1. 什么是合法回复。** 恰好四个字段,每个都有类型和范围。其他一律拒绝,
+错误信息点名字段。
+
+| 契约行 | 测试 |
+| --- | --- |
+| 「去掉……外面包着的一层 Markdown 代码围栏」 | `test_parse_strips_a_markdown_fence` |
+| 「`category` 属于 *categories*」 | `test_parse_rejects_an_unknown_category_naming_the_field` |
+| 「`priority` 是 1 到 4 的 `int`(`bool` 和 `float` 都拒绝)」 | `test_parse_rejects_bad_priorities` |
+| 「缺字段或多字段都抛出点名该字段的 `TriageError`」 | `test_parse_rejects_missing_extra_and_mistyped_fields` |
+
+为什么要点名字段?因为第 3 课会把错误信息回传给模型。「回复无效」没给它
+任何可改的依据;「category must be one of billing, shipping, …; got 'login'」
+准确告诉它该改哪里。
+
+**2. 哪些工具调用可以运行。** `tools` 字典就是白名单。未登记的名字在任何
+东西运行之前就被拒绝;参数在调用函数之前就按声明的名字和类型检查。
+`shop-support` 里那条录好的 `{"tool": "issue_refund", …}` 回复是要记住的
+例子:一个能点名任意函数的模型,能让你的代码做那个函数能做的任何事。
+
+**3. 失败时怎么办。** 带着错误信息重试一次,然后兜底:把工单交给人、不给
+分类。兜底绝不猜——一个自信但错误的分类,比一句诚实的「需要人来看」更糟。
+
+## 练习
+
+从你熟悉的项目里拿一个模型调用——或者编一个——用同样的形状写出它的输出
+契约:确切的字段及其类型和范围、它可以调用的工具及参数类型、失败策略
+(重试几次、兜底返回什么)。然后给每句话打标:测试套件不看你的心思能
+断言它吗?
+
+## 检查点
+
+能回答下面三题时,用 `python verify.py --attest l02` 认领本检查点:
+
+1. `build_messages` 已经说明了格式。为什么 `parse_triage` 还要再检查?
+2. 为什么被拒绝的工具调用绝不能到达工具函数——`issue_refund` 本来可能
+ 做什么?
+3. 为什么兜底返回 `category: None`,而不是最可能的那个分类?
+
+## 预期证据
+
+你起草的输出契约,每句都标明可测或不可测。
diff --git a/courses/llm-features-in-python/lessons/L03.md b/courses/llm-features-in-python/lessons/L03.md
new file mode 100644
index 0000000..6b32c56
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L03.md
@@ -0,0 +1,99 @@
+---
+id: course-llm-features-l03
+type: course
+title: "Lesson 3: Validate, allowlist tools, retry — one group at a time"
+summary: "Drive the starter to green in four bounded turns — the parser, the tool gate, the retry-and-fallback loop, the eval — and watch one fix expose a bug the old code was hiding."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That the starter passes all fifteen tests through changes you supervised — `python verify.py` shows this code only once the starter suite is green."
+ - title: "Expect one test to turn red"
+ body: "After the parser turn, `test_tool_result_is_fed_back_to_the_model` fails. The old loop spotted tool calls by parsing them loosely; a strict parser rejects them. The loop has to decode first, then decide."
+ - title: "Never loosen to pass"
+ body: "If a recorded reply fails validation, the fix is never to accept it. Tests and recordings are read-only; the code moves."
+---
+
+# Lesson 3: Validate, allowlist tools, retry — one group at a time
+
+## Objective
+
+The starter passes all fifteen tests through four bounded turns, and you
+can explain every diff without reading the solution — including the one
+test that went red in the middle.
+
+## Why this lesson exists
+
+The tempting move is "make it robust" in one prompt. You get a large diff
+that wraps everything in `try/except`, accepts anything that looks close,
+and passes some tests for the wrong reasons. Four bounded turns, each pinned
+to one contract decision from Lesson 2, keep every line explainable.
+
+## The lesson
+
+Start a fresh agent session and say:
+
+**"Work through TASK.md against starter/ticket_triage.py, one function per
+turn: parse_triage, then run_tool_call, then triage, then evaluate. After
+each turn run `python verify.py starter`, show me the diff, and wait for me
+before the next turn. Do not edit tests/, solution/, or scenario/."**
+
+Expect the failing count to move like this:
+
+| After turn | Failing | What changed |
+| --- | --- | --- |
+| start | 12 | — |
+| `parse_triage` | 9 | four parser tests pass; **one passing test turns red** |
+| `run_tool_call` | 7 | the allowlist and argument checks |
+| `triage` | 3 | retry with feedback, fallback, the tool-call budget, and the tool path repaired |
+| `evaluate` | 0 | the denominator, fallbacks, and the two end-to-end tests |
+
+The red test after turn one is the most useful moment in the course.
+`test_tool_result_is_fed_back_to_the_model` passed only because the
+starter's `triage` ran every reply through a parser that accepted any
+JSON, then looked for a `tool` key. A strict parser rejects a tool call —
+it is not a triage answer. The fix belongs in `triage` (decode the object,
+then branch on `tool`), not in loosening the parser. If the agent proposes
+to make `parse_triage` accept tool calls again, reject it and point at the
+contract.
+
+Hold every turn to these boundaries:
+
+- Only `starter/ticket_triage.py` changes. Any diff in `tests/`,
+ `solution/`, or `scenario/` is rejected.
+- No bare `except:` and no `except Exception` in `triage` — the loop
+ catches `TriageError` (which `ToolCallError` extends) and nothing else.
+ A client that raises is a different problem and must stay visible.
+- Error messages name the field or argument. "Invalid" is not a message.
+- The fallback never guesses a category.
+- Standard library only.
+
+## Exercise
+
+After the `triage` turn, read the messages a retry sends. Run this in the
+course folder and read the last message of the second call:
+
+```bash
+python -c "import sys; sys.path.insert(0, 'starter'); import ticket_triage as t; c = t.ReplayClient(['{\"category\": \"login\", \"priority\": 2, \"summary\": \"x\", \"needs_human\": false}', '{\"category\": \"account\", \"priority\": 2, \"summary\": \"x\", \"needs_human\": false}']); print(t.triage('cannot log in', c, categories=['account', 'other'])); print(c.calls[1][-1])"
+```
+
+Explain in one sentence why that message is what makes a retry worth
+doing.
+
+## Checkpoint
+
+Run `python verify.py`. This checkpoint's code appears only when the
+starter suite is green. You pass the lesson when you can answer:
+
+1. Which test turned red after the parser turn, and why was the fix in
+ `triage` rather than `parse_triage`?
+2. What exactly does your `run_tool_call` check before it calls the
+ function, and in which order?
+3. Why does the loop catch only `TriageError`?
+
+## Expected evidence
+
+A green `python verify.py starter`, the four diffs, and your explanation of
+the red-then-green test.
diff --git a/courses/llm-features-in-python/lessons/L03_cn.md b/courses/llm-features-in-python/lessons/L03_cn.md
new file mode 100644
index 0000000..06c43d7
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L03_cn.md
@@ -0,0 +1,93 @@
+---
+id: course-llm-features-l03
+type: course
+title: "第 3 课:校验、工具白名单、重试——一次一组"
+summary: "用四个有界轮次把 starter 推到全绿——解析器、工具关口、重试与兜底循环、评测——并亲眼看到一处修复暴露出旧代码藏着的 bug。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "starter 通过你监督的改动让十五个测试全绿——`python verify.py` 只在 starter 套件变绿后显示本检查点的码。"
+ - title: "预期会有一个测试变红"
+ body: "解析器那一轮之后,`test_tool_result_is_fed_back_to_the_model` 会失败。旧循环靠宽松解析来发现工具调用;严格的解析器会拒绝它们。循环必须先解码、再判断。"
+ - title: "绝不为通过而放松"
+ body: "录好的回复校验不过时,修法永远不是接受它。测试和录音都是只读的;动的是代码。"
+---
+
+# 第 3 课:校验、工具白名单、重试——一次一组
+
+## 目标
+
+starter 通过四个有界轮次让十五个测试全绿,并且不看 solution 你也讲得清
+每一个 diff——包括中途变红的那一个测试。
+
+## 为什么有这节课
+
+最诱人的做法是一句「把它改健壮」。你会得到一个大 diff:处处包
+`try/except`、看着差不多就接受、有些测试因为错误的理由通过。四个有界
+轮次,各自钉在第 2 课的一个契约决定上,才能让每一行都讲得清。
+
+## 本课内容
+
+开一个新的 Agent 会话,说:
+
+**“按 TASK.md 改 starter/ticket_triage.py,每轮只做一个函数:先
+parse_triage,再 run_tool_call,再 triage,最后 evaluate。每轮之后跑
+`python verify.py starter`,给我看 diff,等我确认再做下一轮。不要改
+tests/、solution/ 或 scenario/。”**
+
+预期失败数这样变化:
+
+| 哪一轮之后 | 失败数 | 改了什么 |
+| --- | --- | --- |
+| 开始 | 12 | — |
+| `parse_triage` | 9 | 四个解析器测试通过;**一个原本通过的测试变红** |
+| `run_tool_call` | 7 | 白名单与参数检查 |
+| `triage` | 3 | 带反馈重试、兜底、工具调用上限,以及修好的工具路径 |
+| `evaluate` | 0 | 分母、兜底计数,以及两个端到端测试 |
+
+第一轮之后变红的那个测试,是整门课最有价值的时刻。
+`test_tool_result_is_fed_back_to_the_model` 之前能过,只是因为 starter 的
+`triage` 把每条回复都交给一个接受任意 JSON 的解析器,再去找 `tool` 键。
+严格的解析器会拒绝工具调用——它不是分诊答案。修法属于 `triage`(先解码
+对象,再按 `tool` 分支),而不是放松解析器。如果 Agent 提议让
+`parse_triage` 重新接受工具调用,拒绝它,并指向契约。
+
+每一轮都守住这些边界:
+
+- 只动 `starter/ticket_triage.py`。`tests/`、`solution/`、`scenario/` 里的
+ 任何 diff 都拒绝。
+- `triage` 里不许有裸 `except:`,也不许 `except Exception`——循环只捕获
+ `TriageError`(`ToolCallError` 是它的子类),别的都不捕获。client 抛
+ 异常是另一类问题,必须保持可见。
+- 错误信息要点名字段或参数。「无效」不算信息。
+- 兜底绝不猜分类。
+- 只用标准库。
+
+## 练习
+
+`triage` 那一轮之后,读一读重试时发出的消息。在课程文件夹里运行下面的
+命令,读第二次调用的最后一条消息:
+
+```bash
+python -c "import sys; sys.path.insert(0, 'starter'); import ticket_triage as t; c = t.ReplayClient(['{\"category\": \"login\", \"priority\": 2, \"summary\": \"x\", \"needs_human\": false}', '{\"category\": \"account\", \"priority\": 2, \"summary\": \"x\", \"needs_human\": false}']); print(t.triage('cannot log in', c, categories=['account', 'other'])); print(c.calls[1][-1])"
+```
+
+用一句话解释:为什么正是这条消息让重试值得做。
+
+## 检查点
+
+运行 `python verify.py`。本检查点的码只在 starter 套件全绿时出现。不看
+材料能回答即算通过:
+
+1. 解析器那一轮之后哪个测试变红了?为什么修法在 `triage` 而不在
+ `parse_triage`?
+2. 你的 `run_tool_call` 在调用函数之前到底检查了什么、按什么顺序?
+3. 为什么循环只捕获 `TriageError`?
+
+## 预期证据
+
+一次全绿的 `python verify.py starter`、四个 diff,以及你对那个先红后绿的
+测试的解释。
diff --git a/courses/llm-features-in-python/lessons/L04.md b/courses/llm-features-in-python/lessons/L04.md
new file mode 100644
index 0000000..ba887bc
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L04.md
@@ -0,0 +1,104 @@
+---
+id: course-llm-features-l04
+type: course
+title: "Lesson 4: Read the eval honestly"
+summary: "A green suite proves the contract; the eval measures the feature. Run all three skins, accept the one that fails its threshold, and write down what a handful of recorded replies can and cannot tell you."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That starter AND solution both pass — the maintainer pair `verify.py starter` / `verify.py solution` must both be green, and `python verify.py` prints this code once your starter suite passes."
+ - title: "A failing eval can be the correct result"
+ body: "`shop-support` reports 0.750 against a 0.800 threshold and exits 1. Your code is right; the feature on that data is not good enough yet. Lowering the threshold is not a fix."
+ - title: "When you are done"
+ body: "You have a three-line evidence record — verified / not verified / known limits — that a teammate could act on."
+---
+
+# Lesson 4: Read the eval honestly
+
+## Objective
+
+Both suites pass, you have run the eval on every skin, and you hold a
+written record of what the numbers prove and what they do not.
+
+## Why this lesson exists
+
+In Lesson 1 the starter's eval said 100% on a skin where one case in six
+had crashed. That is the most common way model features get shipped too
+early: not a bad model, an eval that hides its failures. Now that the eval
+divides by every case, the numbers can go *down* — and that is the point.
+
+## The lesson
+
+Run the maintainer pair in a terminal yourself:
+
+```bash
+python verify.py starter
+python verify.py solution
+```
+
+Then run your eval on every skin:
+
+```bash
+python starter/ticket_triage.py scenario/shop-support
+python starter/ticket_triage.py scenario/it-helpdesk
+python starter/ticket_triage.py scenario/saas-feedback
+```
+
+You should see:
+
+| Skin | Result | Exit |
+| --- | --- | --- |
+| `shop-support` | `accuracy=0.750 correct=6/8 fallbacks=1` | 1 |
+| `it-helpdesk` | `accuracy=0.833 correct=5/6 fallbacks=1` | 0 |
+| `saas-feedback` | `accuracy=0.800 correct=4/5 fallbacks=0` | 0 |
+
+Compare `it-helpdesk` with Lesson 1: the starter said 1.000, your eval says
+0.833. Same recorded replies, honest denominator.
+
+Now read each failure line as a reviewer:
+
+1. **`shop-support` fails its threshold — correctly.** Case `s08` got a
+ valid but wrong category; `s07` fell back to a human after two prose
+ replies. Nothing in your code is wrong. The feature, on this data, is
+ below the bar you set. Try `--min-accuracy 0.7` and watch it "pass" —
+ then write down why that is not a fix.
+2. **A fallback is safe but not free.** `h06` fell back because both
+ replies broke the 120-character summary limit. Count fallbacks
+ separately: a feature that falls back on half the tickets is safe and
+ useless.
+3. **Look at what the eval does not score.** Correctness here is
+ `category` + `needs_human`. `priority` and `summary` are validated for
+ shape but never compared to a label. Decide whether that is acceptable
+ for your feature.
+4. **Check the edges the suite skips.** What happens if `cases.json` is
+ empty? If `recordings.json` has fewer replies than the loop asks for?
+ (`ReplayClient` raises — your loop should let it.) Run one and write
+ down what happened.
+
+## Exercise
+
+Write the three-line evidence record for this feature:
+
+- **Verified:** the commands you ran and what they proved.
+- **Not verified:** at least — live model behavior, traffic you have not
+ recorded, unscored fields, and anything the recordings do not imitate.
+- **Known limits:** the threshold each skin missed or barely met, and the
+ fallback rate.
+
+## Checkpoint
+
+Run `python verify.py`. This checkpoint's code prints once the starter
+suite passes; the maintainer pair above must both be green for the gate to
+mean what it says. You pass the lesson when you can answer:
+
+1. Why is `shop-support` exiting 1 the correct behavior of your code?
+2. What does `accuracy=0.833` on six hand-written cases tell you about a
+ live model on live tickets?
+3. Which field would you add to the eval's scoring first, and why?
+
+## Expected evidence
+
+Both green runs, the three eval lines, and your three-line evidence record.
diff --git a/courses/llm-features-in-python/lessons/L04_cn.md b/courses/llm-features-in-python/lessons/L04_cn.md
new file mode 100644
index 0000000..1017be6
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L04_cn.md
@@ -0,0 +1,96 @@
+---
+id: course-llm-features-l04
+type: course
+title: "第 4 课:诚实地读评测"
+summary: "套件全绿证明的是契约;评测度量的是功能。跑完三个皮肤,接受那个没过阈值的结果,并写下少量录好的回复能说明什么、不能说明什么。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "starter 和 solution 双双通过——维护者那一对命令 `verify.py starter` / `verify.py solution` 都必须是绿的,你的 starter 套件通过后 `python verify.py` 打印本检查点的码。"
+ - title: "评测不通过也可以是正确结果"
+ body: "`shop-support` 报出 0.750,阈值 0.800,以 1 退出。你的代码是对的;这个功能在这份数据上还不够好。调低阈值不是修复。"
+ - title: "完成的标志"
+ body: "你有一份三行证据记录——已验证 / 未验证 / 已知局限——同事拿到就能据此行动。"
+---
+
+# 第 4 课:诚实地读评测
+
+## 目标
+
+两个套件都通过,你在每个皮肤上跑过评测,并手握一份书面记录:这些数字
+证明了什么、没证明什么。
+
+## 为什么有这节课
+
+第 1 课里,starter 的评测在一个六个用例崩了一个的皮肤上报出 100%。模型
+功能过早上线,最常见的原因就是这个:不是模型差,而是评测把失败藏起来了。
+现在评测的分母是全部用例,数字可能会*下降*——这正是目的所在。
+
+## 本课内容
+
+亲自在终端里跑维护者那一对命令:
+
+```bash
+python verify.py starter
+python verify.py solution
+```
+
+再在每个皮肤上跑你的评测:
+
+```bash
+python starter/ticket_triage.py scenario/shop-support
+python starter/ticket_triage.py scenario/it-helpdesk
+python starter/ticket_triage.py scenario/saas-feedback
+```
+
+你应该看到:
+
+| 皮肤 | 结果 | 退出码 |
+| --- | --- | --- |
+| `shop-support` | `accuracy=0.750 correct=6/8 fallbacks=1` | 1 |
+| `it-helpdesk` | `accuracy=0.833 correct=5/6 fallbacks=1` | 0 |
+| `saas-feedback` | `accuracy=0.800 correct=4/5 fallbacks=0` | 0 |
+
+拿 `it-helpdesk` 和第 1 课对比:starter 说 1.000,你的评测说 0.833。同样的
+录好回复,诚实的分母。
+
+现在像评审者一样读每一行失败:
+
+1. **`shop-support` 没过阈值——这是对的。** 用例 `s08` 得到一个合法但错误
+ 的分类;`s07` 两次散文回复后兜底给了人。你的代码没有任何错。这个功能
+ 在这份数据上低于你定的标准。试试 `--min-accuracy 0.7`,看它「通过」——
+ 然后写下为什么这不是修复。
+2. **兜底安全,但不是免费的。** `h06` 兜底是因为两次回复都超过了 120 字符
+ 的摘要上限。把兜底单独计数:一个对一半工单都兜底的功能,安全但没用。
+3. **看评测没打分的部分。** 这里的正确性是 `category` + `needs_human`。
+ `priority` 和 `summary` 只校验形状,从不与标注比较。判断这对你的功能
+ 是否可以接受。
+4. **检查套件没钉的边界。** `cases.json` 为空会怎样?`recordings.json` 里的
+ 回复比循环要的少会怎样?(`ReplayClient` 会抛异常——你的循环应该让它
+ 抛出来。)挑一个跑一下,记下发生了什么。
+
+## 练习
+
+为这个功能写三行证据记录:
+
+- **已验证:** 你跑过的命令和它们证明了什么。
+- **未验证:** 至少包括——真实模型的行为、你没录到的流量、没打分的字段,
+ 以及录音没有模仿到的一切。
+- **已知局限:** 每个皮肤没达到或勉强达到的阈值,以及兜底率。
+
+## 检查点
+
+运行 `python verify.py`。starter 套件通过后本检查点的码就会打印;上面那
+一对维护者命令都必须是绿的,这道门才名副其实。不看材料能回答即算通过:
+
+1. 为什么 `shop-support` 以 1 退出是你的代码的正确行为?
+2. 在六个手写用例上的 `accuracy=0.833`,能告诉你真实模型处理真实工单时
+ 的什么?
+3. 你会最先把哪个字段加进评测打分,为什么?
+
+## 预期证据
+
+两次全绿运行、三行评测输出,以及你的三行证据记录。
diff --git a/courses/llm-features-in-python/lessons/L05.md b/courses/llm-features-in-python/lessons/L05.md
new file mode 100644
index 0000000..82dc403
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L05.md
@@ -0,0 +1,106 @@
+---
+id: course-llm-features-l05
+type: course
+title: "Lesson 5: Apply it to your own LLM feature"
+summary: "Put a real model behind the same seam: a client callable, an output contract, an allowlist, a fallback, and an eval you record once and replay on every change."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "Self-attested: you confirm you applied the pattern to a model call you own. The evidence is your contract, your recorded cases, and your eval line — not a code we can check."
+ - title: "The client is the seam"
+ body: "Everything in this course depends only on `client(messages) -> str`. A live provider call fits behind that callable; so does a replay of what it said last time."
+ - title: "Recordings are data"
+ body: "Replies recorded from real traffic can contain customer data. Review them like any other dataset before they go into git."
+---
+
+# Lesson 5: Apply it to your own LLM feature
+
+## Objective
+
+You apply the pattern — output contract, validated parsing, tool allowlist,
+retry and fallback, an honest offline eval — to a model call in a project
+you own, and record what the eval said.
+
+## Why this lesson exists
+
+The course used hand-written replies so it could run anywhere, offline.
+Your feature talks to a real model. The pattern transfers because the only
+thing the code ever depended on was a callable that takes messages and
+returns text.
+
+## The lesson
+
+1. **Wrap your provider behind the seam.** Write a function
+ `live(messages) -> str` that calls your provider's API and returns the
+ reply text. Read the API key from an environment variable; never put it
+ in code or in a recording.
+2. **Write the contract first.** Exact fields, types, ranges; the tools and
+ their argument types; the retry count and the fallback. Lesson 2's
+ exercise is your draft.
+3. **Record once, replay on every change.** Wrap the live client so each
+ reply is saved, run your labelled cases through it once, then evaluate
+ from the recordings on every later change — no key, no cost, the same
+ answer every time:
+
+ ```python
+ import json
+ from pathlib import Path
+
+
+ class RecordingClient:
+ """Wrap a live client and keep every reply so an eval can replay it."""
+
+ def __init__(self, live):
+ self.live = live
+ self.replies = []
+
+ def __call__(self, messages):
+ text = self.live(messages)
+ self.replies.append(text)
+ return text
+
+
+ def save_recordings(recorded: dict[str, list[str]], path: Path) -> None:
+ path.write_text(
+ json.dumps({"replies": recorded}, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ ```
+
+ Re-record deliberately — when you change the prompt, the model, or the
+ provider — and compare the new eval line with the old one.
+4. **Gate on the eval.** Pick a threshold before you look at the result,
+ and make the eval command exit non-zero below it — the same shape as
+ `ticket_triage.py`.
+5. **Record what it does not prove.** Write the three lines: verified /
+ not verified / known limits. "Not verified" always includes live traffic
+ you have not recorded.
+
+If your feature does not fit — the output is free text with no fields to
+validate, or there are too few cases to label — write down where the
+pattern stopped working. That judgment is part of the skill.
+
+## Exercise
+
+Label at least ten real or realistic inputs for your feature before you
+record anything. Note which ones you expect to fall back.
+
+## Checkpoint
+
+This checkpoint is self-attested. When you can answer the questions below,
+claim it with `python verify.py --attest l05`:
+
+1. What does your `live` client return, and where does its key come from?
+2. What was your first eval line, and did it pass the threshold you chose
+ in advance?
+3. What is on your "not verified" list that a stakeholder would assume was
+ covered?
+
+## Expected evidence
+
+Your output contract, your labelled cases, the first eval line, and the
+three-line record. Keep recordings with customer data out of public
+repositories.
diff --git a/courses/llm-features-in-python/lessons/L05_cn.md b/courses/llm-features-in-python/lessons/L05_cn.md
new file mode 100644
index 0000000..371bc8f
--- /dev/null
+++ b/courses/llm-features-in-python/lessons/L05_cn.md
@@ -0,0 +1,95 @@
+---
+id: course-llm-features-l05
+type: course
+title: "第 5 课:接到你自己的模型功能上"
+summary: "把真实模型放到同一个接缝后面:一个 client 可调用对象、一份输出契约、一张白名单、一个兜底,以及录一次、每次改动都回放的评测。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "自我报告:你确认把这套模式用到了自己的一个模型调用上。证据是你的契约、你录下的用例和你的评测输出——不是我们查得了的码。"
+ - title: "client 就是接缝"
+ body: "本课程的一切只依赖 `client(messages) -> str`。真实的厂商调用能放进这个可调用对象后面;上次它说过的话的回放也能。"
+ - title: "录音是数据"
+ body: "从真实流量录下的回复可能含有客户数据。进 git 之前像对待任何数据集一样审查它们。"
+---
+
+# 第 5 课:接到你自己的模型功能上
+
+## 目标
+
+你把这套模式——输出契约、带校验的解析、工具白名单、重试与兜底、诚实的
+离线评测——用到你自己项目里的一个模型调用上,并记录评测给出的结果。
+
+## 为什么有这节课
+
+课程用手写回复,是为了在任何地方都能离线运行。你的功能连的是真实模型。
+模式之所以能迁移,是因为代码自始至终只依赖一个东西:一个接收消息、返回
+文本的可调用对象。
+
+## 本课内容
+
+1. **把你的厂商调用包到接缝后面。** 写一个函数 `live(messages) -> str`,
+ 调用你所用厂商的 API 并返回回复文本。API key 从环境变量读取;绝不写进
+ 代码,也绝不进录音。
+2. **先写契约。** 确切的字段、类型、范围;工具及其参数类型;重试次数和
+ 兜底。第 2 课练习里的草稿就是起点。
+3. **录一次,每次改动都回放。** 包一层 live client,把每条回复都存下来;
+ 用它把带标注的用例跑一遍,之后每次改动都从录音评测——不要 key、不花钱、
+ 每次答案相同:
+
+ ```python
+ import json
+ from pathlib import Path
+
+
+ class RecordingClient:
+ """Wrap a live client and keep every reply so an eval can replay it."""
+
+ def __init__(self, live):
+ self.live = live
+ self.replies = []
+
+ def __call__(self, messages):
+ text = self.live(messages)
+ self.replies.append(text)
+ return text
+
+
+ def save_recordings(recorded: dict[str, list[str]], path: Path) -> None:
+ path.write_text(
+ json.dumps({"replies": recorded}, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ ```
+
+ 有意识地重新录制——改了提示词、换了模型或厂商时——并把新的评测输出和
+ 旧的对比。
+4. **用评测做门禁。** 在看结果之前定好阈值,让评测命令低于阈值时以非零
+ 退出——和 `ticket_triage.py` 一样的形状。
+5. **记下它没证明什么。** 写三行:已验证 / 未验证 / 已知局限。「未验证」
+ 永远包括你没录到的真实流量。
+
+如果你的功能不合身——输出是没有字段可校验的自由文本,或者用例太少没法
+标注——写下模式在哪里失效了。这个判断本身就是本事的一部分。
+
+## 练习
+
+在录任何东西之前,先为你的功能标注至少十个真实或贴近真实的输入。标出
+你预计会兜底的那些。
+
+## 检查点
+
+本检查点自我报告。能回答下面三题时,用
+`python verify.py --attest l05` 认领:
+
+1. 你的 `live` client 返回什么?它的 key 从哪里来?
+2. 你的第一行评测输出是什么?它过了你事先定的阈值吗?
+3. 你的「未验证」清单上有什么是利益相关方会以为已覆盖的?
+
+## 预期证据
+
+你的输出契约、带标注的用例、第一行评测输出,以及三行记录。含客户数据的
+录音不要放进公开仓库。
diff --git a/courses/llm-features-in-python/scenario/it-helpdesk/cases.json b/courses/llm-features-in-python/scenario/it-helpdesk/cases.json
new file mode 100644
index 0000000..116672c
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/it-helpdesk/cases.json
@@ -0,0 +1,8 @@
+[
+ {"id": "h01", "ticket": "My laptop LT-204 won't turn on.", "expected": {"category": "hardware", "needs_human": true}},
+ {"id": "h02", "ticket": "VPN keeps disconnecting every 10 minutes.", "expected": {"category": "network", "needs_human": false}},
+ {"id": "h03", "ticket": "I need access to the finance shared drive.", "expected": {"category": "access", "needs_human": true}},
+ {"id": "h04", "ticket": "Excel crashes when I open big files.", "expected": {"category": "software", "needs_human": false}},
+ {"id": "h05", "ticket": "The printer on floor 3 (PR-301) is jammed.", "expected": {"category": "hardware", "needs_human": false}},
+ {"id": "h06", "ticket": "Can you install Photoshop on my machine?", "expected": {"category": "software", "needs_human": true}}
+]
diff --git a/courses/llm-features-in-python/scenario/it-helpdesk/devices.json b/courses/llm-features-in-python/scenario/it-helpdesk/devices.json
new file mode 100644
index 0000000..a4ab8c6
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/it-helpdesk/devices.json
@@ -0,0 +1,4 @@
+{
+ "LT-204": {"type": "laptop", "owner": "finance", "warranty_until": "2027-03-01"},
+ "PR-301": {"type": "printer", "location": "floor 3"}
+}
diff --git a/courses/llm-features-in-python/scenario/it-helpdesk/recordings.json b/courses/llm-features-in-python/scenario/it-helpdesk/recordings.json
new file mode 100644
index 0000000..5763e76
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/it-helpdesk/recordings.json
@@ -0,0 +1,27 @@
+{
+ "note": "Hand-written replies that imitate common model failure shapes. They are course fixtures, not output captured from any model.",
+ "replies": {
+ "h01": [
+ "{\"tool\": \"lookup_device\", \"arguments\": {\"asset_tag\": \"LT-204\"}}",
+ "{\"category\": \"hardware\", \"priority\": 2, \"summary\": \"Laptop LT-204 will not power on; still under warranty.\", \"needs_human\": true}"
+ ],
+ "h02": [
+ "{\"category\": \"network\", \"priority\": 3, \"summary\": \"VPN disconnects about every 10 minutes.\", \"needs_human\": false}"
+ ],
+ "h03": [
+ "```\n{\"category\": \"access\", \"priority\": 3, \"summary\": \"Employee requests access to the finance shared drive.\", \"needs_human\": true}\n```"
+ ],
+ "h04": [
+ "{\"category\": \"software\", \"priority\": 3, \"summary\": \"Excel crashes when opening large files.\", \"needs_human\": false, \"confidence\": 0.9}",
+ "{\"category\": \"software\", \"priority\": 3, \"summary\": \"Excel crashes when opening large files.\", \"needs_human\": false}"
+ ],
+ "h05": [
+ "{\"tool\": \"lookup_device\", \"arguments\": {\"asset_tag\": 301}}",
+ "{\"category\": \"hardware\", \"priority\": 3, \"summary\": \"Printer PR-301 on floor 3 is jammed.\", \"needs_human\": false}"
+ ],
+ "h06": [
+ "{\"category\": \"software\", \"priority\": 4, \"summary\": \"The employee is asking whether someone from IT could install Adobe Photoshop on their work machine, which may need a license approval first.\", \"needs_human\": true}",
+ "{\"category\": \"software\", \"priority\": 4, \"summary\": \"An employee would like Adobe Photoshop installed on their work computer and is asking IT to do it, pending a license check.\", \"needs_human\": true}"
+ ]
+ }
+}
diff --git a/courses/llm-features-in-python/scenario/it-helpdesk/scenario.json b/courses/llm-features-in-python/scenario/it-helpdesk/scenario.json
new file mode 100644
index 0000000..868936f
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/it-helpdesk/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "it-helpdesk",
+ "description": "An internal IT helpdesk. The model may look up a device by asset tag before answering.",
+ "categories": ["hardware", "software", "network", "access", "other"],
+ "min_accuracy": 0.8,
+ "tools": {
+ "lookup_device": {"data": "devices.json", "parameters": {"asset_tag": "str"}}
+ }
+}
diff --git a/courses/llm-features-in-python/scenario/saas-feedback/cases.json b/courses/llm-features-in-python/scenario/saas-feedback/cases.json
new file mode 100644
index 0000000..1130ec8
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/saas-feedback/cases.json
@@ -0,0 +1,7 @@
+[
+ {"id": "f01", "ticket": "The export button does nothing on Safari.", "expected": {"category": "bug", "needs_human": false}},
+ {"id": "f02", "ticket": "Please add a dark mode!", "expected": {"category": "feature_request", "needs_human": false}},
+ {"id": "f03", "ticket": "I was billed again after cancelling my plan.", "expected": {"category": "billing", "needs_human": true}},
+ {"id": "f04", "ticket": "Love the new dashboard, great work.", "expected": {"category": "praise", "needs_human": false}},
+ {"id": "f05", "ticket": "The app is slow sometimes.", "expected": {"category": "bug", "needs_human": false}}
+]
diff --git a/courses/llm-features-in-python/scenario/saas-feedback/recordings.json b/courses/llm-features-in-python/scenario/saas-feedback/recordings.json
new file mode 100644
index 0000000..c064548
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/saas-feedback/recordings.json
@@ -0,0 +1,22 @@
+{
+ "note": "Hand-written replies that imitate common model failure shapes. They are course fixtures, not output captured from any model.",
+ "replies": {
+ "f01": [
+ "{\"category\": \"bug\", \"priority\": 2, \"summary\": \"Export button does nothing in Safari.\", \"needs_human\": false}"
+ ],
+ "f02": [
+ "{\"category\": \"feature_request\", \"priority\": 4, \"summary\": \"User asks for a dark mode.\", \"needs_human\": false}"
+ ],
+ "f03": [
+ "{\"category\": \"billing\", \"priority\": 1, \"summary\": \"User was billed after cancelling the plan.\", \"needs_human\": \"yes\"}",
+ "{\"category\": \"billing\", \"priority\": 1, \"summary\": \"User was billed after cancelling the plan.\", \"needs_human\": true}"
+ ],
+ "f04": [
+ "[{\"category\": \"praise\", \"priority\": 4, \"summary\": \"User likes the new dashboard.\", \"needs_human\": false}]",
+ "{\"category\": \"praise\", \"priority\": 4, \"summary\": \"User likes the new dashboard.\", \"needs_human\": false}"
+ ],
+ "f05": [
+ "{\"category\": \"feature_request\", \"priority\": 3, \"summary\": \"User reports the app is sometimes slow.\", \"needs_human\": false}"
+ ]
+ }
+}
diff --git a/courses/llm-features-in-python/scenario/saas-feedback/scenario.json b/courses/llm-features-in-python/scenario/saas-feedback/scenario.json
new file mode 100644
index 0000000..fd33747
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/saas-feedback/scenario.json
@@ -0,0 +1,7 @@
+{
+ "name": "saas-feedback",
+ "description": "In-app feedback for a small SaaS product. No tools: the model answers from the ticket text alone.",
+ "categories": ["bug", "feature_request", "billing", "praise", "other"],
+ "min_accuracy": 0.8,
+ "tools": {}
+}
diff --git a/courses/llm-features-in-python/scenario/shop-support/cases.json b/courses/llm-features-in-python/scenario/shop-support/cases.json
new file mode 100644
index 0000000..0a98eee
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/shop-support/cases.json
@@ -0,0 +1,10 @@
+[
+ {"id": "s01", "ticket": "I was charged twice for order A-1001. Please fix this.", "expected": {"category": "billing", "needs_human": true}},
+ {"id": "s02", "ticket": "Where is my package? Order A-1002.", "expected": {"category": "shipping", "needs_human": false}},
+ {"id": "s03", "ticket": "How do I return shoes that don't fit?", "expected": {"category": "returns", "needs_human": false}},
+ {"id": "s04", "ticket": "I can't log in after resetting my password.", "expected": {"category": "account", "needs_human": false}},
+ {"id": "s05", "ticket": "Your app deleted all my saved addresses!!!", "expected": {"category": "account", "needs_human": true}},
+ {"id": "s06", "ticket": "Order A-1003 arrived broken. I want my money back.", "expected": {"category": "returns", "needs_human": true}},
+ {"id": "s07", "ticket": "asdf ??? help", "expected": {"category": "other", "needs_human": true}},
+ {"id": "s08", "ticket": "Do you ship to Canada?", "expected": {"category": "shipping", "needs_human": false}}
+]
diff --git a/courses/llm-features-in-python/scenario/shop-support/orders.json b/courses/llm-features-in-python/scenario/shop-support/orders.json
new file mode 100644
index 0000000..af9a265
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/shop-support/orders.json
@@ -0,0 +1,5 @@
+{
+ "A-1001": {"status": "delivered", "total": 59.0, "charges": 2},
+ "A-1002": {"status": "in transit", "carrier_eta": "2026-10-14"},
+ "A-1003": {"status": "delivered", "total": 49.0, "charges": 1}
+}
diff --git a/courses/llm-features-in-python/scenario/shop-support/recordings.json b/courses/llm-features-in-python/scenario/shop-support/recordings.json
new file mode 100644
index 0000000..7f53182
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/shop-support/recordings.json
@@ -0,0 +1,34 @@
+{
+ "note": "Hand-written replies that imitate common model failure shapes. They are course fixtures, not output captured from any model.",
+ "replies": {
+ "s01": [
+ "{\"category\": \"billing\", \"priority\": 2, \"summary\": \"Customer was charged twice for order A-1001.\", \"needs_human\": true}"
+ ],
+ "s02": [
+ "{\"tool\": \"lookup_order\", \"arguments\": {\"order_id\": \"A-1002\"}}",
+ "{\"category\": \"shipping\", \"priority\": 3, \"summary\": \"Order A-1002 is in transit, ETA 2026-10-14.\", \"needs_human\": false}"
+ ],
+ "s03": [
+ "```json\n{\"category\": \"returns\", \"priority\": 4, \"summary\": \"Customer asks how to return shoes that do not fit.\", \"needs_human\": false}\n```"
+ ],
+ "s04": [
+ "{\"category\": \"login\", \"priority\": 2, \"summary\": \"Customer cannot log in after a password reset.\", \"needs_human\": false}",
+ "{\"category\": \"account\", \"priority\": 2, \"summary\": \"Customer cannot log in after a password reset.\", \"needs_human\": false}"
+ ],
+ "s05": [
+ "{\"category\": \"account\", \"priority\": \"high\", \"summary\": \"Saved addresses disappeared from the customer's account.\", \"needs_human\": true}",
+ "{\"category\": \"account\", \"priority\": 1, \"summary\": \"Saved addresses disappeared from the customer's account.\", \"needs_human\": true}"
+ ],
+ "s06": [
+ "{\"tool\": \"issue_refund\", \"arguments\": {\"order_id\": \"A-1003\", \"amount\": 49.0}}",
+ "{\"category\": \"returns\", \"priority\": 2, \"summary\": \"Order A-1003 arrived broken; customer wants a refund.\", \"needs_human\": true}"
+ ],
+ "s07": [
+ "Sure! This looks like a general question, so I would file it under other.",
+ "Category: other. Priority: low. A human should take a look."
+ ],
+ "s08": [
+ "{\"category\": \"other\", \"priority\": 4, \"summary\": \"Customer asks whether the shop ships to Canada.\", \"needs_human\": false}"
+ ]
+ }
+}
diff --git a/courses/llm-features-in-python/scenario/shop-support/scenario.json b/courses/llm-features-in-python/scenario/shop-support/scenario.json
new file mode 100644
index 0000000..491f75a
--- /dev/null
+++ b/courses/llm-features-in-python/scenario/shop-support/scenario.json
@@ -0,0 +1,9 @@
+{
+ "name": "shop-support",
+ "description": "An online shop's support inbox. The model may look up an order before answering.",
+ "categories": ["billing", "shipping", "returns", "account", "other"],
+ "min_accuracy": 0.8,
+ "tools": {
+ "lookup_order": {"data": "orders.json", "parameters": {"order_id": "str"}}
+ }
+}
diff --git a/courses/llm-features-in-python/solution/ticket_triage.py b/courses/llm-features-in-python/solution/ticket_triage.py
new file mode 100644
index 0000000..d283bcc
--- /dev/null
+++ b/courses/llm-features-in-python/solution/ticket_triage.py
@@ -0,0 +1,274 @@
+"""Support-ticket triage with a language model (reference solution).
+
+The model is any callable ``client(messages) -> str``. The course ships a
+``ReplayClient`` that returns recorded replies, so everything runs offline
+and deterministically. The feature code treats every reply as untrusted
+input: it validates structured output, allowlists tool calls, retries with
+the validation error fed back, falls back to a human instead of guessing,
+and measures itself with an eval that divides by every case.
+../TASK.md defines the contract.
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+from pathlib import Path
+
+FIELDS = ("category", "priority", "summary", "needs_human")
+SUMMARY_LIMIT = 120
+DEFAULT_MIN_ACCURACY = 0.8
+TYPE_NAMES = {"str": str, "int": int, "float": float, "bool": bool}
+FENCE = re.compile(r"^```[A-Za-z]*\s*\n(.*?)\n?```$", re.DOTALL)
+
+
+class TriageError(ValueError):
+ """A model reply that does not satisfy the triage contract."""
+
+
+class ToolCallError(TriageError):
+ """A tool call the application refuses to run."""
+
+
+class ReplayClient:
+ """Offline stand-in for a model API: returns recorded replies in order."""
+
+ def __init__(self, replies: list[str]) -> None:
+ self._replies = list(replies)
+ self.calls: list[list[dict]] = []
+
+ def __call__(self, messages: list[dict]) -> str:
+ self.calls.append([dict(message) for message in messages])
+ if not self._replies:
+ raise RuntimeError("replay exhausted: the feature asked for more replies than were recorded")
+ return self._replies.pop(0)
+
+
+def build_messages(ticket_text: str, categories: list[str], tools: dict | None = None) -> list[dict]:
+ """Return the system and user messages for one ticket."""
+ lines = [
+ "You triage customer support tickets.",
+ "Reply with only a JSON object with exactly these fields:",
+ f' "category": one of {", ".join(categories)}',
+ ' "priority": integer 1 (urgent) to 4 (low)',
+ f' "summary": one sentence, at most {SUMMARY_LIMIT} characters',
+ ' "needs_human": true or false',
+ ]
+ for name, spec in sorted((tools or {}).items()):
+ parameters = ", ".join(f"{key}: {kind.__name__}" for key, kind in spec["parameters"].items())
+ lines.append(
+ f'To call the tool {name}({parameters}), reply with only '
+ f'{{"tool": "{name}", "arguments": {{...}}}}.'
+ )
+ return [
+ {"role": "system", "content": "\n".join(lines)},
+ {"role": "user", "content": ticket_text},
+ ]
+
+
+def _decode_object(raw: str) -> dict:
+ if not isinstance(raw, str):
+ raise TriageError("reply must be text")
+ text = raw.strip()
+ fenced = FENCE.match(text)
+ if fenced:
+ text = fenced.group(1).strip()
+ try:
+ value = json.loads(text)
+ except ValueError as error:
+ raise TriageError(f"reply is not valid JSON: {error}") from None
+ if not isinstance(value, dict):
+ raise TriageError("reply must be a JSON object")
+ return value
+
+
+def _validate(value: dict, categories: list[str]) -> dict:
+ for field in FIELDS:
+ if field not in value:
+ raise TriageError(f"missing field: {field}")
+ for field in value:
+ if field not in FIELDS:
+ raise TriageError(f"unexpected field: {field}")
+ category = value["category"]
+ if not isinstance(category, str) or category not in categories:
+ raise TriageError(f"category must be one of {', '.join(categories)}; got {category!r}")
+ priority = value["priority"]
+ if isinstance(priority, bool) or not isinstance(priority, int) or not 1 <= priority <= 4:
+ raise TriageError(f"priority must be an integer from 1 to 4; got {priority!r}")
+ summary = value["summary"]
+ if not isinstance(summary, str) or not summary.strip():
+ raise TriageError("summary must be a non-empty string")
+ if len(summary.strip()) > SUMMARY_LIMIT:
+ raise TriageError(f"summary must be at most {SUMMARY_LIMIT} characters")
+ if not isinstance(value["needs_human"], bool):
+ raise TriageError(f"needs_human must be true or false; got {value['needs_human']!r}")
+ return {
+ "category": category,
+ "priority": priority,
+ "summary": summary.strip(),
+ "needs_human": value["needs_human"],
+ }
+
+
+def parse_triage(raw: str, categories: list[str]) -> dict:
+ """Parse and validate one model reply into the four triage fields."""
+ return _validate(_decode_object(raw), categories)
+
+
+def run_tool_call(call: dict, tools: dict) -> tuple[str, object]:
+ """Run an allowlisted tool with validated arguments; return (name, result)."""
+ if set(call) != {"tool", "arguments"}:
+ raise ToolCallError('a tool call must have exactly the keys "tool" and "arguments"')
+ name, arguments = call["tool"], call["arguments"]
+ if not isinstance(name, str) or name not in tools:
+ raise ToolCallError(f"unknown tool: {name!r}")
+ if not isinstance(arguments, dict):
+ raise ToolCallError(f"tool {name}: arguments must be an object")
+ parameters = tools[name]["parameters"]
+ for key in arguments:
+ if key not in parameters:
+ raise ToolCallError(f"tool {name}: unexpected argument {key}")
+ for key, kind in parameters.items():
+ if key not in arguments:
+ raise ToolCallError(f"tool {name}: missing argument {key}")
+ value = arguments[key]
+ if (isinstance(value, bool) and kind is not bool) or not isinstance(value, kind):
+ raise ToolCallError(f"tool {name}: argument {key} must be {kind.__name__}")
+ return name, tools[name]["function"](**arguments)
+
+
+def _fallback(ticket_text: str) -> dict:
+ return {
+ "category": None,
+ "priority": None,
+ "summary": ticket_text.strip()[:SUMMARY_LIMIT],
+ "needs_human": True,
+ }
+
+
+def triage(
+ ticket_text: str,
+ client,
+ *,
+ categories: list[str],
+ tools: dict | None = None,
+ max_attempts: int = 2,
+ max_tool_calls: int = 3,
+) -> dict:
+ """Triage one ticket: validate, run tool calls, retry, or fall back."""
+ tools = tools or {}
+ messages = build_messages(ticket_text, categories, tools)
+ attempts = 0
+ tool_calls = 0
+ while attempts < max_attempts:
+ raw = client(messages)
+ messages.append({"role": "assistant", "content": raw})
+ try:
+ value = _decode_object(raw)
+ if "tool" in value:
+ if tool_calls >= max_tool_calls:
+ break
+ name, output = run_tool_call(value, tools)
+ tool_calls += 1
+ messages.append({"role": "tool", "name": name, "content": json.dumps(output, ensure_ascii=False)})
+ continue
+ result = _validate(value, categories)
+ except TriageError as error:
+ attempts += 1
+ messages.append({
+ "role": "user",
+ "content": f"Your reply was rejected: {error}. Reply again following the format exactly.",
+ })
+ continue
+ attempts += 1
+ result.update(attempts=attempts, tool_calls=tool_calls, fallback=False)
+ return result
+ result = _fallback(ticket_text)
+ result.update(attempts=attempts, tool_calls=tool_calls, fallback=True)
+ return result
+
+
+def evaluate(cases: list[dict], client_for, *, categories: list[str], tools: dict | None = None) -> dict:
+ """Run every case and score it against its expected label."""
+ correct = 0
+ fallbacks = 0
+ failures = []
+ for case in cases:
+ result = triage(case["ticket"], client_for(case), categories=categories, tools=tools)
+ expected = case["expected"]
+ if result["fallback"]:
+ fallbacks += 1
+ failures.append({"id": case["id"], "reason": "fallback to a human"})
+ elif result["category"] == expected["category"] and result["needs_human"] == expected["needs_human"]:
+ correct += 1
+ else:
+ failures.append({
+ "id": case["id"],
+ "reason": f"expected {expected['category']}/needs_human={expected['needs_human']}, "
+ f"got {result['category']}/needs_human={result['needs_human']}",
+ })
+ total = len(cases)
+ return {
+ "total": total,
+ "schema_valid": total - fallbacks,
+ "correct": correct,
+ "fallbacks": fallbacks,
+ "accuracy": round(correct / total, 3) if total else 0.0,
+ "failures": failures,
+ }
+
+
+def load_scenario(scenario_dir: str | Path) -> dict:
+ """Load scenario.json, cases.json, recordings.json, and lookup tools."""
+ directory = Path(scenario_dir)
+ config = json.loads((directory / "scenario.json").read_text(encoding="utf-8"))
+ cases = json.loads((directory / "cases.json").read_text(encoding="utf-8"))
+ recordings = json.loads((directory / "recordings.json").read_text(encoding="utf-8"))
+ tools = {}
+ for name, spec in config.get("tools", {}).items():
+ records = json.loads((directory / spec["data"]).read_text(encoding="utf-8"))
+ parameters = {key: TYPE_NAMES[kind] for key, kind in spec["parameters"].items()}
+ key_name = next(iter(parameters))
+
+ def lookup(_records=records, _key=key_name, **arguments):
+ return _records.get(arguments[_key], {"error": "not found"})
+
+ tools[name] = {"function": lookup, "parameters": parameters}
+ return {"config": config, "cases": cases, "replies": recordings["replies"], "tools": tools}
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Evaluate one scenario offline and gate on its accuracy threshold."""
+ arguments = sys.argv[1:] if argv is None else argv
+ usage = "usage: python ticket_triage.py [--min-accuracy 0.8]"
+ if len(arguments) not in (1, 3) or (len(arguments) == 3 and arguments[1] != "--min-accuracy"):
+ print(usage, file=sys.stderr)
+ return 2
+ scenario = load_scenario(arguments[0])
+ config = scenario["config"]
+ if len(arguments) == 3:
+ try:
+ threshold = float(arguments[2])
+ except ValueError:
+ print(usage, file=sys.stderr)
+ return 2
+ else:
+ threshold = float(config.get("min_accuracy", DEFAULT_MIN_ACCURACY))
+ report = evaluate(
+ scenario["cases"],
+ lambda case: ReplayClient(scenario["replies"][case["id"]]),
+ categories=config["categories"],
+ tools=scenario["tools"],
+ )
+ print(
+ f"accuracy={report['accuracy']:.3f} correct={report['correct']}/{report['total']} "
+ f"fallbacks={report['fallbacks']} min_accuracy={threshold:.3f}"
+ )
+ for failure in report["failures"]:
+ print(f" {failure['id']}: {failure['reason']}")
+ return 0 if report["accuracy"] >= threshold else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/llm-features-in-python/starter/ticket_triage.py b/courses/llm-features-in-python/starter/ticket_triage.py
new file mode 100644
index 0000000..25ea4a5
--- /dev/null
+++ b/courses/llm-features-in-python/starter/ticket_triage.py
@@ -0,0 +1,184 @@
+"""Support-ticket triage with a language model (starter, incomplete).
+
+The model is any callable ``client(messages) -> str``. The course ships a
+``ReplayClient`` that returns recorded replies, so everything runs offline
+and deterministically. This starter reproduces the demo-day state: it trusts
+whatever JSON the model sends, runs any tool name it is given with whatever
+arguments arrive, never retries, crashes instead of falling back, and its
+eval quietly skips the cases that crashed.
+../TASK.md defines the contract.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+FIELDS = ("category", "priority", "summary", "needs_human")
+SUMMARY_LIMIT = 120
+DEFAULT_MIN_ACCURACY = 0.8
+TYPE_NAMES = {"str": str, "int": int, "float": float, "bool": bool}
+
+
+class TriageError(ValueError):
+ """A model reply that does not satisfy the triage contract."""
+
+
+class ToolCallError(TriageError):
+ """A tool call the application refuses to run."""
+
+
+class ReplayClient:
+ """Offline stand-in for a model API: returns recorded replies in order."""
+
+ def __init__(self, replies: list[str]) -> None:
+ self._replies = list(replies)
+ self.calls: list[list[dict]] = []
+
+ def __call__(self, messages: list[dict]) -> str:
+ self.calls.append([dict(message) for message in messages])
+ if not self._replies:
+ raise RuntimeError("replay exhausted: the feature asked for more replies than were recorded")
+ return self._replies.pop(0)
+
+
+def build_messages(ticket_text: str, categories: list[str], tools: dict | None = None) -> list[dict]:
+ """Return the system and user messages for one ticket."""
+ lines = [
+ "You triage customer support tickets.",
+ "Reply with only a JSON object with exactly these fields:",
+ f' "category": one of {", ".join(categories)}',
+ ' "priority": integer 1 (urgent) to 4 (low)',
+ f' "summary": one sentence, at most {SUMMARY_LIMIT} characters',
+ ' "needs_human": true or false',
+ ]
+ for name, spec in sorted((tools or {}).items()):
+ parameters = ", ".join(f"{key}: {kind.__name__}" for key, kind in spec["parameters"].items())
+ lines.append(
+ f'To call the tool {name}({parameters}), reply with only '
+ f'{{"tool": "{name}", "arguments": {{...}}}}.'
+ )
+ return [
+ {"role": "system", "content": "\n".join(lines)},
+ {"role": "user", "content": ticket_text},
+ ]
+
+
+def parse_triage(raw: str, categories: list[str]) -> dict:
+ """Parse one model reply into the triage fields."""
+ return json.loads(raw)
+
+
+def run_tool_call(call: dict, tools: dict) -> tuple[str, object]:
+ """Run the tool the model asked for; return (name, result)."""
+ name = call["tool"]
+ return name, tools[name]["function"](**call["arguments"])
+
+
+def triage(
+ ticket_text: str,
+ client,
+ *,
+ categories: list[str],
+ tools: dict | None = None,
+ max_attempts: int = 2,
+ max_tool_calls: int = 3,
+) -> dict:
+ """Triage one ticket with the model."""
+ tools = tools or {}
+ messages = build_messages(ticket_text, categories, tools)
+ raw = client(messages)
+ messages.append({"role": "assistant", "content": raw})
+ reply = parse_triage(raw, categories)
+ tool_calls = 0
+ if "tool" in reply:
+ name, output = run_tool_call(reply, tools)
+ tool_calls += 1
+ messages.append({"role": "tool", "name": name, "content": json.dumps(output, ensure_ascii=False)})
+ raw = client(messages)
+ messages.append({"role": "assistant", "content": raw})
+ reply = parse_triage(raw, categories)
+ reply.update(attempts=1, tool_calls=tool_calls, fallback=False)
+ return reply
+
+
+def evaluate(cases: list[dict], client_for, *, categories: list[str], tools: dict | None = None) -> dict:
+ """Run every case and score it against its expected label."""
+ answered = 0
+ correct = 0
+ failures = []
+ for case in cases:
+ try:
+ result = triage(case["ticket"], client_for(case), categories=categories, tools=tools)
+ except Exception:
+ continue
+ answered += 1
+ expected = case["expected"]
+ if result.get("category") == expected["category"] and result.get("needs_human") == expected["needs_human"]:
+ correct += 1
+ else:
+ failures.append({"id": case["id"], "reason": f"expected {expected['category']}"})
+ return {
+ "total": len(cases),
+ "schema_valid": answered,
+ "correct": correct,
+ "fallbacks": 0,
+ "accuracy": round(correct / answered, 3) if answered else 0.0,
+ "failures": failures,
+ }
+
+
+def load_scenario(scenario_dir: str | Path) -> dict:
+ """Load scenario.json, cases.json, recordings.json, and lookup tools."""
+ directory = Path(scenario_dir)
+ config = json.loads((directory / "scenario.json").read_text(encoding="utf-8"))
+ cases = json.loads((directory / "cases.json").read_text(encoding="utf-8"))
+ recordings = json.loads((directory / "recordings.json").read_text(encoding="utf-8"))
+ tools = {}
+ for name, spec in config.get("tools", {}).items():
+ records = json.loads((directory / spec["data"]).read_text(encoding="utf-8"))
+ parameters = {key: TYPE_NAMES[kind] for key, kind in spec["parameters"].items()}
+ key_name = next(iter(parameters))
+
+ def lookup(_records=records, _key=key_name, **arguments):
+ return _records.get(arguments[_key], {"error": "not found"})
+
+ tools[name] = {"function": lookup, "parameters": parameters}
+ return {"config": config, "cases": cases, "replies": recordings["replies"], "tools": tools}
+
+
+def main(argv: list[str] | None = None) -> int:
+ """Evaluate one scenario offline and gate on its accuracy threshold."""
+ arguments = sys.argv[1:] if argv is None else argv
+ usage = "usage: python ticket_triage.py [--min-accuracy 0.8]"
+ if len(arguments) not in (1, 3) or (len(arguments) == 3 and arguments[1] != "--min-accuracy"):
+ print(usage, file=sys.stderr)
+ return 2
+ scenario = load_scenario(arguments[0])
+ config = scenario["config"]
+ if len(arguments) == 3:
+ try:
+ threshold = float(arguments[2])
+ except ValueError:
+ print(usage, file=sys.stderr)
+ return 2
+ else:
+ threshold = float(config.get("min_accuracy", DEFAULT_MIN_ACCURACY))
+ report = evaluate(
+ scenario["cases"],
+ lambda case: ReplayClient(scenario["replies"][case["id"]]),
+ categories=config["categories"],
+ tools=scenario["tools"],
+ )
+ print(
+ f"accuracy={report['accuracy']:.3f} correct={report['correct']}/{report['total']} "
+ f"fallbacks={report['fallbacks']} min_accuracy={threshold:.3f}"
+ )
+ for failure in report["failures"]:
+ print(f" {failure['id']}: {failure['reason']}")
+ return 0 if report["accuracy"] >= threshold else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/llm-features-in-python/tests/test_ticket_triage.py b/courses/llm-features-in-python/tests/test_ticket_triage.py
new file mode 100644
index 0000000..2b0060f
--- /dev/null
+++ b/courses/llm-features-in-python/tests/test_ticket_triage.py
@@ -0,0 +1,195 @@
+"""Contract tests for the ticket-triage LLM feature."""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import unittest
+from pathlib import Path
+
+import ticket_triage
+
+COURSE_ROOT = Path(__file__).resolve().parent.parent
+SCENARIO_ROOT = COURSE_ROOT / "scenario"
+CATEGORIES = ["billing", "shipping", "returns", "account", "other"]
+
+
+def reply(**overrides) -> str:
+ value = {"category": "billing", "priority": 2, "summary": "Charged twice.", "needs_human": True}
+ value.update(overrides)
+ return json.dumps({key: item for key, item in value.items() if item is not ...})
+
+
+def lookup_tools(calls: list) -> dict:
+ def lookup_order(order_id):
+ calls.append(order_id)
+ return {"status": "in transit"}
+
+ return {"lookup_order": {"function": lookup_order, "parameters": {"order_id": str}}}
+
+
+class ParseTriageTest(unittest.TestCase):
+ def test_parse_accepts_a_valid_reply(self) -> None:
+ self.assertEqual(
+ ticket_triage.parse_triage(reply(), CATEGORIES),
+ {"category": "billing", "priority": 2, "summary": "Charged twice.", "needs_human": True},
+ )
+
+ def test_parse_strips_a_markdown_fence(self) -> None:
+ fenced = "```json\n" + reply(category="returns") + "\n```"
+ self.assertEqual(ticket_triage.parse_triage(fenced, CATEGORIES)["category"], "returns")
+
+ def test_parse_rejects_an_unknown_category_naming_the_field(self) -> None:
+ with self.assertRaises(ticket_triage.TriageError) as caught:
+ ticket_triage.parse_triage(reply(category="login"), CATEGORIES)
+ self.assertIn("category", str(caught.exception))
+
+ def test_parse_rejects_bad_priorities(self) -> None:
+ for bad in ("high", 0, 5, 2.5, True):
+ with self.subTest(priority=bad):
+ with self.assertRaises(ticket_triage.TriageError) as caught:
+ ticket_triage.parse_triage(reply(priority=bad), CATEGORIES)
+ self.assertIn("priority", str(caught.exception))
+
+ def test_parse_rejects_missing_extra_and_mistyped_fields(self) -> None:
+ cases = {
+ "summary": reply(summary=...),
+ "confidence": reply(confidence=0.9),
+ "needs_human": reply(needs_human="yes"),
+ }
+ for field, raw in cases.items():
+ with self.subTest(field=field):
+ with self.assertRaises(ticket_triage.TriageError) as caught:
+ ticket_triage.parse_triage(raw, CATEGORIES)
+ self.assertIn(field, str(caught.exception))
+ for raw in ("Sure! Category: billing.", "[]", reply(summary="x" * 121)):
+ with self.subTest(raw=raw[:20]), self.assertRaises(ticket_triage.TriageError):
+ ticket_triage.parse_triage(raw, CATEGORIES)
+
+
+class ToolCallTest(unittest.TestCase):
+ def test_unregistered_tool_is_refused(self) -> None:
+ calls: list = []
+ with self.assertRaises(ticket_triage.ToolCallError) as caught:
+ ticket_triage.run_tool_call(
+ {"tool": "issue_refund", "arguments": {"order_id": "A-1"}}, lookup_tools(calls)
+ )
+ self.assertIn("issue_refund", str(caught.exception))
+ self.assertEqual(calls, [])
+
+ def test_tool_arguments_are_validated_before_the_call(self) -> None:
+ calls: list = []
+ tools = lookup_tools(calls)
+ bad_calls = {
+ "order_id": {"tool": "lookup_order", "arguments": {"order_id": 1002}},
+ "amount": {"tool": "lookup_order", "arguments": {"order_id": "A-1", "amount": 49.0}},
+ }
+ for name, call in bad_calls.items():
+ with self.subTest(argument=name):
+ with self.assertRaises(ticket_triage.ToolCallError) as caught:
+ ticket_triage.run_tool_call(call, tools)
+ self.assertIn(name, str(caught.exception))
+ self.assertEqual(calls, [])
+ self.assertEqual(
+ ticket_triage.run_tool_call({"tool": "lookup_order", "arguments": {"order_id": "A-1"}}, tools),
+ ("lookup_order", {"status": "in transit"}),
+ )
+
+
+class TriageTest(unittest.TestCase):
+ def test_tool_result_is_fed_back_to_the_model(self) -> None:
+ calls: list = []
+ client = ticket_triage.ReplayClient([
+ json.dumps({"tool": "lookup_order", "arguments": {"order_id": "A-2"}}),
+ reply(category="shipping", needs_human=False),
+ ])
+ result = ticket_triage.triage("Where is A-2?", client, categories=CATEGORIES, tools=lookup_tools(calls))
+ self.assertEqual(result["category"], "shipping")
+ self.assertEqual(result["tool_calls"], 1)
+ self.assertEqual(calls, ["A-2"])
+ tool_message = client.calls[1][-1]
+ self.assertEqual(tool_message["role"], "tool")
+ self.assertEqual(json.loads(tool_message["content"]), {"status": "in transit"})
+
+ def test_invalid_reply_is_retried_with_the_error_fed_back(self) -> None:
+ client = ticket_triage.ReplayClient([reply(category="login"), reply(category="account")])
+ result = ticket_triage.triage("Cannot log in", client, categories=CATEGORIES)
+ self.assertEqual(result["category"], "account")
+ self.assertEqual(result["attempts"], 2)
+ self.assertFalse(result["fallback"])
+ feedback = client.calls[1][-1]
+ self.assertEqual(feedback["role"], "user")
+ self.assertIn("category", feedback["content"])
+
+ def test_repeated_invalid_replies_fall_back_to_a_human(self) -> None:
+ client = ticket_triage.ReplayClient(["Sure! It's billing.", "Category: billing"])
+ result = ticket_triage.triage("asdf", client, categories=CATEGORIES, max_attempts=2)
+ self.assertEqual(
+ result,
+ {"category": None, "priority": None, "summary": "asdf", "needs_human": True,
+ "attempts": 2, "tool_calls": 0, "fallback": True},
+ )
+
+ def test_tool_call_budget_ends_in_the_fallback(self) -> None:
+ calls: list = []
+ lookup = json.dumps({"tool": "lookup_order", "arguments": {"order_id": "A-2"}})
+ client = ticket_triage.ReplayClient([lookup, lookup, lookup])
+ result = ticket_triage.triage(
+ "Where is A-2?", client, categories=CATEGORIES, tools=lookup_tools(calls), max_tool_calls=2
+ )
+ self.assertTrue(result["fallback"])
+ self.assertIsNone(result["category"])
+ self.assertEqual(result["tool_calls"], 2)
+ self.assertEqual(result["attempts"], 0)
+ self.assertEqual(calls, ["A-2", "A-2"])
+
+
+class EvaluateTest(unittest.TestCase):
+ def test_accuracy_divides_by_every_case_and_counts_fallbacks(self) -> None:
+ replies = {
+ "a": [reply(category="billing", needs_human=True)],
+ "b": [reply(category="shipping", needs_human=False)],
+ "c": ["not json", "still not json"],
+ "d": [reply(category="other"), "nope"],
+ }
+ cases = [
+ {"id": "a", "ticket": "charged twice", "expected": {"category": "billing", "needs_human": True}},
+ {"id": "b", "ticket": "where is it", "expected": {"category": "shipping", "needs_human": False}},
+ {"id": "c", "ticket": "???", "expected": {"category": "other", "needs_human": True}},
+ {"id": "d", "ticket": "refund", "expected": {"category": "returns", "needs_human": True}},
+ ]
+ report = ticket_triage.evaluate(
+ cases, lambda case: ticket_triage.ReplayClient(replies[case["id"]]), categories=CATEGORIES
+ )
+ self.assertEqual(report["total"], 4)
+ self.assertEqual(report["correct"], 2)
+ self.assertEqual(report["fallbacks"], 1)
+ self.assertEqual(report["schema_valid"], 3)
+ self.assertEqual(report["accuracy"], 0.5)
+ self.assertEqual([failure["id"] for failure in report["failures"]], ["c", "d"])
+
+
+class MainTest(unittest.TestCase):
+ def run_main(self, argv: list[str]) -> tuple[int, str]:
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
+ code = ticket_triage.main(argv)
+ return code, output.getvalue()
+
+ def test_shop_support_eval_is_reported_below_threshold(self) -> None:
+ code, output = self.run_main([str(SCENARIO_ROOT / "shop-support")])
+ self.assertEqual(code, 1)
+ self.assertIn("accuracy=0.750 correct=6/8 fallbacks=1", output)
+
+ def test_it_helpdesk_eval_passes_its_threshold(self) -> None:
+ code, output = self.run_main([str(SCENARIO_ROOT / "it-helpdesk")])
+ self.assertEqual(code, 0)
+ self.assertIn("accuracy=0.833 correct=5/6 fallbacks=1", output)
+
+ def test_missing_argument_prints_usage(self) -> None:
+ self.assertEqual(self.run_main([])[0], 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/courses/llm-features-in-python/verify.py b/courses/llm-features-in-python/verify.py
new file mode 100644
index 0000000..9608558
--- /dev/null
+++ b/courses/llm-features-in-python/verify.py
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""Run the course contract against starter or solution.
+
+Objective completion evidence for "Build LLM features in Python: structured
+output, tool calls, and evals".
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+
+# Optional shared claim-receipt producer (docs/CLAIM-RECEIPT.md). The course
+# folder still verifies standalone — without the tools/ sibling or without
+# FLYPYTHON_CLAIM_SECRET set, verify.py behaves exactly as before.
+try:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools"))
+ import claim_receipt
+except ImportError:
+ claim_receipt = None
+
+EXPECTED_STARTER_FAILURES = (
+ "test_parse_strips_a_markdown_fence",
+ "test_parse_rejects_an_unknown_category_naming_the_field",
+ "test_parse_rejects_bad_priorities",
+ "test_parse_rejects_missing_extra_and_mistyped_fields",
+ "test_unregistered_tool_is_refused",
+ "test_tool_arguments_are_validated_before_the_call",
+ "test_invalid_reply_is_retried_with_the_error_fed_back",
+ "test_repeated_invalid_replies_fall_back_to_a_human",
+ "test_tool_call_budget_ends_in_the_fallback",
+ "test_accuracy_divides_by_every_case_and_counts_fallbacks",
+ "test_shop_support_eval_is_reported_below_threshold",
+ "test_it_helpdesk_eval_passes_its_threshold",
+)
+
+
+
+COURSE_ID = 'course-llm-features'
+# Documented constant: claim codes derive deterministically from
+# (COURSE_ID, checkpoint_id, COURSE_SALT). They are spot-checkable
+# self-reported evidence, not tamper-proof secrets — see
+# docs/repo-plan-0.0.4.md FP-411.
+COURSE_SALT = '815e90d6b6b795ff'
+
+CHECKPOINTS = [
+ {"id": "l01", "gate": "attest", "title": '演示能跑不等于功能可靠 / A demo reply is not a feature'},
+ {"id": "l02", "gate": "attest", "title": '把模型输出写成契约 / Write the output contract'},
+ {"id": "l03", "gate": "starter-suite", "title": '校验、工具调用与重试 / Validate, allowlist tools, retry'},
+ {"id": "l04", "gate": "both-suites", "title": '诚实的离线评测 / Read the eval honestly'},
+ {"id": "l05", "gate": "attest", "title": '接到你自己的模型功能上 / Apply to your own LLM feature'},
+]
+
+def _claim_code(checkpoint_id):
+ digest = hashlib.sha256(
+ (COURSE_ID + ":" + checkpoint_id + ":" + COURSE_SALT).encode("utf-8")
+ ).digest()
+ return base64.b32encode(digest).decode("ascii")[:8]
+
+
+def _display_title(title):
+ # FP-820: shared-core checkpoint titles are stored "Chinese / English";
+ # the default command prints English first, Chinese after (FP-709 debt).
+ if " / " in title:
+ left, _, right = title.partition(" / ")
+ if any("\u4e00" <= character <= "\u9fff" for character in left):
+ return right + " / " + left
+ return title
+
+def _run_suite(implementation):
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(ROOT / implementation)
+ return subprocess.run(
+ [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")],
+ env=environment, check=False, capture_output=True, text=True,
+ )
+
+def run_progress(as_json, receipt_out=None, default=False):
+ import time
+ t0 = time.monotonic()
+ starter = _run_suite("starter")
+ starter_ms = int((time.monotonic() - t0) * 1000)
+ t0 = time.monotonic()
+ solution = _run_suite("solution")
+ solution_ms = int((time.monotonic() - t0) * 1000)
+ starter_ok = starter.returncode == 0
+ solution_ok = solution.returncode == 0
+ rows = []
+ for checkpoint in CHECKPOINTS:
+ gate = checkpoint["gate"]
+ if gate == "attest":
+ status, kind = "attest", "attested"
+ elif gate == "starter-suite":
+ status = "passed" if starter_ok else "open"
+ kind = "objective"
+ else:
+ status = "passed" if (starter_ok and solution_ok) else "open"
+ kind = "objective"
+ code = _claim_code(checkpoint["id"]) if status in ("passed", "attest") else None
+ row = dict(checkpoint)
+ row["status"] = status
+ row["kind"] = kind
+ row["claim_code"] = code
+ rows.append(row)
+ secret = claim_receipt.receipts_enabled() if claim_receipt else None
+ receipts = []
+ if secret:
+ starter_tests = claim_receipt.count_tests(starter.stderr)
+ solution_tests = claim_receipt.count_tests(solution.stderr)
+ solution_hash = claim_receipt.solution_sha256(ROOT)
+ for checkpoint in CHECKPOINTS:
+ gate = checkpoint["gate"]
+ if gate == "starter-suite":
+ passed, tests, ms = starter_ok, starter_tests, starter_ms
+ elif gate == "both-suites":
+ passed = starter_ok and solution_ok
+ tests, ms = starter_tests + solution_tests, starter_ms + solution_ms
+ else:
+ continue
+ receipts.append(claim_receipt.make_receipt(
+ COURSE_ID, checkpoint["id"], passed=passed, tests=tests,
+ duration_ms=ms, impl_dir=ROOT / "starter",
+ solution_hash=solution_hash, secret=secret))
+ if default and not as_json:
+ # FP-820: bare ``python verify.py`` — check the learner's own
+ # implementation, print per-checkpoint status and earned claim codes,
+ # English first then Chinese (FP-709 debt). ``progress`` keeps its
+ # exact published output; this block is the only new surface.
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("passed" if starter_ok else "not passed")
+ + " / solution " + ("passed" if solution_ok else "not passed"))
+ for row in rows:
+ state = row["status"] + (" (self-attested)" if row["kind"] == "attested" else "")
+ code = row["claim_code"] if row["claim_code"] else "\u2014"
+ print(" " + row["id"] + " " + _display_title(row["title"]) + " [" + state + "] " + code)
+ open_gates = [row["id"] for row in rows
+ if row["kind"] == "objective" and row["status"] != "passed"]
+ if open_gates:
+ print("Next: keep implementing starter/ until " + ", ".join(open_gates)
+ + " show [passed]; then re-run: python verify.py")
+ else:
+ print("All objective checkpoints passed — the codes above are ready"
+ " to submit (batch POST /api/claims, see SKILL.md §5).")
+ print("\u4e2d\u6587\uff1a\u68c0\u67e5\u70b9\u72b6\u6001\u4e0e\u8ba4\u9886\u7801\u89c1\u4e0a\uff1b\u5e26 [passed]/[attest] \u7684\u884c\u6709\u8ba4\u9886\u7801\uff0c[open] \u7684\u884c\u7ee7\u7eed\u5728 starter/ \u91cc\u5b9e\u73b0\u540e\u518d\u8dd1\u3002")
+ print("Claim codes are self-reported evidence, recorded at flypython.com; never a certificate.")
+ print("\u8ba4\u9886\u7801\u662f\u81ea\u6211\u62a5\u544a\u7684\u8bc1\u636e\uff0c\u8bb0\u5f55\u5728 flypython.com\uff1b\u4e0d\u662f\u8bc1\u4e66\u3002")
+ if secret:
+ print(f"Signed run receipts prepared for {len(receipts)} gated checkpoint(s);"
+ " submit each with its claim to mark it as a local-run receipt.")
+ return 1 if open_gates else 0
+ if as_json:
+ document = {"course": COURSE_ID,
+ "starter_suite_passed": starter_ok,
+ "solution_suite_passed": solution_ok,
+ "checkpoints": rows}
+ if secret:
+ document["receipts"] = receipts
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ starter_state = "passed" if starter_ok else "not passed"
+ solution_state = "passed" if solution_ok else "not passed"
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + starter_state + " / solution " + solution_state)
+ for row in rows:
+ state = row["status"] + (" (self-attested)" if row["kind"] == "attested" else "")
+ code = "claim code " + row["claim_code"] if row["claim_code"] else "—"
+ print(" " + row["id"] + " " + row["title"] + " [" + state + "] " + code)
+ print("Claim codes are self-reported evidence, recorded at flypython.com; never a certificate.")
+ if secret:
+ print(f"Signed run receipts prepared for {len(receipts)} gated checkpoint(s);"
+ " submit each with its claim to mark them as a local-run receipt.")
+ if receipt_out and secret:
+ Path(receipt_out).write_text(
+ json.dumps({"receipts": receipts}, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8")
+ print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
+ return 0
+
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
+ nargs="?", default=None)
+ parser.add_argument("--json", action="store_true")
+ parser.add_argument("--expect-failure", action="store_true")
+ parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
+ args = parser.parse_args()
+
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
+
+ command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(ROOT / args.implementation)
+ result = subprocess.run(
+ command, env=environment, check=False, capture_output=True, text=True
+ )
+
+ if args.expect_failure:
+ if result.returncode == 0:
+ print("Expected the starter to fail, but it passed.", file=sys.stderr)
+ return 1
+ output = (result.stdout or "") + (result.stderr or "")
+ missing = [name for name in EXPECTED_STARTER_FAILURES if name not in output]
+ if missing:
+ print(
+ "Starter failed for unexpected reasons; missing expected failures:",
+ file=sys.stderr,
+ )
+ print("\n".join(missing), file=sys.stderr)
+ print(output, file=sys.stderr)
+ return 1
+ print(
+ "Expected starter state reproduced: replies are trusted unvalidated, "
+ "any tool name and argument runs, nothing retries or falls back, "
+ "and the eval skips crashed cases."
+ )
+ return 0
+ if result.returncode == 0:
+ print(f"{args.implementation}: all tests passed")
+ else:
+ sys.stderr.write((result.stderr or "") or (result.stdout or ""))
+ return result.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/mcp-server-in-python/COURSE.md b/courses/mcp-server-in-python/COURSE.md
index 5aa52b0..c46c8b4 100644
--- a/courses/mcp-server-in-python/COURSE.md
+++ b/courses/mcp-server-in-python/COURSE.md
@@ -75,7 +75,7 @@ moving existing servers to the 2026-07-28 specification.
- Badge: **MCP Tool Server Badge** (badge id `course-mcp-tools`) - earned by claiming all five checkpoints.
- Challenges: L01-L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` - L03 (validation & isolation) and L04 (the round-trip) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/mcp-server-in-python/COURSE_cn.md b/courses/mcp-server-in-python/COURSE_cn.md
index e2837fd..73db6a6 100644
--- a/courses/mcp-server-in-python/COURSE_cn.md
+++ b/courses/mcp-server-in-python/COURSE_cn.md
@@ -67,7 +67,7 @@ course_id: course-mcp-tools
- 徽章:**MCP 工具服务徽章**(徽章 id `course-mcp-tools`)——认领全部五个检查点后获得。
- 挑战:L01–L05 检查点各 10 分;五项全部在 flypython.com 认领后另加 50 分课程徽章奖励。
- 证据:`python verify.py` —— L03(校验与错误隔离)与 L04(多轮交互)由测试套件客观判定;L01/L02/L05 为学习者自报。
-- 提交:每个通过的检查点会打印确定性认领码,在 flypython.com 上记入你的账号。这是自我报告的证据,绝不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码,在 flypython.com 上记入你的账号。这是自我报告的证据,绝不是证书。
## 文件夹结构
diff --git a/courses/mcp-server-in-python/TASK.md b/courses/mcp-server-in-python/TASK.md
index 814d8c3..76e4647 100644
--- a/courses/mcp-server-in-python/TASK.md
+++ b/courses/mcp-server-in-python/TASK.md
@@ -15,6 +15,7 @@ Change only `starter/mcp_server.py`.
- Supported methods:
- `"tools/list"`: returns `{"result": {"tools": [{"name": ..., "description": ..., "inputSchema": ...}, ...]}}`.
- `"tools/call"`: accepts `params: {"name": ..., "arguments": ..., "inputResponses": {...}?}` and calls the handler as `handler(arguments, inputResponses)`.
+ - If the tool name is missing or is not a string (including lists, objects, booleans, numbers, and null), return JSON-RPC error `-32602` (Invalid params), preserving the request ID.
- If tool not registered: returns `{"result": {"isError": True, "content": [{"type": "text", "text": "Tool not found"}]}}`.
- If required argument missing: returns `{"result": {"isError": True, "content": [{"type": "text", "text": "Missing required argument: "}]}}`.
- If the handler raises `InputRequired(requests)`: returns `{"result": {"resultType": "input_required", "requests": , "content": [{"type": "text", "text": "Additional client input is required before this tool can finish."}]}}`. The client retries the same call with answers in `params.inputResponses`.
diff --git a/courses/mcp-server-in-python/TASK_cn.md b/courses/mcp-server-in-python/TASK_cn.md
index 6f94aeb..6c13a1b 100644
--- a/courses/mcp-server-in-python/TASK_cn.md
+++ b/courses/mcp-server-in-python/TASK_cn.md
@@ -15,6 +15,7 @@
- 支持的核心方法:
- `"tools/list"`:返回 `{"result": {"tools": [{"name": ..., "description": ..., "inputSchema": ...}, ...]}}`。
- `"tools/call"`:接收 `params: {"name": ..., "arguments": ..., "inputResponses": {...}?}`,并以 `handler(arguments, inputResponses)` 调用工具。
+ - 工具名缺失或不是字符串(包括列表、对象、布尔值、数字和 null)时,返回 JSON-RPC 错误码 `-32602`(Invalid params),并保留请求 ID。
- 工具未找到:返回 `{"result": {"isError": True, "content": [{"type": "text", "text": "Tool not found"}]}}`。
- 缺少 schema 中声明的必填参数:返回 `{"result": {"isError": True, "content": [{"type": "text", "text": "Missing required argument: "}]}}`。
- 工具抛出 `InputRequired(requests)`:返回 `{"result": {"resultType": "input_required", "requests": , "content": [{"type": "text", "text": "Additional client input is required before this tool can finish."}]}}`。客户端携带 `params.inputResponses` 重试同一请求。
diff --git a/courses/mcp-server-in-python/solution/mcp_server.py b/courses/mcp-server-in-python/solution/mcp_server.py
index 7179a0a..26358f6 100644
--- a/courses/mcp-server-in-python/solution/mcp_server.py
+++ b/courses/mcp-server-in-python/solution/mcp_server.py
@@ -108,6 +108,15 @@ def handle_request(self, request: Any) -> dict[str, Any]:
}
tool_name = params["name"]
+ if not isinstance(tool_name, str):
+ return {
+ "jsonrpc": "2.0",
+ "id": req_id,
+ "error": {
+ "code": -32602,
+ "message": "Invalid params: tool name must be a string",
+ },
+ }
if tool_name not in self.tools:
return {
"jsonrpc": "2.0",
diff --git a/courses/mcp-server-in-python/tests/test_mcp_server.py b/courses/mcp-server-in-python/tests/test_mcp_server.py
index 124d3a1..d545889 100644
--- a/courses/mcp-server-in-python/tests/test_mcp_server.py
+++ b/courses/mcp-server-in-python/tests/test_mcp_server.py
@@ -55,6 +55,20 @@ def test_tools_list_returns_schemas(self) -> None:
names = {t["name"] for t in tools}
self.assertEqual(names, {"calculate_sum", "risky_operation"})
+ def test_tools_call_rejects_non_string_tool_name(self) -> None:
+ for invalid_name in ([], {}, None, True, False, 1, 1.5):
+ with self.subTest(invalid_name=invalid_name):
+ resp = self.server.handle_request({
+ "jsonrpc": "2.0",
+ "id": 99,
+ "method": "tools/call",
+ "params": {"name": invalid_name, "arguments": {}},
+ })
+ self.assertEqual(resp["jsonrpc"], "2.0")
+ self.assertEqual(resp["id"], 99)
+ self.assertEqual(resp["error"]["code"], -32602)
+ self.assertIn("tool name must be a string", resp["error"]["message"])
+
def test_tools_call_success(self) -> None:
resp = self.server.handle_request({
"jsonrpc": "2.0",
diff --git a/courses/mcp-server-in-python/verify.py b/courses/mcp-server-in-python/verify.py
index c46944e..734df16 100644
--- a/courses/mcp-server-in-python/verify.py
+++ b/courses/mcp-server-in-python/verify.py
@@ -165,21 +165,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser()
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [
sys.executable,
diff --git a/courses/secure-ai-generated-code/COURSE.md b/courses/secure-ai-generated-code/COURSE.md
new file mode 100644
index 0000000..c00c599
--- /dev/null
+++ b/courses/secure-ai-generated-code/COURSE.md
@@ -0,0 +1,127 @@
+---
+id: course-secure-code
+type: course
+title: "Security review for AI-written Python"
+summary: Review a plausible, AI-style notes service the way an attacker would — close SQL injection, path traversal, a hard-coded secret, fast password hashes, and pickled sessions one class at a time, then prove each fix by replaying the attacks.
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+badge:
+ id: course-secure-code
+ name_en: Plausible is not safe
+ name_zh: 看起来对不等于安全
+ requires: All five checkpoints claimed (L01–L05)
+course_id: course-secure-code
+---
+
+# Security review for AI-written Python
+
+> TL;DR: install the FlyPython Skill in your coding agent and let it fetch
+> this course, then say **"start lesson 1"**. (Lesson files arrive via the
+> Skill — you download nothing by hand.) You start with a notes service
+> that works on every friendly input and blocks none of nine attacks. You
+> finish with every attack blocked, a replay command that proves it, and a
+> review habit for the code your assistant writes next. Standard library
+> only.
+
+## What you build
+
+You do not build a new feature — you review and fix one. `notes_app.py` is
+a small notes service written the way a quick first draft often is. It has
+five flaws, each a well-known weakness class:
+
+| Flaw in the starter | Weakness class | Python reference |
+| --- | --- | --- |
+| search SQL built with an f-string | CWE-89 SQL injection | `sqlite3` docs: placeholders |
+| attachment path joined as given | CWE-22 path traversal | `pathlib.Path.resolve`, `is_relative_to` |
+| `"dev-secret-change-me"` fallback key | CWE-798 hard-coded credentials | `os.environ` |
+| unsalted MD5 password hashes | CWE-916 / CWE-759 weak, unsalted password hash | `hashlib.pbkdf2_hmac`, `secrets` |
+| sessions stored as base64 pickles | CWE-502 deserialization of untrusted data | `pickle` docs warning, `hmac` |
+
+Two scenario "skins" ship with seed data and an `attacks.json` each:
+
+| Skin | You are… | Attacks |
+| --- | --- | --- |
+| `scenario/team-notes/` | securing a small team's shared notes | 9 |
+| `scenario/clinic-notes/` | securing a clinic's visit notes (all records fictional) | 8 |
+
+`python starter/attack_replay.py scenario/` replays them. On the
+shipped starter, every attack succeeds.
+
+## Teaching contract (read this first, agent)
+
+- **Audience:** a developer or project owner shipping Python that an AI
+ assistant wrote or heavily edited, who reviews diffs for "does it work"
+ but not yet for "what can a hostile input make it do". Not a penetration
+ testing course and not an introduction to Python.
+- **Prerequisites:** Python 3.11+ on PATH and any coding agent that can run
+ commands. Standard library only.
+- **Tool:** tool-agnostic. The course has not yet been taught in a tool;
+ `REVIEW.md` records the mechanical verification and the pending
+ agent-taught run-through, which will name the exact tool and version.
+- **Lesson order:** L01 → L02 → L03 → L04 → L05, one lesson per session.
+ Never skip the checkpoint at the end of a lesson.
+- **Teaching style:** work from the files in this folder and the Python
+ documentation they cite. Quote the `TASK.md` line you are satisfying.
+ Close one weakness class per turn. Never add dependencies, never edit
+ `solution/`, `tests/`, `scenario/`, or `attack_replay.py`, and ask before
+ touching any file not named in the current lesson. Never make a test
+ pass by special-casing the attack string it uses.
+- **Safety:** the attacks only touch this course's own files and temporary
+ folders. Never point them at a system you do not own. The suite
+ deliberately unpickles a harmless gadget that creates a directory inside
+ a temporary folder — tell the learner before the first run.
+- **When to stop:** a lesson is done when its checkpoint command runs and
+ the learner can say, in their own words, what the attacker could do and
+ why the fix stops it. If the learner cannot, re-teach from the failing
+ test, not from the solution.
+- **How to use `verify.py`:** the learner's command is plain
+ `python verify.py` — it runs the suite against `starter/`, prints
+ per-checkpoint status and earned claim codes, and exits non-zero until
+ every checkpoint is passed (l03, l04) or attested (l01, l02, l05). `starter --expect-failure`
+ and `solution` remain maintainer checks.
+- **Honesty rules:** nine blocked attacks prove those nine attacks are
+ blocked — not that the service is secure. Say what was not reviewed. Do
+ not claim the code is production-ready. Do not promise learning or
+ career outcomes.
+
+## What this course does NOT cover
+
+Authentication and authorization design, CSRF and XSS, TLS, rate limiting,
+dependency vulnerability scanning, secret rotation and storage, deployment
+hardening, threat modeling frameworks, or compliance. Password hashing uses
+PBKDF2-HMAC-SHA256 at 600 000 iterations because it is in the standard
+library; OWASP's Password Storage Cheat Sheet prefers Argon2id, then
+scrypt, with PBKDF2 for FIPS-140 contexts — see Lesson 2. Link to the
+companion repository's guides for the rest; don't improvise.
+
+## Badge contract
+
+- Badge: **Plausible is not safe Badge** (badge id `course-secure-code`) — earned by claiming all five checkpoints.
+- Challenges: L01–L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
+- Evidence: `python verify.py` — L03 (one flaw class per turn) and L04 (review like an attacker) are objectively gated by the suite; L01/L02/L05 are learner-attested.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+
+## Folder map
+
+```
+COURSE.md / COURSE_cn.md this file (EN / 中文)
+lessons/L01.md … L05.md lessons (each has an _cn.md pair)
+scenario// seed.json, attacks.json, attachments/, a fake secrets.txt
+TASK.md / TASK_cn.md the task contract the change must satisfy
+starter/notes_app.py the insecure first draft
+starter/attack_replay.py the provided attack harness (read-only)
+solution/ the reviewed solution (do not copy in lesson 3)
+tests/test_notes_app.py the contract suite (16 tests, read-only)
+verify.py objective pass/fail evidence
+REVIEW.md maintainer run-through record
+```
+
+## Evidence and licensing
+
+`REVIEW.md` records the run-through state with dates, tool versions, and
+observed deviations. Code in this folder is MIT-licensed; lesson prose is
+CC BY 4.0 (see repository `LICENSE`). The `secrets.txt` files contain fake
+values that exist only to be leaked. Report teaching drift or unclear
+lessons via the repository's `course-feedback` issue form.
diff --git a/courses/secure-ai-generated-code/COURSE_cn.md b/courses/secure-ai-generated-code/COURSE_cn.md
new file mode 100644
index 0000000..8812b35
--- /dev/null
+++ b/courses/secure-ai-generated-code/COURSE_cn.md
@@ -0,0 +1,116 @@
+---
+id: course-secure-code
+type: course
+title: "AI 写的 Python 代码的安全审查"
+summary: "像攻击者一样审查一个看起来没问题、AI 风格的笔记服务——一次一类地堵上 SQL 注入、路径穿越、硬编码密钥、快速密码哈希和 pickle 会话,再通过重放攻击证明每处修复。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+badge:
+ id: course-secure-code
+ name_en: Plausible is not safe
+ name_zh: 看起来对不等于安全
+ requires: 认领全部五个检查点(L01–L05)
+course_id: course-secure-code
+---
+
+# AI 写的 Python 代码的安全审查
+
+> 一句话:在你的编码 Agent 里装上 FlyPython Skill,让它取回本课程,然后
+> 说**「开始第 1 课」**。(课程文件经 Skill 送达——你不手动下载任何东西。)
+> 你从一个对所有友好输入都能正常工作、却一个攻击都挡不住的笔记服务开始,
+> 结束时九个攻击全被挡住,有一条能证明这一点的重放命令,并养成一套审查
+> 助手下一次写的代码的习惯。只用标准库。
+
+## 你要做的东西
+
+你不是写新功能——而是审查并修复一个功能。`notes_app.py` 是一个小型
+笔记服务,写法就像一份仓促的初稿常见的样子。它有五个漏洞,每个都属于
+一个广为人知的弱点类别:
+
+| starter 里的漏洞 | 弱点类别 | Python 参考 |
+| --- | --- | --- |
+| 用 f-string 拼出来的搜索 SQL | CWE-89 SQL 注入 | `sqlite3` 文档:占位符 |
+| 原样拼接的附件路径 | CWE-22 路径穿越 | `pathlib.Path.resolve`、`is_relative_to` |
+| `"dev-secret-change-me"` 兜底密钥 | CWE-798 硬编码凭据 | `os.environ` |
+| 无盐 MD5 密码哈希 | CWE-916 / CWE-759 弱且无盐的密码哈希 | `hashlib.pbkdf2_hmac`、`secrets` |
+| 以 base64 pickle 存储的会话 | CWE-502 反序列化不可信数据 | `pickle` 文档警告、`hmac` |
+
+课程自带两个「场景皮肤」,各有种子数据和一份 `attacks.json`:
+
+| 皮肤 | 你是…… | 攻击数 |
+| --- | --- | --- |
+| `scenario/team-notes/` | 为一个小团队的共享笔记做安全加固 | 9 |
+| `scenario/clinic-notes/` | 为一家诊所的就诊笔记做安全加固(所有记录均为虚构) | 8 |
+
+`python starter/attack_replay.py scenario/` 重放这些攻击。在原样的
+starter 上,每个攻击都会成功。
+
+## 教学契约(先读,Agent)
+
+- **受众:** 交付由 AI 助手编写或大幅改写的 Python 代码的开发者或项目
+ 所有者——审 diff 时会看「能不能跑」,但还没有看「恶意输入能让它做
+ 什么」。这不是渗透测试课,也不是 Python 入门。
+- **前置条件:** PATH 上有 Python 3.11+,以及任意一个能执行命令的编码
+ Agent。只用标准库。
+- **工具:** 与工具无关。本课程尚未在任何工具里实际授课;`REVIEW.md`
+ 记录机械验证结果和待完成的 Agent 授课实跑,实跑时会写明确切的工具与
+ 版本。
+- **课序:** L01 → L02 → L03 → L04 → L05,一次课一个会话。
+ 绝不跳过课尾的检查点。
+- **教学风格:** 从本文件夹里的文件及其引用的 Python 文档出发。引用你
+ 正在满足的那条 `TASK.md` 原文。每轮只堵一类弱点。绝不加依赖,绝不改
+ `solution/`、`tests/`、`scenario/` 或 `attack_replay.py`,动当前课程
+ 未点名的任何文件前先询问。绝不通过对测试所用的攻击字符串做特判来
+ 让测试通过。
+- **安全:** 这些攻击只触及本课程自己的文件和临时文件夹。绝不把它们指向
+ 你不拥有的系统。套件会故意反序列化一个无害的 gadget,它在临时文件夹里
+ 创建一个目录——第一次运行前要先告诉学习者。
+- **何时停下:** 一节课的检查点命令跑通、且学习者能用自己的话说清攻击者
+ 能做什么、修复为什么能挡住它,这节课才算完。学习者说不出就从失败测试
+ 重新教,不要从 solution 教。
+- **如何使用 `verify.py`:** 学员命令就是 `python verify.py`——它针对
+ `starter/` 跑套件、打印逐检查点状态与已获认领码,直到每个检查点都
+ 通过(l03、l04)或已自报确认(l01、l02、l05)之前都以非零退出。`starter --expect-failure` 与 `solution` 留作
+ 维护者检查。
+- **诚实规则:** 挡住九个攻击只证明这九个攻击被挡住了——不证明服务是
+ 安全的。说清哪些没有审查。不要声称代码达到生产可用。不要承诺学习或
+ 职业结果。
+
+## 本课程不覆盖什么
+
+认证与授权设计、CSRF 与 XSS、TLS、限流、依赖漏洞扫描、密钥轮换与存储、
+部署加固、威胁建模框架,以及合规。密码哈希使用 600 000 次迭代的
+PBKDF2-HMAC-SHA256,因为它在标准库里;OWASP 的 Password Storage Cheat
+Sheet 首选 Argon2id,其次 scrypt,PBKDF2 用于 FIPS-140 场景——见第 2 课。
+其余内容请链接到配套仓库的指南;不即兴发挥。
+
+## 徽章契约
+
+- 徽章:**看起来对不等于安全徽章**(badge id `course-secure-code`)——认领全部五个检查点获得。
+- 挑战:L01–L05 检查点,各 10 分;在 flypython.com 上集齐五个再加 50 分课程徽章奖励。
+- 证据:`python verify.py`——L03(每轮堵一类漏洞)与 L04(像攻击者一样复查)由套件客观把关;L01/L02/L05 为学习者自我报告。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码;在 flypython.com 上记入你的账号。这是自我报告的证据,从来不是证书。
+
+## 文件夹地图
+
+```
+COURSE.md / COURSE_cn.md 本文件(EN / 中文)
+lessons/L01.md … L05.md 课文(每课都有一个 _cn.md 对)
+scenario// seed.json、attacks.json、attachments/、一个假的 secrets.txt
+TASK.md / TASK_cn.md 改动必须满足的任务契约
+starter/notes_app.py 不安全的初稿
+starter/attack_replay.py 提供的攻击工具(只读)
+solution/ 评审过的解答(第 3 课不要抄)
+tests/test_notes_app.py 契约套件(16 个测试,只读)
+verify.py 客观的通过/失败证据
+REVIEW.md 维护者实跑记录
+```
+
+## 证据与许可
+
+`REVIEW.md` 记录实跑状态——日期、工具版本、观察到的偏差。文件夹内代码
+采用 MIT 许可;课文文字采用 CC BY 4.0(见仓库 `LICENSE`)。`secrets.txt`
+文件里是假值,存在的意义只是被泄露。发现教学漂移或课程不清楚之处,请
+通过仓库的 `course-feedback` issue 表单反馈。
diff --git a/courses/secure-ai-generated-code/REVIEW.md b/courses/secure-ai-generated-code/REVIEW.md
new file mode 100644
index 0000000..eb5fa2f
--- /dev/null
+++ b/courses/secure-ai-generated-code/REVIEW.md
@@ -0,0 +1,79 @@
+# Maintainer run-through record
+
+## 2026-10-11 — contract verification (mechanical)
+
+- Environment: Linux x86_64, Python 3.12.3, standard library only; Ruff
+ 0.16.10 for the Lesson 4 static-scan step.
+- Sources for the flaw classes and the password-hash parameters: the
+ Python `sqlite3`, `pathlib`, `hashlib`, `hmac`, `secrets`, and `pickle`
+ documentation, MITRE CWE-89/22/798/916/759/502, and the OWASP Password
+ Storage Cheat Sheet (PBKDF2-HMAC-SHA256 at 600 000 iterations). The
+ course uses PBKDF2 because it is in the standard library; the OWASP
+ preference for Argon2id is stated in `COURSE.md`.
+- Commands and results:
+ - `python verify.py starter --expect-failure` — exit 0; the ten expected
+ failure names reproduced (the symlink test is excluded from the list
+ because some platforms skip it; on this Linux run it fails on the
+ starter too, for eleven failing tests).
+ - `python verify.py solution` — exit 0; 16/16 tests pass, none skipped.
+ - `python verify.py` — exit 1 on the shipped starter; l03/l04 `[open]`,
+ l01/l02/l05 `[pending]` with no code.
+ - `python solution/attack_replay.py scenario/team-notes` — `blocked=9/9`,
+ exit 0; `scenario/clinic-notes` — `blocked=8/8`, exit 0. The starter
+ reports `blocked=0/9` and `blocked=0/8`.
+ - `ruff check --select S starter/notes_app.py` on the shipped starter:
+ S608 (SQL built from strings), S324 twice (MD5), S301 (`pickle`). It
+ does not flag the path join or the hard-coded fallback secret — the
+ point Lesson 4 makes. The solution has no S findings.
+ - Lesson 3's turn table (failing tests 11 → 9 → 6 → 5 → 3 → 0, replay
+ 0 → 2 → 4 → 6 → 7 → 9 of 9) was measured by swapping the solution's
+ functions into a scratch copy of the starter one flaw class at a time.
+ - The Lesson 3 exercise inputs behave as described: `./agenda.txt` reads,
+ `agenda.txt/../../secrets.txt` raises `SecurityError`.
+ - `python tools/verify_courses.py` — contract satisfied.
+- Safety: the suite and the replay harness unpickle a deliberately
+ harmless gadget that only creates a directory inside a temporary folder.
+ No network, no shell, no files outside the temp folder.
+
+## Pending before this course can be called taught-in-tool
+
+- One full agent-taught run-through ("start lesson 1" through the Lesson 5
+ checkpoint) in a named coding agent and version, recording observed
+ deviations from `COURSE.md` here. The course is tool-agnostic; no
+ agent-taught session has happened yet.
+- A security-practitioner review of the lesson wording. The fixes are
+ standard-library techniques checked against the sources above; nobody
+ with an application-security role has reviewed the course yet.
+
+## Deviation log
+
+### 2026-10-11 — agent solvability run (challenge mode, not taught mode)
+
+- Agent: a Cursor Cloud Agent subagent (Claude Opus 5.5), Linux, Python
+ 3.12.3, standard library only.
+- Method: worked in a copy of the course with `solution/` and this file
+ removed; fixed `starter/notes_app.py` from `TASK.md` and the tests only,
+ without access to the reference solution.
+- Result: 16/16 tests (none skipped) after one edit; `blocked=9/9` and
+ `blocked=8/8`; no Ruff S findings on its fixed file. It independently
+ re-measured Lesson 3's turn table and got the same numbers.
+- Observed deviations and the changes they triggered:
+ - `TASK.md` did not say that `hash_password` accepts any iteration count
+ while `verify_password` enforces the minimum; the agent inferred it
+ from a test. Both TASK files now say it.
+ - The token format `base64url(JSON).hex(...)` read ambiguously; both TASK
+ files now spell out the literal `.` separator.
+ - While checking the agent's notes, the reference solution was found to
+ raise `TypeError` (from `hmac.compare_digest`) on a token containing
+ non-ASCII characters instead of `SecurityError`. The solution now
+ refuses non-ASCII tokens, the TASK files say so, and the tamper test
+ includes such a token. The agent's implementation already handled it.
+ - `COURSE.md` said plain `python verify.py` exits non-zero "while l03 and
+ l04 are open"; it actually exits non-zero until l01/l02/l05 are also
+ attested. Wording corrected in `COURSE.md`/`COURSE_cn.md`.
+- Not changed, recorded for the next review: `TASK.md` sets no upper bound
+ on the iteration count read from a stored hash, so a hostile stored
+ value could make `verify_password` slow; SQLite's `lower()` folds only
+ ASCII, so case-insensitive search does not fold `É`.
+- Limitations: challenge-mode run (TASK.md → tests), not the taught
+ COURSE.md walkthrough; teaching quality remains pending.
diff --git a/courses/secure-ai-generated-code/TASK.md b/courses/secure-ai-generated-code/TASK.md
new file mode 100644
index 0000000..8a50664
--- /dev/null
+++ b/courses/secure-ai-generated-code/TASK.md
@@ -0,0 +1,55 @@
+# Task contract: close five security flaws in a notes service
+
+Change only `starter/notes_app.py`. Standard library only. Never modify
+`tests/`, `solution/`, `scenario/`, or `attack_replay.py` (the provided
+harness, identical in `starter/` and `solution/`). `init_db` is provided and
+already correct. Every argument below is attacker-controlled unless it says
+otherwise. Refusals raise `SecurityError`.
+
+- `find_notes(conn, owner, query) -> list[dict]`:
+ - Return `{"id", "title", "body"}` for *owner*'s notes whose title
+ contains *query*, case-insensitively, ordered by id.
+ - *owner* and *query* reach SQLite only as bound parameters (`?`), never
+ through string formatting. No input can return another owner's notes.
+ - *query* is a literal substring: `%` and `_` match only themselves.
+- `read_attachment(base_dir, name) -> bytes`:
+ - *base_dir* is trusted; *name* is not. Refuse an empty or absolute
+ *name*, and refuse any *name* whose resolved path (after `..` and
+ symlinks) is not inside the resolved *base_dir*.
+ - A refused name never reads a file. A name inside *base_dir* that does
+ not exist raises `FileNotFoundError`.
+- `load_secret_key(env=None) -> bytes`:
+ - Read `NOTES_SECRET_KEY` from *env* (default `os.environ`). Missing or
+ shorter than 32 characters raises `SecurityError` naming
+ `NOTES_SECRET_KEY`. There is no default key.
+- `hash_password(password, *, salt=None, iterations=600_000) -> str`:
+ - Return `pbkdf2_sha256$$$` using
+ `hashlib.pbkdf2_hmac("sha256", …)` and a fresh 16-byte salt from
+ `secrets` when *salt* is `None`. Hashing the same password twice gives
+ different strings. Any *iterations* value is accepted here; the
+ minimum is enforced by `verify_password`.
+- `verify_password(password, stored) -> bool`:
+ - True only for a `pbkdf2_sha256` string with at least 600 000 iterations
+ whose digest matches, compared with `hmac.compare_digest`. Any other
+ format — including a bare MD5 hex digest — returns False.
+- `dump_session(data, key) -> str` / `load_session(token, key) -> dict`:
+ - A token is the base64url-encoded JSON, a literal `.`, then the hex
+ HMAC-SHA256 of the base64url part under *key*; padding may be
+ stripped. Never use `pickle`.
+ - `load_session` checks the signature with `hmac.compare_digest`
+ **before** decoding anything. A malformed token, a wrong signature, a
+ token signed with another key, a token with non-ASCII characters, a
+ payload that is not JSON, or JSON that is not an object raises
+ `SecurityError` — never another exception type.
+
+`python starter/attack_replay.py ` replays the scenario's
+`attacks.json` and prints `blocked=X/Y`; it exits 0 only when every attack
+is blocked.
+
+Running the suite against the shipped starter deliberately unpickles a
+harmless gadget that creates a directory inside a temporary folder — that
+is the proof the starter executes attacker-supplied data.
+
+Done means `python verify.py starter` exits 0 with all sixteen tests
+passing (fifteen where the platform cannot create symlinks and the symlink
+test is skipped).
diff --git a/courses/secure-ai-generated-code/TASK_cn.md b/courses/secure-ai-generated-code/TASK_cn.md
new file mode 100644
index 0000000..5c65b82
--- /dev/null
+++ b/courses/secure-ai-generated-code/TASK_cn.md
@@ -0,0 +1,49 @@
+# 任务契约:堵上一个笔记服务里的五个安全漏洞
+
+只修改 `starter/notes_app.py`。仅用标准库。绝不修改 `tests/`、
+`solution/`、`scenario/` 或 `attack_replay.py`(提供的测试工具,在
+`starter/` 与 `solution/` 中完全相同)。`init_db` 已经提供且正确。除非
+特别说明,下面每个参数都由攻击者控制。拒绝一律抛出 `SecurityError`。
+
+- `find_notes(conn, owner, query) -> list[dict]`:
+ - 返回 *owner* 的、标题包含 *query*(不区分大小写)的笔记
+ `{"id", "title", "body"}`,按 id 排序。
+ - *owner* 和 *query* 只能以绑定参数(`?`)进入 SQLite,绝不经过字符串
+ 格式化。任何输入都不能返回别的 owner 的笔记。
+ - *query* 是字面子串:`%` 和 `_` 只匹配它们自己。
+- `read_attachment(base_dir, name) -> bytes`:
+ - *base_dir* 可信;*name* 不可信。拒绝空的或绝对路径的 *name*,并拒绝
+ 任何解析后路径(处理 `..` 和符号链接之后)不在解析后的 *base_dir*
+ 之内的 *name*。
+ - 被拒绝的名字绝不读取任何文件。位于 *base_dir* 之内但不存在的名字抛出
+ `FileNotFoundError`。
+- `load_secret_key(env=None) -> bytes`:
+ - 从 *env*(缺省为 `os.environ`)读取 `NOTES_SECRET_KEY`。缺失或少于
+ 32 个字符时抛出点名 `NOTES_SECRET_KEY` 的 `SecurityError`。没有缺省
+ 密钥。
+- `hash_password(password, *, salt=None, iterations=600_000) -> str`:
+ - 用 `hashlib.pbkdf2_hmac("sha256", …)` 返回
+ `pbkdf2_sha256$$$`;*salt* 为 `None`
+ 时用 `secrets` 生成新的 16 字节盐。同一个密码哈希两次得到不同字符串。
+ 这里接受任何 *iterations* 值;最低次数由 `verify_password` 把关。
+- `verify_password(password, stored) -> bool`:
+ - 只有迭代次数不少于 600 000、摘要匹配(用 `hmac.compare_digest` 比较)
+ 的 `pbkdf2_sha256` 字符串才返回 True。其他任何格式——包括裸 MD5 十六
+ 进制摘要——都返回 False。
+- `dump_session(data, key) -> str` / `load_session(token, key) -> dict`:
+ - token 是 base64url 编码的 JSON、一个字面的 `.`,再接用 *key* 对
+ base64url 部分算出的十六进制 HMAC-SHA256;可以去掉填充。绝不使用
+ `pickle`。
+ - `load_session` 在解码任何东西**之前**先用 `hmac.compare_digest` 校验
+ 签名。格式错误的 token、签名不对、用别的密钥签的 token、含非 ASCII
+ 字符的 token、不是 JSON 的 payload、不是对象的 JSON,都抛出
+ `SecurityError`——绝不抛别的异常类型。
+
+`python starter/attack_replay.py ` 重放场景的 `attacks.json`
+并打印 `blocked=X/Y`;只有每个攻击都被挡住时才以 0 退出。
+
+针对原样的 starter 运行套件时,会故意反序列化一个无害的 gadget,它在临时
+文件夹里创建一个目录——这正是 starter 会执行攻击者提供数据的证据。
+
+完成的标准是 `python verify.py starter` 以 0 退出且全部十六个测试通过
+(在无法创建符号链接、符号链接测试被跳过的平台上为十五个)。
diff --git a/courses/secure-ai-generated-code/lessons/L01.md b/courses/secure-ai-generated-code/lessons/L01.md
new file mode 100644
index 0000000..fed6e68
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L01.md
@@ -0,0 +1,119 @@
+---
+id: course-secure-code-l01
+type: course
+title: "Lesson 1: Plausible is not safe"
+summary: "Fetch the course through the FlyPython Skill, read a notes service that looks fine, then replay nine attacks against it and watch every one succeed."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That you can reproduce the attacks before fixing anything: `python verify.py` shows l03 and l04 as `[open]`, and `attack_replay.py` prints `blocked=0/9` for `team-notes`."
+ - title: "Read the code before the attacks"
+ body: "Write down what looks wrong before you run anything. Comparing your guess with the replay is the lesson."
+ - title: "When you are done"
+ body: "You can say, for each of the five functions, what an attacker gets. Then claim the checkpoint with `python verify.py --attest l01`."
+---
+
+# Lesson 1: Plausible is not safe
+
+## Objective
+
+The course files are in your working folder, fetched by your agent. You
+have read the starter without running it, written down what you think is
+wrong, and then replayed the attacks — and you can say what an attacker
+gets from each of the five functions. No code is changed in this lesson.
+
+## Step 1 — Let the agent fetch this course (you download nothing)
+
+Open your coding agent in an empty working folder and paste this one
+sentence:
+
+> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `secure-ai-generated-code`.
+
+The agent gives you an authorization link with a short code. Open it, log
+in, make sure the code matches what the agent showed you, and click
+Allow — then tell the agent "done". It writes this course's files to
+`courses/secure-ai-generated-code/`.
+
+**Prerequisite:** these courses need a coding agent that can run commands
+*and* reach the network. A chat-only web AI cannot do them.
+
+**Safety note:** the attacks in this course touch only the course's own
+files and temporary folders. One test deliberately unpickles a harmless
+payload that creates a directory in a temporary folder — that is the
+evidence, not an accident.
+
+## Why this lesson exists
+
+AI-written code is usually readable, consistent, and documented. Those are
+the signals reviewers use to decide code is fine — and none of them say
+anything about what a hostile input can make it do. The starter works for
+every friendly input. That is exactly why it is dangerous.
+
+## Warm-up (5 minutes, no agent)
+
+Open `starter/notes_app.py` and read the five functions after `init_db`.
+For each one, write one line: what input does it trust, and what could
+go wrong? Do not run anything yet.
+
+## The lesson
+
+Ask the agent to survey without changing anything:
+
+**"Read COURSE.md — it is the teaching contract for this folder. Then read
+TASK.md and starter/notes_app.py. Do not change any file. For each function,
+tell me which input it trusts that it should not."**
+
+Then run the learner's command and the attack replay:
+
+```bash
+python verify.py
+python starter/attack_replay.py scenario/team-notes
+```
+
+The replay prints `blocked=0/9`. Read the `[OPEN]` lines — each is a real
+consequence:
+
+| Attack | What the attacker got |
+| --- | --- |
+| `t01`, `t02` (sql) | Bob's and Carol's notes, including "Salary review", from a search as Alice |
+| `t03`, `t04` (path) | the contents of `secrets.txt`, a file outside `attachments/` |
+| `t05`, `t06` (secret-key) | a service that started with a key everyone can read in the source |
+| `t07` (fast-hash) | a stored hash that one MD5 call reproduces — and a leaked MD5 that still logs in |
+| `t08` (forged-session) | an `admin` session the attacker signed with their own key |
+| `t09` (pickle-session) | code execution: the payload created a directory on load |
+
+The suite has eleven failing tests that pin these behaviors. The
+maintainer command prints them:
+
+```bash
+python verify.py starter --expect-failure
+```
+
+Five tests already pass: a normal search works and is owner-scoped, a
+normal attachment reads, a password verifies against its own hash, a
+session round-trips, and the replay prints usage. Every friendly path
+works.
+
+## Exercise
+
+Compare your warm-up notes with the replay. Which flaws did you spot by
+reading, and which did you miss? Then pick the flaw you missed and ask the
+agent to explain the attack in two sentences — not to fix it.
+
+## Checkpoint
+
+Run `python verify.py`. When you can answer these without looking, claim
+the checkpoint with `python verify.py --attest l01`:
+
+1. How does query `%' OR owner != '` turn Alice's search into everyone's
+ notes? Write out the SQL the starter actually runs.
+2. Why does `t09` count as code execution and not just "bad data"?
+3. Which flaw did you miss in the warm-up, and what about the code made it
+ look fine?
+
+## Expected evidence
+
+Your warm-up notes, the replay output, and your three answers.
diff --git a/courses/secure-ai-generated-code/lessons/L01_cn.md b/courses/secure-ai-generated-code/lessons/L01_cn.md
new file mode 100644
index 0000000..b5cfe01
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L01_cn.md
@@ -0,0 +1,107 @@
+---
+id: course-secure-code-l01
+type: course
+title: "第 1 课:看起来对不等于安全"
+summary: "通过 FlyPython Skill 取回课程,读一个看起来没问题的笔记服务,然后对它重放九个攻击,看着每一个都得手。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "先复现攻击再动手修:`python verify.py` 把 l03 和 l04 显示为 `[open]`,`attack_replay.py` 对 `team-notes` 打印 `blocked=0/9`。"
+ - title: "先读代码,再看攻击"
+ body: "运行任何东西之前,先写下你觉得哪里不对。把你的猜测和重放结果对比,就是这一课。"
+ - title: "完成的标志"
+ body: "对五个函数中的每一个,你都能说出攻击者能得到什么。然后用 `python verify.py --attest l01` 认领本检查点。"
+---
+
+# 第 1 课:看起来对不等于安全
+
+## 目标
+
+课程文件已由你的 Agent 取回工作目录。你在不运行的情况下读过 starter、
+写下了你认为哪里不对,然后重放了攻击——并能说出攻击者从五个函数中的每
+一个能得到什么。本课不改任何代码。
+
+## 第 1 步——让 Agent 取回本课(你不下载)
+
+在一个空工作目录里打开你的编码 Agent,粘贴这一句话:
+
+> Read https://flypython.com/skills/flypython/SKILL.md and start the FlyPython course `secure-ai-generated-code`.
+
+Agent 会给你一个授权链接和一组短码。打开链接、登录、核对码与 Agent
+显示的一致后点「允许」,然后对 Agent 说「好了」。它把本课文件写到
+`courses/secure-ai-generated-code/`。
+
+**前提**:这些课程需要一个能执行命令**并且**能联网的编码 Agent。只能
+聊天的网页 AI 做不了。
+
+**安全说明:** 本课程的攻击只触及课程自己的文件和临时文件夹。有一个测试
+会故意反序列化一个无害的 payload,它在临时文件夹里创建一个目录——那是
+证据,不是意外。
+
+## 为什么有这一课
+
+AI 写的代码通常可读、风格一致、有文档。评审者正是靠这些信号判断代码
+没问题——而它们对「恶意输入能让它做什么」一个字也没说。starter 对每个
+友好输入都能正常工作。这正是它危险的原因。
+
+## 热身(5 分钟,不用 Agent)
+
+打开 `starter/notes_app.py`,读 `init_db` 之后的五个函数。每个写一行:
+它信任了什么输入,可能出什么问题?先别运行任何东西。
+
+## 本课内容
+
+让 Agent 先只读不动手:
+
+**“读 COURSE.md——它是本文件夹的教学契约。再读 TASK.md 和
+starter/notes_app.py。不要改任何文件。告诉我每个函数信任了哪个它不该
+信任的输入。”**
+
+然后运行学员命令和攻击重放:
+
+```bash
+python verify.py
+python starter/attack_replay.py scenario/team-notes
+```
+
+重放打印 `blocked=0/9`。读那些 `[OPEN]` 行——每一行都是真实后果:
+
+| 攻击 | 攻击者得到了什么 |
+| --- | --- |
+| `t01`、`t02`(sql) | 以 Alice 身份搜索,却拿到 Bob 和 Carol 的笔记,包括「Salary review」 |
+| `t03`、`t04`(path) | `secrets.txt` 的内容——一个在 `attachments/` 之外的文件 |
+| `t05`、`t06`(secret-key) | 一个用源码里人人可见的密钥启动的服务 |
+| `t07`(fast-hash) | 一次 MD5 调用就能复现的存储哈希——以及一个泄露的 MD5 仍然能登录 |
+| `t08`(forged-session) | 攻击者用自己的密钥签出来的 `admin` 会话 |
+| `t09`(pickle-session) | 代码执行:payload 在加载时创建了一个目录 |
+
+套件里有十一个失败测试钉住这些行为。维护者命令会打印它们:
+
+```bash
+python verify.py starter --expect-failure
+```
+
+有五个测试已经通过:正常搜索能用且限定 owner、正常附件能读、密码能与
+自己的哈希验证通过、会话能往返、重放工具能打印用法。每条友好路径都能用。
+
+## 练习
+
+把热身笔记和重放结果对比。哪些漏洞你读代码就发现了,哪些漏掉了?挑一个
+你漏掉的,让 Agent 用两句话解释这个攻击——不是让它修。
+
+## 检查点
+
+运行 `python verify.py`。不看材料能回答下面三题时,用
+`python verify.py --attest l01` 认领本检查点:
+
+1. 查询 `%' OR owner != '` 是怎样把 Alice 的搜索变成所有人的笔记的?
+ 写出 starter 实际执行的 SQL。
+2. 为什么 `t09` 算代码执行,而不只是「坏数据」?
+3. 热身时你漏掉了哪个漏洞?代码的什么地方让它看起来没问题?
+
+## 预期证据
+
+你的热身笔记、重放输出,以及三个答案。
diff --git a/courses/secure-ai-generated-code/lessons/L02.md b/courses/secure-ai-generated-code/lessons/L02.md
new file mode 100644
index 0000000..3c66190
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L02.md
@@ -0,0 +1,89 @@
+---
+id: course-secure-code-l02
+type: course
+title: "Lesson 2: Write the threat model as a contract"
+summary: "For each function, name who controls the input, what they want, and what the code must refuse — then trace every refusal in TASK.md to a test and to the Python documentation behind it."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That you can read `TASK.md` as a list of refusals, trace each to a test, and say which Python feature makes the refusal reliable."
+ - title: "Trust boundaries first"
+ body: "In `read_attachment(base_dir, name)`, `base_dir` comes from your configuration and `name` comes from a request. The contract treats them differently on purpose."
+ - title: "Prefer the mechanism to the filter"
+ body: "Bound parameters, resolved paths, keyed signatures: each removes a whole class of attack. A blocklist of bad strings removes the strings you thought of."
+---
+
+# Lesson 2: Write the threat model as a contract
+
+## Objective
+
+You can say, for each function, who controls each input and what they
+want; trace every refusal in `TASK.md` to a test; and name the standard
+library mechanism that makes the refusal reliable rather than lucky.
+
+## Why this lesson exists
+
+"Make it secure" is the vaguest request you can give an agent. It produces
+input filters that strip quotes, check for `..`, or look for the word
+`admin` — each of which an attacker routes around. A threat model written
+as a contract says what must be refused and lets the tests check it; the
+mechanism you choose then decides whether the refusal holds for inputs
+nobody wrote a test for.
+
+## The lesson
+
+Read `TASK.md` with the tests beside it. For each function, the contract
+answers three questions:
+
+| Function | Who controls the input | What they want | Mechanism that refuses it |
+| --- | --- | --- | --- |
+| `find_notes` | the person searching | other owners' notes | `?` placeholders — the `sqlite3` docs' recommended way to bind values; plus `instr` so `%` and `_` stay literal |
+| `read_attachment` | whoever names the file | any file the process can read | `Path.resolve()` then `is_relative_to(base)` — checks where the path really goes, symlinks included |
+| `load_secret_key` | whoever reads your source code | the signing key | no default; refuse to start below 32 characters |
+| `hash_password` / `verify_password` | whoever steals the database | the passwords | salted, slow PBKDF2 via `hashlib.pbkdf2_hmac`; `hmac.compare_digest` for the comparison |
+| `dump_session` / `load_session` | whoever holds the cookie | another identity — or code execution | JSON plus an HMAC-SHA256 signature checked *before* decoding; never `pickle` |
+
+The Python documentation is the primary source for each choice:
+
+- **`pickle`:** the module documentation opens with a warning that pickle is
+ not secure and that you should only unpickle data you trust. A session
+ token comes from a client — it is never data you trust.
+- **`sqlite3`:** the documentation's how-to on placeholders shows binding
+ values with `?` and warns against assembling SQL with string formatting.
+- **`hmac.compare_digest`:** compares in a way designed to resist timing
+ attacks; `==` on secrets does not.
+- **`secrets`:** the module for security tokens and salts — not `random`.
+
+On password hashing, the contract chooses PBKDF2-HMAC-SHA256 at 600 000
+iterations because it is in the standard library and it is the setting
+the OWASP Password Storage Cheat Sheet gives for PBKDF2. The same cheat
+sheet prefers Argon2id, then scrypt, and recommends PBKDF2 where FIPS-140
+compliance is required. In a real project, decide which applies to you.
+
+## Exercise
+
+Pick a function in a project you know that takes input from a request,
+a file, or a message queue. Fill in the same three columns for it. Then
+check: is your mechanism a mechanism, or a filter that removes the attack
+strings you happened to think of?
+
+## Checkpoint
+
+When you can answer these, claim the checkpoint with
+`python verify.py --attest l02`:
+
+1. What does `Path(base) / "/etc/passwd"` evaluate to, and why does the
+ contract refuse every absolute `name` — even one that happens to point
+ inside `base_dir`?
+2. Why must `load_session` check the signature *before* it decodes the
+ payload?
+3. Why is "strip quotes from the query" not an acceptable fix for
+ `find_notes`?
+
+## Expected evidence
+
+Your three-column threat model for one function you own, with the
+mechanism marked as a mechanism or a filter.
diff --git a/courses/secure-ai-generated-code/lessons/L02_cn.md b/courses/secure-ai-generated-code/lessons/L02_cn.md
new file mode 100644
index 0000000..1b4a64c
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L02_cn.md
@@ -0,0 +1,77 @@
+---
+id: course-secure-code-l02
+type: course
+title: "第 2 课:把威胁模型写成契约"
+summary: "对每个函数,说清谁控制输入、他想要什么、代码必须拒绝什么——再把 TASK.md 里的每一条拒绝追查到一个测试,以及它背后的 Python 文档。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "你能把 `TASK.md` 当成一张拒绝清单来读,把每一条追查到一个测试,并说出是哪个 Python 特性让这条拒绝可靠。"
+ - title: "先划信任边界"
+ body: "在 `read_attachment(base_dir, name)` 里,`base_dir` 来自你的配置,`name` 来自请求。契约故意区别对待它们。"
+ - title: "用机制,不用过滤"
+ body: "绑定参数、解析后的路径、带密钥的签名:每一种都消除一整类攻击。坏字符串黑名单只消除你想到的那些字符串。"
+---
+
+# 第 2 课:把威胁模型写成契约
+
+## 目标
+
+你能对每个函数说出每个输入由谁控制、他想要什么;把 `TASK.md` 里的每一条
+拒绝追查到一个测试;并说出让这条拒绝可靠(而不是碰巧有效)的标准库机制。
+
+## 为什么有这节课
+
+「把它弄安全」是你能给 Agent 的最含糊的请求。它产出的是去掉引号、检查
+`..`、或者查找 `admin` 字样的输入过滤——攻击者每一个都能绕过去。写成契约
+的威胁模型说清必须拒绝什么,让测试去检查;你选的机制则决定了这条拒绝对
+没人写过测试的输入是否依然成立。
+
+## 本课内容
+
+把测试并排打开,读 `TASK.md`。对每个函数,契约回答三个问题:
+
+| 函数 | 谁控制输入 | 他想要什么 | 拒绝它的机制 |
+| --- | --- | --- | --- |
+| `find_notes` | 搜索的人 | 别的 owner 的笔记 | `?` 占位符——`sqlite3` 文档推荐的绑定值方式;再加 `instr`,让 `%` 和 `_` 保持字面意义 |
+| `read_attachment` | 给文件起名的人 | 进程能读的任何文件 | 先 `Path.resolve()` 再 `is_relative_to(base)`——检查路径真正指向哪里,符号链接也算 |
+| `load_secret_key` | 读得到你源码的人 | 签名密钥 | 没有缺省值;不足 32 个字符就拒绝启动 |
+| `hash_password` / `verify_password` | 偷到数据库的人 | 密码 | 通过 `hashlib.pbkdf2_hmac` 做加盐慢哈希;比较用 `hmac.compare_digest` |
+| `dump_session` / `load_session` | 持有 cookie 的人 | 另一个身份——或代码执行 | JSON 加 HMAC-SHA256 签名,在解码*之前*校验;绝不用 `pickle` |
+
+每个选择的一手来源都是 Python 文档:
+
+- **`pickle`:** 模块文档开头就警告 pickle 不安全,只应反序列化你信任的
+ 数据。会话 token 来自客户端——它永远不是你信任的数据。
+- **`sqlite3`:** 文档里关于占位符的操作指南展示了用 `?` 绑定值,并警告
+ 不要用字符串格式化拼 SQL。
+- **`hmac.compare_digest`:** 以抵御计时攻击为设计目标进行比较;对秘密值
+ 用 `==` 做不到这一点。
+- **`secrets`:** 用于安全 token 和盐的模块——不是 `random`。
+
+在密码哈希上,契约选了 600 000 次迭代的 PBKDF2-HMAC-SHA256,因为它在
+标准库里,而且这正是 OWASP Password Storage Cheat Sheet 给 PBKDF2 的设置。
+同一份 cheat sheet 首选 Argon2id,其次 scrypt,在需要 FIPS-140 合规时
+推荐 PBKDF2。在真实项目里,判断哪种情况适用于你。
+
+## 练习
+
+在你熟悉的项目里挑一个接收请求、文件或消息队列输入的函数,为它填同样的
+三列。然后检查:你的机制真的是机制,还是一个只去掉了你恰好想到的攻击
+字符串的过滤器?
+
+## 检查点
+
+能回答下面三题时,用 `python verify.py --attest l02` 认领本检查点:
+
+1. `Path(base) / "/etc/passwd"` 的结果是什么?为什么契约拒绝所有绝对路径
+ 的 `name`——哪怕它恰好指向 `base_dir` 之内?
+2. 为什么 `load_session` 必须在解码 payload *之前*校验签名?
+3. 为什么「从查询里去掉引号」不是 `find_notes` 可以接受的修复?
+
+## 预期证据
+
+为你拥有的一个函数写的三列威胁模型,并标明其机制是机制还是过滤器。
diff --git a/courses/secure-ai-generated-code/lessons/L03.md b/courses/secure-ai-generated-code/lessons/L03.md
new file mode 100644
index 0000000..e7e7c9a
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L03.md
@@ -0,0 +1,104 @@
+---
+id: course-secure-code-l03
+type: course
+title: "Lesson 3: Close one flaw class per turn"
+summary: "Five bounded turns — search, attachments, secret key, passwords, sessions — each closing one weakness class with a mechanism, never a filter, and each proven by its tests and the replay."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That the starter passes all sixteen tests through changes you supervised — `python verify.py` shows this code only once the starter suite is green."
+ - title: "Watch for the filter"
+ body: "If a diff adds `replace(\"'\", \"\")`, checks `'..' in name`, or compares against the attack strings in `attacks.json`, reject it. The contract asks for a mechanism."
+ - title: "The replay goes green last"
+ body: "`test_replay_blocks_every_team_notes_attack` needs all five classes closed. It stays red until the final turn."
+---
+
+# Lesson 3: Close one flaw class per turn
+
+## Objective
+
+The starter passes all sixteen tests through five bounded turns, each
+closing one weakness class, and you can explain why each fix holds for
+inputs the tests never try.
+
+## Why this lesson exists
+
+Ask an agent to "fix the security issues" and you get one large diff that
+mixes real fixes with filters, renames, and new helpers. You cannot tell
+which line stops which attack. One class per turn keeps each fix small
+enough to check against its mechanism from Lesson 2.
+
+## The lesson
+
+Start a fresh agent session and say:
+
+**"Work through TASK.md against starter/notes_app.py, one weakness class
+per turn: find_notes, then read_attachment, then load_secret_key, then
+hash_password and verify_password together, then dump_session and
+load_session together. After each turn run `python verify.py starter` and
+`python starter/attack_replay.py scenario/team-notes`, show me the diff,
+and wait for me. Do not edit tests/, solution/, scenario/, or
+attack_replay.py."**
+
+Expect the failing count and the replay to move like this:
+
+| After turn | Failing tests | Replay (`team-notes`) |
+| --- | --- | --- |
+| start | 11 | 0/9 |
+| `find_notes` | 9 | 2/9 |
+| `read_attachment` | 6 | 4/9 |
+| `load_secret_key` | 5 | 6/9 |
+| passwords | 3 | 7/9 |
+| sessions | 0 | 9/9 |
+
+(Where the platform cannot create symlinks, the symlink test is skipped and
+the first two rows read 10 and 8.)
+
+Hold each turn to its mechanism:
+
+- **Search:** the SQL string is a constant; values go in the parameter
+ tuple. Reject any diff that escapes quotes or strips characters. `LIKE`
+ with escaped wildcards is acceptable only if the escaping is complete;
+ `instr` avoids the question.
+- **Attachments:** resolve first, then check containment with
+ `is_relative_to`. Reject `if ".." in name` — it misses symlinks and
+ absolute paths, and it refuses legitimate names like `notes..txt`.
+- **Secret key:** no fallback, ever. If the agent adds a "development
+ default", it has reintroduced the flaw.
+- **Passwords:** `secrets.token_bytes(16)` for the salt,
+ `hashlib.pbkdf2_hmac` for the digest, `hmac.compare_digest` for the
+ check. `verify_password` returns False for any format it did not write.
+- **Sessions:** remove `pickle` from the file entirely. Verify the
+ signature before decoding; catch only the decoding errors you expect and
+ turn them into `SecurityError`.
+
+After the sessions turn, run `grep -n pickle starter/notes_app.py`. It
+should print nothing.
+
+## Exercise
+
+For the attachments fix, try two names the tests do not use, against
+`scenario/team-notes/attachments`: `./agenda.txt` (legitimate — it should
+read) and `agenda.txt/../../secrets.txt` (it should be refused). Predict
+each result before you run it, and write down whether the mechanism
+handled both without a new line of code.
+
+## Checkpoint
+
+Run `python verify.py`. This checkpoint's code appears only when the
+starter suite is green. You pass the lesson when you can answer:
+
+1. Which diff did you reject or narrow because it was a filter instead of
+ a mechanism?
+2. Why does `test_replay_blocks_every_team_notes_attack` only pass on the
+ last turn?
+3. What would happen if `load_session` decoded the payload first and
+ checked the signature second?
+
+## Expected evidence
+
+A green `python verify.py starter`, the replay printing `blocked=9/9`, the
+five diffs, and your exercise result.
diff --git a/courses/secure-ai-generated-code/lessons/L03_cn.md b/courses/secure-ai-generated-code/lessons/L03_cn.md
new file mode 100644
index 0000000..3b5923d
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L03_cn.md
@@ -0,0 +1,95 @@
+---
+id: course-secure-code-l03
+type: course
+title: "第 3 课:每轮只堵一类漏洞"
+summary: "五个有界轮次——搜索、附件、密钥、密码、会话——每轮用机制(绝不用过滤)堵上一类弱点,并用测试和重放证明。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "starter 通过你监督的改动让十六个测试全绿——`python verify.py` 只在 starter 套件变绿后显示本检查点的码。"
+ - title: "警惕过滤器"
+ body: "如果 diff 加了 `replace(\"'\", \"\")`、检查 `'..' in name`,或者拿 `attacks.json` 里的攻击字符串做比较,拒绝它。契约要的是机制。"
+ - title: "重放最后才变绿"
+ body: "`test_replay_blocks_every_team_notes_attack` 需要五类漏洞全部堵上。直到最后一轮它都是红的。"
+---
+
+# 第 3 课:每轮只堵一类漏洞
+
+## 目标
+
+starter 通过五个有界轮次让十六个测试全绿,每轮堵上一类弱点,并且你能
+解释每处修复为什么对测试从没试过的输入同样有效。
+
+## 为什么有这节课
+
+让 Agent「修掉安全问题」,你会得到一个大 diff:真正的修复与过滤器、
+重命名、新辅助函数混在一起。你分不清哪一行挡住了哪个攻击。每轮一类,才能
+让每处修复小到可以对照第 2 课的机制逐一核对。
+
+## 本课内容
+
+开一个新的 Agent 会话,说:
+
+**“按 TASK.md 改 starter/notes_app.py,每轮只处理一类弱点:先
+find_notes,再 read_attachment,再 load_secret_key,再把 hash_password 和
+verify_password 一起,最后把 dump_session 和 load_session 一起。每轮之后
+跑 `python verify.py starter` 和
+`python starter/attack_replay.py scenario/team-notes`,给我看 diff,然后
+等我。不要改 tests/、solution/、scenario/ 或 attack_replay.py。”**
+
+预期失败数和重放结果这样变化:
+
+| 哪一轮之后 | 失败测试数 | 重放(`team-notes`) |
+| --- | --- | --- |
+| 开始 | 11 | 0/9 |
+| `find_notes` | 9 | 2/9 |
+| `read_attachment` | 6 | 4/9 |
+| `load_secret_key` | 5 | 6/9 |
+| 密码 | 3 | 7/9 |
+| 会话 | 0 | 9/9 |
+
+(在无法创建符号链接的平台上,符号链接测试会被跳过,前两行分别是 10 和
+8。)
+
+让每一轮守住它的机制:
+
+- **搜索:** SQL 字符串是常量;值放进参数元组。拒绝任何转义引号或删除
+ 字符的 diff。用 `LIKE` 加通配符转义也可以,但前提是转义完整;`instr`
+ 干脆避开了这个问题。
+- **附件:** 先 resolve,再用 `is_relative_to` 检查是否包含在内。拒绝
+ `if ".." in name`——它漏掉符号链接和绝对路径,还会误拒 `notes..txt`
+ 这样的正常名字。
+- **密钥:** 永远没有兜底值。如果 Agent 加了一个「开发环境缺省值」,它就
+ 把漏洞又带回来了。
+- **密码:** 盐用 `secrets.token_bytes(16)`,摘要用 `hashlib.pbkdf2_hmac`,
+ 校验用 `hmac.compare_digest`。`verify_password` 对任何不是它自己写出的
+ 格式都返回 False。
+- **会话:** 把 `pickle` 从文件里彻底删掉。先校验签名再解码;只捕获你
+ 预期的解码错误,并把它们转成 `SecurityError`。
+
+会话那一轮之后,运行 `grep -n pickle starter/notes_app.py`。它应该什么都
+不打印。
+
+## 练习
+
+针对附件修复,对 `scenario/team-notes/attachments` 试两个测试没用过的
+名字:`./agenda.txt`(正常名字——应该能读)和
+`agenda.txt/../../secrets.txt`(应该被拒绝)。运行前先预测每个结果,
+记下机制是否无需新增一行代码就处理了这两个。
+
+## 检查点
+
+运行 `python verify.py`。本检查点的码只在 starter 套件全绿时出现。不看
+材料能回答即算通过:
+
+1. 你因为它是过滤器而不是机制,拒绝或收窄过哪个 diff?
+2. 为什么 `test_replay_blocks_every_team_notes_attack` 只在最后一轮才通过?
+3. 如果 `load_session` 先解码 payload、后校验签名,会发生什么?
+
+## 预期证据
+
+一次全绿的 `python verify.py starter`、打印 `blocked=9/9` 的重放、五个
+diff,以及你的练习结果。
diff --git a/courses/secure-ai-generated-code/lessons/L04.md b/courses/secure-ai-generated-code/lessons/L04.md
new file mode 100644
index 0000000..4dc26ea
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L04.md
@@ -0,0 +1,101 @@
+---
+id: course-secure-code-l04
+type: course
+title: "Lesson 4: Review like an attacker"
+summary: "Green tests and a 9/9 replay are the floor. Replay the second skin, run a static scanner and note what it misses, attack one thing the suite never tried, and write down what is still unreviewed."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "That starter AND solution both pass — the maintainer pair `verify.py starter` / `verify.py solution` must both be green, and `python verify.py` prints this code once your starter suite passes."
+ - title: "Scanners find patterns, not intent"
+ body: "On this starter, ruff's `S` rules flag the f-string SQL, MD5, and `pickle.loads` — and say nothing about the path join or the fallback secret. Treat a clean scan as one line of evidence."
+ - title: "When you are done"
+ body: "You have a security evidence record — verified / not verified / known limits — that names what nobody reviewed."
+---
+
+# Lesson 4: Review like an attacker
+
+## Objective
+
+Both suites pass, both skins replay fully blocked, and you hold a written
+record of what was reviewed, what a scanner did and did not catch, and
+what nobody has reviewed yet.
+
+## Why this lesson exists
+
+`blocked=9/9` is easy to over-read. It means nine specific attacks are
+blocked. A real attacker tries a tenth. Security review is the habit of
+asking what the tests did not try — and writing the answer down instead of
+assuming it.
+
+## The lesson
+
+Run the maintainer pair and both replays yourself:
+
+```bash
+python verify.py starter
+python verify.py solution
+python starter/attack_replay.py scenario/team-notes
+python starter/attack_replay.py scenario/clinic-notes
+```
+
+The suite only replays `team-notes`. `clinic-notes` uses different
+payloads — `x' OR 1=1 --`, an injected *owner* instead of an injected
+query, `scans/../../secrets.txt`, an empty key. If your fixes are
+mechanisms, it prints `blocked=8/8` without any change. If it does not,
+one of your fixes was a filter.
+
+Then do the review the suite cannot do:
+
+1. **Run a static scanner, then read what it missed.** If you have Ruff
+ (the repository's development dependencies include it), run its
+ flake8-bandit rules on your fix:
+
+ ```bash
+ ruff check --isolated --select S starter/notes_app.py
+ ```
+
+ Your fix should report nothing. For comparison, on the original starter
+ Ruff 0.16.10 reports S608 (SQL built from strings), S324 (MD5, twice),
+ and S301 (`pickle.loads`) — and nothing for the path join or the
+ `"dev-secret-change-me"` fallback. If you committed a baseline, run the
+ same command on it to see this yourself. A scanner is a fast first pass
+ that catches known patterns; it is not a review.
+2. **Attack one thing the suite never tried.** Pick one: a search query
+ with Unicode case (`É` vs `é`), an attachment name that is a directory,
+ a session token with a valid signature over a JSON list, a password
+ that is 10 000 characters long. Predict, run, and write down the result.
+3. **Read the diff for what it removed.** `git diff` the starter against
+ your baseline. Confirm `pickle` and `md5` are gone and that no
+ `except Exception` swallows a `SecurityError`.
+
+## Exercise
+
+Write the security evidence record for this change:
+
+- **Verified:** the commands you ran, both replays, the scanner output.
+- **Not verified:** at least — authentication and authorization as a whole,
+ how the session token is transported (cookie flags, TLS), rate limiting
+ on login, key storage and rotation, dependency vulnerabilities, and
+ every attack not in `attacks.json`.
+- **Known limits:** anything you found in step 2 that the contract never
+ promised, and the password-hash choice (PBKDF2 rather than Argon2id).
+
+## Checkpoint
+
+Run `python verify.py`. This checkpoint's code prints once the starter
+suite passes; the maintainer pair above must both be green for the gate to
+mean what it says. You pass the lesson when you can answer:
+
+1. Did `clinic-notes` pass without changes? If not, which fix was a filter?
+2. Which two flaw classes did the scanner miss on the original starter, and
+ why would a pattern-based tool miss them?
+3. What did your extra attack do, and is it on your "not verified" list?
+
+## Expected evidence
+
+Both green runs, both replay outputs, the scanner output, and your
+three-line evidence record.
diff --git a/courses/secure-ai-generated-code/lessons/L04_cn.md b/courses/secure-ai-generated-code/lessons/L04_cn.md
new file mode 100644
index 0000000..28eada4
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L04_cn.md
@@ -0,0 +1,92 @@
+---
+id: course-secure-code-l04
+type: course
+title: "第 4 课:像攻击者一样复查"
+summary: "测试全绿和 9/9 的重放只是地板。重放第二个皮肤、跑一遍静态扫描并记下它漏了什么、攻击一处套件从没试过的地方,并写下还有什么没审查。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "starter 和 solution 双双通过——维护者那一对命令 `verify.py starter` / `verify.py solution` 都必须是绿的,你的 starter 套件通过后 `python verify.py` 打印本检查点的码。"
+ - title: "扫描器找的是模式,不是意图"
+ body: "在这个 starter 上,ruff 的 `S` 规则标出了 f-string SQL、MD5 和 `pickle.loads`——对路径拼接和兜底密钥一言不发。把干净的扫描结果当作证据中的一行。"
+ - title: "完成的标志"
+ body: "你有一份安全证据记录——已验证 / 未验证 / 已知局限——点名了没人审查过的部分。"
+---
+
+# 第 4 课:像攻击者一样复查
+
+## 目标
+
+两个套件都通过、两个皮肤的重放全部被挡住,你手握一份书面记录:审查了
+什么、扫描器抓到和没抓到什么、还有什么没人审查过。
+
+## 为什么有这节课
+
+`blocked=9/9` 很容易被过度解读。它的意思是九个特定攻击被挡住了。真正的
+攻击者会试第十个。安全审查就是追问「测试没试过什么」的习惯——并把答案
+写下来,而不是想当然。
+
+## 本课内容
+
+亲自跑维护者那一对命令和两次重放:
+
+```bash
+python verify.py starter
+python verify.py solution
+python starter/attack_replay.py scenario/team-notes
+python starter/attack_replay.py scenario/clinic-notes
+```
+
+套件只重放 `team-notes`。`clinic-notes` 用的是不同的 payload——
+`x' OR 1=1 --`、注入到 *owner* 而不是查询里、`scans/../../secrets.txt`、
+空密钥。如果你的修复都是机制,它不做任何改动就会打印 `blocked=8/8`。如果
+没有,说明你有一处修复是过滤器。
+
+然后做套件做不了的审查:
+
+1. **跑静态扫描,再读它漏了什么。** 如果你有 Ruff(仓库的开发依赖里有
+ 它),对你的修复跑它的 flake8-bandit 规则:
+
+ ```bash
+ ruff check --isolated --select S starter/notes_app.py
+ ```
+
+ 你的修复应该什么都不报。作为对照,在原始 starter 上 Ruff 0.16.10 报告
+ S608(由字符串拼出的 SQL)、S324(MD5,两处)和 S301(`pickle.loads`)
+ ——对路径拼接和 `"dev-secret-change-me"` 兜底值什么都不报。如果你提交过
+ 基线,对它跑同一条命令就能亲眼看到。扫描器是能抓住已知模式的快速初筛;
+ 它不是审查。
+2. **攻击一处套件从没试过的地方。** 挑一个:带 Unicode 大小写的搜索
+ (`É` 与 `é`)、一个是目录的附件名、一个签名有效但内容是 JSON 列表的
+ 会话 token、一个 10 000 字符长的密码。先预测,再运行,记下结果。
+3. **读 diff 里删掉了什么。** 用 `git diff` 对比 starter 与你的基线。确认
+ `pickle` 和 `md5` 都没了,也没有 `except Exception` 吞掉
+ `SecurityError`。
+
+## 练习
+
+为这次改动写安全证据记录:
+
+- **已验证:** 你跑过的命令、两次重放、扫描输出。
+- **未验证:** 至少包括——整体的认证与授权、会话 token 如何传输(cookie
+ 标志、TLS)、登录限流、密钥的存储与轮换、依赖漏洞,以及所有不在
+ `attacks.json` 里的攻击。
+- **已知局限:** 第 2 步里你发现的、契约从未承诺过的东西,以及密码哈希的
+ 选择(PBKDF2 而不是 Argon2id)。
+
+## 检查点
+
+运行 `python verify.py`。starter 套件通过后本检查点的码就会打印;上面那
+一对维护者命令都必须是绿的,这道门才名副其实。不看材料能回答即算通过:
+
+1. `clinic-notes` 不做改动就通过了吗?如果没有,哪处修复是过滤器?
+2. 在原始 starter 上扫描器漏掉了哪两类漏洞?为什么基于模式的工具会漏掉
+ 它们?
+3. 你额外的那次攻击结果如何?它在你的「未验证」清单上吗?
+
+## 预期证据
+
+两次全绿运行、两次重放输出、扫描输出,以及你的三行证据记录。
diff --git a/courses/secure-ai-generated-code/lessons/L05.md b/courses/secure-ai-generated-code/lessons/L05.md
new file mode 100644
index 0000000..e5a2fa7
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L05.md
@@ -0,0 +1,84 @@
+---
+id: course-secure-code-l05
+type: course
+title: "Lesson 5: Review your own AI-written code"
+summary: "Take the five questions and the scanner pass to a repository you own: find where input crosses a trust boundary, check each crossing for a mechanism, and write down what you did not review."
+lang: en-US
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "What this checkpoint tests"
+ body: "Self-attested: you confirm you reviewed code you own with this method. The evidence is your findings list and your evidence record — not a code we can check."
+ - title: "Start where input enters"
+ body: "Request handlers, CLI arguments, file uploads, message consumers, anything that reads an environment variable. Each is a trust boundary."
+ - title: "Fix with a test first"
+ body: "For each real finding, write the attack as a failing test before the fix — exactly as this course's suite did."
+---
+
+# Lesson 5: Review your own AI-written code
+
+## Objective
+
+You review a repository you own — ideally code an assistant wrote — with
+the same method, fix at least one finding with a test that proves it, and
+record what you did not review.
+
+## Why this lesson exists
+
+The notes service was a sandbox with planted flaws and known answers. Your
+code has neither. What transfers is the method: find each place input
+crosses a trust boundary, ask who controls it and what they want, and
+check that the code uses a mechanism rather than a hope.
+
+## The lesson
+
+1. **List the trust boundaries.** Search for where input enters: web
+ routes, CLI arguments, uploaded files, queue consumers, `os.environ`
+ reads. Write the list before reading any of them closely.
+2. **Run the scanner.** `ruff check --select S` (or the scanner your team
+ already uses) gives you the pattern-level findings in seconds. Triage
+ each one: real, false positive, or accepted risk — and write down why.
+3. **Ask the five questions the scanner cannot.** For each boundary:
+ - Does any input reach SQL, a shell, or a template other than as a bound
+ parameter or argument list?
+ - Does any input become part of a filesystem path, and is containment
+ checked after resolving?
+ - Is there any default, fallback, or example secret in the code path
+ that runs in production?
+ - Are passwords or tokens hashed with a slow, salted function and
+ compared with `hmac.compare_digest`?
+ - Does anything deserialize client data with `pickle`, `marshal`,
+ `shelve`, or an unsafe YAML loader?
+4. **Fix one finding with a test first.** Write the attack as a failing
+ test, fix it with a mechanism, watch the test pass. Ask your agent for
+ the fix *after* the test exists, and review its diff against the
+ mechanism, not just the test.
+5. **Record.** Verified / not verified / known limits. "Not verified"
+ includes every boundary you listed but did not read.
+
+If you find something serious in code other people depend on, follow your
+organization's or the project's security disclosure process before you
+open a public issue.
+
+## Exercise
+
+Ask your coding agent to list the trust boundaries in your repository, then
+compare its list with yours. Note what each of you missed.
+
+## Checkpoint
+
+This checkpoint is self-attested. When you can answer the questions below,
+claim it with `python verify.py --attest l05`:
+
+1. How many trust boundaries did you list, and how many did you actually
+ read?
+2. Which finding did you fix, and what test proves it?
+3. Which scanner finding did you mark as a false positive or accepted
+ risk, and why?
+
+## Expected evidence
+
+Your boundary list, the scanner triage, the failing-then-passing test, and
+the three-line record. Keep findings about unfixed vulnerabilities out of
+public places until they are fixed.
diff --git a/courses/secure-ai-generated-code/lessons/L05_cn.md b/courses/secure-ai-generated-code/lessons/L05_cn.md
new file mode 100644
index 0000000..e2ccd4c
--- /dev/null
+++ b/courses/secure-ai-generated-code/lessons/L05_cn.md
@@ -0,0 +1,74 @@
+---
+id: course-secure-code-l05
+type: course
+title: "第 5 课:审查你自己的 AI 代码"
+summary: "把五个问题和扫描步骤带到你自己的仓库:找出输入跨越信任边界的地方,检查每处是否用了机制,并写下你没有审查的部分。"
+lang: zh-CN
+content_version: 1
+status: reviewed
+reviewed_on: 2026-10-11
+hints:
+ - title: "本检查点考察什么"
+ body: "自我报告:你确认用这套方法审查了自己拥有的代码。证据是你的发现清单和证据记录——不是我们查得了的码。"
+ - title: "从输入进入的地方开始"
+ body: "请求处理函数、CLI 参数、文件上传、消息消费者,以及任何读取环境变量的地方。每一处都是信任边界。"
+ - title: "先写测试再修"
+ body: "对每个真实发现,先把攻击写成一个失败的测试,再修——正如本课程的套件所做的那样。"
+---
+
+# 第 5 课:审查你自己的 AI 代码
+
+## 目标
+
+你用同样的方法审查一个自己拥有的仓库——最好是助手写的代码——用一个能证明
+修复的测试修掉至少一个发现,并记录你没有审查的部分。
+
+## 为什么有这节课
+
+那个笔记服务是一个预埋了漏洞、答案已知的沙箱。你的代码两样都没有。能
+迁移的是方法:找到输入跨越信任边界的每一处,问清谁控制它、他想要什么,
+并检查代码用的是机制而不是侥幸。
+
+## 本课内容
+
+1. **列出信任边界。** 搜索输入进入的地方:Web 路由、CLI 参数、上传的
+ 文件、队列消费者、读取 `os.environ` 的地方。在细读任何一处之前先写下
+ 清单。
+2. **跑扫描器。** `ruff check --select S`(或你团队已经在用的扫描器)几秒
+ 内给出模式层面的发现。逐条分诊:真问题、误报,还是接受的风险——并写下
+ 理由。
+3. **问扫描器问不了的五个问题。** 对每个边界:
+ - 有没有输入不是以绑定参数或参数列表的形式,进入了 SQL、shell 或模板?
+ - 有没有输入成为文件系统路径的一部分?解析之后检查包含关系了吗?
+ - 在生产环境会运行的代码路径里,有没有缺省、兜底或示例密钥?
+ - 密码或 token 是否用加盐的慢函数哈希、并用 `hmac.compare_digest`
+ 比较?
+ - 有没有东西用 `pickle`、`marshal`、`shelve` 或不安全的 YAML 加载器
+ 反序列化客户端数据?
+4. **先写测试再修一个发现。** 把攻击写成失败的测试,用机制修复,看着测试
+ 通过。测试写好*之后*再让 Agent 给出修复,并对照机制审它的 diff,而不
+ 只是看测试。
+5. **记录。** 已验证 / 未验证 / 已知局限。「未验证」包括你列出却没有读的
+ 每一个边界。
+
+如果你在别人依赖的代码里发现了严重问题,先遵循你所在组织或该项目的安全
+披露流程,再考虑公开提 issue。
+
+## 练习
+
+让你的编码 Agent 列出你仓库里的信任边界,再和你自己的清单对比。记下你们
+各自漏掉了什么。
+
+## 检查点
+
+本检查点自我报告。能回答下面三题时,用
+`python verify.py --attest l05` 认领:
+
+1. 你列出了多少个信任边界,实际读了多少个?
+2. 你修了哪个发现?哪个测试证明了它?
+3. 你把哪个扫描发现标成了误报或接受的风险,为什么?
+
+## 预期证据
+
+你的边界清单、扫描分诊、先失败后通过的测试,以及三行记录。未修复漏洞的
+发现在修好之前不要放到公开的地方。
diff --git a/courses/secure-ai-generated-code/scenario/clinic-notes/attachments/intake-form.txt b/courses/secure-ai-generated-code/scenario/clinic-notes/attachments/intake-form.txt
new file mode 100644
index 0000000..f8677ec
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/clinic-notes/attachments/intake-form.txt
@@ -0,0 +1 @@
+Fictional intake form template: name, date of birth, reason for visit.
diff --git a/courses/secure-ai-generated-code/scenario/clinic-notes/attachments/scans/index.txt b/courses/secure-ai-generated-code/scenario/clinic-notes/attachments/scans/index.txt
new file mode 100644
index 0000000..27953e4
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/clinic-notes/attachments/scans/index.txt
@@ -0,0 +1 @@
+Fictional scan index: scan-001 (chest X-ray placeholder).
diff --git a/courses/secure-ai-generated-code/scenario/clinic-notes/attacks.json b/courses/secure-ai-generated-code/scenario/clinic-notes/attacks.json
new file mode 100644
index 0000000..f9c00df
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/clinic-notes/attacks.json
@@ -0,0 +1,48 @@
+[
+ {
+ "id": "c01",
+ "kind": "sql",
+ "owner": "dr_lee",
+ "query": "x' OR 1=1 --"
+ },
+ {
+ "id": "c02",
+ "kind": "sql",
+ "owner": "dr_lee' OR '1'='1",
+ "query": ""
+ },
+ {
+ "id": "c03",
+ "kind": "path",
+ "name": "scans/../../secrets.txt"
+ },
+ {
+ "id": "c04",
+ "kind": "path",
+ "name": "{scenario}/secrets.txt"
+ },
+ {
+ "id": "c05",
+ "kind": "secret-key",
+ "env": {
+ "NOTES_SECRET_KEY": ""
+ }
+ },
+ {
+ "id": "c06",
+ "kind": "fast-hash",
+ "password": "clinic-2026"
+ },
+ {
+ "id": "c07",
+ "kind": "forged-session",
+ "claims": {
+ "user": "dr_lee",
+ "role": "records-admin"
+ }
+ },
+ {
+ "id": "c08",
+ "kind": "pickle-session"
+ }
+]
diff --git a/courses/secure-ai-generated-code/scenario/clinic-notes/scenario.json b/courses/secure-ai-generated-code/scenario/clinic-notes/scenario.json
new file mode 100644
index 0000000..f9d5074
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/clinic-notes/scenario.json
@@ -0,0 +1,4 @@
+{
+ "name": "clinic-notes",
+ "description": "A small clinic's visit notes: every note belongs to one clinician. All names and records are fictional."
+}
diff --git a/courses/secure-ai-generated-code/scenario/clinic-notes/secrets.txt b/courses/secure-ai-generated-code/scenario/clinic-notes/secrets.txt
new file mode 100644
index 0000000..7c4873b
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/clinic-notes/secrets.txt
@@ -0,0 +1,2 @@
+FAKE CREDENTIAL FOR THE COURSE: db_password=not-a-real-password
+If read_attachment returned this file, the path check failed.
diff --git a/courses/secure-ai-generated-code/scenario/clinic-notes/seed.json b/courses/secure-ai-generated-code/scenario/clinic-notes/seed.json
new file mode 100644
index 0000000..a878ab1
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/clinic-notes/seed.json
@@ -0,0 +1,24 @@
+{
+ "notes": [
+ {
+ "owner": "dr_lee",
+ "title": "Visit 1042 follow-up",
+ "body": "Fictional record: recheck blood pressure in two weeks."
+ },
+ {
+ "owner": "dr_lee",
+ "title": "Referral letters",
+ "body": "Fictional record: two letters pending signature."
+ },
+ {
+ "owner": "dr_patel",
+ "title": "Visit 2210 results",
+ "body": "Fictional record: lab results within range."
+ },
+ {
+ "owner": "dr_patel",
+ "title": "On-call swap",
+ "body": "Fictional record: swap Saturday with Dr. Lee."
+ }
+ ]
+}
diff --git a/courses/secure-ai-generated-code/scenario/team-notes/attachments/agenda.txt b/courses/secure-ai-generated-code/scenario/team-notes/attachments/agenda.txt
new file mode 100644
index 0000000..51820f0
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/team-notes/attachments/agenda.txt
@@ -0,0 +1 @@
+Monday: planning. Wednesday: demo. Friday: retro.
diff --git a/courses/secure-ai-generated-code/scenario/team-notes/attacks.json b/courses/secure-ai-generated-code/scenario/team-notes/attacks.json
new file mode 100644
index 0000000..f552145
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/team-notes/attacks.json
@@ -0,0 +1,53 @@
+[
+ {
+ "id": "t01",
+ "kind": "sql",
+ "owner": "alice",
+ "query": "%' OR owner != '"
+ },
+ {
+ "id": "t02",
+ "kind": "sql",
+ "owner": "alice",
+ "query": "' UNION SELECT id, owner || ':' || title, body FROM notes --"
+ },
+ {
+ "id": "t03",
+ "kind": "path",
+ "name": "../secrets.txt"
+ },
+ {
+ "id": "t04",
+ "kind": "path",
+ "name": "{scenario}/secrets.txt"
+ },
+ {
+ "id": "t05",
+ "kind": "secret-key",
+ "env": {}
+ },
+ {
+ "id": "t06",
+ "kind": "secret-key",
+ "env": {
+ "NOTES_SECRET_KEY": "dev"
+ }
+ },
+ {
+ "id": "t07",
+ "kind": "fast-hash",
+ "password": "correct horse battery staple"
+ },
+ {
+ "id": "t08",
+ "kind": "forged-session",
+ "claims": {
+ "user": "alice",
+ "role": "admin"
+ }
+ },
+ {
+ "id": "t09",
+ "kind": "pickle-session"
+ }
+]
diff --git a/courses/secure-ai-generated-code/scenario/team-notes/scenario.json b/courses/secure-ai-generated-code/scenario/team-notes/scenario.json
new file mode 100644
index 0000000..88c9fc6
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/team-notes/scenario.json
@@ -0,0 +1,4 @@
+{
+ "name": "team-notes",
+ "description": "A small team's shared notes app: search, attachments, logins, and sessions."
+}
diff --git a/courses/secure-ai-generated-code/scenario/team-notes/secrets.txt b/courses/secure-ai-generated-code/scenario/team-notes/secrets.txt
new file mode 100644
index 0000000..7c4873b
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/team-notes/secrets.txt
@@ -0,0 +1,2 @@
+FAKE CREDENTIAL FOR THE COURSE: db_password=not-a-real-password
+If read_attachment returned this file, the path check failed.
diff --git a/courses/secure-ai-generated-code/scenario/team-notes/seed.json b/courses/secure-ai-generated-code/scenario/team-notes/seed.json
new file mode 100644
index 0000000..2a5fa82
--- /dev/null
+++ b/courses/secure-ai-generated-code/scenario/team-notes/seed.json
@@ -0,0 +1,29 @@
+{
+ "notes": [
+ {
+ "owner": "alice",
+ "title": "Sprint plan",
+ "body": "Ship the export fix first."
+ },
+ {
+ "owner": "alice",
+ "title": "Retro notes",
+ "body": "Fewer meetings on Fridays."
+ },
+ {
+ "owner": "bob",
+ "title": "Salary review",
+ "body": "Confidential: band changes for Q4."
+ },
+ {
+ "owner": "bob",
+ "title": "Offsite budget",
+ "body": "Venue deposit due Friday."
+ },
+ {
+ "owner": "carol",
+ "title": "Hiring pipeline",
+ "body": "Three candidates in final round."
+ }
+ ]
+}
diff --git a/courses/secure-ai-generated-code/solution/attack_replay.py b/courses/secure-ai-generated-code/solution/attack_replay.py
new file mode 100644
index 0000000..730b984
--- /dev/null
+++ b/courses/secure-ai-generated-code/solution/attack_replay.py
@@ -0,0 +1,146 @@
+"""Replay a scenario's attacks against notes_app (provided harness).
+
+``python attack_replay.py `` loads ``seed.json`` and
+``attacks.json``, runs each attack against the functions in ``notes_app``,
+prints ``blocked=X/Y`` with one line per attack, and exits 0 only when every
+attack was blocked. An attack counts as blocked when the function refuses it
+with ``SecurityError`` or returns nothing it should not; an unexpected crash
+is not a defense. This file is the same in starter/ and solution/ — do not
+edit it.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import os
+import pickle
+import sqlite3
+import sys
+import tempfile
+from pathlib import Path
+
+import notes_app
+
+AUDIT_KEY = b"audit-only-key-0123456789abcdefghijklmnop"
+ATTACKER_KEY = b"attacker-chosen-key-not-the-real-one"
+
+
+class _Marker:
+ """Pickle gadget: unpickling it creates a directory at *path*."""
+
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+ def __reduce__(self):
+ return (os.mkdir, (self.path,))
+
+
+def _sql(conn, owned, attack, directory):
+ rows = notes_app.find_notes(conn, attack["owner"], attack["query"])
+ leaked = sorted({row["id"] for row in rows} - owned.get(attack["owner"], set()))
+ if leaked:
+ return False, f"leaked other owners' note ids {leaked}"
+ return True, "no other owner's notes returned"
+
+
+def _path(conn, owned, attack, directory):
+ name = attack["name"].replace("{scenario}", str(directory))
+ notes_app.read_attachment(directory / "attachments", name)
+ return False, "a file outside attachments/ was served"
+
+
+def _secret_key(conn, owned, attack, directory):
+ notes_app.load_secret_key(attack["env"])
+ return False, "the service started without a strong secret key"
+
+
+def _fast_hash(conn, owned, attack, directory):
+ password = attack["password"].encode("utf-8")
+ stored = notes_app.hash_password(attack["password"])
+ cheap = {getattr(hashlib, name)(password).hexdigest() for name in ("md5", "sha1", "sha256")}
+ if stored in cheap:
+ return False, "the stored hash is one fast unsalted digest"
+ if not notes_app.verify_password(attack["password"], stored):
+ return False, "the stored hash does not verify"
+ if notes_app.verify_password(attack["password"], hashlib.md5(password).hexdigest()):
+ return False, "a leaked MD5 hash still logs in"
+ return True, "salted slow hash; legacy MD5 refused"
+
+
+def _forged_session(conn, owned, attack, directory):
+ forged = notes_app.dump_session(attack["claims"], ATTACKER_KEY)
+ notes_app.load_session(forged, AUDIT_KEY)
+ return False, "a session signed with another key was accepted"
+
+
+def _pickle_session(conn, owned, attack, directory):
+ with tempfile.TemporaryDirectory() as tmp:
+ marker = os.path.join(tmp, "executed")
+ token = base64.b64encode(pickle.dumps(_Marker(marker))).decode("ascii")
+ refused = None
+ try:
+ notes_app.load_session(token, AUDIT_KEY)
+ except notes_app.SecurityError as error:
+ refused = error
+ if os.path.exists(marker):
+ return False, "an attacker-supplied pickle executed code"
+ if refused is None:
+ return False, "an attacker-supplied pickle was accepted"
+ return True, f"refused: {refused}"
+
+
+CHECKS = {
+ "sql": _sql,
+ "path": _path,
+ "secret-key": _secret_key,
+ "fast-hash": _fast_hash,
+ "forged-session": _forged_session,
+ "pickle-session": _pickle_session,
+}
+
+
+def audit(scenario_dir: str | Path) -> dict:
+ """Replay every attack in ``attacks.json`` and report which were blocked."""
+ directory = Path(scenario_dir).resolve()
+ seed = json.loads((directory / "seed.json").read_text(encoding="utf-8"))
+ attacks = json.loads((directory / "attacks.json").read_text(encoding="utf-8"))
+ conn = sqlite3.connect(":memory:")
+ notes_app.init_db(conn, seed["notes"])
+ owned: dict[str, set[int]] = {}
+ for note_id, note in enumerate(seed["notes"], start=1):
+ owned.setdefault(note["owner"], set()).add(note_id)
+ results = []
+ for attack in attacks:
+ check = CHECKS[attack["kind"]]
+ try:
+ blocked, detail = check(conn, owned, attack, directory)
+ except notes_app.SecurityError as error:
+ blocked, detail = True, f"refused: {error}"
+ except Exception as error:
+ blocked, detail = False, f"crashed: {type(error).__name__}: {error}"
+ results.append({"id": attack["id"], "kind": attack["kind"], "blocked": blocked, "detail": detail})
+ conn.close()
+ return {
+ "total": len(results),
+ "blocked": sum(result["blocked"] for result in results),
+ "results": results,
+ }
+
+
+def main(argv: list[str] | None = None) -> int:
+ arguments = sys.argv[1:] if argv is None else argv
+ if len(arguments) != 1:
+ print("usage: python attack_replay.py ", file=sys.stderr)
+ return 2
+ report = audit(arguments[0])
+ print(f"blocked={report['blocked']}/{report['total']}")
+ for result in report["results"]:
+ state = "blocked" if result["blocked"] else "OPEN"
+ print(f" [{state}] {result['id']} ({result['kind']}): {result['detail']}")
+ return 0 if report["blocked"] == report["total"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/secure-ai-generated-code/solution/notes_app.py b/courses/secure-ai-generated-code/solution/notes_app.py
new file mode 100644
index 0000000..b0da3e3
--- /dev/null
+++ b/courses/secure-ai-generated-code/solution/notes_app.py
@@ -0,0 +1,129 @@
+"""A small notes service (reference solution).
+
+Search, attachments, configuration, passwords, and sessions — five places
+where AI-written web code commonly trusts input it should not. Every
+function here treats its caller's input as attacker-controlled.
+``attack_replay.py`` replays a scenario's attacks against these functions.
+../TASK.md defines the contract.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+import os
+import secrets
+import sqlite3
+from pathlib import Path
+
+KEY_ENV = "NOTES_SECRET_KEY"
+MIN_KEY_LENGTH = 32
+PBKDF2_ITERATIONS = 600_000
+MIN_ITERATIONS = 600_000
+HASH_SCHEME = "pbkdf2_sha256"
+
+
+class SecurityError(ValueError):
+ """Input the service refuses to act on."""
+
+
+def init_db(conn: sqlite3.Connection, notes: list[dict]) -> None:
+ """Create the notes table and insert *notes* (owner, title, body)."""
+ conn.execute(
+ "CREATE TABLE notes (id INTEGER PRIMARY KEY, owner TEXT NOT NULL,"
+ " title TEXT NOT NULL, body TEXT NOT NULL)"
+ )
+ conn.executemany(
+ "INSERT INTO notes (owner, title, body) VALUES (?, ?, ?)",
+ [(note["owner"], note["title"], note["body"]) for note in notes],
+ )
+ conn.commit()
+
+
+def find_notes(conn: sqlite3.Connection, owner: str, query: str) -> list[dict]:
+ """Return *owner*'s notes whose title contains *query* (case-insensitive)."""
+ rows = conn.execute(
+ "SELECT id, title, body FROM notes"
+ " WHERE owner = ? AND instr(lower(title), lower(?)) > 0 ORDER BY id",
+ (owner, query),
+ ).fetchall()
+ return [{"id": row[0], "title": row[1], "body": row[2]} for row in rows]
+
+
+def read_attachment(base_dir: str | Path, name: str) -> bytes:
+ """Return the bytes of attachment *name*, which must live inside *base_dir*."""
+ if not name or Path(name).is_absolute():
+ raise SecurityError(f"attachment name must be a relative path: {name!r}")
+ base = Path(base_dir).resolve()
+ target = (base / name).resolve()
+ if not target.is_relative_to(base):
+ raise SecurityError(f"attachment escapes the attachments directory: {name!r}")
+ if not target.is_file():
+ raise FileNotFoundError(name)
+ return target.read_bytes()
+
+
+def load_secret_key(env: dict | None = None) -> bytes:
+ """Return the session signing key from the environment; never a default."""
+ source = os.environ if env is None else env
+ value = source.get(KEY_ENV, "")
+ if len(value) < MIN_KEY_LENGTH:
+ raise SecurityError(f"{KEY_ENV} must be set to at least {MIN_KEY_LENGTH} characters")
+ return value.encode("utf-8")
+
+
+def hash_password(password: str, *, salt: bytes | None = None, iterations: int = PBKDF2_ITERATIONS) -> str:
+ """Return a salted PBKDF2-SHA256 hash as ``scheme$iterations$salt$digest``."""
+ salt = secrets.token_bytes(16) if salt is None else salt
+ digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
+ return f"{HASH_SCHEME}${iterations}${salt.hex()}${digest.hex()}"
+
+
+def verify_password(password: str, stored: str) -> bool:
+ """Check *password* against a hash from ``hash_password``; refuse anything else."""
+ parts = stored.split("$")
+ if len(parts) != 4 or parts[0] != HASH_SCHEME:
+ return False
+ try:
+ iterations = int(parts[1])
+ salt = bytes.fromhex(parts[2])
+ except ValueError:
+ return False
+ if iterations < MIN_ITERATIONS:
+ return False
+ candidate = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
+ return hmac.compare_digest(candidate.hex(), parts[3])
+
+
+def _b64encode(raw: bytes) -> str:
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
+
+
+def _b64decode(text: str) -> bytes:
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
+
+
+def dump_session(data: dict, key: bytes) -> str:
+ """Serialize *data* as signed JSON: ``base64url(json).hmac_sha256_hex``."""
+ payload = _b64encode(json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8"))
+ signature = hmac.new(key, payload.encode("ascii"), hashlib.sha256).hexdigest()
+ return f"{payload}.{signature}"
+
+
+def load_session(token: str, key: bytes) -> dict:
+ """Verify the signature first, then decode the JSON payload."""
+ payload, dot, signature = token.rpartition(".")
+ if not dot or not payload or not token.isascii():
+ raise SecurityError("session token is malformed")
+ expected = hmac.new(key, payload.encode("ascii", "replace"), hashlib.sha256).hexdigest()
+ if not hmac.compare_digest(expected, signature):
+ raise SecurityError("session signature does not match")
+ try:
+ data = json.loads(_b64decode(payload))
+ except ValueError:
+ raise SecurityError("session payload is not valid JSON") from None
+ if not isinstance(data, dict):
+ raise SecurityError("session payload must be an object")
+ return data
diff --git a/courses/secure-ai-generated-code/starter/attack_replay.py b/courses/secure-ai-generated-code/starter/attack_replay.py
new file mode 100644
index 0000000..730b984
--- /dev/null
+++ b/courses/secure-ai-generated-code/starter/attack_replay.py
@@ -0,0 +1,146 @@
+"""Replay a scenario's attacks against notes_app (provided harness).
+
+``python attack_replay.py `` loads ``seed.json`` and
+``attacks.json``, runs each attack against the functions in ``notes_app``,
+prints ``blocked=X/Y`` with one line per attack, and exits 0 only when every
+attack was blocked. An attack counts as blocked when the function refuses it
+with ``SecurityError`` or returns nothing it should not; an unexpected crash
+is not a defense. This file is the same in starter/ and solution/ — do not
+edit it.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import json
+import os
+import pickle
+import sqlite3
+import sys
+import tempfile
+from pathlib import Path
+
+import notes_app
+
+AUDIT_KEY = b"audit-only-key-0123456789abcdefghijklmnop"
+ATTACKER_KEY = b"attacker-chosen-key-not-the-real-one"
+
+
+class _Marker:
+ """Pickle gadget: unpickling it creates a directory at *path*."""
+
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+ def __reduce__(self):
+ return (os.mkdir, (self.path,))
+
+
+def _sql(conn, owned, attack, directory):
+ rows = notes_app.find_notes(conn, attack["owner"], attack["query"])
+ leaked = sorted({row["id"] for row in rows} - owned.get(attack["owner"], set()))
+ if leaked:
+ return False, f"leaked other owners' note ids {leaked}"
+ return True, "no other owner's notes returned"
+
+
+def _path(conn, owned, attack, directory):
+ name = attack["name"].replace("{scenario}", str(directory))
+ notes_app.read_attachment(directory / "attachments", name)
+ return False, "a file outside attachments/ was served"
+
+
+def _secret_key(conn, owned, attack, directory):
+ notes_app.load_secret_key(attack["env"])
+ return False, "the service started without a strong secret key"
+
+
+def _fast_hash(conn, owned, attack, directory):
+ password = attack["password"].encode("utf-8")
+ stored = notes_app.hash_password(attack["password"])
+ cheap = {getattr(hashlib, name)(password).hexdigest() for name in ("md5", "sha1", "sha256")}
+ if stored in cheap:
+ return False, "the stored hash is one fast unsalted digest"
+ if not notes_app.verify_password(attack["password"], stored):
+ return False, "the stored hash does not verify"
+ if notes_app.verify_password(attack["password"], hashlib.md5(password).hexdigest()):
+ return False, "a leaked MD5 hash still logs in"
+ return True, "salted slow hash; legacy MD5 refused"
+
+
+def _forged_session(conn, owned, attack, directory):
+ forged = notes_app.dump_session(attack["claims"], ATTACKER_KEY)
+ notes_app.load_session(forged, AUDIT_KEY)
+ return False, "a session signed with another key was accepted"
+
+
+def _pickle_session(conn, owned, attack, directory):
+ with tempfile.TemporaryDirectory() as tmp:
+ marker = os.path.join(tmp, "executed")
+ token = base64.b64encode(pickle.dumps(_Marker(marker))).decode("ascii")
+ refused = None
+ try:
+ notes_app.load_session(token, AUDIT_KEY)
+ except notes_app.SecurityError as error:
+ refused = error
+ if os.path.exists(marker):
+ return False, "an attacker-supplied pickle executed code"
+ if refused is None:
+ return False, "an attacker-supplied pickle was accepted"
+ return True, f"refused: {refused}"
+
+
+CHECKS = {
+ "sql": _sql,
+ "path": _path,
+ "secret-key": _secret_key,
+ "fast-hash": _fast_hash,
+ "forged-session": _forged_session,
+ "pickle-session": _pickle_session,
+}
+
+
+def audit(scenario_dir: str | Path) -> dict:
+ """Replay every attack in ``attacks.json`` and report which were blocked."""
+ directory = Path(scenario_dir).resolve()
+ seed = json.loads((directory / "seed.json").read_text(encoding="utf-8"))
+ attacks = json.loads((directory / "attacks.json").read_text(encoding="utf-8"))
+ conn = sqlite3.connect(":memory:")
+ notes_app.init_db(conn, seed["notes"])
+ owned: dict[str, set[int]] = {}
+ for note_id, note in enumerate(seed["notes"], start=1):
+ owned.setdefault(note["owner"], set()).add(note_id)
+ results = []
+ for attack in attacks:
+ check = CHECKS[attack["kind"]]
+ try:
+ blocked, detail = check(conn, owned, attack, directory)
+ except notes_app.SecurityError as error:
+ blocked, detail = True, f"refused: {error}"
+ except Exception as error:
+ blocked, detail = False, f"crashed: {type(error).__name__}: {error}"
+ results.append({"id": attack["id"], "kind": attack["kind"], "blocked": blocked, "detail": detail})
+ conn.close()
+ return {
+ "total": len(results),
+ "blocked": sum(result["blocked"] for result in results),
+ "results": results,
+ }
+
+
+def main(argv: list[str] | None = None) -> int:
+ arguments = sys.argv[1:] if argv is None else argv
+ if len(arguments) != 1:
+ print("usage: python attack_replay.py ", file=sys.stderr)
+ return 2
+ report = audit(arguments[0])
+ print(f"blocked={report['blocked']}/{report['total']}")
+ for result in report["results"]:
+ state = "blocked" if result["blocked"] else "OPEN"
+ print(f" [{state}] {result['id']} ({result['kind']}): {result['detail']}")
+ return 0 if report["blocked"] == report["total"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/secure-ai-generated-code/starter/notes_app.py b/courses/secure-ai-generated-code/starter/notes_app.py
new file mode 100644
index 0000000..527f143
--- /dev/null
+++ b/courses/secure-ai-generated-code/starter/notes_app.py
@@ -0,0 +1,82 @@
+"""A small notes service (starter, insecure).
+
+Search, attachments, configuration, passwords, and sessions — five places
+where AI-written web code commonly trusts input it should not. This starter
+works on every friendly input and is written the way a quick first draft
+often is: SQL built with an f-string, file paths joined as given, a
+hard-coded fallback secret, unsalted MD5 passwords, and pickled sessions.
+``attack_replay.py`` replays a scenario's attacks against these functions.
+../TASK.md defines the contract.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import os
+import pickle
+import sqlite3
+from pathlib import Path
+
+KEY_ENV = "NOTES_SECRET_KEY"
+MIN_KEY_LENGTH = 32
+PBKDF2_ITERATIONS = 600_000
+MIN_ITERATIONS = 600_000
+HASH_SCHEME = "pbkdf2_sha256"
+
+
+class SecurityError(ValueError):
+ """Input the service refuses to act on."""
+
+
+def init_db(conn: sqlite3.Connection, notes: list[dict]) -> None:
+ """Create the notes table and insert *notes* (owner, title, body)."""
+ conn.execute(
+ "CREATE TABLE notes (id INTEGER PRIMARY KEY, owner TEXT NOT NULL,"
+ " title TEXT NOT NULL, body TEXT NOT NULL)"
+ )
+ conn.executemany(
+ "INSERT INTO notes (owner, title, body) VALUES (?, ?, ?)",
+ [(note["owner"], note["title"], note["body"]) for note in notes],
+ )
+ conn.commit()
+
+
+def find_notes(conn: sqlite3.Connection, owner: str, query: str) -> list[dict]:
+ """Return *owner*'s notes whose title contains *query* (case-insensitive)."""
+ rows = conn.execute(
+ f"SELECT id, title, body FROM notes WHERE owner = '{owner}'"
+ f" AND title LIKE '%{query}%' ORDER BY id"
+ ).fetchall()
+ return [{"id": row[0], "title": row[1], "body": row[2]} for row in rows]
+
+
+def read_attachment(base_dir: str | Path, name: str) -> bytes:
+ """Return the bytes of attachment *name*, which must live inside *base_dir*."""
+ return (Path(base_dir) / name).read_bytes()
+
+
+def load_secret_key(env: dict | None = None) -> bytes:
+ """Return the session signing key from the environment."""
+ source = os.environ if env is None else env
+ return source.get(KEY_ENV, "dev-secret-change-me").encode("utf-8")
+
+
+def hash_password(password: str, *, salt: bytes | None = None, iterations: int = PBKDF2_ITERATIONS) -> str:
+ """Return a hash of *password* for storage."""
+ return hashlib.md5(password.encode("utf-8")).hexdigest()
+
+
+def verify_password(password: str, stored: str) -> bool:
+ """Check *password* against a stored hash."""
+ return hashlib.md5(password.encode("utf-8")).hexdigest() == stored
+
+
+def dump_session(data: dict, key: bytes) -> str:
+ """Serialize *data* into a session token."""
+ return base64.b64encode(pickle.dumps(data)).decode("ascii")
+
+
+def load_session(token: str, key: bytes) -> dict:
+ """Restore session data from a token."""
+ return pickle.loads(base64.b64decode(token))
diff --git a/courses/secure-ai-generated-code/tests/test_notes_app.py b/courses/secure-ai-generated-code/tests/test_notes_app.py
new file mode 100644
index 0000000..d38c427
--- /dev/null
+++ b/courses/secure-ai-generated-code/tests/test_notes_app.py
@@ -0,0 +1,172 @@
+"""Contract tests for the notes service security fixes."""
+
+from __future__ import annotations
+
+import base64
+import contextlib
+import hashlib
+import io
+import os
+import pickle
+import shutil
+import sqlite3
+import tempfile
+import unittest
+from pathlib import Path
+
+import attack_replay
+import notes_app
+
+COURSE_ROOT = Path(__file__).resolve().parent.parent
+SCENARIO_ROOT = COURSE_ROOT / "scenario"
+STRONG_KEY = "k" * 32
+
+
+class _Marker:
+ def __init__(self, path: str) -> None:
+ self.path = path
+
+ def __reduce__(self):
+ return (os.mkdir, (self.path,))
+
+
+def notes_db() -> sqlite3.Connection:
+ conn = sqlite3.connect(":memory:")
+ notes_app.init_db(conn, [
+ {"owner": "alice", "title": "Sprint plan", "body": "a"},
+ {"owner": "alice", "title": "100% done list", "body": "b"},
+ {"owner": "bob", "title": "Salary review", "body": "secret"},
+ ])
+ return conn
+
+
+class FindNotesTest(unittest.TestCase):
+ def test_search_is_case_insensitive_and_owner_scoped(self) -> None:
+ conn = notes_db()
+ self.assertEqual([note["id"] for note in notes_app.find_notes(conn, "alice", "SPRINT")], [1])
+ self.assertEqual(notes_app.find_notes(conn, "alice", "salary"), [])
+
+ def test_search_injection_cannot_cross_owners(self) -> None:
+ conn = notes_db()
+ for query in ("%' OR owner != '", "' UNION SELECT id, title, body FROM notes --", "x' OR 1=1 --"):
+ with self.subTest(query=query):
+ ids = {note["id"] for note in notes_app.find_notes(conn, "alice", query)}
+ self.assertNotIn(3, ids)
+ self.assertEqual(notes_app.find_notes(conn, "alice' OR '1'='1", ""), [])
+
+ def test_search_wildcards_are_literal(self) -> None:
+ conn = notes_db()
+ self.assertEqual([note["id"] for note in notes_app.find_notes(conn, "alice", "%")], [2])
+ self.assertEqual(notes_app.find_notes(conn, "alice", "_print"), [])
+
+
+class ReadAttachmentTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.root = Path(tempfile.mkdtemp(prefix="attachments-"))
+ self.addCleanup(shutil.rmtree, self.root, True)
+ self.base = self.root / "attachments"
+ (self.base / "docs").mkdir(parents=True)
+ (self.base / "docs" / "agenda.txt").write_bytes(b"agenda")
+ (self.root / "secrets.txt").write_bytes(b"do not serve")
+
+ def test_attachment_inside_base_is_read(self) -> None:
+ self.assertEqual(notes_app.read_attachment(self.base, "docs/agenda.txt"), b"agenda")
+
+ def test_attachment_traversal_is_refused(self) -> None:
+ for name in ("../secrets.txt", "docs/../../secrets.txt"):
+ with self.subTest(name=name), self.assertRaises(notes_app.SecurityError):
+ notes_app.read_attachment(self.base, name)
+
+ def test_absolute_attachment_path_is_refused(self) -> None:
+ with self.assertRaises(notes_app.SecurityError):
+ notes_app.read_attachment(self.base, str(self.root / "secrets.txt"))
+
+ def test_symlink_escaping_base_is_refused(self) -> None:
+ link = self.base / "shortcut.txt"
+ try:
+ link.symlink_to(self.root / "secrets.txt")
+ except (OSError, NotImplementedError):
+ self.skipTest("symlinks are not available on this platform")
+ with self.assertRaises(notes_app.SecurityError):
+ notes_app.read_attachment(self.base, "shortcut.txt")
+
+
+class SecretKeyTest(unittest.TestCase):
+ def test_missing_or_short_secret_key_is_refused(self) -> None:
+ for env in ({}, {"NOTES_SECRET_KEY": ""}, {"NOTES_SECRET_KEY": "dev"}, {"NOTES_SECRET_KEY": "k" * 31}):
+ with self.subTest(env=env):
+ with self.assertRaises(notes_app.SecurityError) as caught:
+ notes_app.load_secret_key(env)
+ self.assertIn("NOTES_SECRET_KEY", str(caught.exception))
+ self.assertEqual(notes_app.load_secret_key({"NOTES_SECRET_KEY": STRONG_KEY}), STRONG_KEY.encode())
+
+
+class PasswordTest(unittest.TestCase):
+ def test_password_hash_is_salted_pbkdf2(self) -> None:
+ first = notes_app.hash_password("hunter2")
+ second = notes_app.hash_password("hunter2")
+ self.assertNotEqual(first, second)
+ scheme, iterations, salt, digest = first.split("$")
+ self.assertEqual(scheme, "pbkdf2_sha256")
+ self.assertGreaterEqual(int(iterations), 600_000)
+ self.assertEqual(len(bytes.fromhex(salt)), 16)
+ self.assertEqual(
+ digest,
+ hashlib.pbkdf2_hmac("sha256", b"hunter2", bytes.fromhex(salt), int(iterations)).hex(),
+ )
+
+ def test_verify_accepts_right_and_rejects_wrong_password(self) -> None:
+ stored = notes_app.hash_password("hunter2")
+ self.assertTrue(notes_app.verify_password("hunter2", stored))
+ self.assertFalse(notes_app.verify_password("hunter3", stored))
+
+ def test_verify_rejects_fast_and_weak_hashes(self) -> None:
+ self.assertFalse(notes_app.verify_password("hunter2", hashlib.md5(b"hunter2").hexdigest()))
+ weak = notes_app.hash_password("hunter2", salt=b"s" * 16, iterations=1_000)
+ self.assertFalse(notes_app.verify_password("hunter2", weak))
+ self.assertFalse(notes_app.verify_password("hunter2", "not-a-hash"))
+
+
+class SessionTest(unittest.TestCase):
+ key = STRONG_KEY.encode()
+
+ def test_session_round_trip(self) -> None:
+ token = notes_app.dump_session({"user": "alice", "role": "member"}, self.key)
+ self.assertEqual(notes_app.load_session(token, self.key), {"user": "alice", "role": "member"})
+
+ def test_tampered_or_foreign_session_is_refused(self) -> None:
+ token = notes_app.dump_session({"user": "alice", "role": "member"}, self.key)
+ forged = notes_app.dump_session({"user": "alice", "role": "admin"}, b"another-key" * 4)
+ flipped = ("A" if token[0] != "A" else "B") + token[1:]
+ non_ascii = token.rpartition(".")[0] + ".é" + token.rpartition(".")[2][1:]
+ for bad in (forged, flipped, non_ascii, "", "no-signature-here"):
+ with self.subTest(token=bad[:16]), self.assertRaises(notes_app.SecurityError):
+ notes_app.load_session(bad, self.key)
+
+ def test_pickle_payload_is_never_executed(self) -> None:
+ with tempfile.TemporaryDirectory() as tmp:
+ marker = os.path.join(tmp, "executed")
+ token = base64.b64encode(pickle.dumps(_Marker(marker))).decode("ascii")
+ with self.assertRaises(notes_app.SecurityError):
+ notes_app.load_session(token, self.key)
+ self.assertFalse(os.path.exists(marker), "load_session ran attacker-supplied code")
+
+
+class AttackReplayTest(unittest.TestCase):
+ def run_replay(self, argv: list[str]) -> tuple[int, str]:
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output), contextlib.redirect_stderr(io.StringIO()):
+ code = attack_replay.main(argv)
+ return code, output.getvalue()
+
+ def test_replay_blocks_every_team_notes_attack(self) -> None:
+ code, output = self.run_replay([str(SCENARIO_ROOT / "team-notes")])
+ self.assertIn("blocked=9/9", output)
+ self.assertEqual(code, 0)
+
+ def test_replay_usage_error(self) -> None:
+ self.assertEqual(self.run_replay([])[0], 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/courses/secure-ai-generated-code/verify.py b/courses/secure-ai-generated-code/verify.py
new file mode 100644
index 0000000..7d55cac
--- /dev/null
+++ b/courses/secure-ai-generated-code/verify.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+"""Run the course contract against starter or solution.
+
+Objective completion evidence for "Security review for AI-written Python".
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import hashlib
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent
+
+# Optional shared claim-receipt producer (docs/CLAIM-RECEIPT.md). The course
+# folder still verifies standalone — without the tools/ sibling or without
+# FLYPYTHON_CLAIM_SECRET set, verify.py behaves exactly as before.
+try:
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "tools"))
+ import claim_receipt
+except ImportError:
+ claim_receipt = None
+
+EXPECTED_STARTER_FAILURES = (
+ "test_search_injection_cannot_cross_owners",
+ "test_search_wildcards_are_literal",
+ "test_attachment_traversal_is_refused",
+ "test_absolute_attachment_path_is_refused",
+ "test_missing_or_short_secret_key_is_refused",
+ "test_password_hash_is_salted_pbkdf2",
+ "test_verify_rejects_fast_and_weak_hashes",
+ "test_tampered_or_foreign_session_is_refused",
+ "test_pickle_payload_is_never_executed",
+ "test_replay_blocks_every_team_notes_attack",
+)
+
+
+
+COURSE_ID = 'course-secure-code'
+# Documented constant: claim codes derive deterministically from
+# (COURSE_ID, checkpoint_id, COURSE_SALT). They are spot-checkable
+# self-reported evidence, not tamper-proof secrets — see
+# docs/repo-plan-0.0.4.md FP-411.
+COURSE_SALT = '3e0c08a9dd12de2f'
+
+CHECKPOINTS = [
+ {"id": "l01", "gate": "attest", "title": '看起来对不等于安全 / Plausible is not safe'},
+ {"id": "l02", "gate": "attest", "title": '把威胁模型写成契约 / Write the threat model as a contract'},
+ {"id": "l03", "gate": "starter-suite", "title": '一次修一类漏洞 / Close one flaw class per turn'},
+ {"id": "l04", "gate": "both-suites", "title": '像攻击者一样复查 / Review like an attacker'},
+ {"id": "l05", "gate": "attest", "title": '审查你自己的 AI 代码 / Review your own AI-written code'},
+]
+
+def _claim_code(checkpoint_id):
+ digest = hashlib.sha256(
+ (COURSE_ID + ":" + checkpoint_id + ":" + COURSE_SALT).encode("utf-8")
+ ).digest()
+ return base64.b32encode(digest).decode("ascii")[:8]
+
+
+def _display_title(title):
+ # FP-820: shared-core checkpoint titles are stored "Chinese / English";
+ # the default command prints English first, Chinese after (FP-709 debt).
+ if " / " in title:
+ left, _, right = title.partition(" / ")
+ if any("\u4e00" <= character <= "\u9fff" for character in left):
+ return right + " / " + left
+ return title
+
+def _run_suite(implementation):
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(ROOT / implementation)
+ return subprocess.run(
+ [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")],
+ env=environment, check=False, capture_output=True, text=True,
+ )
+
+def run_progress(as_json, receipt_out=None, default=False):
+ import time
+ t0 = time.monotonic()
+ starter = _run_suite("starter")
+ starter_ms = int((time.monotonic() - t0) * 1000)
+ t0 = time.monotonic()
+ solution = _run_suite("solution")
+ solution_ms = int((time.monotonic() - t0) * 1000)
+ starter_ok = starter.returncode == 0
+ solution_ok = solution.returncode == 0
+ rows = []
+ for checkpoint in CHECKPOINTS:
+ gate = checkpoint["gate"]
+ if gate == "attest":
+ status, kind = "attest", "attested"
+ elif gate == "starter-suite":
+ status = "passed" if starter_ok else "open"
+ kind = "objective"
+ else:
+ status = "passed" if (starter_ok and solution_ok) else "open"
+ kind = "objective"
+ code = _claim_code(checkpoint["id"]) if status in ("passed", "attest") else None
+ row = dict(checkpoint)
+ row["status"] = status
+ row["kind"] = kind
+ row["claim_code"] = code
+ rows.append(row)
+ secret = claim_receipt.receipts_enabled() if claim_receipt else None
+ receipts = []
+ if secret:
+ starter_tests = claim_receipt.count_tests(starter.stderr)
+ solution_tests = claim_receipt.count_tests(solution.stderr)
+ solution_hash = claim_receipt.solution_sha256(ROOT)
+ for checkpoint in CHECKPOINTS:
+ gate = checkpoint["gate"]
+ if gate == "starter-suite":
+ passed, tests, ms = starter_ok, starter_tests, starter_ms
+ elif gate == "both-suites":
+ passed = starter_ok and solution_ok
+ tests, ms = starter_tests + solution_tests, starter_ms + solution_ms
+ else:
+ continue
+ receipts.append(claim_receipt.make_receipt(
+ COURSE_ID, checkpoint["id"], passed=passed, tests=tests,
+ duration_ms=ms, impl_dir=ROOT / "starter",
+ solution_hash=solution_hash, secret=secret))
+ if default and not as_json:
+ # FP-820: bare ``python verify.py`` — check the learner's own
+ # implementation, print per-checkpoint status and earned claim codes,
+ # English first then Chinese (FP-709 debt). ``progress`` keeps its
+ # exact published output; this block is the only new surface.
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("passed" if starter_ok else "not passed")
+ + " / solution " + ("passed" if solution_ok else "not passed"))
+ for row in rows:
+ state = row["status"] + (" (self-attested)" if row["kind"] == "attested" else "")
+ code = row["claim_code"] if row["claim_code"] else "\u2014"
+ print(" " + row["id"] + " " + _display_title(row["title"]) + " [" + state + "] " + code)
+ open_gates = [row["id"] for row in rows
+ if row["kind"] == "objective" and row["status"] != "passed"]
+ if open_gates:
+ print("Next: keep implementing starter/ until " + ", ".join(open_gates)
+ + " show [passed]; then re-run: python verify.py")
+ else:
+ print("All objective checkpoints passed — the codes above are ready"
+ " to submit (batch POST /api/claims, see SKILL.md §5).")
+ print("\u4e2d\u6587\uff1a\u68c0\u67e5\u70b9\u72b6\u6001\u4e0e\u8ba4\u9886\u7801\u89c1\u4e0a\uff1b\u5e26 [passed]/[attest] \u7684\u884c\u6709\u8ba4\u9886\u7801\uff0c[open] \u7684\u884c\u7ee7\u7eed\u5728 starter/ \u91cc\u5b9e\u73b0\u540e\u518d\u8dd1\u3002")
+ print("Claim codes are self-reported evidence, recorded at flypython.com; never a certificate.")
+ print("\u8ba4\u9886\u7801\u662f\u81ea\u6211\u62a5\u544a\u7684\u8bc1\u636e\uff0c\u8bb0\u5f55\u5728 flypython.com\uff1b\u4e0d\u662f\u8bc1\u4e66\u3002")
+ if secret:
+ print(f"Signed run receipts prepared for {len(receipts)} gated checkpoint(s);"
+ " submit each with its claim to mark it as a local-run receipt.")
+ return 1 if open_gates else 0
+ if as_json:
+ document = {"course": COURSE_ID,
+ "starter_suite_passed": starter_ok,
+ "solution_suite_passed": solution_ok,
+ "checkpoints": rows}
+ if secret:
+ document["receipts"] = receipts
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ starter_state = "passed" if starter_ok else "not passed"
+ solution_state = "passed" if solution_ok else "not passed"
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + starter_state + " / solution " + solution_state)
+ for row in rows:
+ state = row["status"] + (" (self-attested)" if row["kind"] == "attested" else "")
+ code = "claim code " + row["claim_code"] if row["claim_code"] else "—"
+ print(" " + row["id"] + " " + row["title"] + " [" + state + "] " + code)
+ print("Claim codes are self-reported evidence, recorded at flypython.com; never a certificate.")
+ if secret:
+ print(f"Signed run receipts prepared for {len(receipts)} gated checkpoint(s);"
+ " submit each with its claim to mark them as a local-run receipt.")
+ if receipt_out and secret:
+ Path(receipt_out).write_text(
+ json.dumps({"receipts": receipts}, ensure_ascii=False, indent=2) + "\n",
+ encoding="utf-8")
+ print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
+ return 0
+
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
+ nargs="?", default=None)
+ parser.add_argument("--json", action="store_true")
+ parser.add_argument("--expect-failure", action="store_true")
+ parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
+ args = parser.parse_args()
+
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
+
+ command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
+ environment = os.environ.copy()
+ environment["PYTHONPATH"] = str(ROOT / args.implementation)
+ result = subprocess.run(
+ command, env=environment, check=False, capture_output=True, text=True
+ )
+
+ if args.expect_failure:
+ if result.returncode == 0:
+ print("Expected the starter to fail, but it passed.", file=sys.stderr)
+ return 1
+ output = (result.stdout or "") + (result.stderr or "")
+ missing = [name for name in EXPECTED_STARTER_FAILURES if name not in output]
+ if missing:
+ print(
+ "Starter failed for unexpected reasons; missing expected failures:",
+ file=sys.stderr,
+ )
+ print("\n".join(missing), file=sys.stderr)
+ print(output, file=sys.stderr)
+ return 1
+ print(
+ "Expected starter state reproduced: SQL is built from input, paths "
+ "escape the attachments folder, a default secret is accepted, "
+ "passwords are unsalted MD5, and sessions are unsigned pickles."
+ )
+ return 0
+ if result.returncode == 0:
+ print(f"{args.implementation}: all tests passed")
+ else:
+ sys.stderr.write((result.stderr or "") or (result.stdout or ""))
+ return result.returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/courses/verifying-ai-generated-code/COURSE.md b/courses/verifying-ai-generated-code/COURSE.md
index b19d3ab..4fa1b4a 100644
--- a/courses/verifying-ai-generated-code/COURSE.md
+++ b/courses/verifying-ai-generated-code/COURSE.md
@@ -69,7 +69,7 @@ release checklist; this course builds the evidence core.
- Badge: **It runs is not evidence Badge** (badge id `course-verify-ship`) - earned by claiming all five checkpoints.
- Challenges: L01-L05 checkpoints, 10 points each; +50 course-badge bonus when all five are claimed on flypython.com.
- Evidence: `python verify.py` - L03 (bounded change) and L04 (verify & review) are objectively gated by the suite; L01/L02/L05 are learner-attested.
-- Submission: each passed checkpoint prints a deterministic claim code; record it on flypython.com against your account. Self-reported evidence, never a certificate.
+- Submission: each test-passed checkpoint prints a deterministic claim code; a reflection checkpoint prints one only after you answer its questions and run `python verify.py --attest ID`; record it on flypython.com against your account. Self-reported evidence, never a certificate.
## Folder map
diff --git a/courses/verifying-ai-generated-code/COURSE_cn.md b/courses/verifying-ai-generated-code/COURSE_cn.md
index 9a33bc0..9ddd4d3 100644
--- a/courses/verifying-ai-generated-code/COURSE_cn.md
+++ b/courses/verifying-ai-generated-code/COURSE_cn.md
@@ -63,7 +63,7 @@ course_id: course-verify-ship
- 徽章:**「能跑」不是证据徽章**(徽章 id `course-verify-ship`)——认领全部五个检查点后获得。
- 挑战:L01–L05 检查点各 10 分;五项全部在 flypython.com 认领后另加 50 分课程徽章奖励。
- 证据:`python verify.py` —— L03(边界修改)与 L04(验证与审查)由测试套件客观判定;L01/L02/L05 为学习者自报。
-- 提交:每个通过的检查点会打印确定性认领码,在 flypython.com 上记入你的账号。这是自我报告的证据,绝不是证书。
+- 提交:测试通过的检查点打印确定性认领码;自报检查点要先回答课后问题,再运行 `python verify.py --attest ID` 才打印码,在 flypython.com 上记入你的账号。这是自我报告的证据,绝不是证书。
## 文件夹结构
diff --git a/courses/verifying-ai-generated-code/verify.py b/courses/verifying-ai-generated-code/verify.py
index 37dcd96..d03b3c6 100644
--- a/courses/verifying-ai-generated-code/verify.py
+++ b/courses/verifying-ai-generated-code/verify.py
@@ -179,21 +179,69 @@ def run_progress(as_json, receipt_out=None, default=False):
print(f"Wrote {len(receipts)} receipt(s) to {receipt_out}", file=sys.stderr)
return 0
+def run_check(as_json=False, attested=()):
+ """Learner-facing v2 check: run only starter/ and require explicit attestation.
+
+ The published progress command remains the v1 claim-receipt interface.
+ The reference solution is checked by the maintainer's course validation,
+ not rerun on every learner check.
+ """
+ selected = set(attested)
+ allowed = {item["id"] for item in CHECKPOINTS if item["gate"] == "attest"}
+ unknown = selected - allowed
+ if unknown:
+ print("Only self-reported checkpoints can be attested: " + ", ".join(sorted(unknown)), file=sys.stderr)
+ return 2
+ dependency_check = globals().get("_deps_available")
+ blocked = callable(dependency_check) and not dependency_check()
+ result = None if blocked else _run_suite("starter")
+ passed = result is not None and result.returncode == 0
+ rows = []
+ for item in CHECKPOINTS:
+ self_report = item["gate"] == "attest"
+ status = ("attested" if item["id"] in selected else "pending") if self_report else ("blocked" if blocked else "passed" if passed else "open")
+ rows.append({"id": item["id"], "title": _display_title(item["title"]),
+ "kind": "self-reported" if self_report else "objective",
+ "status": status,
+ "claim_code": _claim_code(item["id"]) if status in ("passed", "attested") else None})
+ document = {"v": 2, "course": COURSE_ID, "implementation": "starter",
+ "suite": {"status": "blocked" if blocked else "passed" if passed else "failed",
+ "reason": "Install this course's requirements first." if blocked else None},
+ "checkpoints": rows}
+ if as_json:
+ print(json.dumps(document, ensure_ascii=False, indent=2))
+ else:
+ print("Course " + COURSE_ID)
+ print("Suites: starter " + ("blocked: install course requirements" if blocked else "passed" if passed else "not passed"))
+ for row in rows:
+ print(" " + row["id"] + " " + row["title"] + " [" + row["status"] + "] " + (row["claim_code"] or "—"))
+ pending = [row["id"] for row in rows if row["status"] == "pending"]
+ if pending:
+ print("Confirm completed reflection checkpoints explicitly with --attest ID (repeat for each): " + ", ".join(pending))
+ if result is not None and not passed:
+ print((result.stderr or result.stdout or "").strip()[-3000:], file=sys.stderr)
+ print("Codes record self-reported progress, not a certificate. / 认领码只记录自报进度,不是证书。")
+ return 0 if all(row["status"] in ("passed", "attested") for row in rows) else 1
+
+
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("implementation", choices=("progress", "starter", "solution"),
+ parser.add_argument("implementation", choices=("check", "progress", "starter", "solution"),
nargs="?", default=None)
parser.add_argument("--json", action="store_true")
parser.add_argument("--expect-failure", action="store_true")
parser.add_argument("--receipt-out", metavar="PATH", help="write signed run receipts JSON (requires FLYPYTHON_CLAIM_SECRET)")
+ parser.add_argument("--attest", action="append", default=[], metavar="ID", help="confirm one self-reported checkpoint after doing its work")
args = parser.parse_args()
- if args.implementation in (None, "progress"):
- # FP-820: the bare command is the learner's default — same engine as
- # ``progress``, English-first output, exit 1 while gates stay open.
- # The four explicit usages remain for maintainers.
- return run_progress(args.json, args.receipt_out,
- default=args.implementation is None)
+ if args.implementation in (None, "check"):
+ if args.receipt_out or args.expect_failure:
+ parser.error("--receipt-out and --expect-failure are for legacy progress/fixture commands")
+ return run_check(args.json, args.attest)
+ if args.attest:
+ parser.error("--attest is only valid with the learner check command")
+ if args.implementation == "progress":
+ return run_progress(args.json, args.receipt_out)
command = [sys.executable, "-m", "unittest", "discover", "-s", str(ROOT / "tests")]
environment = os.environ.copy()
diff --git a/docs/ENGINEERING_CONFIG.md b/docs/ENGINEERING_CONFIG.md
new file mode 100644
index 0000000..a0fee51
--- /dev/null
+++ b/docs/ENGINEERING_CONFIG.md
@@ -0,0 +1,43 @@
+# Engineering configuration
+
+The Python repository already has a locked Python toolchain, a Makefile,
+SHA-pinned GitHub Actions and deterministic bilingual-content gates. These remain
+the source of truth. The configuration additions reference
+[yetone/magpie](https://github.com/yetone/magpie) (reviewed 2026-10-22), adapting
+its engineering practices rather than its Go/desktop stack.
+
+| Reference | FlyPython adaptation |
+| --- | --- |
+| [Makefile](https://github.com/yetone/magpie/blob/main/Makefile) | Guard repository-relative recipes against the wrong working directory; extend the existing command entry point rather than add another task runner. |
+| [.gitattributes](https://github.com/yetone/magpie/blob/main/.gitattributes) | Force LF for text checkouts, keeping source checksums and generated output stable across platforms. Binary files remain auto-detected. |
+| [CLAUDE.md](https://github.com/yetone/magpie/blob/main/CLAUDE.md) | Import the existing AGENTS.md; do not maintain two copies of the same agent rules. |
+| [test workflow](https://github.com/yetone/magpie/blob/main/.github/workflows/test.yml) | Allow manual validation; do not cancel an in-progress default-branch run on every new push, while superseded PR runs are canceled. |
+
+## Local commands
+
+Use a POSIX shell and `make` from this repository (or `make -C path/to/python`).
+`make setup` requires uv, selects `.python-version`, and syncs
+`requirements-dev.lock.txt` into `.venv`. It does not update dependency locks.
+Use `make check PYTHON=.venv/bin/python` after setup; the Make default remains
+`python3` so existing CI and activated environments keep working. Native Windows
+contributors can run the direct Python commands in CONTRIBUTING.md or use WSL.
+
+`make generated-check` runs validation and read-only checks of catalog/radar,
+README sections, content manifest and course solution hashes. `make generate`
+regenerates those artifacts sequentially; `make all` regenerates, then checks.
+The ordering holds under `make -j` too. No target publishes content or deploys.
+
+## Intentionally not copied
+
+- Go, Bun, GTK/WebKit, native binary packaging and Docker publishing do not fit
+ this content-and-Python-tools repository.
+- No automatic website pin update, release or production deployment is added.
+- Existing exact locks, CI Python version matrix, least-privilege permissions,
+ SHA-pinned actions and job timeout remain intact.
+- No mass formatting or line-ending renormalization is performed. If a future
+ byte-sensitive fixture deliberately needs CRLF, add a narrow per-file
+ attributes exception; do not disable LF for ordinary source files.
+
+Configuration regression tests verify the thin agent entry point, wrong-directory
+Make guard, locked setup command, read-only generated checks and parallel-safe
+regeneration ordering. They run in the existing pytest/CI suite.
diff --git a/docs/REPO_TO_WEBSITE.md b/docs/REPO_TO_WEBSITE.md
index b5d3743..f8985d1 100644
--- a/docs/REPO_TO_WEBSITE.md
+++ b/docs/REPO_TO_WEBSITE.md
@@ -29,9 +29,14 @@ Avoid generic banners, repeated marketing copy, and links to placeholder pages.
## Attribution and acceptance
-Until a dedicated GitHub landing route exists in production, link to the live
-site root. When the website ships a route such as `/from-github`, verify it on
-the custom domain before changing repository links. Then measure, at minimum:
+The dedicated `/from-github` landing route is now in production. Repository
+calls to action may link to that route when attribution matters, while generic
+“continue learning” links may still use the site root. The English README's
+“continue on flypython.com” calls to action link `/from-github`; the Chinese
+README keeps the site root until that route has a Chinese version, so Chinese
+readers do not land on an English-only page. Badges and navigation links stay
+on the site root. Re-verify the custom domain after each website release.
+Measure, at minimum:
- repository link clicks by source document;
- landing-page engagement with a learning path;
@@ -43,3 +48,16 @@ the custom domain before changing repository links. Then measure, at minimum:
Do not treat stars, traffic, email signups, a checkout route, or a deploy log as
paid-demand evidence. Review the funnel monthly and remove calls to action that
do not help visitors take a useful next step.
+
+## Repository release status
+
+The package version is `0.1.2`. The immutable `v0.1.2` tag is cut on the
+`master` commit that merges the 0.1.2 release change, and its GitHub Release
+names that SHA; the previous `v0.1.1` tag points to `db85cdd`. 0.1.2 adds
+learner verifier v2 (tests must pass and reflection checkpoints must be
+explicitly confirmed with `--attest`), CI and course-setup fixes, and a
+corrected `llms.txt`; see the repository CHANGELOG. FlyPython.com builds
+from an exact commit pinned on the website side, so `master` can be ahead of
+what the site serves. A pinned, served commit proves release delivery, not
+learner outcomes or paid demand; the website's payment and email operations
+still have separate acceptance gates.
diff --git a/docs/repo-plan-0.0.3.md b/docs/repo-plan-0.0.3.md
index cbcf531..b1da829 100644
--- a/docs/repo-plan-0.0.3.md
+++ b/docs/repo-plan-0.0.3.md
@@ -1,17 +1,22 @@
# FlyPython 仓库 0.0.3 更新计划
+> 历史版本记录:本文件保留当时的范围与假设,正文不再随仓库更新。§8 TODO 的
+> 勾选状态已于 2026-10-11 对照仓库核对;当前发布状态以 `CHANGELOG.md` 与
+> `docs/REPO_TO_WEBSITE.md` 为准,网站固定的提交记录在网站仓库
+> `src/data/content-pin.json`。
+
版本:0.0.3(规划稿)
-更新:2026-09-11
+更新:2026-09-11(TODO 勾选状态核对于 2026-10-11)
历史边界说明:本文记录 0.0.3 的内容模型和实施计划。当前挑战叙事、认领码、
-徽章与公开学习路线已经由 0.0.4 计划接续;网站当前固定到后续状态提交
-`920f790`。本文中的
-“Agent 授课”和未勾选 TODO 不代表当前状态。本文件按所有者决策以中文保留。
+徽章与公开学习路线已经由 0.0.4 计划接续,本文中的“Agent 授课”叙事不代表
+当前产品形态。本文件按所有者决策以中文保留。
关联:flypython.com `docs/product-and-growth-plan-0.0.3.md`
-状态:规划文档,不代表已实现。本计划遵守 `AGENTS.md` 的仓库边界:本仓库
-负责已审核内容、可运行证据与稳定 JSON 契约;网站负责展示与转化。
-所有新内容中英文同步产出。
+状态:已基本实现(2026-10-11 核对)。§8 中只有 FP-326 仍有未完成部分
+(旗舰课的授课式实机走查),见该项备注;其余各项已在仓库中落地。本计划遵守 `AGENTS.md` 的
+仓库边界:本仓库负责已审核内容、可运行证据与稳定 JSON 契约;网站负责展示与
+转化。所有新内容中英文同步产出。
## 1. 网站 0.0.3 方向对本仓库的要求
@@ -137,32 +142,56 @@ risk: "…"
5. `/from-github` 线上验证后做 README 导流改造。
6. 首个课程落地时开始 Releases/tagging 节奏。
-## 8. TODO(编号续接网站规划;全部未验收)
+## 8. TODO(编号续接网站规划)
+
+勾选状态于 2026-10-11 对照仓库核对。网站侧的部分(pin、llms-links 测试)
+不在本仓库,无法从这里核实,备注中单独说明。
-- [ ] FP-325 `courses/` 规范写入 `AGENTS.md`;COURSE.md 授课契约经评审。
+- [x] FP-325 `courses/` 规范写入 `AGENTS.md`;COURSE.md 授课契约经评审。
+ (核对:`AGENTS.md` 内容规则含完整课程文件夹规范;12 门课均有 `COURSE.md`
+ 与 `REVIEW.md`)
- [ ] FP-326 C1 课程文件夹完成(3 个场景皮、verify.py 双向、EN+CN、
- 真实跟课的 REVIEW.md)。
-- [ ] FP-327 manifest `type: "course"` + schema + `build_content_manifest.py`
- 遍历;网站 pin 更新同一次刻意完成(FP-224 规则)。
-- [ ] FP-328 `verify_courses.py` 进入验证工作流与 Makefile。
-- [ ] FP-329 `catalog/projects/` 迁为 per-project YAML(迁移 7 条种子)+
- `render_readmes.py` 重生成验证。
-- [ ] FP-330 `radar.json` 导出 + `schema/radar-v1.schema.json` +
- `export_catalog.py` 扩展;`--check` 全绿。
-- [ ] FP-331 `tools/radar_scan.py` 合入:限速与"不生成描述"有测试保证。
-- [ ] FP-332 llms.txt / llms-full.txt 增加 courses+radar 区块;深链有效
- (pin 更新后网站侧 llms-links 测试保持绿)。
-- [ ] FP-333 `CURATION_POLICY.md` scope + `ai_familiarity` 分级规则;
- `course-feedback.yml` issue 模板。
-- [ ] FP-334 仓库描述/topics/关键词;第一个 GitHub Release。
-- [ ] FP-335 README/README_cn 横幅 + 各文档情境化文末链接,以线上
- `/from-github` 验证为前置。
-- [ ] FP-344 双许可 LICENSE 就位(MIT 代码 / CC BY 4.0 内容,两仓库同)——
- 课程文件夹分发与 FP-326 的前置。
-- [ ] FP-350 校验器与 schema 追上政策:`validate_catalog.py` 与
+ 真实跟课的 REVIEW.md)。(核对:3 个场景皮 `api-tool` / `data-monitor` /
+ `excel-report`、verify.py 双向、EN+CN 均已完成,挑战模式可解性实测已录
+ `REVIEW.md`;**授课式实机走查仍待做**,见该课 `REVIEW.md` 的 Pending 一节)
+- [x] FP-327 manifest `type: "course"` + schema + `build_content_manifest.py`
+ 遍历;网站 pin 更新同一次刻意完成(FP-224 规则)。(核对:manifest schema
+ 含 `course` 与 `path`,`build_content_manifest.py --check` 通过;网站 pin
+ 在网站仓库 `src/data/content-pin.json`)
+- [x] FP-328 `verify_courses.py` 进入验证工作流与 Makefile。(核对:
+ `make check` 执行,CI 的 Validate 即 `make check`)
+- [x] FP-329 `catalog/projects/` 迁为 per-project YAML(迁移 7 条种子)+
+ `render_readmes.py` 重生成验证。(核对:7 个 `*.yml`,
+ `render_readmes.py --check` 通过)
+- [x] FP-330 `radar.json` 导出 + `schema/radar-v1.schema.json` +
+ `export_catalog.py` 扩展;`--check` 全绿。(核对:
+ `export_catalog.py --check --target both` 通过)
+- [x] FP-331 `tools/radar_scan.py` 合入:限速与"不生成描述"有测试保证。
+ (核对:`tests/test_radar_scan.py` 含每来源上限与“候选不带结论字段”测试)
+- [x] FP-332 llms.txt / llms-full.txt 增加 courses+radar 区块;深链有效
+ (pin 更新后网站侧 llms-links 测试保持绿)。(核对:`llms.txt` 已有
+ courses 与 radar 区块;`llms-full.txt` 由网站提供,本仓库只引用。
+ 2026-10-11 补齐此前缺的 4 门工具课,修正失效的 Codex 课程深链,并新增
+ `tests/test_llms_txt.py`:列出的路径必须存在,每门课都必须列出)
+- [x] FP-333 `CURATION_POLICY.md` scope + `ai_familiarity` 分级规则;
+ `course-feedback.yml` issue 模板。(核对:两者均已在仓库中)
+- [x] FP-334 仓库描述/topics/关键词;第一个 GitHub Release。(核对:仓库
+ 描述与 10 个 topics 已设置;`v0.1.0` 标签于 2026-09-12 打在 `e420fca`,
+ 首个 GitHub Release 为 `v0.1.1`,2026-09-14)
+- [x] FP-335 README/README_cn 横幅 + 各文档情境化文末链接,以线上
+ `/from-github` 验证为前置。(核对:guides / playbooks / examples 的
+ 文末情境化链接已全部加上。2026-10-11 确认 `/from-github` 返回 200 后,
+ 英文 README 的两处“继续前往”链接改为 `/from-github`;该页暂无中文版,
+ 中文 README 仍链根域,规则记录在 `docs/REPO_TO_WEBSITE.md`)
+- [x] FP-344 双许可 LICENSE 就位(MIT 代码 / CC BY 4.0 内容,两仓库同)——
+ 课程文件夹分发与 FP-326 的前置。(核对:本仓库 `LICENSE` 已是双许可;
+ 网站仓库未在此核实)
+- [x] FP-350 校验器与 schema 追上政策:`validate_catalog.py` 与
`catalog-v1`/`radar-v1` 支持 Radar 生命周期状态与 `ai_familiarity`;
manifest schema 增加 `type: "course"`;首个 Release(FP-334)时把
- CHANGELOG `[Unreleased]` 切入 `0.1.0` 段。
+ CHANGELOG `[Unreleased]` 切入 `0.1.0` 段。(核对:`radar-v1` schema 与
+ `tools/catalog.py` 校验 6 种生命周期状态与 `ai_familiarity`,由
+ `export_catalog.py --check` 执行;CHANGELOG 已有 `[0.1.0] - 2026-09-12`)
## 9. 非目标与风险
diff --git a/docs/repo-plan-0.0.4.md b/docs/repo-plan-0.0.4.md
index d166045..0c9394f 100644
--- a/docs/repo-plan-0.0.4.md
+++ b/docs/repo-plan-0.0.4.md
@@ -1,13 +1,21 @@
# FlyPython 仓库 0.0.4 更新计划
+> 历史版本记录:本文件保留当时的挑战模型和发布证据,正文不再随仓库更新;
+> 下文出现的提交 SHA 只表示当时快照。当前发布状态以 `CHANGELOG.md` 与
+> `docs/REPO_TO_WEBSITE.md` 为准,网站固定的提交记录在网站仓库
+> `src/data/content-pin.json`。
+
版本:0.0.4(规划草稿,第 3 版)
-更新日期:2026-09-12(第 3 版——挑战模型定稿 + A/B 阶段拆分)
+更新日期:2026-09-12(第 3 版——挑战模型定稿 + A/B 阶段拆分;状态说明核对于 2026-10-11)
关联:flypython.com `docs/product-and-growth-plan-0.0.4.md`
-状态:除 FP-414 外本计划已实现;真实 Agent 可解性试跑已于 2026-09-13
-完成(Devin, SWE-2 Max,全部 8 门课,挑战模式实测记录见各课
-`REVIEW.md`;授课式走查仍为另行待办)。网站当前固定
-到 `920f790`(阶段 A + 数据分析模块 live 状态)。`AGENTS.md` 的仓库边界不变:
+状态(2026-10-11 核对):本计划已实现,FP-414 评估记录见 §9。之后 2026-09-16 的
+learner verifier v2 把认领入口从 `progress --json` 换成 `check --json` +
+`--attest`,`progress --json` 只保留给旧版签名回执(见 CHANGELOG)。真实
+Agent 可解性试跑已于 2026-09-13 完成(Devin, SWE-2 Max,当时全部 8 门课,
+挑战模式实测记录见各课 `REVIEW.md`)。仍未完成的是授课式实机走查:目前
+9 门课的 `REVIEW.md` 仍列为 Pending,需要维护者在各目标工具里实跑,属于发布
+证据,不阻塞内容。`AGENTS.md` 的仓库边界不变:
本仓库拥有经审核的内容、可运行的证据与稳定的 JSON 契约;网站拥有呈现、
账号体系与转化。所有新内容中英同一次变更交付。
@@ -128,7 +136,9 @@
- [x] FP-413 供网站渲染的结构化徽章元数据。(本地 2026-09-12:全部
8 门课 `COURSE.md`/`COURSE_cn.md` frontmatter 增加 `badge`(id、
name_en、name_zh、requires)与 `course_id`)
-- [ ] FP-414 SKILL.md 打包评估,附书面记录。
+- [x] FP-414 SKILL.md 打包评估,附书面记录。(2026-10-11:见 §9。结论是
+ 不按课程打包,保留网站单一平台 Skill;线上 Skill 有两项规范不合规和一条
+ 依赖安装说明需网站侧修正)
- [x] FP-415 `verify_courses.py` 认领码覆盖进 CI。(本地 2026-09-12:
进度契约检查——双运行确定性、JSON 结构、5 检查点、码格式——已入
验证器并随 `make check`/validate.yml 执行)
@@ -240,3 +250,57 @@ FP-409 渲染消费,网站侧不手抄定义。
3. `paths/foundation/`(复用现有课,最快可见)
4. `da-eda` / `da-visualization` / `da-report` 新课 + `paths/data-analysis/`
+ M0 入门模块
+
+## 9. FP-414 评估记录:把课程打包为 SKILL.md skill(2026-10-11)
+
+起草:Cursor Cloud Agent。结论由所有者合并本节时确认。原计划提到的
+OpenMAIC 本次未评估。
+
+### 9.1 现状
+
+- 网站已发布一个覆盖全部课程的平台级 Skill:
+ `https://flypython.com/skills/flypython/SKILL.md`(`name:
+ flypython-challenges`,0.1.0,2026-09-14)。它负责一次性授权链接、按课程
+ 拉取文件清单、先读 `TASK.md`、人在回路关卡、`python verify.py` 与
+ `--attest`、批量提交认领码。0.0.8 起它是课程主入口,0.0.9 起工具课第 1 课
+ 把安装它列为可选但推荐。
+- 课程文件夹本身已能单独运行(0.0.8 FP-821:单个文件夹取出即可
+ `python verify.py`)。
+- 格式依据:Agent Skills 规范 。
+
+### 9.2 方案对比
+
+| 方案 | 做法 | 判断 |
+| --- | --- | --- |
+| A. 每门课一个 skill | 12 个 `SKILL.md`,把 `COURSE.md` / `TASK.md` 写进正文或 `references/` | 不做。与课程文件重复且要中英两份同步;12 个几乎相同的 `description` 在 Agent 启动时都会加载(规范的渐进加载第 1 层),容易触发错课;若把 `starter/` / `solution/` 一起打包,等于把答案交给 Agent |
+| B. 单一平台 Skill(现状) | 网站一个 Skill,课程文件按固定提交从 API 拉取 | 保留。一处维护,课程契约仍只在本仓库 |
+| C. 本仓库另做离线 skill | 无账号、无认领,只读 clone 下来的文件夹并跑 `verify.py` | 不做。README 与 `COURSE.md` 已能让 Agent 直接开跑,多一个入口只会分流和漂移 |
+
+### 9.3 试点记录
+
+- 用规范的参考校验器 `skills-ref` 0.1.1(`agentskills validate`)校验线上
+ Skill,两项不合规:
+ 1. frontmatter 含规范外字段 `version`、`updated`。规范只允许 `name`、
+ `description`、`license`、`compatibility`、`metadata`、`allowed-tools`,
+ 版本信息应放进 `metadata`。
+ 2. `name: flypython-challenges` 与 Skill 自己给 Claude Code 的安装目录
+ `.claude/skills/flypython/` 不一致;规范要求 `name` 与父目录同名。
+- Skill 的 Environment notes 建议 pandas / matplotlib 课程用
+ `uv run python verify.py`。课程文件夹没有 `pyproject.toml`,这条命令不会安装
+ `requirements.txt` 中的依赖,与本仓库 2026-10-04 对 `da-eda` /
+ `da-visualization` 的修正(改为 venv + `pip install -r requirements.txt`)不一致。
+- Skill 的 Cursor 安装行把它写成规则文件 `.cursor/rules/flypython.mdc`,
+ 而不是 skill 目录;需要网站侧按 Cursor 当前文档核实应放在哪里。
+- Skill 描述的课程侧契约(`TASK.md`、`python verify.py`、`--attest ID`、
+ `check --json`、旧版 `progress --json` 只用于签名回执)与本仓库 learner
+ verifier v2 一致。
+- 未做:没有在 Claude Code、Codex 等外部工具里实际安装 Skill 并跑完一门课
+ (本次环境没有这些工具)。以上结论基于规范、校验器和线上文件。
+
+### 9.4 结论
+
+- 不按课程打包 skill,也不在本仓库另做离线 skill;保持网站单一平台 Skill。
+- Skill 归网站仓库,需在网站侧修正:把 `version` / `updated` 移进
+ `metadata`;统一 `name` 与安装目录;pandas / matplotlib 课程的运行说明改成
+ 与课程 `requirements.txt` 一致的 venv + pip;核实 Cursor 安装位置。
+- 本仓库无需为 FP-414 增加文件。
diff --git a/docs/repo-plan-0.1.x.md b/docs/repo-plan-0.1.x.md
index 09a22f7..e11b1c0 100644
--- a/docs/repo-plan-0.1.x.md
+++ b/docs/repo-plan-0.1.x.md
@@ -1,10 +1,14 @@
# FlyPython 仓库 0.1.x 规划:社区贡献课程
版本:0.1.x(规划,第 3 稿;2026-09-14 从 `repo-plan-0.1.0.md` 改名,避免与已发布标签混淆)
-更新日期:2026-09-14
-状态:**规划,网站 0.1.0 实际发布之后再立项;当前目标日为 2026-09-19,若发布闸门导致
-顺延,本计划同样顺延,发布前不实施。**
-关联:网站仓库 `docs/product-and-growth-plan-0.0.9.md`(附录 B 记录了移出决定)·
+更新日期:2026-10-04
+状态:**暂缓 —— 1.0 之后再立项(所有者 2026-10-04 决定)。** 网站 0.1.0 已发布,
+前置条件已满足,但当前重点是外部公开课督学路线(候选评估见组织项目板
+[Open-course supervision candidates](https://github.com/orgs/flypythoncom/projects/1))。
+在此之前本文件只是规划,不能当成已开放社区课程贡献;届时是否立项由真实激活、
+维护容量和收费验收数据决定。
+关联:网站仓库 0.0.9 计划附录 B 记录了移出决定(该文件已于 2026-10-02 删除,用
+`git show e16cb3c:docs/product-and-growth-plan-0.0.9.md` 查看)·
网站仓库 `docs/courses/README.md`(课程标准)· 本仓库 `CONTRIBUTING.md`、
`docs/CURATION_POLICY.md`、`LICENSE`
@@ -16,7 +20,7 @@
- 官方示范(视频、终端回放、文字稿)是 Pro 专属;社区课是否配官方示范、如何开放,
届时按网站当时的 Pro 规则另行决定。本规划不承诺社区课有示范。
-## 现状(2026-09-13 核对)
+## 现状(2026-10-04 核对)
- `LICENSE` 已是双许可:代码(starter、solution、`verify.py`、工具)MIT;课文等书面
内容 CC BY 4.0,署名「FlyPython (flypython.com)」。
@@ -27,6 +31,8 @@
0.0.8 起课程文件夹单独取出也能运行 `python verify.py`。
- 网站的课程数据与认领契约目前是手工镜像本仓库,每加一门课都要改网站代码——
社区课一多就成为瓶颈(见第 6 项)。
+- 12 门公开课程自 2026-09-16 起使用 learner verifier v2;社区课程 proposal、脚手架、
+ CODEOWNERS、安全审查和自动接入仍全部未实现。
## 待办(0.1.x 立项时细化)
diff --git a/examples/async-fetcher/TASK.md b/examples/async-fetcher/TASK.md
index d601b8a..968a6eb 100644
--- a/examples/async-fetcher/TASK.md
+++ b/examples/async-fetcher/TASK.md
@@ -4,6 +4,7 @@ Change only `starter/fetcher.py`.
- Implement `async_batch_fetch(items: list[dict], fetch_fn: Callable[[dict], Awaitable[dict]], max_concurrency: int = 3, max_retries: int = 3) -> list[dict]`.
- Concurrency bounding:
+ - `max_concurrency` must be a positive integer; reject zero, negative values, booleans, and non-integers with `ValueError` before starting any tasks, even for empty input.
- At most `max_concurrency` calls to `fetch_fn` may be executing concurrently at any moment.
- Retry & Backoff contract:
- `fetch_fn` returns `{"status_code": int, "data": ...}` or raises an exception.
diff --git a/examples/async-fetcher/TASK_cn.md b/examples/async-fetcher/TASK_cn.md
index ff33f21..e95f63f 100644
--- a/examples/async-fetcher/TASK_cn.md
+++ b/examples/async-fetcher/TASK_cn.md
@@ -4,6 +4,7 @@
- 实现 `async_batch_fetch(items: list[dict], fetch_fn: Callable[[dict], Awaitable[dict]], max_concurrency: int = 3, max_retries: int = 3) -> list[dict]`。
- 并发控制要求:
+ - `max_concurrency` 必须是正整数;零、负数、布尔值及非整数必须在启动任何任务之前抛出 `ValueError`,即使输入为空也不例外。
- 任何时刻并发执行 `fetch_fn` 的协程数量不得超过 `max_concurrency`。
- 重试与退避策略:
- `fetch_fn` 返回 `{"status_code": int, "data": ...}` 或抛出异常。
diff --git a/examples/async-fetcher/solution/fetcher.py b/examples/async-fetcher/solution/fetcher.py
index 86906a0..093f68a 100644
--- a/examples/async-fetcher/solution/fetcher.py
+++ b/examples/async-fetcher/solution/fetcher.py
@@ -74,6 +74,8 @@ async def async_batch_fetch(
max_concurrency: int = 3,
max_retries: int = 3,
) -> list[dict[str, Any]]:
+ if isinstance(max_concurrency, bool) or not isinstance(max_concurrency, int) or max_concurrency < 1:
+ raise ValueError("max_concurrency must be a positive integer")
semaphore = asyncio.Semaphore(max_concurrency)
async with asyncio.TaskGroup() as group:
tasks = [
diff --git a/examples/async-fetcher/tests/test_fetcher.py b/examples/async-fetcher/tests/test_fetcher.py
index d12d6b4..b73e0d7 100644
--- a/examples/async-fetcher/tests/test_fetcher.py
+++ b/examples/async-fetcher/tests/test_fetcher.py
@@ -7,6 +7,33 @@
class TestAsyncBatchFetcher(unittest.TestCase):
+ def test_rejects_invalid_concurrency(self) -> None:
+ async def mock_fetch(item: dict) -> dict:
+ return {"status_code": 200, "data": item}
+
+ async def run_invalid(value: object) -> None:
+ # Bound the regression test itself: Semaphore(0) used to hang.
+ await asyncio.wait_for(
+ async_batch_fetch([{"id": 1}], mock_fetch, max_concurrency=value),
+ timeout=1,
+ )
+
+ for invalid in (0, -1, True, False, 1.5, "2", None, float("nan"), float("inf")):
+ with self.subTest(max_concurrency=invalid):
+ with self.assertRaisesRegex(ValueError, "max_concurrency must be a positive integer"):
+ asyncio.run(run_invalid(invalid))
+ with self.assertRaisesRegex(ValueError, "max_concurrency must be a positive integer"):
+ asyncio.run(async_batch_fetch([], mock_fetch, max_concurrency=invalid))
+
+ def test_single_concurrency_and_empty_input(self) -> None:
+ async def mock_fetch(item: dict) -> dict:
+ return {"status_code": 200, "data": item["id"]}
+
+ self.assertEqual(asyncio.run(async_batch_fetch([], mock_fetch, max_concurrency=1)), [])
+ results = asyncio.run(async_batch_fetch([{"id": 1}], mock_fetch, max_concurrency=1))
+ self.assertTrue(results[0]["success"])
+ self.assertEqual(results[0]["data"], 1)
+
def test_bounded_concurrency(self) -> None:
active_concurrency = 0
peak_concurrency = 0
diff --git a/examples/mcp-server/TASK.md b/examples/mcp-server/TASK.md
index 9bc43db..586cd59 100644
--- a/examples/mcp-server/TASK.md
+++ b/examples/mcp-server/TASK.md
@@ -15,6 +15,7 @@ Change only `starter/mcp_server.py`.
- Supported methods:
- `"tools/list"`: returns `{"result": {"tools": [{"name": ..., "description": ..., "inputSchema": ...}, ...]}}`.
- `"tools/call"`: accepts `params: {"name": ..., "arguments": ..., "inputResponses": {...}?}` and calls the handler as `handler(arguments, inputResponses)`.
+ - If the tool name is missing or is not a string (including lists, objects, booleans, numbers, and null), return JSON-RPC error `-32602` (Invalid params), preserving the request ID.
- If tool not registered: returns `{"result": {"isError": True, "content": [{"type": "text", "text": "Tool not found"}]}}`.
- If required argument missing: returns `{"result": {"isError": True, "content": [{"type": "text", "text": "Missing required argument: "}]}}`.
- If the handler raises `InputRequired(requests)`: returns `{"result": {"resultType": "input_required", "requests": , "content": [{"type": "text", "text": "Additional client input is required before this tool can finish."}]}}`. The client retries the same call with answers in `params.inputResponses`.
diff --git a/examples/mcp-server/TASK_cn.md b/examples/mcp-server/TASK_cn.md
index 94a59f1..d9cd528 100644
--- a/examples/mcp-server/TASK_cn.md
+++ b/examples/mcp-server/TASK_cn.md
@@ -15,6 +15,7 @@
- 支持的核心方法:
- `"tools/list"`:返回 `{"result": {"tools": [{"name": ..., "description": ..., "inputSchema": ...}, ...]}}`。
- `"tools/call"`:接收 `params: {"name": ..., "arguments": ..., "inputResponses": {...}?}`,并以 `handler(arguments, inputResponses)` 调用工具。
+ - 工具名缺失或不是字符串(包括列表、对象、布尔值、数字和 null)时,返回 JSON-RPC 错误码 `-32602`(Invalid params),并保留请求 ID。
- 工具未找到:返回 `{"result": {"isError": True, "content": [{"type": "text", "text": "Tool not found"}]}}`。
- 缺少 schema 中声明的必填参数:返回 `{"result": {"isError": True, "content": [{"type": "text", "text": "Missing required argument: "}]}}`。
- 工具抛出 `InputRequired(requests)`:返回 `{"result": {"resultType": "input_required", "requests": , "content": [{"type": "text", "text": "Additional client input is required before this tool can finish."}]}}`。客户端携带 `params.inputResponses` 重试同一请求。
diff --git a/examples/mcp-server/solution/mcp_server.py b/examples/mcp-server/solution/mcp_server.py
index 7179a0a..26358f6 100644
--- a/examples/mcp-server/solution/mcp_server.py
+++ b/examples/mcp-server/solution/mcp_server.py
@@ -108,6 +108,15 @@ def handle_request(self, request: Any) -> dict[str, Any]:
}
tool_name = params["name"]
+ if not isinstance(tool_name, str):
+ return {
+ "jsonrpc": "2.0",
+ "id": req_id,
+ "error": {
+ "code": -32602,
+ "message": "Invalid params: tool name must be a string",
+ },
+ }
if tool_name not in self.tools:
return {
"jsonrpc": "2.0",
diff --git a/examples/mcp-server/tests/test_mcp_server.py b/examples/mcp-server/tests/test_mcp_server.py
index 124d3a1..d545889 100644
--- a/examples/mcp-server/tests/test_mcp_server.py
+++ b/examples/mcp-server/tests/test_mcp_server.py
@@ -55,6 +55,20 @@ def test_tools_list_returns_schemas(self) -> None:
names = {t["name"] for t in tools}
self.assertEqual(names, {"calculate_sum", "risky_operation"})
+ def test_tools_call_rejects_non_string_tool_name(self) -> None:
+ for invalid_name in ([], {}, None, True, False, 1, 1.5):
+ with self.subTest(invalid_name=invalid_name):
+ resp = self.server.handle_request({
+ "jsonrpc": "2.0",
+ "id": 99,
+ "method": "tools/call",
+ "params": {"name": invalid_name, "arguments": {}},
+ })
+ self.assertEqual(resp["jsonrpc"], "2.0")
+ self.assertEqual(resp["id"], 99)
+ self.assertEqual(resp["error"]["code"], -32602)
+ self.assertIn("tool name must be a string", resp["error"]["message"])
+
def test_tools_call_success(self) -> None:
resp = self.server.handle_request({
"jsonrpc": "2.0",
diff --git a/llms.txt b/llms.txt
index e9d7ebc..445d34f 100644
--- a/llms.txt
+++ b/llms.txt
@@ -4,7 +4,9 @@
## Quick API and Automation Commands
-- Run full test suite with uv: `uv run pytest`
+- Install development dependencies: `python -m pip install -r requirements-dev.lock.txt`
+- Run every CI check (lint, tests, exports, examples, courses, paths): `make check`
+- Run the test suite only: `python -m pytest`
- Verify runnable examples: `python tools/verify_examples.py`
- Verify course folders: `python tools/verify_courses.py`
- Validate and inspect resource catalog: `python tools/validate_catalog.py`
@@ -19,9 +21,16 @@
- `catalog/projects/`: One reviewed YAML file per Project Radar entry (discovery candidates are produced on demand by `tools/radar_scan.py`).
- `courses/`: Challenge course folders — solve the `TASK.md` contract with a coding agent and prove it with `verify.py` claim codes; `COURSE.md` still offers a guided, agent-taught mode:
- `courses/hands-on-python-with-claude-code/`: Task contract, bounded change, and objective verify.py evidence with Claude Code (EN+ZH lessons, three scenario skins).
- - `courses/hands-on-with-openai-codex-cli/`: The same verified workflow taught with the OpenAI Codex CLI (EN+ZH lessons, reuses the flagship scenario skins).
+ - `courses/hands-on-with-openai-codex/`: The same verified workflow taught hands-on in the Codex desktop app, where an agent thread runs the loop and you supervise diffs and approvals (EN+ZH).
+ - `courses/hands-on-with-cursor/`: The same verified workflow taught hands-on in Cursor, where Agent mode runs the loop and you supervise diffs and project rules (EN+ZH).
+ - `courses/hands-on-with-deepseek-harness/`: The same verified workflow taught hands-on in DeepSeek Harness, where the agent loop is composed from plugins and every step lands in an inspectable trajectory (EN+ZH).
+ - `courses/hands-on-with-kimi-code/`: The same verified workflow taught hands-on in Kimi Code, where built-in subagents split read-only exploration, planning, and the edits you approve (EN+ZH).
+ - `courses/hands-on-with-zcode/`: The same verified workflow taught hands-on in the ZCode desktop workspace, where Goal Mode decomposes the work and the Git state shows every change (EN+ZH).
+ - `courses/hands-on-with-github-copilot/`: The same verified workflow taught hands-on with GitHub Copilot in VS Code, where the Agent role runs the loop and you supervise permissions, instructions files, and diffs (EN+ZH).
- `courses/agent-rules-single-source/`: Build a checker that proves one AGENTS.md source of truth across CLAUDE.md and .cursorrules (EN+ZH, three drift scenarios).
- `courses/verifying-ai-generated-code/`: Build the release-evidence machine: run checks, parse results, refuse zero-test projects, write SHIP-RECORD.json (EN+ZH, three project skins).
+ - `courses/llm-features-in-python/`: Turn a demo-day model call into a shippable feature: validate structured output, allowlist tool calls, retry with the error fed back, fall back to a human, and measure with an offline eval that divides by every case (EN+ZH, three skins).
+ - `courses/secure-ai-generated-code/`: Review a plausible AI-style notes service like an attacker: close SQL injection, path traversal, a hard-coded secret, fast password hashes, and pickled sessions, then replay the attacks (EN+ZH, two skins).
- `courses/mcp-server-in-python/`: Build a stateless 2026-07-28 MCP tool server in pure Python — reuses the reviewed mcp-server example core (EN+ZH).
- `courses/da-eda/`: Exploratory data analysis with pandas over a messy shop export: exact duplicates, bad amounts, one bad date (EN+ZH).
- `courses/da-visualization/`: Data visualization with matplotlib from clean long-format daily sales: summaries, one-figure charts, Agg-before-pyplot discipline (EN+ZH).
diff --git a/paths/README.md b/paths/README.md
index 21af3b5..2f4dd3f 100644
--- a/paths/README.md
+++ b/paths/README.md
@@ -5,7 +5,8 @@ own folder with a machine-checked `path.json` contract plus bilingual module
and challenge documents.
- [Agent tools foundation](foundation/) — drive a coding agent (Claude
- Code, Codex, Cursor, DeepSeek Harness, Kimi Code, or ZCode — pick one),
+ Code, Codex, Cursor, GitHub Copilot, DeepSeek Harness, Kimi Code, or
+ ZCode — pick one),
write a single-source rules file, and verify agent output by hand.
Modules: the pick-one tool courses, the rules and verification courses,
and a dual-agent route challenge (200 points). The orientation module is
diff --git a/paths/README_cn.md b/paths/README_cn.md
index d1356f0..5cbae99 100644
--- a/paths/README_cn.md
+++ b/paths/README_cn.md
@@ -4,9 +4,9 @@
`path.json` 契约与双语的模块、挑战文档。
- [Agent 工具基础](foundation/)——驱动一门编码 Agent(Claude Code、
- Codex、Cursor、DeepSeek Harness、Kimi Code、ZCode 六选一),写出单一
- 真源规则文件,并亲手验证 Agent 的产出。模块:六选一工具课、规则与
- 验证两门课,以及一个双 Agent 路线挑战(200 分)。入门总览模块为
+ Codex、Cursor、GitHub Copilot、DeepSeek Harness、Kimi Code、ZCode
+ 七选一),写出单一真源规则文件,并亲手验证 Agent 的产出。模块:
+ 七选一工具课、规则与验证两门课,以及一个双 Agent 路线挑战(200 分)。入门总览模块为
可选加分项(10 分)——不计入徽章。
- [用 Agent 做数据分析](data-analysis/)——对真实数据集完成清洗、探索、
可视化与报告。模块:`da-eda`、`da-visualization`、`da-report` 三门课程,
diff --git a/paths/foundation/modules/m0-orientation.md b/paths/foundation/modules/m0-orientation.md
index 42e4f0d..c4f9c80 100644
--- a/paths/foundation/modules/m0-orientation.md
+++ b/paths/foundation/modules/m0-orientation.md
@@ -2,11 +2,11 @@
id: path-foundation-m0
type: path
title: The agent tool landscape
-summary: Foundation path module 0 — orientation across six agentic coding tools; pick the one that fits your environment and observe its evidence habits.
+summary: Foundation path module 0 — orientation across seven agentic coding tools; pick the one that fits your environment and observe its evidence habits.
lang: en-US
-content_version: 2
+content_version: 3
status: reviewed
-reviewed_on: 2026-09-13
+reviewed_on: 2026-10-11
---
# Module 0: The agent tool landscape
@@ -18,7 +18,7 @@ have in common, and which one you will drive first.
## The landscape
-Six agentic coding tools carry this path's hands-on courses. Pick by where
+Seven agentic coding tools carry this path's hands-on courses. Pick by where
you want to work:
| Tool | Where it runs | What it's good at |
@@ -26,6 +26,7 @@ you want to work:
| **Claude Code** | terminal | long multi-step tasks, strong instruction following, reads `CLAUDE.md`/`AGENTS.md` |
| **Codex (desktop app)** | desktop app | task threads with approvals and diff review, reads `AGENTS.md` |
| **Cursor** | IDE | Agent/Ask/Plan modes inside an editor, `.cursor/rules` project rules |
+| **GitHub Copilot** | IDE (VS Code) | Agent/Plan/Ask roles with Manual or Allow-all permissions, `.github/copilot-instructions.md` and `*.instructions.md` files |
| **DeepSeek Harness** | Python framework | plugin-composed agents (`cordis.yml`), auditable append-only trajectories |
| **Kimi Code** | terminal | built-in `explore`/`plan`/`coder` subagents that split reading, planning, editing |
| **ZCode** | desktop app | long-horizon Goal Mode, task lists, workspace Git state, remote check-ins |
@@ -36,13 +37,13 @@ the important part — **can be wrong in ways that look right**. That is why
this path spends so much effort on rules and verification, not on prompts.
**Choosing by environment:** live in the terminal → Claude Code or Kimi Code.
-Live in a GUI → Codex app, Cursor, or ZCode. Want to assemble and audit the
+Live in a GUI → Codex app, Cursor, GitHub Copilot in VS Code, or ZCode. Want to assemble and audit the
agent loop itself → DeepSeek Harness. You only need **one** to start; the
route challenge at the end asks for a second, different one.
## Your first task (no code)
-1. Pick **one** of the six tools and install it following its official docs
+1. Pick **one** of the seven tools and install it following its official docs
(linked from the course pages).
2. Open it on an empty folder and ask it: "create a Python script that
prints the current date, then run it."
diff --git a/paths/foundation/modules/m0-orientation_cn.md b/paths/foundation/modules/m0-orientation_cn.md
index e2a6c3d..403590a 100644
--- a/paths/foundation/modules/m0-orientation_cn.md
+++ b/paths/foundation/modules/m0-orientation_cn.md
@@ -2,11 +2,11 @@
id: path-foundation-m0
type: path
title: Agent 工具生态总览
-summary: 基础路线模块 0——六个 Agentic 编程工具生态导览;按你的工作环境选一个,并观察它的证据习惯。
+summary: 基础路线模块 0——七个 Agentic 编程工具生态导览;按你的工作环境选一个,并观察它的证据习惯。
lang: zh-CN
-content_version: 2
+content_version: 3
status: reviewed
-reviewed_on: 2026-09-13
+reviewed_on: 2026-10-11
---
# 模块 0:Agent 工具生态总览
@@ -17,13 +17,14 @@ reviewed_on: 2026-09-13
## 生态概览
-六个 Agentic 编程工具承载本路线的实战课程。按你想工作的位置选:
+七个 Agentic 编程工具承载本路线的实战课程。按你想工作的位置选:
| 工具 | 运行位置 | 擅长什么 |
| --- | --- | --- |
| **Claude Code** | 终端 | 长链路多步任务,指令遵循强,读 `CLAUDE.md`/`AGENTS.md` |
| **Codex(桌面应用)** | 桌面应用 | 带审批与 diff 审查的任务线程,读 `AGENTS.md` |
| **Cursor** | IDE | 编辑器内 Agent/Ask/Plan 模式,`.cursor/rules` 项目规则 |
+| **GitHub Copilot** | IDE(VS Code) | Agent/Plan/Ask 角色,可选手动审批或全部允许,`.github/copilot-instructions.md` 与 `*.instructions.md` 指令文件 |
| **DeepSeek Harness** | Python 框架 | 插件组装的 Agent(`cordis.yml`),可审计的只增轨迹 |
| **Kimi Code** | 终端 | 内置 `explore`/`plan`/`coder` 子代理,读、规划、改码分道 |
| **ZCode** | 桌面应用 | 长程 Goal 模式、任务列表、工作区 Git 状态、远程查岗 |
@@ -33,12 +34,12 @@ reviewed_on: 2026-09-13
路线把大量精力放在规则和验证上,而不是提示词上。
**按环境选:** 住终端 → Claude Code 或 Kimi Code;住图形界面 → Codex
-应用、Cursor 或 ZCode;想自己组装并审计 Agent 循环 → DeepSeek Harness。
+应用、Cursor、VS Code 里的 GitHub Copilot 或 ZCode;想自己组装并审计 Agent 循环 → DeepSeek Harness。
起步只需要**一个**;路线末尾的挑战会要求第二个不同的工具。
## 第一个任务(不写代码)
-1. 从六个工具里挑**一个**,按官方文档安装(课程页有链接)。
+1. 从七个工具里挑**一个**,按官方文档安装(课程页有链接)。
2. 在一个空文件夹里打开它,让它「写一个打印当前日期的 Python 脚本并运行」。
3. 观察它的行为:创建了哪些文件、跑了哪些命令、回报了什么。
4. 问它:「你怎么知道它能跑?」——注意它是*给出证据*还是只*说跑过了*。
diff --git a/paths/foundation/modules/route-challenge.md b/paths/foundation/modules/route-challenge.md
index 3fbd20f..d14c398 100644
--- a/paths/foundation/modules/route-challenge.md
+++ b/paths/foundation/modules/route-challenge.md
@@ -2,11 +2,11 @@
id: path-foundation-challenge
type: path
title: "Route challenge: dual-agent, double-verified"
-summary: Foundation path capstone — run the same task through any two of the six path agents and verify both outputs yourself.
+summary: Foundation path capstone — run the same task through any two of the seven path agents and verify both outputs yourself.
lang: en-US
-content_version: 2
+content_version: 3
status: reviewed
-reviewed_on: 2026-09-13
+reviewed_on: 2026-10-11
---
# Route challenge: dual-agent, double-verified
@@ -21,12 +21,14 @@ different** agents through the same task — and verify both results yourself.
Pick a real small task you care about (suggestion: "a CLI that counts the
lines, words, and bytes of a file" or reuse any scenario skin from the path
courses: `hands-on-python-with-claude-code`, `hands-on-with-openai-codex`,
-`hands-on-with-cursor`, `hands-on-with-deepseek-harness`,
-`hands-on-with-kimi-code`, `hands-on-with-zcode`, `agent-rules-single-source`,
+`hands-on-with-cursor`, `hands-on-with-github-copilot`,
+`hands-on-with-deepseek-harness`, `hands-on-with-kimi-code`,
+`hands-on-with-zcode`, `agent-rules-single-source`,
`verifying-ai-generated-code`).
-Choose **any two** of the six agent tools this path teaches (Claude Code,
-Codex app, Cursor, DeepSeek Harness, Kimi Code, ZCode). Then:
+Choose **any two** of the seven agent tools this path teaches (Claude Code,
+Codex app, Cursor, GitHub Copilot in VS Code, DeepSeek Harness, Kimi Code,
+ZCode). Then:
1. **Round one — agent A.** In a fresh folder, write a `TASK.md`
describing the contract (inputs, outputs, edge cases, done = tests pass),
diff --git a/paths/foundation/modules/route-challenge_cn.md b/paths/foundation/modules/route-challenge_cn.md
index 70fdf2a..278ab95 100644
--- a/paths/foundation/modules/route-challenge_cn.md
+++ b/paths/foundation/modules/route-challenge_cn.md
@@ -2,11 +2,11 @@
id: path-foundation-challenge
type: path
title: "路线挑战:双 Agent、双重验证"
-summary: 基础路线收官——把同一个任务分别交给六个路线 Agent 中任选的两个,并亲手验证两份产出。
+summary: 基础路线收官——把同一个任务分别交给七个路线 Agent 中任选的两个,并亲手验证两份产出。
lang: zh-CN
-content_version: 2
+content_version: 3
status: reviewed
-reviewed_on: 2026-09-13
+reviewed_on: 2026-10-11
---
# 路线挑战:双 Agent、双重验证
@@ -21,12 +21,13 @@ reviewed_on: 2026-09-13
挑一个你真正在乎的小任务(建议:「一个统计文件行数、词数、字节数的
CLI」,或复用路线课程里的任一场景皮肤:
`hands-on-python-with-claude-code`、`hands-on-with-openai-codex`、
-`hands-on-with-cursor`、`hands-on-with-deepseek-harness`、
+`hands-on-with-cursor`、`hands-on-with-github-copilot`、
+`hands-on-with-deepseek-harness`、
`hands-on-with-kimi-code`、`hands-on-with-zcode`、
`agent-rules-single-source`、`verifying-ai-generated-code`)。
-从本路线教授的六个 Agent 工具中任选**两个**(Claude Code、Codex 应用、
-Cursor、DeepSeek Harness、Kimi Code、ZCode),然后:
+从本路线教授的七个 Agent 工具中任选**两个**(Claude Code、Codex 应用、
+Cursor、VS Code 里的 GitHub Copilot、DeepSeek Harness、Kimi Code、ZCode),然后:
1. **第一轮——Agent A。** 在全新文件夹里写一份描述契约的 `TASK.md`
(输入、输出、边界情况、完成 = 测试通过),驱动第一个 Agent 实现,
diff --git a/paths/foundation/path.json b/paths/foundation/path.json
index 9d026f5..b6d2624 100644
--- a/paths/foundation/path.json
+++ b/paths/foundation/path.json
@@ -4,8 +4,8 @@
"status": "live",
"title_en": "Agent tools foundation",
"title_zh": "Agent 工具基础",
- "summary_en": "Learn to drive real coding agents - Claude Code, Codex, Cursor, DeepSeek Harness, Kimi Code, or ZCode - give them good rules, and verify everything they produce.",
- "summary_zh": "学会驱动真正的编码 Agent(Claude Code、Codex、Cursor、DeepSeek Harness、Kimi Code、ZCode 任选),给它们好规则,并验证它们产出的一切。",
+ "summary_en": "Learn to drive real coding agents - Claude Code, Codex, Cursor, GitHub Copilot, DeepSeek Harness, Kimi Code, or ZCode - give them good rules, and verify everything they produce.",
+ "summary_zh": "学会驱动真正的编码 Agent(Claude Code、Codex、Cursor、GitHub Copilot、DeepSeek Harness、Kimi Code、ZCode 任选),给它们好规则,并验证它们产出的一切。",
"audience_en": "Beginners who have never (or barely) used AI coding agents.",
"audience_zh": "从未用过(或刚接触)AI 编程 Agent 的初学者。",
"outcome_en": "You can independently drive two different coding agents on small tasks, write a single-source rules file, and verify agent output with your own hands.",
@@ -90,6 +90,18 @@
},
{
"order": 6,
+ "id": "m3e",
+ "kind": "course",
+ "course": "hands-on-with-github-copilot",
+ "title_en": "GitHub Copilot hands-on",
+ "title_zh": "GitHub Copilot 实战",
+ "points": 100,
+ "branch": "first-agent",
+ "branch_label_en": "pick your first agent",
+ "branch_label_zh": "选你的第一个 Agent"
+ },
+ {
+ "order": 7,
"id": "m3",
"kind": "course",
"course": "agent-rules-single-source",
@@ -98,7 +110,7 @@
"points": 100
},
{
- "order": 7,
+ "order": 8,
"id": "m4",
"kind": "course",
"course": "verifying-ai-generated-code",
@@ -107,7 +119,7 @@
"points": 100
},
{
- "order": 8,
+ "order": 9,
"id": "route-challenge",
"kind": "module",
"ref": "modules/route-challenge.md",
@@ -117,7 +129,7 @@
"kind_label": "project"
},
{
- "order": 9,
+ "order": 10,
"id": "m0",
"kind": "module",
"ref": "modules/m0-orientation.md",
diff --git a/pyproject.toml b/pyproject.toml
index 2e9a0a4..fa21d0c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,24 +1,21 @@
[project]
name = "flypython"
-version = "0.1.1"
+version = "0.1.2"
description = "Community-maintained source for building reliable Python products in the AI-coding era."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"PyYAML>=6.0.2",
- "requests>=2.32.3",
- "urllib3>=2.3.0",
+ "requests>=2.34.2",
+ "urllib3>=2.8.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"jsonschema>=4.20.0",
- "ruff>=0.9.0",
- "mypy>=1.14.0",
- "types-PyYAML>=6.0.12.20241230",
- "types-requests>=2.32.0.20241016",
+ "ruff>=0.16.10",
"pandas>=2.3,<2.4",
"matplotlib>=3.10,<3.11",
]
@@ -40,10 +37,3 @@ ignore = ["E501"]
# Course starters are deliberately unfinished: pre-imported modules are
# scaffolding the learner will use when implementing the contract.
"courses/*/starter/*.py" = ["F401"]
-
-[tool.mypy]
-python_version = "3.11"
-strict = false
-warn_return_any = true
-warn_unused_configs = true
-disallow_untyped_defs = false
diff --git a/requirements-dev.lock.txt b/requirements-dev.lock.txt
index 9d5b0a2..1649d52 100644
--- a/requirements-dev.lock.txt
+++ b/requirements-dev.lock.txt
@@ -1,24 +1,99 @@
--r requirements.lock.txt
+# This file was autogenerated by uv via the following command:
+# make lock
attrs==26.1.0
+ # via
+ # jsonschema
+ # referencing
+certifi==2026.7.22
+ # via
+ # -c requirements.lock.txt
+ # requests
+charset-normalizer==3.5.2
+ # via
+ # -c requirements.lock.txt
+ # requests
+colorama==0.4.6 ; sys_platform == 'win32'
+ # via pytest
+contourpy==1.3.3 ; python_full_version < '3.12'
+ # via matplotlib
+contourpy==1.4.0 ; python_full_version >= '3.12'
+ # via matplotlib
+cycler==0.12.1
+ # via matplotlib
+fonttools==4.66.1
+ # via matplotlib
+idna==3.20
+ # via
+ # -c requirements.lock.txt
+ # requests
iniconfig==2.3.0
+ # via pytest
jsonschema==4.26.0
+ # via flypython (pyproject.toml)
jsonschema-specifications==2025.9.1
-packaging==26.3
-pluggy==1.6.0
-Pygments==2.21.0
-pytest==9.1.1
-referencing==0.37.0
-rpds-py==2026.6.3
-contourpy==1.4.0
-cycler==0.12.1
-fonttools==4.65.0
+ # via jsonschema
kiwisolver==1.5.1
+ # via matplotlib
matplotlib==3.10.9
-numpy==2.5.3
+ # via flypython (pyproject.toml)
+numpy==2.4.6 ; python_full_version < '3.12'
+ # via
+ # contourpy
+ # matplotlib
+ # pandas
+numpy==2.5.3 ; python_full_version >= '3.12'
+ # via
+ # contourpy
+ # matplotlib
+ # pandas
+packaging==26.3
+ # via
+ # matplotlib
+ # pytest
pandas==2.3.3
+ # via flypython (pyproject.toml)
pillow==12.3.0
-pyparsing==3.3.2
+ # via matplotlib
+pluggy==1.6.0
+ # via pytest
+pygments==2.21.0
+ # via pytest
+pyparsing==3.3.3
+ # via matplotlib
+pytest==9.1.1
+ # via flypython (pyproject.toml)
python-dateutil==2.9.0.post0
-pytz==2026.3.post1
+ # via
+ # matplotlib
+ # pandas
+pytz==2026.4
+ # via pandas
+pyyaml==6.0.3
+ # via
+ # -c requirements.lock.txt
+ # flypython (pyproject.toml)
+referencing==0.37.0
+ # via
+ # jsonschema
+ # jsonschema-specifications
+requests==2.34.2
+ # via
+ # -c requirements.lock.txt
+ # flypython (pyproject.toml)
+rpds-py==2026.6.3
+ # via
+ # jsonschema
+ # referencing
+ruff==0.16.10
+ # via flypython (pyproject.toml)
six==1.17.0
-tzdata==2026.3
+ # via python-dateutil
+typing-extensions==4.16.0 ; python_full_version < '3.13'
+ # via referencing
+tzdata==2026.4
+ # via pandas
+urllib3==2.8.0
+ # via
+ # -c requirements.lock.txt
+ # flypython (pyproject.toml)
+ # requests
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index 2137822..0000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1,5 +0,0 @@
--r requirements.txt
-pytest==9.1.1
-jsonschema>=4.20.0
-pandas==2.3.3
-matplotlib==3.10.9
diff --git a/requirements.lock.txt b/requirements.lock.txt
index bbdff30..8ec274b 100644
--- a/requirements.lock.txt
+++ b/requirements.lock.txt
@@ -1,6 +1,16 @@
-PyYAML==6.0.3
+# This file was autogenerated by uv via the following command:
+# make lock
certifi==2026.7.22
-charset-normalizer==3.5.1
-idna==3.19
-requests==2.32.5
-urllib3==2.6.3
+ # via requests
+charset-normalizer==3.5.2
+ # via requests
+idna==3.20
+ # via requests
+pyyaml==6.0.3
+ # via flypython (pyproject.toml)
+requests==2.34.2
+ # via flypython (pyproject.toml)
+urllib3==2.8.0
+ # via
+ # flypython (pyproject.toml)
+ # requests
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 4d2c6a5..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-PyYAML==6.0.3
-requests==2.32.5
-urllib3==2.6.3
diff --git a/tests/test_ai_coding_content.py b/tests/test_ai_coding_content.py
index ee01898..dd6c25b 100644
--- a/tests/test_ai_coding_content.py
+++ b/tests/test_ai_coding_content.py
@@ -39,7 +39,7 @@ def test_ai_coding_guides_have_matching_identity_and_version() -> None:
def test_ai_coding_guides_cover_the_same_eight_step_workflow() -> None:
for path in GUIDES.values():
_, body = load_guide(path)
- headings = re.findall(r"^## ([1-8])\.", body, flags=re.MULTILINE)
+ headings = re.findall(r"^## ([1-8])\.\s", body, flags=re.MULTILINE)
assert headings == [str(number) for number in range(1, 9)]
@@ -49,7 +49,7 @@ def test_guide_reference_urls_come_from_reviewed_catalog() -> None:
for path in GUIDES.values():
_, body = load_guide(path)
- urls = set(re.findall(r"\]\((https://[^)]+)\)", body))
+ urls = set(re.findall(r"\]\((https://[^)\s]+)", body))
assert urls
assert {url for url in urls if not url.startswith(FIRST_PARTY_PREFIX)} <= catalog_urls
diff --git a/tests/test_courses.py b/tests/test_courses.py
index d9a93b3..b2a70ba 100644
--- a/tests/test_courses.py
+++ b/tests/test_courses.py
@@ -18,8 +18,11 @@ def test_courses_directory_is_discovered() -> None:
"hands-on-with-deepseek-harness",
"hands-on-with-kimi-code",
"hands-on-with-zcode",
+ "hands-on-with-github-copilot",
"agent-rules-single-source",
"verifying-ai-generated-code",
+ "llm-features-in-python",
+ "secure-ai-generated-code",
"mcp-server-in-python",
} <= names
@@ -62,8 +65,11 @@ def test_course_documents_enter_the_content_manifest() -> None:
"course-deepseek-harness",
"course-kimi-code",
"course-zcode",
+ "course-github-copilot",
"course-agent-rules",
"course-verify-ship",
+ "course-llm-features",
+ "course-secure-code",
"course-mcp-tools",
}
assert expected_courses <= ids
diff --git a/tests/test_dependency_pins.py b/tests/test_dependency_pins.py
new file mode 100644
index 0000000..1bd2254
--- /dev/null
+++ b/tests/test_dependency_pins.py
@@ -0,0 +1,69 @@
+"""Learners install a course's own requirements.txt; CI verifies the course
+with requirements-dev.lock.txt. Both must resolve to the same versions, or CI
+would pass on a stack learners never get (the pandas 3 risk in PR #94)."""
+
+from __future__ import annotations
+
+import re
+import subprocess
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+PIN = re.compile(r"^([A-Za-z0-9][A-Za-z0-9._-]*)==([^\s;]+)\s*(;.*)?$")
+
+
+def _normalize(name: str) -> str:
+ return re.sub(r"[-_.]+", "-", name).lower()
+
+
+def _requirement_lines(path: Path) -> list[str]:
+ lines = []
+ for raw in path.read_text(encoding="utf-8").splitlines():
+ line = raw.split("#", 1)[0].strip()
+ if line:
+ lines.append(line)
+ return lines
+
+
+def _learner_requirement_files() -> list[Path]:
+ tracked = subprocess.run(
+ ["git", "-C", str(ROOT), "ls-files", "courses/*/requirements.txt", "paths/**/requirements.txt"],
+ check=True,
+ capture_output=True,
+ text=True,
+ ).stdout.split()
+ return [ROOT / name for name in sorted(tracked)]
+
+
+def _dev_lock_pins() -> dict[str, list[tuple[str, str | None]]]:
+ pins: dict[str, list[tuple[str, str | None]]] = {}
+ for line in _requirement_lines(ROOT / "requirements-dev.lock.txt"):
+ match = PIN.match(line)
+ if match:
+ name, version, marker = match.groups()
+ pins.setdefault(_normalize(name), []).append((version, marker))
+ return pins
+
+
+def test_learner_requirement_files_exist() -> None:
+ assert _learner_requirement_files(), "expected course/path requirements.txt files"
+
+
+def test_learner_requirements_match_the_ci_lock() -> None:
+ lock = _dev_lock_pins()
+ problems = []
+ for path in _learner_requirement_files():
+ rel = path.relative_to(ROOT)
+ for line in _requirement_lines(path):
+ match = PIN.match(line)
+ if not match or match.group(3):
+ problems.append(f"{rel}: '{line}' must be an exact, unconditional == pin")
+ continue
+ name, version, _ = match.groups()
+ locked = lock.get(_normalize(name))
+ if locked is None:
+ problems.append(f"{rel}: {name} is not in requirements-dev.lock.txt")
+ elif locked != [(version, None)]:
+ found = ", ".join(v + (f" ({m.lstrip('; ')})" if m else "") for v, m in locked)
+ problems.append(f"{rel}: {name}=={version}, but the CI lock has {found}")
+ assert problems == []
diff --git a/tests/test_engineering_config.py b/tests/test_engineering_config.py
new file mode 100644
index 0000000..ca22680
--- /dev/null
+++ b/tests/test_engineering_config.py
@@ -0,0 +1,49 @@
+"""Keep developer entry points safe and aligned with generated-content gates."""
+
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def _make(*args: str, cwd: Path = ROOT) -> subprocess.CompletedProcess[str]:
+ if not shutil.which("make"):
+ pytest.skip("GNU/POSIX make is not installed")
+ return subprocess.run(
+ ["make", *args], cwd=cwd, capture_output=True, text=True, timeout=15,
+ check=False,
+ )
+
+
+def test_agent_entrypoint_has_single_source() -> None:
+ assert (ROOT / "CLAUDE.md").read_text().strip() == "@AGENTS.md"
+ assert "* text=auto eol=lf" in (ROOT / ".gitattributes").read_text()
+
+
+def test_make_rejects_unrelated_working_directory(tmp_path: Path) -> None:
+ result = _make("-f", str(ROOT / "Makefile"), "-n", "setup", cwd=tmp_path)
+ assert result.returncode != 0
+ assert "run make in the Python repository" in result.stderr
+
+
+def test_make_generated_checks_are_read_only_and_include_solution_hashes() -> None:
+ result = _make("-n", "generated-check")
+ assert result.returncode == 0, result.stderr
+ for name in ("export_catalog", "render_readmes", "build_content_manifest", "build_solution_hashes"):
+ assert f"tools/{name}.py --check" in result.stdout
+
+
+def test_make_setup_uses_pinned_interpreter_and_locked_dependencies() -> None:
+ result = _make("-n", "setup")
+ assert result.returncode == 0, result.stderr
+ assert ".python-version" in result.stdout
+ assert "pip sync --python .venv/bin/python requirements-dev.lock.txt" in result.stdout
+
+
+def test_make_all_regenerates_before_checks_even_in_parallel() -> None:
+ result = _make("-n", "-j4", "all")
+ assert result.returncode == 0, result.stderr
+ assert result.stdout.index("tools/build_solution_hashes.py\n") < result.stdout.index("-m ruff check .")
diff --git a/tests/test_llms_txt.py b/tests/test_llms_txt.py
new file mode 100644
index 0000000..b5b7735
--- /dev/null
+++ b/tests/test_llms_txt.py
@@ -0,0 +1,33 @@
+"""llms.txt is the entry point agents read first, so a stale path or a missing
+course sends them to a 404 or hides a course entirely."""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+LLMS = ROOT / "llms.txt"
+REPO_PATH = re.compile(
+ r"`((?:catalog|courses|examples|guides|paths|playbooks|schema|templates|tools)/[^`\s]*)`"
+)
+
+
+def _listed_paths() -> list[str]:
+ return REPO_PATH.findall(LLMS.read_text(encoding="utf-8"))
+
+
+def test_every_repository_path_exists() -> None:
+ missing = [path for path in _listed_paths() if not (ROOT / path).exists()]
+ assert not missing, f"llms.txt points at paths that do not exist: {missing}"
+
+
+def test_every_course_folder_is_listed() -> None:
+ listed = {path.rstrip("/") for path in _listed_paths()}
+ courses = sorted(
+ f"courses/{course.name}"
+ for course in (ROOT / "courses").iterdir()
+ if (course / "COURSE.md").is_file()
+ )
+ unlisted = [course for course in courses if course not in listed]
+ assert not unlisted, f"llms.txt does not list these courses: {unlisted}"
diff --git a/tests/test_render_readmes.py b/tests/test_render_readmes.py
index 951c777..d3f866b 100644
--- a/tests/test_render_readmes.py
+++ b/tests/test_render_readmes.py
@@ -21,10 +21,10 @@ def test_checked_in_readme_catalog_sections_are_current() -> None:
assert run(["--check"]) == 0
-def test_readmes_expose_every_catalog_resource() -> None:
+def test_catalog_readmes_expose_every_catalog_resource() -> None:
catalog = load_catalog(ROOT / "catalog")
- english = (ROOT / "README.md").read_text(encoding="utf-8")
- chinese = (ROOT / "README_cn.md").read_text(encoding="utf-8")
+ english = (ROOT / "catalog" / "README.md").read_text(encoding="utf-8")
+ chinese = (ROOT / "catalog" / "README_cn.md").read_text(encoding="utf-8")
for resource in catalog["resources"]:
link = f"[{resource['title']}]({resource['url']})"
@@ -34,6 +34,14 @@ def test_readmes_expose_every_catalog_resource() -> None:
assert resource["why_zh"] in chinese
+def test_root_readmes_link_to_the_full_catalog() -> None:
+ english = (ROOT / "README.md").read_text(encoding="utf-8")
+ chinese = (ROOT / "README_cn.md").read_text(encoding="utf-8")
+
+ assert "catalog/README.md" in english
+ assert "catalog/README_cn.md" in chinese
+
+
def test_generated_catalog_escapes_markdown_and_html(valid_catalog: dict) -> None:
data = deepcopy(valid_catalog)
data["resources"][0]["title"] = "Unsafe ]